mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
feat: interactive mode, so the bare command opens the vault
Python's `seedpass` launches its TUI when invoked with no subcommand. The port printed a help dump instead, which is why running the bare command looked broken: nothing was wrong with it, there was simply no way in. This adds the interactive mode that was missing. Scope is the daily path -- browse, search, copy, reveal, add a password / TOTP / key-value entry, archive, switch profile -- not a port of Python's v2 and v3 TUIs, which are around 20k lines of Textual between them. Piped or redirected, the bare command still prints the help: there is no terminal to drive. No new runtime dependencies. Node's tty and ANSI directly, rather than a TUI framework, because everything bundled here shares a process with unlocked seed phrases and the CLI's runtime surface is currently commander plus the noble/scure crypto libraries. The agent-blind rule from plan section 9.3 carries over intact, and matters more here than in the CLI because this screen renders the whole vault at once: list and detail views show metadata only, `c` copies without ever drawing the value, and `r` is the single explicit reveal -- on screen until the next keypress, then gone from the following frame. Everything is drawn on the alternate screen buffer, so a revealed secret cannot be recovered by scrolling back after quitting, and never lands in a terminal log that captures the main buffer. The key-value field is masked while being typed for the same reason. terminal.ts owes the user one thing above all: their shell back. Raw mode with a hidden cursor is an unusable terminal, so restoration is idempotent and wired to exit, SIGINT, SIGTERM, SIGHUP, and the uncaught-exception paths -- verified through a pty for each. Holding raw mode across the whole unlock sequence rather than toggling it per prompt also fixes a narrow leak: echo came back on between password attempts, so a password retyped after a typo was echoed in clear text. Tests drive the real screens through an injected terminal, including the redaction properties above. Two harness bugs worth recording, because both produced confident nonsense: sampling the frame count after dispatching a key made a synchronous redraw look like no redraw at all, and waiting only for "the screen stopped changing" returned during the second-long PBKDF2 a mutation performs, so the next keystroke landed on the previous screen and typed "q" into a form field instead of quitting. 122 CLI tests, 220 core tests, 36/36 cross-impl checks. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
b0e2a309a4
commit
1eab3bf137
8 files changed
+1699
-11
No files matched your search
@@ -97,10 +97,35 @@ so this only applies if you jump backwards several releases.
|
||||
generation is not reproducible, so the TypeScript port refuses rather than
|
||||
deriving a different key. Keep the Python implementation for RSA entries.
|
||||
- The `semantic` (vector search) and `api` (FastAPI server) command groups.
|
||||
- The TUI. The terminal experience is the CLI.
|
||||
- Most of Python's TUI. `seedpass-js` with no subcommand opens an interactive
|
||||
mode, as `seedpass` does, but it covers the daily path only: browse,
|
||||
search, copy, reveal, add a password/TOTP/key-value entry, archive, and
|
||||
switch profile. Python's v2 and v3 TUIs have screens this does not — the
|
||||
command palette, the inspector, the bulk operations.
|
||||
|
||||
If you rely on any of those, run both: they operate on the same profile.
|
||||
|
||||
## Interactive mode
|
||||
|
||||
```bash
|
||||
seedpass-js # opens the vault; asks for your master password
|
||||
```
|
||||
|
||||
It unlocks the same way the CLI does: `SEEDPASS_MNEMONIC` if set, then the
|
||||
session agent, then a password prompt. Press `?` for the keys.
|
||||
|
||||
Two things behave deliberately:
|
||||
|
||||
- Nothing secret is drawn until you ask. The list and detail views show
|
||||
metadata; `c` copies to the clipboard without displaying anything, and `r`
|
||||
is the one explicit reveal, on screen only until the next keypress.
|
||||
- The whole session runs on the terminal's alternate screen, so quitting
|
||||
takes it with it. A revealed secret cannot be recovered by scrolling back,
|
||||
and it never reaches a terminal log that captures the main buffer.
|
||||
|
||||
Piped or redirected, the bare command prints the help instead — there is no
|
||||
terminal to drive.
|
||||
|
||||
## Starting fresh (no existing vault)
|
||||
|
||||
If you have no seed yet, `fingerprint create` generates one — 32 bytes of OS
|
||||
|
||||
@@ -15,6 +15,12 @@ export function capabilities(): Record<string, unknown> {
|
||||
portable_backup_format: 1,
|
||||
nostr_event_kinds: { manifest: 30070, snapshot_chunk: 30071, delta: 30072 },
|
||||
},
|
||||
interactive: {
|
||||
command: "seedpass-js (no subcommand, on a terminal)",
|
||||
covers: ["browse", "search", "copy", "reveal", "add", "archive", "switch profile"],
|
||||
secrets: "metadata only until 'c' (copy) or 'r' (reveal); alternate screen, no scrollback",
|
||||
non_tty: "prints help",
|
||||
},
|
||||
agent_surface: {
|
||||
reference_scheme: "sp://entry/<id>",
|
||||
reference_first_output: true,
|
||||
@@ -70,6 +76,7 @@ export function capabilities(): Record<string, unknown> {
|
||||
"index0/atlas",
|
||||
"approval gates and high-risk partitions",
|
||||
"semantic (vector search) and api (FastAPI server) command groups",
|
||||
"most of Python's TUI (interactive mode covers the daily path only)",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,8 @@ import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js";
|
||||
import { loadConfig, saveConfig, mutateConfig } from "./configFile.js";
|
||||
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
|
||||
import { AuditLog } from "./audit.js";
|
||||
import { runTui } from "./tui/app.js";
|
||||
import { Terminal } from "./tui/terminal.js";
|
||||
|
||||
export interface ProgramIo {
|
||||
out(line: string): void;
|
||||
@@ -392,6 +394,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
})
|
||||
.exitOverride();
|
||||
|
||||
// No subcommand: launch the interactive mode, matching Python, whose bare
|
||||
// `seedpass` opens its TUI. Without this the first thing a new user saw
|
||||
// was a help dump — the command they were told to run appeared to do
|
||||
// nothing. Piped or redirected, there is no terminal to drive, so the help
|
||||
// remains the sensible answer.
|
||||
program.action(async () => {
|
||||
const opts = program.opts() as GlobalOpts;
|
||||
if (!Terminal.isInteractive()) {
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const code = await runTui({
|
||||
...(opts.appDir !== undefined && { appDir: opts.appDir }),
|
||||
...(opts.fingerprint !== undefined && { fingerprint: opts.fingerprint }),
|
||||
});
|
||||
if (code !== 0) process.exitCode = code;
|
||||
});
|
||||
|
||||
program
|
||||
.command("capabilities")
|
||||
.description("machine-readable capability map")
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* The interactive mode: `seedpass-js` with no subcommand.
|
||||
*
|
||||
* Parity note: Python's `seedpass` launches a TUI when invoked bare, and this
|
||||
* exists because the port did not — a new user typing the bare command got a
|
||||
* help dump and no way in.
|
||||
*
|
||||
* The agent-blind rule from plan section 9.3 holds here exactly as it does in
|
||||
* the CLI: the list and detail views render metadata only, `c` copies to the
|
||||
* clipboard without ever displaying the value, and `r` is the one explicit
|
||||
* plaintext egress — it requires a keypress, says what it is about to do, and
|
||||
* the value is erased from the screen when dismissed. Combined with the
|
||||
* alternate screen buffer (see terminal.ts) a revealed secret cannot be
|
||||
* recovered from scrollback after quitting.
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
archiveEntry,
|
||||
restoreEntry,
|
||||
addPasswordEntry,
|
||||
addTotpDeterministic,
|
||||
addKeyValueEntry,
|
||||
assertValidMnemonic,
|
||||
isValidMnemonic,
|
||||
type Entry,
|
||||
} from "@seedpass/core";
|
||||
import { AppDir, resolveAppDir, INDEX_FILENAME } from "../appDir.js";
|
||||
import { openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js";
|
||||
import { entryMetadata, refFor } from "../refs.js";
|
||||
import { materializeSecret } from "../secrets.js";
|
||||
import { clipboardSink } from "../sinks.js";
|
||||
import { AgentClient, agentSocketPath } from "../agent.js";
|
||||
import { Terminal, ansi, visibleLength, type Key } from "./terminal.js";
|
||||
|
||||
type Screen = "list" | "detail" | "reveal" | "add" | "help" | "profiles";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
archived: boolean;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ADD_KINDS = ["password", "totp", "key-value"] as const;
|
||||
type AddKind = (typeof ADD_KINDS)[number];
|
||||
|
||||
interface AddState {
|
||||
kind: AddKind;
|
||||
/** Which field is focused; -1 means the kind picker. */
|
||||
field: number;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
const ADD_FIELDS: Record<AddKind, string[]> = {
|
||||
password: ["label", "length", "username"],
|
||||
totp: ["label"],
|
||||
"key-value": ["label", "key", "value"],
|
||||
};
|
||||
|
||||
export interface TuiOptions {
|
||||
appDir?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app needs from a terminal. Narrowed to an interface so tests can
|
||||
* drive the real screens through a fake, rather than a pty the test runner
|
||||
* cannot allocate — the redaction rules below are worth asserting directly.
|
||||
*/
|
||||
export interface TerminalLike {
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
start(): void;
|
||||
restore(): void;
|
||||
onKey(handler: (key: Key) => void): void;
|
||||
onResize(handler: () => void): void;
|
||||
draw(lines: string[]): void;
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise<number> {
|
||||
if (!terminal && !Terminal.isInteractive()) {
|
||||
process.stderr.write(
|
||||
"seedpass-js: interactive mode needs a terminal on both stdin and stdout.\n" +
|
||||
"Run a subcommand instead — 'seedpass-js --help' lists them.\n",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const app = new AppDir(resolveAppDir(opts.appDir));
|
||||
const fps = await app.readFingerprints();
|
||||
if (fps.fingerprints.length === 0) {
|
||||
process.stderr.write(
|
||||
"No vault yet. Create one with:\n\n" +
|
||||
" export SEEDPASS_PASSWORD='a master password'\n" +
|
||||
" seedpass-js fingerprint create --name personal --words 24 --out ~/seed.txt\n",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const tui = new Tui(app, opts, terminal ?? new Terminal());
|
||||
return tui.run();
|
||||
}
|
||||
|
||||
class Tui {
|
||||
private screen: Screen = "list";
|
||||
private vault: OpenedVault | null = null;
|
||||
private fingerprint = "";
|
||||
private profileNames: Record<string, string | null> = {};
|
||||
private profiles: string[] = [];
|
||||
private rows: Row[] = [];
|
||||
private filtered: Row[] = [];
|
||||
private cursor = 0;
|
||||
private scroll = 0;
|
||||
private query = "";
|
||||
private searching = false;
|
||||
private status = "";
|
||||
private statusKind: "info" | "error" | "ok" = "info";
|
||||
private revealed: { label: string; value: string; descriptor: string } | null = null;
|
||||
private add: AddState | null = null;
|
||||
private showArchived = false;
|
||||
private profileCursor = 0;
|
||||
private done = false;
|
||||
private exitCode = 0;
|
||||
private resolveDone: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly app: AppDir,
|
||||
private readonly opts: TuiOptions,
|
||||
private readonly term: TerminalLike,
|
||||
) {}
|
||||
|
||||
async run(): Promise<number> {
|
||||
// Unlock before touching the terminal: the password prompt is a plain
|
||||
// line-mode read, and failures should print normally rather than inside
|
||||
// an alternate screen that is about to be torn down.
|
||||
try {
|
||||
await this.unlock();
|
||||
} catch (e) {
|
||||
process.stderr.write(`seedpass-js: ${(e as Error).message}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
this.term.start();
|
||||
this.term.onResize(() => this.render());
|
||||
this.term.onKey((key) => {
|
||||
void this.handleKey(key).catch((e: unknown) => {
|
||||
this.setStatus((e as Error).message, "error");
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
this.render();
|
||||
await new Promise<void>((resolve) => {
|
||||
this.resolveDone = resolve;
|
||||
});
|
||||
this.term.restore();
|
||||
return this.exitCode;
|
||||
}
|
||||
|
||||
private quit(code = 0): void {
|
||||
this.done = true;
|
||||
this.exitCode = code;
|
||||
this.resolveDone?.();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- unlock
|
||||
|
||||
private async unlock(): Promise<void> {
|
||||
const fps = await this.app.readFingerprints();
|
||||
this.profiles = fps.fingerprints;
|
||||
this.profileNames = fps.names;
|
||||
this.fingerprint = this.opts.fingerprint ?? fps.last_used ?? fps.fingerprints[0]!;
|
||||
|
||||
const mnemonic = await this.resolveMnemonic(this.fingerprint);
|
||||
await this.loadVault(this.fingerprint, mnemonic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same resolution order as the CLI: environment, then the session agent,
|
||||
* then ask. Asking is last because the other two mean the user already
|
||||
* decided how this session is unlocked.
|
||||
*/
|
||||
private async resolveMnemonic(fingerprint: string): Promise<string> {
|
||||
const env = process.env["SEEDPASS_MNEMONIC"];
|
||||
if (env) {
|
||||
assertValidMnemonic(env, "SEEDPASS_MNEMONIC");
|
||||
return env;
|
||||
}
|
||||
try {
|
||||
const held = await new AgentClient(agentSocketPath(this.app.root)).ownerMnemonic(fingerprint);
|
||||
if (held) return held;
|
||||
} catch {
|
||||
// no agent, or it holds nothing for this profile — fall through to ask
|
||||
}
|
||||
|
||||
const name = this.profileNames[fingerprint];
|
||||
const prompt = new HiddenPrompt();
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const password = await prompt.ask(
|
||||
`Master password for ${name ? `${name} (${fingerprint})` : fingerprint}: `,
|
||||
);
|
||||
if (password === null) throw new Error("cancelled");
|
||||
try {
|
||||
return await this.app.decryptParentSeed(fingerprint, password);
|
||||
} catch {
|
||||
process.stderr.write("Wrong password.\n");
|
||||
}
|
||||
}
|
||||
throw new Error("too many failed password attempts");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadVault(fingerprint: string, mnemonic: string): Promise<void> {
|
||||
const path = join(this.app.profileDir(fingerprint), INDEX_FILENAME);
|
||||
this.vault = await openVault(path, mnemonic);
|
||||
this.fingerprint = fingerprint;
|
||||
this.refreshRows();
|
||||
}
|
||||
|
||||
private refreshRows(): void {
|
||||
const index = this.vault!.index;
|
||||
this.rows = Object.entries(index.entries)
|
||||
.map(([id, entry]) => {
|
||||
const meta = entryMetadata(id, entry as Entry);
|
||||
return {
|
||||
id,
|
||||
label: String((entry as Entry).label ?? ""),
|
||||
kind: String((entry as Entry).kind ?? (entry as Entry).type ?? "unknown"),
|
||||
archived: Boolean((entry as Entry).archived),
|
||||
meta,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
this.filtered = this.rows.filter((r) => {
|
||||
if (r.archived && !this.showArchived) return false;
|
||||
if (!q) return true;
|
||||
const tags = Array.isArray(r.meta["tags"]) ? (r.meta["tags"] as string[]).join(" ") : "";
|
||||
return `${r.label} ${r.kind} ${tags} ${String(r.meta["username"] ?? "")}`
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
if (this.cursor >= this.filtered.length) this.cursor = Math.max(0, this.filtered.length - 1);
|
||||
}
|
||||
|
||||
private current(): Row | null {
|
||||
return this.filtered[this.cursor] ?? null;
|
||||
}
|
||||
|
||||
private setStatus(text: string, kind: "info" | "error" | "ok" = "info"): void {
|
||||
this.status = text;
|
||||
this.statusKind = kind;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ keys
|
||||
|
||||
private async handleKey(key: Key): Promise<void> {
|
||||
if (this.done) return;
|
||||
if (key.name === "ctrl-c") {
|
||||
this.quit(130);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.screen) {
|
||||
case "list":
|
||||
await this.keyList(key);
|
||||
break;
|
||||
case "detail":
|
||||
await this.keyDetail(key);
|
||||
break;
|
||||
case "reveal":
|
||||
this.keyReveal(key);
|
||||
break;
|
||||
case "add":
|
||||
await this.keyAdd(key);
|
||||
break;
|
||||
case "help":
|
||||
this.screen = "list";
|
||||
break;
|
||||
case "profiles":
|
||||
await this.keyProfiles(key);
|
||||
break;
|
||||
}
|
||||
if (!this.done) this.render();
|
||||
}
|
||||
|
||||
private async keyList(key: Key): Promise<void> {
|
||||
if (this.searching) {
|
||||
switch (key.name) {
|
||||
case "enter":
|
||||
case "escape":
|
||||
this.searching = false;
|
||||
if (key.name === "escape") {
|
||||
this.query = "";
|
||||
this.applyFilter();
|
||||
}
|
||||
return;
|
||||
case "backspace":
|
||||
this.query = this.query.slice(0, -1);
|
||||
this.applyFilter();
|
||||
return;
|
||||
case "char":
|
||||
this.query += key.ch;
|
||||
this.applyFilter();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (key.name) {
|
||||
case "up":
|
||||
this.move(-1);
|
||||
return;
|
||||
case "down":
|
||||
this.move(1);
|
||||
return;
|
||||
case "pageup":
|
||||
this.move(-this.pageSize());
|
||||
return;
|
||||
case "pagedown":
|
||||
this.move(this.pageSize());
|
||||
return;
|
||||
case "home":
|
||||
this.cursor = 0;
|
||||
return;
|
||||
case "end":
|
||||
this.cursor = Math.max(0, this.filtered.length - 1);
|
||||
return;
|
||||
case "enter":
|
||||
if (this.current()) this.screen = "detail";
|
||||
return;
|
||||
case "escape":
|
||||
if (this.query) {
|
||||
this.query = "";
|
||||
this.applyFilter();
|
||||
}
|
||||
return;
|
||||
case "char":
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key.ch) {
|
||||
case "/":
|
||||
this.searching = true;
|
||||
this.setStatus("");
|
||||
return;
|
||||
case "q":
|
||||
this.quit(0);
|
||||
return;
|
||||
case "?":
|
||||
this.screen = "help";
|
||||
return;
|
||||
case "c":
|
||||
await this.copyCurrent();
|
||||
return;
|
||||
case "r":
|
||||
this.revealCurrent();
|
||||
return;
|
||||
case "a":
|
||||
this.add = { kind: "password", field: -1, values: ["", "", ""] };
|
||||
this.screen = "add";
|
||||
return;
|
||||
case "d":
|
||||
await this.toggleArchive();
|
||||
return;
|
||||
case "A":
|
||||
this.showArchived = !this.showArchived;
|
||||
this.applyFilter();
|
||||
this.setStatus(this.showArchived ? "showing archived" : "hiding archived");
|
||||
return;
|
||||
case "p":
|
||||
this.screen = "profiles";
|
||||
this.profileCursor = Math.max(0, this.profiles.indexOf(this.fingerprint));
|
||||
return;
|
||||
case "g":
|
||||
await this.reload();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async keyDetail(key: Key): Promise<void> {
|
||||
if (key.name === "escape" || (key.name === "char" && (key.ch === "q" || key.ch === "h"))) {
|
||||
this.screen = "list";
|
||||
return;
|
||||
}
|
||||
if (key.name === "char") {
|
||||
switch (key.ch) {
|
||||
case "c":
|
||||
await this.copyCurrent();
|
||||
return;
|
||||
case "r":
|
||||
this.revealCurrent();
|
||||
return;
|
||||
case "d":
|
||||
await this.toggleArchive();
|
||||
return;
|
||||
case "?":
|
||||
this.screen = "help";
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (key.name === "up") this.move(-1);
|
||||
if (key.name === "down") this.move(1);
|
||||
}
|
||||
|
||||
private keyReveal(_key: Key): void {
|
||||
// Any key dismisses. The value is dropped here and the frame is redrawn
|
||||
// without it, so it exists on screen only while being looked at.
|
||||
this.revealed = null;
|
||||
this.screen = "detail";
|
||||
this.setStatus("hidden");
|
||||
}
|
||||
|
||||
private async keyProfiles(key: Key): Promise<void> {
|
||||
switch (key.name) {
|
||||
case "up":
|
||||
this.profileCursor = Math.max(0, this.profileCursor - 1);
|
||||
return;
|
||||
case "down":
|
||||
this.profileCursor = Math.min(this.profiles.length - 1, this.profileCursor + 1);
|
||||
return;
|
||||
case "escape":
|
||||
this.screen = "list";
|
||||
return;
|
||||
case "enter": {
|
||||
const fp = this.profiles[this.profileCursor];
|
||||
if (!fp || fp === this.fingerprint) {
|
||||
this.screen = "list";
|
||||
return;
|
||||
}
|
||||
await this.switchProfile(fp);
|
||||
return;
|
||||
}
|
||||
case "char":
|
||||
if (key.ch === "q") this.screen = "list";
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async keyAdd(key: Key): Promise<void> {
|
||||
const state = this.add!;
|
||||
const fields = ADD_FIELDS[state.kind];
|
||||
|
||||
if (key.name === "escape") {
|
||||
this.add = null;
|
||||
this.screen = "list";
|
||||
this.setStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Kind picker.
|
||||
if (state.field === -1) {
|
||||
if (key.name === "left" || key.name === "up") {
|
||||
const i = ADD_KINDS.indexOf(state.kind);
|
||||
state.kind = ADD_KINDS[(i - 1 + ADD_KINDS.length) % ADD_KINDS.length]!;
|
||||
state.values = ADD_FIELDS[state.kind].map(() => "");
|
||||
return;
|
||||
}
|
||||
if (key.name === "right" || key.name === "down" || key.name === "tab") {
|
||||
const i = ADD_KINDS.indexOf(state.kind);
|
||||
state.kind = ADD_KINDS[(i + 1) % ADD_KINDS.length]!;
|
||||
state.values = ADD_FIELDS[state.kind].map(() => "");
|
||||
return;
|
||||
}
|
||||
if (key.name === "enter") {
|
||||
state.field = 0;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key.name) {
|
||||
case "enter":
|
||||
if (state.field < fields.length - 1) {
|
||||
state.field += 1;
|
||||
return;
|
||||
}
|
||||
await this.commitAdd();
|
||||
return;
|
||||
case "tab":
|
||||
state.field = (state.field + 1) % fields.length;
|
||||
return;
|
||||
case "up":
|
||||
state.field = Math.max(0, state.field - 1);
|
||||
return;
|
||||
case "down":
|
||||
state.field = Math.min(fields.length - 1, state.field + 1);
|
||||
return;
|
||||
case "backspace":
|
||||
state.values[state.field] = state.values[state.field]!.slice(0, -1);
|
||||
return;
|
||||
case "char":
|
||||
state.values[state.field] += key.ch;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private move(delta: number): void {
|
||||
if (this.filtered.length === 0) return;
|
||||
this.cursor = Math.min(this.filtered.length - 1, Math.max(0, this.cursor + delta));
|
||||
}
|
||||
|
||||
private pageSize(): number {
|
||||
return Math.max(1, this.term.rows - 6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
private async copyCurrent(): Promise<void> {
|
||||
const row = this.current();
|
||||
if (!row) return;
|
||||
const secret = materializeSecret(
|
||||
this.vault!.index,
|
||||
row.id,
|
||||
this.vault!.index.entries[row.id] as Entry,
|
||||
this.vault!.mnemonic,
|
||||
);
|
||||
const result = await clipboardSink(secret.value);
|
||||
// Deliberately reports the descriptor, never the value.
|
||||
this.setStatus(`copied ${secret.descriptor} (${result.detail})`, "ok");
|
||||
}
|
||||
|
||||
private revealCurrent(): void {
|
||||
const row = this.current();
|
||||
if (!row) return;
|
||||
const secret = materializeSecret(
|
||||
this.vault!.index,
|
||||
row.id,
|
||||
this.vault!.index.entries[row.id] as Entry,
|
||||
this.vault!.mnemonic,
|
||||
);
|
||||
this.revealed = { label: row.label, value: secret.value, descriptor: secret.descriptor };
|
||||
this.screen = "reveal";
|
||||
}
|
||||
|
||||
private async toggleArchive(): Promise<void> {
|
||||
const row = this.current();
|
||||
if (!row) return;
|
||||
const wasArchived = row.archived;
|
||||
await this.mutate((vault) => {
|
||||
if (wasArchived) restoreEntry(vault.index, row.id);
|
||||
else archiveEntry(vault.index, row.id);
|
||||
});
|
||||
this.setStatus(`${wasArchived ? "unarchived" : "archived"} ${row.label}`, "ok");
|
||||
}
|
||||
|
||||
private async commitAdd(): Promise<void> {
|
||||
const state = this.add!;
|
||||
const fields = ADD_FIELDS[state.kind];
|
||||
const value = (name: string): string => state.values[fields.indexOf(name)]?.trim() ?? "";
|
||||
|
||||
const label = value("label");
|
||||
if (!label) {
|
||||
this.setStatus("label is required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let newId = "";
|
||||
await this.mutate((vault) => {
|
||||
switch (state.kind) {
|
||||
case "password": {
|
||||
const raw = value("length");
|
||||
const length = raw ? Number(raw) : 20;
|
||||
if (!Number.isInteger(length) || length < 8 || length > 128) {
|
||||
throw new Error("length must be a whole number between 8 and 128");
|
||||
}
|
||||
const username = value("username");
|
||||
newId = addPasswordEntry(vault.index, label, length, {
|
||||
...(username && { username }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "totp":
|
||||
newId = addTotpDeterministic(vault.index, label, vault.mnemonic);
|
||||
return;
|
||||
case "key-value": {
|
||||
const key = value("key");
|
||||
if (!key) throw new Error("key is required");
|
||||
newId = addKeyValueEntry(vault.index, label, key, value("value"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.add = null;
|
||||
this.screen = "list";
|
||||
// Land the cursor on what was just created.
|
||||
const at = this.filtered.findIndex((r) => r.id === newId);
|
||||
if (at >= 0) this.cursor = at;
|
||||
this.setStatus(`added ${refFor(newId)} ${label}`, "ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a change under the vault lock and re-read afterwards.
|
||||
*
|
||||
* The whole read-modify-write cycle is inside the lock, matching the CLI:
|
||||
* the TUI holds an in-memory index for the length of a session, so writing
|
||||
* it back without re-reading would clobber anything a concurrent
|
||||
* `seedpass-js` or a `nostr restore` wrote in the meantime.
|
||||
*/
|
||||
private async mutate(fn: (vault: OpenedVault) => void): Promise<void> {
|
||||
const path = this.vault!.path;
|
||||
const mnemonic = this.vault!.mnemonic;
|
||||
await withVaultLock(path, async () => {
|
||||
const fresh = await openVault(path, mnemonic);
|
||||
fn(fresh);
|
||||
await saveVaultHoldingLock(fresh);
|
||||
this.vault = fresh;
|
||||
});
|
||||
this.refreshRows();
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
this.vault = await openVault(this.vault!.path, this.vault!.mnemonic);
|
||||
this.refreshRows();
|
||||
this.setStatus("reloaded", "ok");
|
||||
}
|
||||
|
||||
private async switchProfile(fingerprint: string): Promise<void> {
|
||||
// A different profile is a different seed; the current one must not be
|
||||
// reused to open it.
|
||||
this.term.restore();
|
||||
try {
|
||||
const mnemonic = await this.resolveMnemonic(fingerprint);
|
||||
await this.loadVault(fingerprint, mnemonic);
|
||||
await this.app.switchProfile(fingerprint);
|
||||
this.cursor = 0;
|
||||
this.query = "";
|
||||
this.applyFilter();
|
||||
this.setStatus(`switched to ${this.profileNames[fingerprint] ?? fingerprint}`, "ok");
|
||||
} finally {
|
||||
this.term.start();
|
||||
}
|
||||
this.screen = "list";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render
|
||||
|
||||
private render(): void {
|
||||
if (this.done) return;
|
||||
const width = this.term.columns;
|
||||
const rows = this.term.rows;
|
||||
const lines: string[] = [];
|
||||
|
||||
const name = this.profileNames[this.fingerprint];
|
||||
const title = ` SeedPass ${ansi.dim}${name ? `${name} · ` : ""}${this.fingerprint}${ansi.reset}`;
|
||||
lines.push(`${ansi.reverse}${pad(title, width)}${ansi.reset}`);
|
||||
|
||||
switch (this.screen) {
|
||||
case "list":
|
||||
case "detail":
|
||||
this.renderList(lines, width, rows);
|
||||
break;
|
||||
case "reveal":
|
||||
this.renderReveal(lines, width);
|
||||
break;
|
||||
case "add":
|
||||
this.renderAdd(lines, width);
|
||||
break;
|
||||
case "help":
|
||||
this.renderHelp(lines);
|
||||
break;
|
||||
case "profiles":
|
||||
this.renderProfiles(lines);
|
||||
break;
|
||||
}
|
||||
|
||||
while (lines.length < rows - 1) lines.push("");
|
||||
lines.push(this.renderStatus(width));
|
||||
this.term.draw(lines.slice(0, rows));
|
||||
}
|
||||
|
||||
private renderList(lines: string[], width: number, rows: number): void {
|
||||
const detail = this.screen === "detail";
|
||||
const listHeight = Math.max(3, (detail ? Math.floor((rows - 4) / 2) : rows - 4));
|
||||
|
||||
// Keep the cursor inside the window.
|
||||
if (this.cursor < this.scroll) this.scroll = this.cursor;
|
||||
if (this.cursor >= this.scroll + listHeight) this.scroll = this.cursor - listHeight + 1;
|
||||
if (this.scroll > Math.max(0, this.filtered.length - listHeight)) {
|
||||
this.scroll = Math.max(0, this.filtered.length - listHeight);
|
||||
}
|
||||
|
||||
const header = this.searching
|
||||
? `${ansi.cyan}/${this.query}${ansi.reset}${ansi.dim}▌${ansi.reset}`
|
||||
: this.query
|
||||
? `${ansi.dim}filter:${ansi.reset} ${this.query} ${ansi.dim}(esc clears)${ansi.reset}`
|
||||
: `${ansi.dim}${this.filtered.length} entr${this.filtered.length === 1 ? "y" : "ies"}${
|
||||
this.showArchived ? " (incl. archived)" : ""
|
||||
}${ansi.reset}`;
|
||||
lines.push(` ${header}`);
|
||||
|
||||
if (this.filtered.length === 0) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${ansi.dim}${this.rows.length === 0 ? "No entries yet — press 'a' to add one." : "Nothing matches."}${ansi.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = this.scroll; i < Math.min(this.filtered.length, this.scroll + listHeight); i++) {
|
||||
const row = this.filtered[i]!;
|
||||
const selected = i === this.cursor;
|
||||
const marker = selected ? `${ansi.cyan}❯${ansi.reset}` : " ";
|
||||
const kind = pad(row.kind, 10);
|
||||
const label = row.archived ? `${ansi.dim}${row.label}${ansi.reset}` : row.label;
|
||||
const secretFlag = hasSecret(row.meta) ? `${ansi.dim}•${ansi.reset}` : " ";
|
||||
const line = ` ${marker} ${ansi.grey}${pad(refFor(row.id), 14)}${ansi.reset}${secretFlag} ${ansi.dim}${kind}${ansi.reset} ${label}`;
|
||||
lines.push(selected ? highlight(line, width) : line);
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
lines.push(`${ansi.dim}${"─".repeat(width)}${ansi.reset}`);
|
||||
this.renderDetail(lines);
|
||||
}
|
||||
}
|
||||
|
||||
private renderDetail(lines: string[]): void {
|
||||
const row = this.current();
|
||||
if (!row) return;
|
||||
// Straight from entryMetadata, which is the same redaction the CLI and
|
||||
// the agent's read scope use — secret fields arrive as has_* flags.
|
||||
for (const [k, v] of Object.entries(row.meta)) {
|
||||
if (v === "" || v === null || (Array.isArray(v) && v.length === 0)) continue;
|
||||
lines.push(` ${ansi.dim}${pad(k, 16)}${ansi.reset} ${formatValue(v)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderReveal(lines: string[], width: number): void {
|
||||
const r = this.revealed!;
|
||||
lines.push("");
|
||||
// materializeSecret's descriptor already names the entry ("password for
|
||||
// github.com"), so appending the label again read as a stutter.
|
||||
lines.push(` ${ansi.yellow}${r.descriptor}${ansi.reset}`);
|
||||
lines.push("");
|
||||
lines.push(` ${ansi.bold}${r.value}${ansi.reset}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${ansi.dim}On screen only until you press a key. Nothing is written to${ansi.reset}`,
|
||||
);
|
||||
lines.push(` ${ansi.dim}scrollback — this view disappears with the session.${ansi.reset}`);
|
||||
void width;
|
||||
}
|
||||
|
||||
private renderAdd(lines: string[], width: number): void {
|
||||
const state = this.add!;
|
||||
const fields = ADD_FIELDS[state.kind];
|
||||
lines.push("");
|
||||
const picker = ADD_KINDS.map((k) =>
|
||||
k === state.kind ? `${ansi.reverse} ${k} ${ansi.reset}` : ` ${ansi.dim}${k}${ansi.reset} `,
|
||||
).join(" ");
|
||||
lines.push(` ${state.field === -1 ? `${ansi.cyan}❯${ansi.reset}` : " "} ${picker}`);
|
||||
lines.push("");
|
||||
fields.forEach((field, i) => {
|
||||
const focused = state.field === i;
|
||||
const marker = focused ? `${ansi.cyan}❯${ansi.reset}` : " ";
|
||||
const raw = state.values[i] ?? "";
|
||||
// The key-value "value" is a secret being imported, so it is masked
|
||||
// like any other secret: the rule here is that nothing secret reaches
|
||||
// the screen without an explicit reveal, and a field being typed is no
|
||||
// exception. Check it afterwards with 'r'.
|
||||
const secretField = state.kind === "key-value" && field === "value";
|
||||
const shown = secretField ? "•".repeat(raw.length) : raw;
|
||||
const hint =
|
||||
field === "length" && !raw
|
||||
? `${ansi.dim}20${ansi.reset}`
|
||||
: secretField
|
||||
? `${ansi.dim}${raw ? "hidden" : "(optional, hidden as you type)"}${ansi.reset}`
|
||||
: "";
|
||||
lines.push(
|
||||
` ${marker} ${ansi.dim}${pad(field, 10)}${ansi.reset} ${shown}${focused ? `${ansi.cyan}▌${ansi.reset}` : ""} ${hint}`,
|
||||
);
|
||||
});
|
||||
lines.push("");
|
||||
if (state.kind === "password") {
|
||||
lines.push(
|
||||
` ${ansi.dim}The password is derived from your seed — it is never shown here${ansi.reset}`,
|
||||
);
|
||||
lines.push(` ${ansi.dim}and never stored. Use 'c' to copy it afterwards.${ansi.reset}`);
|
||||
}
|
||||
if (state.kind === "totp") {
|
||||
lines.push(` ${ansi.dim}A deterministic TOTP secret is derived at the next free index.${ansi.reset}`);
|
||||
}
|
||||
void width;
|
||||
}
|
||||
|
||||
private renderHelp(lines: string[]): void {
|
||||
const keys: [string, string][] = [
|
||||
["↑ ↓ / pgup pgdn", "move"],
|
||||
["enter", "open entry"],
|
||||
["/", "search (esc clears)"],
|
||||
["c", "copy secret to clipboard (never displayed)"],
|
||||
["r", "reveal secret on screen until a key is pressed"],
|
||||
["a", "add an entry"],
|
||||
["d", "archive / unarchive"],
|
||||
["A", "show or hide archived entries"],
|
||||
["p", "switch profile"],
|
||||
["g", "reload the vault from disk"],
|
||||
["?", "this help"],
|
||||
["q", "quit"],
|
||||
];
|
||||
lines.push("");
|
||||
for (const [k, what] of keys) lines.push(` ${ansi.cyan}${pad(k, 18)}${ansi.reset} ${what}`);
|
||||
lines.push("");
|
||||
lines.push(` ${ansi.dim}Entries show metadata only. 'c' and 'r' are the only ways${ansi.reset}`);
|
||||
lines.push(` ${ansi.dim}a secret leaves the vault, and both need a keypress.${ansi.reset}`);
|
||||
lines.push("");
|
||||
lines.push(` ${ansi.dim}press any key${ansi.reset}`);
|
||||
}
|
||||
|
||||
private renderProfiles(lines: string[]): void {
|
||||
lines.push("");
|
||||
this.profiles.forEach((fp, i) => {
|
||||
const marker = i === this.profileCursor ? `${ansi.cyan}❯${ansi.reset}` : " ";
|
||||
const active = fp === this.fingerprint ? `${ansi.green}●${ansi.reset}` : " ";
|
||||
lines.push(` ${marker} ${active} ${pad(fp, 20)} ${ansi.dim}${this.profileNames[fp] ?? ""}${ansi.reset}`);
|
||||
});
|
||||
lines.push("");
|
||||
lines.push(` ${ansi.dim}enter to switch (asks for that profile's password), esc to cancel${ansi.reset}`);
|
||||
}
|
||||
|
||||
private renderStatus(width: number): string {
|
||||
const hints =
|
||||
this.screen === "add"
|
||||
? "enter next · esc cancel"
|
||||
: this.screen === "reveal"
|
||||
? "any key hides"
|
||||
: "/ search · c copy · r reveal · a add · ? help · q quit";
|
||||
const colour =
|
||||
this.statusKind === "error" ? ansi.red : this.statusKind === "ok" ? ansi.green : ansi.dim;
|
||||
const left = this.status ? `${colour}${this.status}${ansi.reset}` : `${ansi.dim}${hints}${ansi.reset}`;
|
||||
return ` ${truncate(left, width - 2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
function hasSecret(meta: Record<string, unknown>): boolean {
|
||||
return Object.entries(meta).some(([k, v]) => k.startsWith("has_") && v === true);
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function pad(s: string, width: number): string {
|
||||
const len = visibleLength(s);
|
||||
return len >= width ? s : s + " ".repeat(width - len);
|
||||
}
|
||||
|
||||
function truncate(s: string, width: number): string {
|
||||
return visibleLength(s) <= width ? s : s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
function highlight(line: string, width: number): string {
|
||||
return `${ansi.reverse}${pad(truncate(line, width), width)}${ansi.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads passwords with echo off.
|
||||
*
|
||||
* Raw mode is entered once and held for the whole sequence rather than per
|
||||
* prompt. Toggling it around each read re-enables echo between attempts, so
|
||||
* anything typed in that window — a retried password after a typo, most
|
||||
* likely — was echoed to the screen in clear text.
|
||||
*
|
||||
* Written against the tty directly rather than readline, whose line editing
|
||||
* echoes what it receives.
|
||||
*/
|
||||
class HiddenPrompt {
|
||||
private readonly wasRaw: boolean;
|
||||
private closed = false;
|
||||
|
||||
constructor() {
|
||||
this.wasRaw = process.stdin.isRaw ?? false;
|
||||
process.stdin.setRawMode?.(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
}
|
||||
|
||||
ask(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
process.stderr.write(prompt);
|
||||
let value = "";
|
||||
const finish = (result: string | null): void => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stderr.write("\n");
|
||||
resolve(result);
|
||||
};
|
||||
const onData = (chunk: string): void => {
|
||||
for (const ch of chunk) {
|
||||
if (ch === "\r" || ch === "\n") return finish(value);
|
||||
if (ch === "\x03") return finish(null);
|
||||
if (ch === "\x7f" || ch === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (ch >= " ") value += ch;
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
process.stdin.setRawMode?.(this.wasRaw);
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export { isValidMnemonic };
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Terminal control for the interactive mode: raw input, the alternate screen,
|
||||
* and — above all — putting the terminal back the way we found it.
|
||||
*
|
||||
* Two properties this file is responsible for:
|
||||
*
|
||||
* 1. **The shell survives.** Raw mode with the cursor hidden is a broken
|
||||
* terminal: no echo, no line editing, no visible cursor. If we exit
|
||||
* without restoring — including on a crash or a signal — the user's shell
|
||||
* is left unusable and `reset` is the only way out. Restoration is
|
||||
* therefore idempotent and wired to every exit path there is.
|
||||
*
|
||||
* 2. **Secrets do not reach scrollback.** Everything is drawn on the
|
||||
* alternate screen buffer, which the terminal discards on exit. A revealed
|
||||
* password cannot be recovered by scrolling up after quitting, and never
|
||||
* lands in a terminal-logging setup that captures the main buffer.
|
||||
*
|
||||
* No dependencies: this is a security tool, and a TUI framework would pull a
|
||||
* React runtime and its transitive tree into the process that handles seed
|
||||
* phrases.
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
import type { ReadStream } from "node:tty";
|
||||
|
||||
export const ESC = "\x1b";
|
||||
const CSI = `${ESC}[`;
|
||||
|
||||
export const ansi = {
|
||||
reset: `${CSI}0m`,
|
||||
bold: `${CSI}1m`,
|
||||
dim: `${CSI}2m`,
|
||||
italic: `${CSI}3m`,
|
||||
underline: `${CSI}4m`,
|
||||
reverse: `${CSI}7m`,
|
||||
red: `${CSI}31m`,
|
||||
green: `${CSI}32m`,
|
||||
yellow: `${CSI}33m`,
|
||||
blue: `${CSI}34m`,
|
||||
magenta: `${CSI}35m`,
|
||||
cyan: `${CSI}36m`,
|
||||
grey: `${CSI}90m`,
|
||||
} as const;
|
||||
|
||||
/** Strip SGR sequences so width maths counts glyphs, not escape bytes. */
|
||||
export function visibleLength(s: string): number {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
|
||||
export interface Key {
|
||||
/** Symbolic name for non-printing keys, else "char". */
|
||||
name:
|
||||
| "char"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "enter"
|
||||
| "backspace"
|
||||
| "delete"
|
||||
| "tab"
|
||||
| "escape"
|
||||
| "home"
|
||||
| "end"
|
||||
| "pageup"
|
||||
| "pagedown"
|
||||
| "ctrl-c"
|
||||
| "ctrl-d"
|
||||
| "unknown";
|
||||
/** The character, for name === "char". */
|
||||
ch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one input chunk into keys.
|
||||
*
|
||||
* A chunk can hold several keypresses (fast typing, or a paste), so this
|
||||
* returns a list rather than a single key — dropping the remainder would
|
||||
* silently swallow input.
|
||||
*/
|
||||
export function decodeKeys(data: string): Key[] {
|
||||
const keys: Key[] = [];
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const ch = data[i]!;
|
||||
|
||||
if (ch === ESC) {
|
||||
const rest = data.slice(i);
|
||||
const seq = matchEscape(rest);
|
||||
if (seq) {
|
||||
keys.push(seq.key);
|
||||
i += seq.length;
|
||||
continue;
|
||||
}
|
||||
keys.push({ name: "escape", ch: "" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case "\x03":
|
||||
keys.push({ name: "ctrl-c", ch: "" });
|
||||
break;
|
||||
case "\x04":
|
||||
keys.push({ name: "ctrl-d", ch: "" });
|
||||
break;
|
||||
case "\r":
|
||||
case "\n":
|
||||
keys.push({ name: "enter", ch: "" });
|
||||
break;
|
||||
case "\x7f":
|
||||
case "\b":
|
||||
keys.push({ name: "backspace", ch: "" });
|
||||
break;
|
||||
case "\t":
|
||||
keys.push({ name: "tab", ch: "" });
|
||||
break;
|
||||
default:
|
||||
// Ignore remaining C0 controls; they are not text and have no binding.
|
||||
if (ch >= " ") keys.push({ name: "char", ch });
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function matchEscape(s: string): { key: Key; length: number } | null {
|
||||
const table: Record<string, Key["name"]> = {
|
||||
"[A": "up",
|
||||
"[B": "down",
|
||||
"[C": "right",
|
||||
"[D": "left",
|
||||
"[H": "home",
|
||||
"[F": "end",
|
||||
"OA": "up",
|
||||
"OB": "down",
|
||||
"OC": "right",
|
||||
"OD": "left",
|
||||
"OH": "home",
|
||||
"OF": "end",
|
||||
"[5~": "pageup",
|
||||
"[6~": "pagedown",
|
||||
"[3~": "delete",
|
||||
"[1~": "home",
|
||||
"[4~": "end",
|
||||
"[7~": "home",
|
||||
"[8~": "end",
|
||||
};
|
||||
for (const [seq, name] of Object.entries(table)) {
|
||||
if (s.startsWith(ESC + seq)) return { key: { name, ch: "" }, length: seq.length + 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class Terminal {
|
||||
private restored = false;
|
||||
private readonly cleanup: () => void;
|
||||
private onKeyHandler: ((key: Key) => void) | null = null;
|
||||
private onResizeHandler: (() => void) | null = null;
|
||||
private readonly stdin: ReadStream;
|
||||
|
||||
constructor() {
|
||||
this.stdin = process.stdin as ReadStream;
|
||||
this.cleanup = () => this.restore();
|
||||
}
|
||||
|
||||
static isInteractive(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return process.stdout.columns ?? 80;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return process.stdout.rows ?? 24;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Alternate screen first, so nothing we draw touches the scrollback the
|
||||
// user keeps.
|
||||
this.write(`${CSI}?1049h${CSI}?25l`);
|
||||
this.stdin.setRawMode?.(true);
|
||||
this.stdin.resume();
|
||||
this.stdin.setEncoding("utf8");
|
||||
|
||||
this.stdin.on("data", this.handleData);
|
||||
process.stdout.on("resize", this.handleResize);
|
||||
|
||||
// Every path out of the process restores the terminal. "exit" covers
|
||||
// normal returns and process.exit; the signals cover Ctrl-C at the OS
|
||||
// level and `kill`; the exception handlers cover our own bugs, which is
|
||||
// exactly when a broken terminal is least welcome.
|
||||
process.on("exit", this.cleanup);
|
||||
process.on("SIGINT", this.onSignal);
|
||||
process.on("SIGTERM", this.onSignal);
|
||||
process.on("SIGHUP", this.onSignal);
|
||||
process.on("uncaughtException", this.onFatal);
|
||||
process.on("unhandledRejection", this.onFatal);
|
||||
}
|
||||
|
||||
private readonly handleData = (chunk: string): void => {
|
||||
if (!this.onKeyHandler) return;
|
||||
for (const key of decodeKeys(chunk)) this.onKeyHandler(key);
|
||||
};
|
||||
|
||||
private readonly handleResize = (): void => {
|
||||
this.onResizeHandler?.();
|
||||
};
|
||||
|
||||
private readonly onSignal = (): void => {
|
||||
this.restore();
|
||||
process.exit(130);
|
||||
};
|
||||
|
||||
private readonly onFatal = (err: unknown): void => {
|
||||
this.restore();
|
||||
// Report on the real screen, after the alternate one is gone, or the
|
||||
// message is discarded along with it.
|
||||
process.stderr.write(
|
||||
`seedpass-js: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
onKey(handler: (key: Key) => void): void {
|
||||
this.onKeyHandler = handler;
|
||||
}
|
||||
|
||||
onResize(handler: () => void): void {
|
||||
this.onResizeHandler = handler;
|
||||
}
|
||||
|
||||
write(s: string): void {
|
||||
process.stdout.write(s);
|
||||
}
|
||||
|
||||
/** Redraw the whole frame. Cheap enough at this size, and never tears. */
|
||||
draw(lines: string[]): void {
|
||||
const rows = this.rows;
|
||||
const visible = lines.slice(0, rows);
|
||||
// Home, then clear each line as we go: clearing the whole screen first
|
||||
// makes the display flicker on slower terminals.
|
||||
let out = `${CSI}H`;
|
||||
for (let i = 0; i < rows; i++) {
|
||||
out += `${CSI}${i + 1};1H${CSI}2K`;
|
||||
if (i < visible.length) out += visible[i];
|
||||
}
|
||||
this.write(out);
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
if (this.restored) return;
|
||||
this.restored = true;
|
||||
try {
|
||||
this.stdin.setRawMode?.(false);
|
||||
this.stdin.pause();
|
||||
this.stdin.removeListener("data", this.handleData);
|
||||
process.stdout.removeListener("resize", this.handleResize);
|
||||
process.removeListener("exit", this.cleanup);
|
||||
process.removeListener("SIGINT", this.onSignal);
|
||||
process.removeListener("SIGTERM", this.onSignal);
|
||||
process.removeListener("SIGHUP", this.onSignal);
|
||||
process.removeListener("uncaughtException", this.onFatal);
|
||||
process.removeListener("unhandledRejection", this.onFatal);
|
||||
} finally {
|
||||
// Show the cursor and leave the alternate screen even if unwinding the
|
||||
// listeners threw: this is the part the user's shell depends on.
|
||||
this.write(`${CSI}?25h${CSI}?1049l`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,23 +435,27 @@ describe("document import/export", () => {
|
||||
});
|
||||
|
||||
describe("exit behaviour", () => {
|
||||
it("does not report help output as an error", async () => {
|
||||
// exitOverride() makes commander throw when it prints help, so a bare
|
||||
// invocation printed the help and then claimed to have failed with
|
||||
// "(outputHelp)".
|
||||
it("prints help without reporting it as an error when stdout is not a terminal", async () => {
|
||||
// Bare invocation launches the interactive mode on a terminal, but there
|
||||
// is no terminal to drive here, so it falls back to the help. Two bugs
|
||||
// met on this path: exitOverride() makes commander throw when it prints
|
||||
// help, and the handler reported that throw as "error: (outputHelp)".
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
|
||||
let thrown: { code?: string; exitCode?: number } | undefined;
|
||||
const previous = process.exitCode;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await buildProgram(io).parseAsync(["node", "seedpass-js"]);
|
||||
} catch (e) {
|
||||
thrown = e as { code?: string; exitCode?: number };
|
||||
thrown = e;
|
||||
}
|
||||
// commander signals help with its own code; main.ts must treat any
|
||||
// commander.* code as commander's business, not as a SeedPass error.
|
||||
expect(thrown?.code).toMatch(/^commander\./);
|
||||
expect(err.join("\n")).not.toContain("outputHelp");
|
||||
const printed = [...out, ...err].join("\n");
|
||||
expect(thrown).toBeUndefined();
|
||||
expect(printed).toContain("Usage: seedpass-js");
|
||||
expect(printed).not.toContain("outputHelp");
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = previous;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Interactive-mode input decoding and layout helpers.
|
||||
*
|
||||
* The screens themselves are exercised through a real pty in
|
||||
* `test/tui-pty.test.ts`; this file covers the pure logic, where the
|
||||
* interesting failures are silent: a dropped keypress, or a width
|
||||
* calculation that counts escape bytes as visible characters and wraps every
|
||||
* highlighted row.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeKeys, visibleLength, ansi, ESC } from "../src/tui/terminal.js";
|
||||
|
||||
describe("decodeKeys", () => {
|
||||
it("decodes printable characters", () => {
|
||||
expect(decodeKeys("abc")).toEqual([
|
||||
{ name: "char", ch: "a" },
|
||||
{ name: "char", ch: "b" },
|
||||
{ name: "char", ch: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("decodes arrow keys in both cursor modes", () => {
|
||||
// Terminals send SS3 (ESC O A) in application cursor mode and CSI
|
||||
// (ESC [ A) otherwise; handling only one leaves arrows dead in some
|
||||
// terminals and not others.
|
||||
expect(decodeKeys(`${ESC}[A`)[0]!.name).toBe("up");
|
||||
expect(decodeKeys(`${ESC}OA`)[0]!.name).toBe("up");
|
||||
expect(decodeKeys(`${ESC}[B`)[0]!.name).toBe("down");
|
||||
expect(decodeKeys(`${ESC}[D`)[0]!.name).toBe("left");
|
||||
});
|
||||
|
||||
it("decodes navigation and editing keys", () => {
|
||||
expect(decodeKeys("\r")[0]!.name).toBe("enter");
|
||||
expect(decodeKeys("\n")[0]!.name).toBe("enter");
|
||||
expect(decodeKeys("\x7f")[0]!.name).toBe("backspace");
|
||||
expect(decodeKeys("\t")[0]!.name).toBe("tab");
|
||||
expect(decodeKeys("\x03")[0]!.name).toBe("ctrl-c");
|
||||
expect(decodeKeys(`${ESC}[5~`)[0]!.name).toBe("pageup");
|
||||
expect(decodeKeys(`${ESC}[6~`)[0]!.name).toBe("pagedown");
|
||||
expect(decodeKeys(`${ESC}[H`)[0]!.name).toBe("home");
|
||||
});
|
||||
|
||||
it("treats a lone escape as escape", () => {
|
||||
expect(decodeKeys(ESC)).toEqual([{ name: "escape", ch: "" }]);
|
||||
});
|
||||
|
||||
it("decodes every keypress in a chunk, not just the first", () => {
|
||||
// Fast typing and pastes arrive as one chunk. Returning a single key
|
||||
// silently drops the rest of what the user typed.
|
||||
const keys = decodeKeys(`ab${ESC}[Bc\r`);
|
||||
expect(keys.map((k) => k.name)).toEqual(["char", "char", "down", "char", "enter"]);
|
||||
expect(keys.map((k) => k.ch).join("")).toBe("abc");
|
||||
});
|
||||
|
||||
it("ignores control bytes that are not bound to anything", () => {
|
||||
expect(decodeKeys("\x01\x02")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports editing keys in sequence rather than applying them", () => {
|
||||
// The decoder's job is to say what was pressed; the screen decides what
|
||||
// a backspace means. Keeping that split is why the same decoder serves
|
||||
// the search box and the add form.
|
||||
const keys = decodeKeys("git\x7fhub");
|
||||
expect(keys.map((k) => k.name)).toEqual([
|
||||
"char",
|
||||
"char",
|
||||
"char",
|
||||
"backspace",
|
||||
"char",
|
||||
"char",
|
||||
"char",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleLength", () => {
|
||||
it("counts glyphs, not escape sequences", () => {
|
||||
expect(visibleLength("abc")).toBe(3);
|
||||
expect(visibleLength(`${ansi.bold}abc${ansi.reset}`)).toBe(3);
|
||||
expect(visibleLength(`${ansi.dim}${ansi.red}x${ansi.reset}`)).toBe(1);
|
||||
});
|
||||
|
||||
it("is what keeps a highlighted row from wrapping", () => {
|
||||
// A styled row is much longer in bytes than on screen; padding to a
|
||||
// terminal width using raw .length would overflow every line.
|
||||
const styled = `${ansi.reverse}${ansi.cyan}entry${ansi.reset}`;
|
||||
expect(styled.length).toBeGreaterThan(visibleLength(styled));
|
||||
expect(visibleLength(styled)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* The interactive screens, driven through a fake terminal.
|
||||
*
|
||||
* The load-bearing assertions are the same agent-blind properties the CLI is
|
||||
* held to (plan section 9.3), because the TUI is now the default entry point
|
||||
* and renders the whole vault by itself:
|
||||
*
|
||||
* - no screen shows a secret until `r` is pressed
|
||||
* - `c` copies without ever drawing the value
|
||||
* - dismissing a reveal removes the value from the next frame
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { mnemonics } from "@seedpass/test-vectors";
|
||||
import {
|
||||
generateFingerprint,
|
||||
deriveIndexKeyBytes,
|
||||
encryptV3,
|
||||
utf8,
|
||||
addPasswordEntry,
|
||||
addKeyValueEntry,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
import { runTui, type TerminalLike } from "../src/tui/app.js";
|
||||
import { decodeKeys, visibleLength, type Key } from "../src/tui/terminal.js";
|
||||
import { AppDir, INDEX_FILENAME } from "../src/appDir.js";
|
||||
|
||||
const MNEMONIC = mnemonics["abandon12"]!;
|
||||
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
||||
const SECRET_VALUE = "tui-stored-secret-value";
|
||||
|
||||
let appDir: string;
|
||||
|
||||
/** A terminal that records frames and lets a test press keys. */
|
||||
class FakeTerminal implements TerminalLike {
|
||||
readonly columns = 100;
|
||||
readonly rows = 30;
|
||||
frames: string[][] = [];
|
||||
private handler: ((key: Key) => void) | null = null;
|
||||
restored = false;
|
||||
|
||||
start(): void {}
|
||||
restore(): void {
|
||||
this.restored = true;
|
||||
}
|
||||
onKey(handler: (key: Key) => void): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
onResize(): void {}
|
||||
draw(lines: string[]): void {
|
||||
this.frames.push(lines);
|
||||
}
|
||||
|
||||
/** Feed keystrokes the way the real decoder would produce them. */
|
||||
async press(input: string): Promise<void> {
|
||||
for (const key of decodeKeys(input)) {
|
||||
// Sample the frame count BEFORE dispatching: a key handled entirely
|
||||
// synchronously (dismissing the reveal or help overlay) has already
|
||||
// drawn by the time the handler returns, and sampling afterwards made
|
||||
// settle() wait out its deadline for a second frame that never came.
|
||||
const before = this.frames.length;
|
||||
this.handler?.(key);
|
||||
await this.settle(before);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the keypress to actually reach the screen, then for the screen
|
||||
* to stop changing.
|
||||
*
|
||||
* Waiting only for "no change for a while" is not enough, and produced a
|
||||
* genuinely confusing hang: a mutation reopens the vault, which re-derives
|
||||
* the index key through PBKDF2 and takes about a second, during which
|
||||
* nothing is drawn at all. A quiet-only wait returned immediately, the next
|
||||
* keystroke landed on the screen that was still up, and the test typed its
|
||||
* "q" into a form field instead of quitting.
|
||||
*
|
||||
* Every handled key ends in a render, so waiting for one new frame is the
|
||||
* reliable signal. Quitting is the exception — it restores the terminal
|
||||
* instead of drawing — hence the second exit condition.
|
||||
*/
|
||||
private async settle(before: number, quietMs = 60, timeoutMs = 15_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (this.frames.length === before && !this.restored && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
let seen = this.frames.length;
|
||||
let quietSince = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
if (this.frames.length !== seen) {
|
||||
seen = this.frames.length;
|
||||
quietSince = Date.now();
|
||||
continue;
|
||||
}
|
||||
if (Date.now() - quietSince >= quietMs) return;
|
||||
}
|
||||
}
|
||||
|
||||
get lastFrame(): string {
|
||||
return (this.frames.at(-1) ?? []).join("\n");
|
||||
}
|
||||
|
||||
/** Everything ever drawn — for "this never appeared on screen" checks. */
|
||||
get everything(): string {
|
||||
return this.frames.flat().join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildVault(): Promise<void> {
|
||||
appDir = await mkdtemp(join(tmpdir(), "seedpass-tui-"));
|
||||
const app = new AppDir(appDir);
|
||||
const index: VaultIndex = { schema_version: 4, entries: {} } as VaultIndex;
|
||||
addPasswordEntry(index, "github.com", 20, { username: "adam" });
|
||||
addPasswordEntry(index, "gitlab.com", 16, {});
|
||||
addKeyValueEntry(index, "ci-token", "CI_TOKEN", SECRET_VALUE);
|
||||
addPasswordEntry(index, "aws-prod", 24, {});
|
||||
|
||||
const dir = app.profileDir(FINGERPRINT);
|
||||
await app.mutateFingerprints((data) => {
|
||||
data.fingerprints.push(FINGERPRINT);
|
||||
data.names[FINGERPRINT] = "daily";
|
||||
data.last_used = FINGERPRINT;
|
||||
});
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
join(dir, INDEX_FILENAME),
|
||||
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
|
||||
);
|
||||
}
|
||||
|
||||
/** Start the TUI and return once it has drawn its first frame. */
|
||||
async function launch(): Promise<{ term: FakeTerminal; exit: Promise<number> }> {
|
||||
const term = new FakeTerminal();
|
||||
const exit = runTui({ appDir }, term);
|
||||
// Let unlock + first render complete.
|
||||
for (let i = 0; i < 50 && term.frames.length === 0; i++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
return { term, exit };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await buildVault();
|
||||
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["SEEDPASS_MNEMONIC"];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("list screen", () => {
|
||||
it("shows every entry as a reference with its kind, and no secret", async () => {
|
||||
const { term, exit } = await launch();
|
||||
expect(term.lastFrame).toContain("sp://entry/0");
|
||||
expect(term.lastFrame).toContain("github.com");
|
||||
expect(term.lastFrame).toContain("ci-token");
|
||||
expect(term.lastFrame).toContain("4 entries");
|
||||
// The one entry with a stored secret must not render it anywhere.
|
||||
expect(term.everything).not.toContain(SECRET_VALUE);
|
||||
await term.press("q");
|
||||
expect(await exit).toBe(0);
|
||||
});
|
||||
|
||||
it("filters as you type and clears on escape", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("/git");
|
||||
expect(term.lastFrame).toContain("github.com");
|
||||
expect(term.lastFrame).toContain("gitlab.com");
|
||||
expect(term.lastFrame).not.toContain("aws-prod");
|
||||
await term.press("\x1b");
|
||||
expect(term.lastFrame).toContain("aws-prod");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("moves the selection with the arrow keys", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("\x1b[B\x1b[B");
|
||||
await term.press("\r");
|
||||
// Detail of the third entry, reached by two downs and enter.
|
||||
expect(term.lastFrame).toContain("ci-token");
|
||||
expect(term.lastFrame).toContain("CI_TOKEN");
|
||||
expect(term.lastFrame).toContain("has_value");
|
||||
await term.press("q");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret handling", () => {
|
||||
it("shows a stored secret only after r, and hides it again on the next key", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("\x1b[B\x1b[B"); // to ci-token
|
||||
const beforeReveal = term.frames.length;
|
||||
expect(term.everything).not.toContain(SECRET_VALUE);
|
||||
|
||||
await term.press("r");
|
||||
expect(term.lastFrame).toContain(SECRET_VALUE);
|
||||
|
||||
await term.press(" ");
|
||||
// Gone from the current frame; only the frames drawn while revealing
|
||||
// ever contained it.
|
||||
expect(term.lastFrame).not.toContain(SECRET_VALUE);
|
||||
const revealFrames = term.frames
|
||||
.slice(beforeReveal)
|
||||
.filter((f) => f.join("\n").includes(SECRET_VALUE));
|
||||
expect(revealFrames).toHaveLength(1);
|
||||
|
||||
// Dismissing lands on the detail screen, where q means "back"; a second
|
||||
// q leaves the app.
|
||||
await term.press("qq");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("copies without drawing the value", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("\x1b[B\x1b[B");
|
||||
await term.press("c");
|
||||
// Either the clipboard tool is present or it is not; what matters is
|
||||
// that neither outcome puts the secret on screen.
|
||||
expect(term.everything).not.toContain(SECRET_VALUE);
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("masks a secret being typed into the add form", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("a");
|
||||
await term.press("\x1b[C\x1b[C"); // pick key-value
|
||||
await term.press("\r");
|
||||
await term.press("label-x\rKEY\rhunter2xyz");
|
||||
expect(term.lastFrame).not.toContain("hunter2xyz");
|
||||
expect(term.lastFrame).toContain("•".repeat("hunter2xyz".length));
|
||||
await term.press("\x1b");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutations", () => {
|
||||
it("adds a password entry and persists it to the vault", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("a");
|
||||
await term.press("\r"); // password kind
|
||||
await term.press("new-site.example\r24\rbob\r");
|
||||
expect(term.lastFrame).toContain("new-site.example");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
|
||||
// Re-open from disk: the TUI must have written, not just redrawn.
|
||||
const { openVault } = await import("../src/vaultFile.js");
|
||||
const reopened = await openVault(
|
||||
join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME),
|
||||
MNEMONIC,
|
||||
);
|
||||
const labels = Object.values(reopened.index.entries).map((e) => e.label);
|
||||
expect(labels).toContain("new-site.example");
|
||||
});
|
||||
|
||||
it("rejects a nonsense password length instead of storing it", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("a");
|
||||
await term.press("\r");
|
||||
await term.press("bad-length\rnot-a-number\r\r");
|
||||
// NaN once reached the vault through a --length flag and made the index
|
||||
// unreadable; the form must refuse the same input.
|
||||
expect(term.lastFrame).toContain("whole number");
|
||||
await term.press("\x1b");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("archives and unarchives the selected entry", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("d");
|
||||
expect(term.lastFrame).toContain("archived github.com");
|
||||
expect(term.lastFrame).toContain("3 entries");
|
||||
await term.press("A");
|
||||
expect(term.lastFrame).toContain("4 entries");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
});
|
||||
|
||||
describe("chrome", () => {
|
||||
it("lists the keys in help", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("?");
|
||||
expect(term.lastFrame).toContain("copy secret to clipboard");
|
||||
expect(term.lastFrame).toContain("quit");
|
||||
await term.press(" ");
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("never draws a line wider than the terminal", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("/git");
|
||||
await term.press("\r");
|
||||
for (const frame of term.frames) {
|
||||
for (const line of frame) {
|
||||
// Counting glyphs, not escape bytes — a styled row that overflows
|
||||
// wraps and corrupts every frame after it.
|
||||
expect(visibleLength(line)).toBeLessThanOrEqual(term.columns);
|
||||
}
|
||||
}
|
||||
await term.press("q");
|
||||
await exit;
|
||||
});
|
||||
|
||||
it("restores the terminal on exit", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("q");
|
||||
await exit;
|
||||
expect(term.restored).toBe(true);
|
||||
});
|
||||
|
||||
it("exits 130 on ctrl-c, as a signal-terminated program should", async () => {
|
||||
const { term, exit } = await launch();
|
||||
await term.press("\x03");
|
||||
expect(await exit).toBe(130);
|
||||
expect(term.restored).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user