From 6f34f75f0a1bdc9efb44242491468a51211a0cae Mon Sep 17 00:00:00 2001
From: David Nussio
Date: Fri, 27 Mar 2026 09:06:55 +0100
Subject: [PATCH 1/4] feat(tui): add interactive TUI components and main menu
view
- Implement reusable TUI components for rendering headers, menus, tables, and messages.
- Create entry point for TUI that wires the interactive UI to SecretStore.
- Develop main menu view with navigation and context management.
- Add functionality for viewing, adding, searching, and exporting secrets.
- Implement context management features including listing, deleting, and switching contexts.
- Introduce audit view for expiring secrets and import/export functionality for .env files.
---
packages/cli/src/cli/tui.ts | 12 +
src/tui/components.ts | 196 +++++++
src/tui/index.ts | 23 +
src/tui/terminal.ts | 228 ++++++++
src/tui/views.ts | 1014 +++++++++++++++++++++++++++++++++++
5 files changed, 1473 insertions(+)
create mode 100644 packages/cli/src/cli/tui.ts
create mode 100644 src/tui/components.ts
create mode 100644 src/tui/index.ts
create mode 100644 src/tui/terminal.ts
create mode 100644 src/tui/views.ts
diff --git a/packages/cli/src/cli/tui.ts b/packages/cli/src/cli/tui.ts
new file mode 100644
index 0000000..3ce9ca1
--- /dev/null
+++ b/packages/cli/src/cli/tui.ts
@@ -0,0 +1,12 @@
+import { Command } from "@effect/cli";
+import { Effect, Option } from "effect";
+import { runTUI } from "../tui/index.js";
+import { optionalContext } from "./root.js";
+
+export const tuiCommand = Command.make("tui", {}, () =>
+ Effect.gen(function* () {
+ const context = yield* optionalContext;
+ const ctx = Option.isSome(context) ? context.value : null;
+ yield* runTUI(ctx);
+ })
+);
diff --git a/src/tui/components.ts b/src/tui/components.ts
new file mode 100644
index 0000000..21096ff
--- /dev/null
+++ b/src/tui/components.ts
@@ -0,0 +1,196 @@
+/**
+ * Reusable TUI components: menus, lists, status bar, etc.
+ */
+
+import { c, cursor, getSize, screen, write, writeLine } from "./terminal.js";
+
+// ── Header ──────────────────────────────────────────────────────────
+
+export const renderHeader = (
+ context: string | null,
+ title: string,
+ startRow = 1
+): number => {
+ const { cols } = getSize();
+ const ctx = context ? c.cyan(`[${context}]`) : c.dim("[no context]");
+ const line = `${c.bold(c.green("🔒 envsec"))} ${c.dim("›")} ${c.bold(title)} ${ctx}`;
+ writeLine(startRow, ` ${line}`);
+ writeLine(startRow + 1, ` ${c.dim("─".repeat(Math.min(cols - 2, 60)))}`);
+ return startRow + 2;
+};
+
+// ── Menu list ───────────────────────────────────────────────────────
+
+export interface MenuItem {
+ hint?: string;
+ icon?: string;
+ key: string;
+ label: string;
+}
+
+export const renderMenu = (
+ items: MenuItem[],
+ selected: number,
+ startRow: number,
+ maxVisible?: number
+): number => {
+ const visible = maxVisible ?? items.length;
+ const { rows } = getSize();
+ const safeVisible = Math.min(visible, rows - startRow - 3);
+
+ let offset = 0;
+ if (selected >= offset + safeVisible) {
+ offset = selected - safeVisible + 1;
+ }
+ if (selected < offset) {
+ offset = selected;
+ }
+
+ let row = startRow;
+ for (let i = offset; i < Math.min(items.length, offset + safeVisible); i++) {
+ const item = items[i];
+ if (!item) {
+ continue;
+ }
+ const isSelected = i === selected;
+ const prefix = isSelected ? c.cyan("❯") : " ";
+ const icon = item.icon ?? "";
+ const label = isSelected ? c.bold(c.cyan(item.label)) : item.label;
+ const hint = item.hint ? ` ${c.dim(item.hint)}` : "";
+ writeLine(row, ` ${prefix} ${icon}${icon ? " " : ""}${label}${hint}`);
+ row++;
+ }
+
+ // Clear remaining lines
+ for (let r = row; r < startRow + safeVisible; r++) {
+ writeLine(r, "");
+ }
+
+ return row;
+};
+
+// ── Table ───────────────────────────────────────────────────────────
+
+export interface TableColumn {
+ align?: "left" | "right";
+ header: string;
+ width: number;
+}
+
+const calcOffset = (selected: number, visible: number): number => {
+ if (selected >= visible) {
+ return selected - visible + 1;
+ }
+ return 0;
+};
+
+export const renderTable = (
+ columns: TableColumn[],
+ rows: string[][],
+ selected: number,
+ startRow: number,
+ maxVisible?: number
+): number => {
+ const { rows: termRows, cols } = getSize();
+ const visible = Math.min(maxVisible ?? rows.length, termRows - startRow - 3);
+
+ // Header
+ let headerLine = " ";
+ for (const col of columns) {
+ const text = col.header.padEnd(col.width).slice(0, col.width);
+ headerLine += `${c.bold(c.dim(text))} `;
+ }
+ writeLine(startRow, headerLine);
+ writeLine(startRow + 1, ` ${c.dim("─".repeat(Math.min(cols - 2, 70)))}`);
+
+ const offset = calcOffset(selected, visible);
+
+ let row = startRow + 2;
+ for (let i = offset; i < Math.min(rows.length, offset + visible); i++) {
+ const data = rows[i];
+ if (!data) {
+ continue;
+ }
+ const isSelected = i === selected;
+ const prefix = isSelected ? c.cyan("❯") : " ";
+ const line = formatTableRow(data, columns, isSelected);
+ writeLine(row, ` ${prefix} ${line}`);
+ row++;
+ }
+
+ for (let r = row; r < startRow + 2 + visible; r++) {
+ writeLine(r, "");
+ }
+
+ return row;
+};
+
+const formatTableRow = (
+ data: string[],
+ columns: TableColumn[],
+ isSelected: boolean
+): string => {
+ let line = "";
+ for (let j = 0; j < columns.length; j++) {
+ const col = columns[j];
+ if (!col) {
+ continue;
+ }
+ const cell = (data[j] ?? "").slice(0, col.width);
+ const padded =
+ col.align === "right" ? cell.padStart(col.width) : cell.padEnd(col.width);
+ line += `${isSelected ? c.cyan(padded) : padded} `;
+ }
+ return line;
+};
+
+// ── Status bar / footer ─────────────────────────────────────────────
+
+export const renderFooter = (hints: string[], row?: number): void => {
+ const { rows } = getSize();
+ const r = row ?? rows;
+ const text = hints.join(c.dim(" │ "));
+ writeLine(r, ` ${c.dim(text)}`);
+};
+
+// ── Message / toast ─────────────────────────────────────────────────
+
+export const renderMessage = (
+ row: number,
+ msg: string,
+ type: "success" | "error" | "info" | "warning" = "info"
+): void => {
+ let icon: string;
+ if (type === "success") {
+ icon = c.green("✔");
+ } else if (type === "error") {
+ icon = c.red("✖");
+ } else if (type === "warning") {
+ icon = c.yellow("⚠");
+ } else {
+ icon = c.blue("ℹ");
+ }
+ writeLine(row, ` ${icon} ${msg}`);
+};
+
+// ── Screen management ───────────────────────────────────────────────
+
+export const enterTUI = (): void => {
+ write(screen.altBuffer);
+ write(cursor.hide);
+ write(screen.clear);
+};
+
+export const exitTUI = (): void => {
+ write(cursor.show);
+ write(screen.mainBuffer);
+};
+
+// ── Empty state ─────────────────────────────────────────────────────
+
+export const renderEmpty = (row: number, message: string): number => {
+ writeLine(row, "");
+ writeLine(row + 1, ` ${c.dim("∅")} ${c.dim(message)}`);
+ writeLine(row + 2, "");
+ return row + 3;
+};
diff --git a/src/tui/index.ts b/src/tui/index.ts
new file mode 100644
index 0000000..b42d1cc
--- /dev/null
+++ b/src/tui/index.ts
@@ -0,0 +1,23 @@
+/**
+ * TUI entry point — wires the interactive UI to SecretStore.
+ */
+
+import { Effect } from "effect";
+import type { SecretStore } from "../services/secret-store.js";
+import { enterTUI, exitTUI } from "./components.js";
+import { mainMenuView } from "./views.js";
+
+export const runTUI = (
+ context: string | null
+): Effect.Effect =>
+ Effect.gen(function* () {
+ enterTUI();
+
+ yield* mainMenuView(context).pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ exitTUI();
+ })
+ )
+ );
+ });
diff --git a/src/tui/terminal.ts b/src/tui/terminal.ts
new file mode 100644
index 0000000..4f3e6c7
--- /dev/null
+++ b/src/tui/terminal.ts
@@ -0,0 +1,228 @@
+/**
+ * Low-level terminal helpers for the interactive TUI.
+ * Raw ANSI escape sequences — zero dependencies.
+ */
+
+import { Effect } from "effect";
+
+// ── ANSI escape sequences ───────────────────────────────────────────
+
+export const ESC = "\x1b";
+export const CSI = `${ESC}[`;
+
+export const cursor = {
+ hide: `${CSI}?25l`,
+ show: `${CSI}?25h`,
+ moveTo: (row: number, col: number) => `${CSI}${row};${col}H`,
+ moveUp: (n = 1) => `${CSI}${n}A`,
+ moveDown: (n = 1) => `${CSI}${n}B`,
+ saveCursor: `${ESC}7`,
+ restoreCursor: `${ESC}8`,
+};
+
+export const screen = {
+ clear: `${CSI}2J`,
+ clearLine: `${CSI}2K`,
+ clearDown: `${CSI}J`,
+ altBuffer: `${CSI}?1049h`,
+ mainBuffer: `${CSI}?1049l`,
+};
+
+// ── Colors (reuse project conventions) ──────────────────────────────
+
+const useColor = (() => {
+ if (process.env.NO_COLOR) {
+ return false;
+ }
+ if (process.env.FORCE_COLOR) {
+ return true;
+ }
+ return process.stdout.isTTY ?? false;
+})();
+
+const ansi = (code: string) => (text: string) =>
+ useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
+
+export const c = {
+ bold: ansi("1"),
+ dim: ansi("2"),
+ italic: ansi("3"),
+ underline: ansi("4"),
+ inverse: ansi("7"),
+ green: ansi("32"),
+ red: ansi("31"),
+ yellow: ansi("33"),
+ blue: ansi("34"),
+ cyan: ansi("36"),
+ magenta: ansi("35"),
+ white: ansi("37"),
+ gray: ansi("90"),
+ bgBlue: ansi("44"),
+ bgGreen: ansi("42"),
+ bgYellow: ansi("43"),
+ bgRed: ansi("41"),
+ bgCyan: ansi("46"),
+ bgWhite: ansi("47;30"),
+};
+
+// ── Terminal size ───────────────────────────────────────────────────
+
+export const getSize = (): { rows: number; cols: number } => ({
+ rows: process.stdout.rows ?? 24,
+ cols: process.stdout.columns ?? 80,
+});
+
+// ── Write helpers ───────────────────────────────────────────────────
+
+export const write = (s: string): void => {
+ process.stdout.write(s);
+};
+
+export const writeLine = (row: number, text: string): void => {
+ write(`${cursor.moveTo(row, 1)}${screen.clearLine}${text}`);
+};
+
+// ── Raw mode key reading ────────────────────────────────────────────
+
+export interface KeyPress {
+ ctrl: boolean;
+ name: string;
+ raw: string;
+ shift: boolean;
+}
+
+const CTRL_KEYS: Record = {
+ "\x03": { name: "c", ctrl: true },
+ "\x04": { name: "d", ctrl: true },
+ "\x1a": { name: "z", ctrl: true },
+};
+
+const SPECIAL_KEYS: Record = {
+ "\r": "return",
+ "\n": "return",
+ "\x1b": "escape",
+ "\x7f": "backspace",
+ "\b": "backspace",
+ "\t": "tab",
+ " ": "space",
+ "\x1b[A": "up",
+ "\x1b[B": "down",
+ "\x1b[C": "right",
+ "\x1b[D": "left",
+ "\x1b[5~": "pageup",
+ "\x1b[6~": "pagedown",
+ "\x1b[H": "home",
+ "\x1b[1~": "home",
+ "\x1b[F": "end",
+ "\x1b[4~": "end",
+};
+
+const parseKey = (data: Buffer): KeyPress => {
+ const raw = data.toString("utf-8");
+ const base: KeyPress = { ctrl: false, name: "", raw, shift: false };
+
+ const ctrl = CTRL_KEYS[raw];
+ if (ctrl) {
+ return { ...base, ...ctrl };
+ }
+
+ const special = SPECIAL_KEYS[raw];
+ if (special) {
+ return { ...base, name: special };
+ }
+
+ if (raw.length === 1 && raw >= " ") {
+ return { ...base, name: raw };
+ }
+
+ return { ...base, name: raw };
+};
+
+export const readKey: Effect.Effect = Effect.async(
+ (resume) => {
+ const wasRaw = process.stdin.isRaw;
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(true);
+ }
+ process.stdin.resume();
+
+ const onData = (data: Buffer) => {
+ process.stdin.removeListener("data", onData);
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(wasRaw);
+ }
+ process.stdin.pause();
+ resume(Effect.succeed(parseKey(data)));
+ };
+
+ process.stdin.on("data", onData);
+ }
+);
+
+// ── Bracketed input reading (for text fields) ──────────────────────
+
+export const readLine = (
+ prompt: string,
+ opts?: { mask?: boolean }
+): Effect.Effect =>
+ Effect.async((resume) => {
+ write(prompt);
+ const wasRaw = process.stdin.isRaw;
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(true);
+ }
+ process.stdin.resume();
+ process.stdin.setEncoding("utf-8");
+
+ let buf = "";
+
+ const cleanup = () => {
+ process.stdin.removeListener("data", onData);
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(wasRaw);
+ }
+ process.stdin.pause();
+ };
+
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: char-by-char input handling
+ const handleChar = (ch: string): "cancel" | "continue" | "done" => {
+ if (ch === "\r" || ch === "\n") {
+ return "done";
+ }
+ if (ch === "\x03") {
+ return "cancel";
+ }
+ if (ch === "\x7f" || ch === "\b") {
+ if (buf.length > 0) {
+ buf = buf.slice(0, -1);
+ write("\b \b");
+ }
+ return "continue";
+ }
+ if (ch >= " ") {
+ buf += ch;
+ write(opts?.mask ? "*" : ch);
+ }
+ return "continue";
+ };
+
+ const onData = (chunk: string) => {
+ for (const ch of chunk) {
+ const result = handleChar(ch);
+ if (result === "done") {
+ cleanup();
+ write("\n");
+ resume(Effect.succeed(buf));
+ return;
+ }
+ if (result === "cancel") {
+ cleanup();
+ write("\n");
+ resume(Effect.succeed(null));
+ return;
+ }
+ }
+ };
+
+ process.stdin.on("data", onData);
+ });
diff --git a/src/tui/views.ts b/src/tui/views.ts
new file mode 100644
index 0000000..ad553fa
--- /dev/null
+++ b/src/tui/views.ts
@@ -0,0 +1,1014 @@
+/**
+ * TUI views — each view is a self-contained interactive screen.
+ * All views consume SecretStore via Effect dependency injection.
+ */
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { Effect } from "effect";
+import { expiresAtFromNow, parseDuration } from "../domain/duration.js";
+import type { SecretMetadata } from "../services/metadata-store.js";
+import { SecretStore } from "../services/secret-store.js";
+import {
+ renderEmpty,
+ renderFooter,
+ renderHeader,
+ renderMenu,
+ renderMessage,
+ renderTable,
+} from "./components.js";
+import {
+ c,
+ cursor,
+ readKey,
+ readLine,
+ screen,
+ write,
+ writeLine,
+} from "./terminal.js";
+
+// ── Types ───────────────────────────────────────────────────────────
+
+type ViewResult = "back" | "quit" | "refresh";
+
+// ── Main Menu ───────────────────────────────────────────────────────
+
+const mainMenuItems = [
+ {
+ key: "contexts",
+ label: "Contexts",
+ icon: "📁",
+ hint: "Browse & manage contexts",
+ },
+ {
+ key: "secrets",
+ label: "Secrets",
+ icon: "🔑",
+ hint: "View secrets in current context",
+ },
+ { key: "add", label: "Add Secret", icon: "➕", hint: "Store a new secret" },
+ {
+ key: "search",
+ label: "Search",
+ icon: "🔍",
+ hint: "Search secrets or contexts",
+ },
+ {
+ key: "commands",
+ label: "Saved Commands",
+ icon: "⚡",
+ hint: "Manage saved commands",
+ },
+ { key: "audit", label: "Audit", icon: "📊", hint: "Check expiring secrets" },
+ {
+ key: "import",
+ label: "Import .env",
+ icon: "⬆",
+ hint: "Load secrets from .env file",
+ },
+ {
+ key: "export",
+ label: "Export .env",
+ icon: "⬇",
+ hint: "Export secrets to .env file",
+ },
+];
+
+export const mainMenuView = (
+ initialContext: string | null
+): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop with menu routing
+ Effect.gen(function* () {
+ let ctx = initialContext;
+ let selected = 0;
+ let message: {
+ text: string;
+ type: "success" | "error" | "info" | "warning";
+ } | null = null;
+
+ const render = () => {
+ write(screen.clear);
+ let row = renderHeader(ctx, "Main Menu");
+ row++;
+ row = renderMenu(mainMenuItems, selected, row);
+ row++;
+ if (message) {
+ renderMessage(row, message.text, message.type);
+ row++;
+ }
+ renderFooter([
+ "↑↓ navigate",
+ "Enter select",
+ "c change context",
+ "q quit",
+ ]);
+ };
+
+ let running = true;
+ while (running) {
+ render();
+ const key = yield* readKey;
+
+ message = null;
+
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ running = false;
+ continue;
+ }
+
+ if (key.name === "up") {
+ selected = (selected - 1 + mainMenuItems.length) % mainMenuItems.length;
+ } else if (key.name === "down") {
+ selected = (selected + 1) % mainMenuItems.length;
+ } else if (key.name === "c") {
+ const newCtx = yield* promptContext();
+ if (newCtx !== null) {
+ ctx = newCtx;
+ }
+ } else if (key.name === "return") {
+ const item = mainMenuItems[selected];
+ if (!item) {
+ continue;
+ }
+
+ switch (item.key) {
+ case "contexts": {
+ const result = yield* contextsView();
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "secrets": {
+ if (!ctx) {
+ message = {
+ text: "Set a context first (press c)",
+ type: "warning",
+ };
+ break;
+ }
+ const result = yield* secretsView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "add": {
+ if (!ctx) {
+ message = {
+ text: "Set a context first (press c)",
+ type: "warning",
+ };
+ break;
+ }
+ const result = yield* addSecretView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "search": {
+ const result = yield* searchView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "commands": {
+ const result = yield* commandsView();
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "audit": {
+ const result = yield* auditView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "import": {
+ if (!ctx) {
+ message = {
+ text: "Set a context first (press c)",
+ type: "warning",
+ };
+ break;
+ }
+ const result = yield* importView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ case "export": {
+ if (!ctx) {
+ message = {
+ text: "Set a context first (press c)",
+ type: "warning",
+ };
+ break;
+ }
+ const result = yield* exportView(ctx);
+ if (result === "quit") {
+ running = false;
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+ });
+
+// ── Prompt Context ──────────────────────────────────────────────────
+
+const promptContext = (): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ renderHeader(null, "Set Context");
+
+ // Show existing contexts as hints
+ const contexts = yield* SecretStore.listContexts().pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ let row = 4;
+ if (contexts.length > 0) {
+ writeLine(row, ` ${c.dim("Existing contexts:")}`);
+ row++;
+ for (const ctx of contexts.slice(0, 10)) {
+ writeLine(
+ row,
+ ` ${c.dim("•")} ${ctx.context} ${c.dim(`(${ctx.count} secrets)`)}`
+ );
+ row++;
+ }
+ row++;
+ }
+
+ write(cursor.show);
+ const input = yield* readLine(` ${c.cyan("Context name:")} `);
+ write(cursor.hide);
+
+ if (input === null || input.trim() === "") {
+ return null;
+ }
+ return input.trim();
+ });
+
+// ── Contexts View ───────────────────────────────────────────────────
+
+const contextsView = (): Effect.Effect =>
+ Effect.gen(function* () {
+ let selected = 0;
+
+ const loop = (): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop
+ Effect.gen(function* () {
+ const contexts = yield* SecretStore.listContexts().pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ write(screen.clear);
+ let row = renderHeader(null, "Contexts");
+ row++;
+
+ if (contexts.length === 0) {
+ row = renderEmpty(
+ row,
+ "No contexts found. Add secrets to create one."
+ );
+ renderFooter(["Esc back", "q quit"]);
+ const key = yield* readKey;
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ return "back" as ViewResult;
+ }
+
+ const items = contexts.map((ctx) => ({
+ key: ctx.context,
+ label: ctx.context,
+ icon: "📁",
+ hint: `${ctx.count} secrets`,
+ }));
+
+ selected = Math.min(selected, items.length - 1);
+ row = renderMenu(items, selected, row);
+ row++;
+ renderFooter([
+ "↑↓ navigate",
+ "Enter view secrets",
+ "d delete all",
+ "Esc back",
+ "q quit",
+ ]);
+
+ const key = yield* readKey;
+
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ if (key.name === "escape") {
+ return "back" as ViewResult;
+ }
+ if (key.name === "up") {
+ selected = (selected - 1 + items.length) % items.length;
+ }
+ if (key.name === "down") {
+ selected = (selected + 1) % items.length;
+ }
+
+ if (key.name === "return") {
+ const ctx = contexts[selected];
+ if (ctx) {
+ const result = yield* secretsView(ctx.context);
+ if (result === "quit") {
+ return "quit" as ViewResult;
+ }
+ }
+ }
+
+ if (key.name === "d") {
+ const ctx = contexts[selected];
+ if (ctx) {
+ yield* confirmDeleteContext(ctx.context);
+ }
+ }
+
+ return yield* loop();
+ });
+
+ return yield* loop();
+ });
+
+// ── Confirm delete context ──────────────────────────────────────────
+
+const confirmDeleteContext = (
+ context: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ renderHeader(context, "Delete All Secrets");
+ writeLine(
+ 5,
+ ` ${c.red("⚠")} ${c.bold("Delete ALL secrets")} in context ${c.bold(c.cyan(`"${context}"`))}?`
+ );
+ writeLine(7, ` ${c.dim("This cannot be undone.")}`);
+ writeLine(
+ 9,
+ ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel`
+ );
+
+ const key = yield* readKey;
+ if (key.name === "y") {
+ const secrets = yield* SecretStore.list(context).pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+ yield* SecretStore.beginBatch().pipe(Effect.catchAll(() => Effect.void));
+ for (const s of secrets) {
+ yield* SecretStore.remove(context, s.key).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+ }
+ yield* SecretStore.endBatch().pipe(Effect.catchAll(() => Effect.void));
+ return true;
+ }
+ return false;
+ });
+
+// ── Secrets View ────────────────────────────────────────────────────
+
+const secretsView = (
+ context: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ let selected = 0;
+ let message: {
+ text: string;
+ type: "success" | "error" | "info" | "warning";
+ } | null = null;
+
+ const loop = (): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop
+ Effect.gen(function* () {
+ const secrets = yield* SecretStore.list(context).pipe(
+ Effect.catchAll(() => Effect.succeed([] as SecretMetadata[]))
+ );
+
+ write(screen.clear);
+ let row = renderHeader(context, "Secrets");
+ row++;
+
+ if (secrets.length === 0) {
+ row = renderEmpty(row, "No secrets in this context.");
+ renderFooter(["a add secret", "Esc back", "q quit"]);
+ if (message) {
+ renderMessage(row, message.text, message.type);
+ }
+ const key = yield* readKey;
+ message = null;
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ if (key.name === "escape") {
+ return "back" as ViewResult;
+ }
+ if (key.name === "a") {
+ yield* addSecretView(context);
+ return yield* loop();
+ }
+ return yield* loop();
+ }
+
+ selected = Math.min(selected, secrets.length - 1);
+
+ const columns = [
+ { header: "KEY", width: 30 },
+ { header: "UPDATED", width: 20 },
+ { header: "EXPIRES", width: 20 },
+ ];
+
+ const tableRows = secrets.map((s) => [
+ s.key,
+ s.updated_at.slice(0, 16).replace("T", " "),
+ s.expires_at
+ ? s.expires_at.slice(0, 16).replace("T", " ")
+ : c.dim("never"),
+ ]);
+
+ row = renderTable(columns, tableRows, selected, row);
+ row++;
+
+ if (message) {
+ renderMessage(row, message.text, message.type);
+ row++;
+ }
+
+ writeLine(row + 1, ` ${c.dim(`${secrets.length} secrets`)}`);
+
+ renderFooter([
+ "↑↓ navigate",
+ "Enter reveal",
+ "a add",
+ "d delete",
+ "Esc back",
+ "q quit",
+ ]);
+
+ const key = yield* readKey;
+ message = null;
+
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ if (key.name === "escape") {
+ return "back" as ViewResult;
+ }
+ if (key.name === "up") {
+ selected = (selected - 1 + secrets.length) % secrets.length;
+ }
+ if (key.name === "down") {
+ selected = (selected + 1) % secrets.length;
+ }
+
+ if (key.name === "return") {
+ const secret = secrets[selected];
+ if (secret) {
+ yield* revealSecretView(context, secret.key);
+ }
+ }
+
+ if (key.name === "a") {
+ yield* addSecretView(context);
+ }
+
+ if (key.name === "d") {
+ const secret = secrets[selected];
+ if (secret) {
+ const confirmed = yield* confirmDelete(context, secret.key);
+ if (confirmed) {
+ message = { text: `Deleted "${secret.key}"`, type: "success" };
+ }
+ }
+ }
+
+ return yield* loop();
+ });
+
+ return yield* loop();
+ });
+
+// ── Reveal Secret View ──────────────────────────────────────────────
+
+const revealSecretView = (
+ context: string,
+ key: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Secret Detail");
+ row++;
+
+ const meta = yield* SecretStore.getMetadata(context, key).pipe(
+ Effect.catchAll(() => Effect.succeed(null))
+ );
+
+ writeLine(row, ` ${c.bold("Key:")} ${c.cyan(key)}`);
+ row++;
+
+ if (meta) {
+ writeLine(row, ` ${c.bold("Created:")} ${c.dim(meta.created_at)}`);
+ row++;
+ writeLine(row, ` ${c.bold("Updated:")} ${c.dim(meta.updated_at)}`);
+ row++;
+ writeLine(
+ row,
+ ` ${c.bold("Expires:")} ${meta.expires_at ? c.yellow(meta.expires_at) : c.dim("never")}`
+ );
+ row++;
+ }
+
+ row++;
+ writeLine(row, ` ${c.dim("Press 'r' to reveal value, Esc to go back")}`);
+
+ renderFooter(["r reveal value", "Esc back", "q quit"]);
+
+ const loop = (): Effect.Effect =>
+ Effect.gen(function* () {
+ const k = yield* readKey;
+ if (k.name === "q" || (k.ctrl && k.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ if (k.name === "escape") {
+ return "back" as ViewResult;
+ }
+
+ if (k.name === "r") {
+ const value = yield* SecretStore.get(context, key).pipe(
+ Effect.catchAll((e) => Effect.succeed(`[error: ${e._tag}]`))
+ );
+ row++;
+ writeLine(row, ` ${c.bold("Value:")} ${c.green(String(value))}`);
+ row += 2;
+ writeLine(
+ row,
+ ` ${c.dim("Press any key to go back (value will be hidden)")}`
+ );
+ renderFooter(["any key to go back"]);
+ yield* readKey;
+ return "back" as ViewResult;
+ }
+
+ return yield* loop();
+ });
+
+ return yield* loop();
+ });
+
+// ── Confirm Delete ──────────────────────────────────────────────────
+
+const confirmDelete = (
+ context: string,
+ key: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ renderHeader(context, "Delete Secret");
+ writeLine(5, ` ${c.red("⚠")} Delete secret ${c.bold(c.cyan(`"${key}"`))}?`);
+ writeLine(
+ 7,
+ ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel`
+ );
+
+ const k = yield* readKey;
+ if (k.name === "y") {
+ yield* SecretStore.remove(context, key).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+ return true;
+ }
+ return false;
+ });
+
+// ── Add Secret View ─────────────────────────────────────────────────
+
+const addSecretView = (
+ context: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Add Secret");
+ row++;
+
+ write(cursor.show);
+
+ writeLine(row, "");
+ row++;
+ const key = yield* readLine(` ${c.cyan("Key:")} `);
+ if (key === null || key.trim() === "") {
+ write(cursor.hide);
+ return "back" as ViewResult;
+ }
+
+ const value = yield* readLine(` ${c.cyan("Value:")} `, { mask: true });
+ if (value === null || value.trim() === "") {
+ write(cursor.hide);
+ return "back" as ViewResult;
+ }
+
+ const expiresInput = yield* readLine(
+ ` ${c.cyan("Expires (e.g. 30d, 1y, empty for never):")} `
+ );
+
+ write(cursor.hide);
+
+ let expiresAt: string | null = null;
+ if (expiresInput && expiresInput.trim() !== "") {
+ const duration = yield* parseDuration(expiresInput.trim()).pipe(
+ Effect.catchAll(() => Effect.succeed(null))
+ );
+ if (duration) {
+ expiresAt = expiresAtFromNow(duration);
+ }
+ }
+
+ yield* SecretStore.set(context, key.trim(), value, expiresAt).pipe(
+ Effect.catchAll((e) => {
+ renderMessage(row + 2, `Error: ${e.message}`, "error");
+ return Effect.void;
+ })
+ );
+
+ row += 2;
+ renderMessage(row, `Secret "${key.trim()}" stored`, "success");
+ row++;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+
+ return "back" as ViewResult;
+ });
+
+// ── Search View ─────────────────────────────────────────────────────
+
+const searchView = (
+ context: string | null
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Search");
+ row++;
+
+ write(cursor.show);
+ const pattern = yield* readLine(` ${c.cyan("Pattern (glob):")} `);
+ write(cursor.hide);
+
+ if (pattern === null || pattern.trim() === "") {
+ return "back" as ViewResult;
+ }
+
+ row += 2;
+
+ if (context) {
+ const results = yield* SecretStore.search(context, pattern.trim()).pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ if (results.length === 0) {
+ renderMessage(row, "No secrets found.", "info");
+ } else {
+ writeLine(row, ` ${c.bold(`${results.length} results:`)}`);
+ row++;
+ for (const r of results.slice(0, 20)) {
+ writeLine(row, ` ${c.yellow("🔑")} ${r.key}`);
+ row++;
+ }
+ }
+ } else {
+ const results = yield* SecretStore.searchContexts(pattern.trim()).pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ if (results.length === 0) {
+ renderMessage(row, "No contexts found.", "info");
+ } else {
+ writeLine(row, ` ${c.bold(`${results.length} contexts:`)}`);
+ row++;
+ for (const r of results.slice(0, 20)) {
+ writeLine(
+ row,
+ ` ${c.blue("📁")} ${r.context} ${c.dim(`(${r.count} secrets)`)}`
+ );
+ row++;
+ }
+ }
+ }
+
+ row += 2;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+ return "back" as ViewResult;
+ });
+
+// ── Commands View ───────────────────────────────────────────────────
+
+const commandsView = (): Effect.Effect =>
+ Effect.gen(function* () {
+ let selected = 0;
+
+ const loop = (): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop
+ Effect.gen(function* () {
+ const commands = yield* SecretStore.listCommands().pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ write(screen.clear);
+ let row = renderHeader(null, "Saved Commands");
+ row++;
+
+ if (commands.length === 0) {
+ row = renderEmpty(row, "No saved commands.");
+ renderFooter(["Esc back", "q quit"]);
+ const key = yield* readKey;
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ return "back" as ViewResult;
+ }
+
+ selected = Math.min(selected, commands.length - 1);
+
+ const columns = [
+ { header: "NAME", width: 20 },
+ { header: "COMMAND", width: 30 },
+ { header: "CONTEXT", width: 20 },
+ ];
+
+ const tableRows = commands.map((cmd) => [
+ cmd.name,
+ cmd.command.length > 30
+ ? `${cmd.command.slice(0, 27)}...`
+ : cmd.command,
+ cmd.context,
+ ]);
+
+ row = renderTable(columns, tableRows, selected, row);
+ row++;
+ writeLine(row, ` ${c.dim(`${commands.length} commands`)}`);
+
+ renderFooter(["↑↓ navigate", "d delete", "Esc back", "q quit"]);
+
+ const key = yield* readKey;
+
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
+ return "quit" as ViewResult;
+ }
+ if (key.name === "escape") {
+ return "back" as ViewResult;
+ }
+ if (key.name === "up") {
+ selected = (selected - 1 + commands.length) % commands.length;
+ }
+ if (key.name === "down") {
+ selected = (selected + 1) % commands.length;
+ }
+
+ if (key.name === "d") {
+ const cmd = commands[selected];
+ if (cmd) {
+ yield* SecretStore.removeCommand(cmd.name).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+ }
+ }
+
+ return yield* loop();
+ });
+
+ return yield* loop();
+ });
+
+// ── Audit View ──────────────────────────────────────────────────────
+
+const auditView = (
+ context: string | null
+): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI view
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Audit — Expiring Secrets");
+ row++;
+
+ const windowMs = 30 * 24 * 60 * 60 * 1000; // 30 days
+ const now = Date.now();
+
+ if (context) {
+ const secrets = yield* SecretStore.listExpiring(context, windowMs).pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ if (secrets.length === 0) {
+ renderMessage(row, "No secrets expiring within 30 days.", "success");
+ } else {
+ writeLine(
+ row,
+ ` ${c.bold(`${secrets.length} secrets expiring within 30 days:`)}`
+ );
+ row += 2;
+ for (const s of secrets.slice(0, 20)) {
+ const expired = s.expires_at
+ ? new Date(`${s.expires_at}Z`).getTime() <= now
+ : false;
+ const icon = expired ? c.red("⏰") : c.yellow("🕐");
+ const status = expired ? c.red("EXPIRED") : c.yellow("expiring");
+ writeLine(
+ row,
+ ` ${icon} ${s.key} ${status} ${c.dim(s.expires_at ?? "")}`
+ );
+ row++;
+ }
+ }
+ } else {
+ const secrets = yield* SecretStore.listAllExpiring(windowMs).pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ if (secrets.length === 0) {
+ renderMessage(
+ row,
+ "No secrets expiring within 30 days across all contexts.",
+ "success"
+ );
+ } else {
+ writeLine(
+ row,
+ ` ${c.bold(`${secrets.length} secrets expiring within 30 days:`)}`
+ );
+ row += 2;
+ for (const s of secrets.slice(0, 20)) {
+ const expired = s.expires_at
+ ? new Date(`${s.expires_at}Z`).getTime() <= now
+ : false;
+ const icon = expired ? c.red("⏰") : c.yellow("🕐");
+ const status = expired ? c.red("EXPIRED") : c.yellow("expiring");
+ writeLine(
+ row,
+ ` ${icon} ${c.dim(`[${s.env}]`)} ${s.key} ${status} ${c.dim(s.expires_at ?? "")}`
+ );
+ row++;
+ }
+ }
+ }
+
+ row += 2;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ renderFooter(["any key to go back"]);
+ yield* readKey;
+ return "back" as ViewResult;
+ });
+
+// ── Import View ─────────────────────────────────────────────────────
+
+const importView = (
+ context: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Import .env File");
+ row++;
+
+ write(cursor.show);
+ const filePath = yield* readLine(
+ ` ${c.cyan("File path (default: .env):")} `
+ );
+ write(cursor.hide);
+
+ const path = filePath && filePath.trim() !== "" ? filePath.trim() : ".env";
+
+ row += 2;
+ writeLine(row, ` ${c.dim("Reading")} ${path}${c.dim("...")}`);
+
+ const content = yield* Effect.try({
+ try: () => readFileSync(path, "utf-8"),
+ catch: () => new Error(`Cannot read file: ${path}`),
+ }).pipe(
+ Effect.catchAll((e) => {
+ renderMessage(row + 1, String(e), "error");
+ return Effect.succeed(null);
+ })
+ );
+
+ if (content === null) {
+ row += 3;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+ return "back" as ViewResult;
+ }
+
+ const lines = content.split("\n");
+ let added = 0;
+
+ yield* SecretStore.beginBatch().pipe(Effect.catchAll(() => Effect.void));
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed === "" || trimmed.startsWith("#")) {
+ continue;
+ }
+ const eqIndex = trimmed.indexOf("=");
+ if (eqIndex === -1) {
+ continue;
+ }
+ const key = trimmed.slice(0, eqIndex).trim();
+ const value = trimmed
+ .slice(eqIndex + 1)
+ .trim()
+ .replace(/^["']|["']$/g, "");
+ const secretKey = key.toLowerCase().replaceAll("_", ".");
+ yield* SecretStore.set(context, secretKey, value).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+ added++;
+ }
+
+ yield* SecretStore.endBatch().pipe(Effect.catchAll(() => Effect.void));
+
+ row++;
+ renderMessage(row, `Imported ${added} secrets from ${path}`, "success");
+ row += 2;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+ return "back" as ViewResult;
+ });
+
+// ── Export View ─────────────────────────────────────────────────────
+
+const exportView = (
+ context: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(context, "Export .env File");
+ row++;
+
+ write(cursor.show);
+ const filePath = yield* readLine(
+ ` ${c.cyan("Output path (default: .env):")} `
+ );
+ write(cursor.hide);
+
+ const path = filePath && filePath.trim() !== "" ? filePath.trim() : ".env";
+
+ row += 2;
+ writeLine(row, ` ${c.dim("Exporting secrets...")}`);
+
+ const secrets = yield* SecretStore.list(context).pipe(
+ Effect.catchAll(() => Effect.succeed([] as SecretMetadata[]))
+ );
+
+ if (secrets.length === 0) {
+ row++;
+ renderMessage(row, "No secrets to export.", "info");
+ row += 2;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+ return "back" as ViewResult;
+ }
+
+ const lines: string[] = [];
+ for (const item of secrets) {
+ const value = yield* SecretStore.get(context, item.key).pipe(
+ Effect.catchAll(() => Effect.succeed(""))
+ );
+ const envKey = item.key.toUpperCase().replaceAll(".", "_");
+ const escaped = String(value)
+ .replaceAll("\\", "\\\\")
+ .replaceAll('"', '\\"')
+ .replaceAll("\n", "\\n");
+ lines.push(`${envKey}="${escaped}"`);
+ }
+
+ yield* Effect.try({
+ try: () => writeFileSync(path, `${lines.join("\n")}\n`, "utf-8"),
+ catch: () => new Error(`Failed to write: ${path}`),
+ }).pipe(
+ Effect.catchAll((e) => {
+ renderMessage(row + 1, String(e), "error");
+ return Effect.void;
+ })
+ );
+
+ row++;
+ renderMessage(
+ row,
+ `Exported ${lines.length} secrets to ${path}`,
+ "success"
+ );
+ row += 2;
+ writeLine(row, ` ${c.dim("Press any key to continue...")}`);
+ yield* readKey;
+ return "back" as ViewResult;
+ });
From 424a1f682971af3326935e6fcc30542607b42cd1 Mon Sep 17 00:00:00 2001
From: David Nussio
Date: Wed, 1 Apr 2026 17:59:32 +0200
Subject: [PATCH 2/4] chore: move tui into package
---
packages/cli/package.json | 1 +
packages/cli/src/cli-runner.ts | 2 +
packages/cli/src/cli/tui.ts | 2 +-
packages/tui/package.json | 54 +++++++++++++++++
{src/tui => packages/tui/src}/components.ts | 23 +++----
{src/tui => packages/tui/src}/index.ts | 4 +-
{src/tui => packages/tui/src}/terminal.ts | 0
{src/tui => packages/tui/src}/views.ts | 67 +++++++++++++--------
packages/tui/tsconfig.json | 5 ++
pnpm-lock.yaml | 19 ++++++
10 files changed, 137 insertions(+), 40 deletions(-)
create mode 100644 packages/tui/package.json
rename {src/tui => packages/tui/src}/components.ts (92%)
rename {src/tui => packages/tui/src}/index.ts (76%)
rename {src/tui => packages/tui/src}/terminal.ts (100%)
rename {src/tui => packages/tui/src}/views.ts (95%)
create mode 100644 packages/tui/tsconfig.json
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 0350660..e316eec 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -49,6 +49,7 @@
},
"dependencies": {
"@envsec/core": "workspace:*",
+ "@envsec/tui": "workspace:*",
"@effect/cli": "^0.75.0",
"@effect/platform": "^0.96.0",
"@effect/platform-node": "^0.106.0",
diff --git a/packages/cli/src/cli-runner.ts b/packages/cli/src/cli-runner.ts
index 3b6da0e..1ec19f9 100644
--- a/packages/cli/src/cli-runner.ts
+++ b/packages/cli/src/cli-runner.ts
@@ -26,6 +26,7 @@ import { rootCommand } from "./cli/root.js";
import { runCommand } from "./cli/run.js";
import { searchCommand } from "./cli/search.js";
import { shareCommand } from "./cli/share.js";
+import { tuiCommand } from "./cli/tui.js";
import { generateCompletions, type ShellType } from "./completions/index.js";
const require = createRequire(import.meta.url);
@@ -48,6 +49,7 @@ const command = rootCommand.pipe(
envCommand,
loadCommand,
shareCommand,
+ tuiCommand,
auditCommand,
doctorCommand,
])
diff --git a/packages/cli/src/cli/tui.ts b/packages/cli/src/cli/tui.ts
index 3ce9ca1..161d986 100644
--- a/packages/cli/src/cli/tui.ts
+++ b/packages/cli/src/cli/tui.ts
@@ -1,6 +1,6 @@
import { Command } from "@effect/cli";
+import { runTUI } from "@envsec/tui";
import { Effect, Option } from "effect";
-import { runTUI } from "../tui/index.js";
import { optionalContext } from "./root.js";
export const tuiCommand = Command.make("tui", {}, () =>
diff --git a/packages/tui/package.json b/packages/tui/package.json
new file mode 100644
index 0000000..88f65b8
--- /dev/null
+++ b/packages/tui/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@envsec/tui",
+ "version": "1.0.0-beta.11",
+ "description": "Interactive terminal UI for envsec secrets management",
+ "keywords": [
+ "tui",
+ "terminal",
+ "interactive",
+ "secrets",
+ "envsec"
+ ],
+ "license": "MIT",
+ "author": "David Nussio",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/davidnussio/envsec.git",
+ "directory": "packages/tui"
+ },
+ "homepage": "https://envsec.dev",
+ "bugs": {
+ "url": "https://github.com/davidnussio/envsec/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "lint": "tsc --noEmit",
+ "clean": "rm -rf dist"
+ },
+ "dependencies": {
+ "@envsec/core": "workspace:*",
+ "effect": "^3.21.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0",
+ "typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/src/tui/components.ts b/packages/tui/src/components.ts
similarity index 92%
rename from src/tui/components.ts
rename to packages/tui/src/components.ts
index 21096ff..a1c54ae 100644
--- a/src/tui/components.ts
+++ b/packages/tui/src/components.ts
@@ -2,6 +2,7 @@
* Reusable TUI components: menus, lists, status bar, etc.
*/
+import { icons } from "@envsec/core";
import { c, cursor, getSize, screen, write, writeLine } from "./terminal.js";
// ── Header ──────────────────────────────────────────────────────────
@@ -13,7 +14,7 @@ export const renderHeader = (
): number => {
const { cols } = getSize();
const ctx = context ? c.cyan(`[${context}]`) : c.dim("[no context]");
- const line = `${c.bold(c.green("🔒 envsec"))} ${c.dim("›")} ${c.bold(title)} ${ctx}`;
+ const line = `${c.bold(c.green(`${icons.lock} envsec`))} ${c.dim("›")} ${c.bold(title)} ${ctx}`;
writeLine(startRow, ` ${line}`);
writeLine(startRow + 1, ` ${c.dim("─".repeat(Math.min(cols - 2, 60)))}`);
return startRow + 2;
@@ -160,17 +161,13 @@ export const renderMessage = (
msg: string,
type: "success" | "error" | "info" | "warning" = "info"
): void => {
- let icon: string;
- if (type === "success") {
- icon = c.green("✔");
- } else if (type === "error") {
- icon = c.red("✖");
- } else if (type === "warning") {
- icon = c.yellow("⚠");
- } else {
- icon = c.blue("ℹ");
- }
- writeLine(row, ` ${icon} ${msg}`);
+ const iconMap = {
+ success: icons.success,
+ error: icons.error,
+ warning: icons.warning,
+ info: icons.info,
+ };
+ writeLine(row, ` ${iconMap[type]} ${msg}`);
};
// ── Screen management ───────────────────────────────────────────────
@@ -190,7 +187,7 @@ export const exitTUI = (): void => {
export const renderEmpty = (row: number, message: string): number => {
writeLine(row, "");
- writeLine(row + 1, ` ${c.dim("∅")} ${c.dim(message)}`);
+ writeLine(row + 1, ` ${icons.empty} ${c.dim(message)}`);
writeLine(row + 2, "");
return row + 3;
};
diff --git a/src/tui/index.ts b/packages/tui/src/index.ts
similarity index 76%
rename from src/tui/index.ts
rename to packages/tui/src/index.ts
index b42d1cc..2cb63b7 100644
--- a/src/tui/index.ts
+++ b/packages/tui/src/index.ts
@@ -1,9 +1,9 @@
/**
- * TUI entry point — wires the interactive UI to SecretStore.
+ * @envsec/tui — Interactive terminal UI for envsec secrets management.
*/
+import type { SecretStore } from "@envsec/core";
import { Effect } from "effect";
-import type { SecretStore } from "../services/secret-store.js";
import { enterTUI, exitTUI } from "./components.js";
import { mainMenuView } from "./views.js";
diff --git a/src/tui/terminal.ts b/packages/tui/src/terminal.ts
similarity index 100%
rename from src/tui/terminal.ts
rename to packages/tui/src/terminal.ts
diff --git a/src/tui/views.ts b/packages/tui/src/views.ts
similarity index 95%
rename from src/tui/views.ts
rename to packages/tui/src/views.ts
index ad553fa..1342032 100644
--- a/src/tui/views.ts
+++ b/packages/tui/src/views.ts
@@ -4,10 +4,15 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
+import {
+ expiresAtFromNow,
+ formatTimeDistance,
+ icons,
+ parseDuration,
+ type SecretMetadata,
+ SecretStore,
+} from "@envsec/core";
import { Effect } from "effect";
-import { expiresAtFromNow, parseDuration } from "../domain/duration.js";
-import type { SecretMetadata } from "../services/metadata-store.js";
-import { SecretStore } from "../services/secret-store.js";
import {
renderEmpty,
renderFooter,
@@ -36,39 +41,49 @@ const mainMenuItems = [
{
key: "contexts",
label: "Contexts",
- icon: "📁",
+ icon: icons.folder,
hint: "Browse & manage contexts",
},
{
key: "secrets",
label: "Secrets",
- icon: "🔑",
+ icon: icons.key,
hint: "View secrets in current context",
},
- { key: "add", label: "Add Secret", icon: "➕", hint: "Store a new secret" },
+ {
+ key: "add",
+ label: "Add Secret",
+ icon: icons.save,
+ hint: "Store a new secret",
+ },
{
key: "search",
label: "Search",
- icon: "🔍",
+ icon: icons.search,
hint: "Search secrets or contexts",
},
{
key: "commands",
label: "Saved Commands",
- icon: "⚡",
+ icon: icons.bolt,
hint: "Manage saved commands",
},
- { key: "audit", label: "Audit", icon: "📊", hint: "Check expiring secrets" },
+ {
+ key: "audit",
+ label: "Audit",
+ icon: icons.chart,
+ hint: "Check expiring secrets",
+ },
{
key: "import",
label: "Import .env",
- icon: "⬆",
+ icon: icons.upload,
hint: "Load secrets from .env file",
},
{
key: "export",
label: "Export .env",
- icon: "⬇",
+ icon: icons.download,
hint: "Export secrets to .env file",
},
];
@@ -291,7 +306,7 @@ const contextsView = (): Effect.Effect =>
const items = contexts.map((ctx) => ({
key: ctx.context,
label: ctx.context,
- icon: "📁",
+ icon: icons.folder,
hint: `${ctx.count} secrets`,
}));
@@ -354,7 +369,7 @@ const confirmDeleteContext = (
renderHeader(context, "Delete All Secrets");
writeLine(
5,
- ` ${c.red("⚠")} ${c.bold("Delete ALL secrets")} in context ${c.bold(c.cyan(`"${context}"`))}?`
+ ` ${icons.warning} ${c.bold("Delete ALL secrets")} in context ${c.bold(c.cyan(`"${context}"`))}?`
);
writeLine(7, ` ${c.dim("This cannot be undone.")}`);
writeLine(
@@ -577,7 +592,10 @@ const confirmDelete = (
Effect.gen(function* () {
write(screen.clear);
renderHeader(context, "Delete Secret");
- writeLine(5, ` ${c.red("⚠")} Delete secret ${c.bold(c.cyan(`"${key}"`))}?`);
+ writeLine(
+ 5,
+ ` ${icons.warning} Delete secret ${c.bold(c.cyan(`"${key}"`))}?`
+ );
writeLine(
7,
` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel`
@@ -662,7 +680,9 @@ const searchView = (
row++;
write(cursor.show);
- const pattern = yield* readLine(` ${c.cyan("Pattern (glob):")} `);
+ writeLine(row, ` ${c.cyan("Pattern (glob):")}`);
+ row++;
+ const pattern = yield* readLine(` ${c.dim("›")} `);
write(cursor.hide);
if (pattern === null || pattern.trim() === "") {
@@ -682,7 +702,7 @@ const searchView = (
writeLine(row, ` ${c.bold(`${results.length} results:`)}`);
row++;
for (const r of results.slice(0, 20)) {
- writeLine(row, ` ${c.yellow("🔑")} ${r.key}`);
+ writeLine(row, ` ${icons.key} ${r.key}`);
row++;
}
}
@@ -699,7 +719,7 @@ const searchView = (
for (const r of results.slice(0, 20)) {
writeLine(
row,
- ` ${c.blue("📁")} ${r.context} ${c.dim(`(${r.count} secrets)`)}`
+ ` ${icons.folder} ${r.context} ${c.dim(`(${r.count} secrets)`)}`
);
row++;
}
@@ -822,12 +842,10 @@ const auditView = (
const expired = s.expires_at
? new Date(`${s.expires_at}Z`).getTime() <= now
: false;
- const icon = expired ? c.red("⏰") : c.yellow("🕐");
+ const icon = expired ? icons.expired : icons.clock;
const status = expired ? c.red("EXPIRED") : c.yellow("expiring");
- writeLine(
- row,
- ` ${icon} ${s.key} ${status} ${c.dim(s.expires_at ?? "")}`
- );
+ const distance = s.expires_at ? formatTimeDistance(s.expires_at) : "";
+ writeLine(row, ` ${icon} ${s.key} ${status} ${c.dim(distance)}`);
row++;
}
}
@@ -852,11 +870,12 @@ const auditView = (
const expired = s.expires_at
? new Date(`${s.expires_at}Z`).getTime() <= now
: false;
- const icon = expired ? c.red("⏰") : c.yellow("🕐");
+ const icon = expired ? icons.expired : icons.clock;
const status = expired ? c.red("EXPIRED") : c.yellow("expiring");
+ const distance = s.expires_at ? formatTimeDistance(s.expires_at) : "";
writeLine(
row,
- ` ${icon} ${c.dim(`[${s.env}]`)} ${s.key} ${status} ${c.dim(s.expires_at ?? "")}`
+ ` ${icon} ${c.dim(`[${s.env}]`)} ${s.key} ${status} ${c.dim(distance)}`
);
row++;
}
diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json
new file mode 100644
index 0000000..c222e7b
--- /dev/null
+++ b/packages/tui/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4cb6541..1de1a0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -93,6 +93,9 @@ importers:
'@envsec/core':
specifier: workspace:*
version: link:../core
+ '@envsec/tui':
+ specifier: workspace:*
+ version: link:../tui
effect:
specifier: ^3.21.0
version: 3.21.0
@@ -154,6 +157,22 @@ importers:
specifier: ^5.9.3
version: 5.9.3
+ packages/tui:
+ dependencies:
+ '@envsec/core':
+ specifier: workspace:*
+ version: link:../core
+ effect:
+ specifier: ^3.21.0
+ version: 3.21.0
+ devDependencies:
+ '@types/node':
+ specifier: ^25.5.0
+ version: 25.5.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages:
'@alloc/quick-lru@5.2.0':
From d48190778fcdcd0899cde5940c34fe4b8a275338 Mon Sep 17 00:00:00 2001
From: David Nussio
Date: Wed, 1 Apr 2026 18:25:35 +0200
Subject: [PATCH 3/4] chore: improve navigation
---
packages/tui/src/terminal.ts | 11 ++
packages/tui/src/views.ts | 233 +++++++++++++++++++++++++++++------
2 files changed, 205 insertions(+), 39 deletions(-)
diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts
index 4f3e6c7..3c38b4d 100644
--- a/packages/tui/src/terminal.ts
+++ b/packages/tui/src/terminal.ts
@@ -207,7 +207,18 @@ export const readLine = (
};
const onData = (chunk: string) => {
+ // Bare escape key (not part of an ANSI sequence like \x1b[A)
+ if (chunk === "\x1b") {
+ cleanup();
+ write("\n");
+ resume(Effect.succeed(null));
+ return;
+ }
for (const ch of chunk) {
+ // Skip escape bytes that are part of ANSI sequences
+ if (ch === "\x1b") {
+ continue;
+ }
const result = handleChar(ch);
if (result === "done") {
cleanup();
diff --git a/packages/tui/src/views.ts b/packages/tui/src/views.ts
index 1342032..22c4ebc 100644
--- a/packages/tui/src/views.ts
+++ b/packages/tui/src/views.ts
@@ -3,8 +3,10 @@
* All views consume SecretStore via Effect dependency injection.
*/
-import { readFileSync, writeFileSync } from "node:fs";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
import {
+ type EnvFileExport,
expiresAtFromNow,
formatTimeDistance,
icons,
@@ -154,28 +156,32 @@ export const mainMenuView = (
break;
}
case "secrets": {
- if (!ctx) {
- message = {
- text: "Set a context first (press c)",
- type: "warning",
- };
- break;
+ let secretsCtx = ctx;
+ if (!secretsCtx) {
+ const picked = yield* selectContext("Secrets — Select Context");
+ if (!picked) {
+ break;
+ }
+ secretsCtx = picked;
}
- const result = yield* secretsView(ctx);
+ const result = yield* secretsView(secretsCtx);
if (result === "quit") {
running = false;
}
break;
}
case "add": {
- if (!ctx) {
- message = {
- text: "Set a context first (press c)",
- type: "warning",
- };
- break;
+ let addCtx = ctx;
+ if (!addCtx) {
+ const picked = yield* selectContext(
+ "Add Secret — Select Context"
+ );
+ if (!picked) {
+ break;
+ }
+ addCtx = picked;
}
- const result = yield* addSecretView(ctx);
+ const result = yield* addSecretView(addCtx);
if (result === "quit") {
running = false;
}
@@ -203,28 +209,30 @@ export const mainMenuView = (
break;
}
case "import": {
- if (!ctx) {
- message = {
- text: "Set a context first (press c)",
- type: "warning",
- };
- break;
+ let importCtx = ctx;
+ if (!importCtx) {
+ const picked = yield* selectContext("Import — Select Context");
+ if (!picked) {
+ break;
+ }
+ importCtx = picked;
}
- const result = yield* importView(ctx);
+ const result = yield* importView(importCtx);
if (result === "quit") {
running = false;
}
break;
}
case "export": {
- if (!ctx) {
- message = {
- text: "Set a context first (press c)",
- type: "warning",
- };
- break;
+ let exportCtx = ctx;
+ if (!exportCtx) {
+ const picked = yield* selectContext("Export — Select Context");
+ if (!picked) {
+ break;
+ }
+ exportCtx = picked;
}
- const result = yield* exportView(ctx);
+ const result = yield* exportView(exportCtx);
if (result === "quit") {
running = false;
}
@@ -237,6 +245,68 @@ export const mainMenuView = (
}
});
+// ── Select Context (arrow navigation) ───────────────────────────────
+
+const selectContext = (
+ title: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const contexts = yield* SecretStore.listContexts().pipe(
+ Effect.catchAll(() => Effect.succeed([]))
+ );
+
+ if (contexts.length === 0) {
+ write(screen.clear);
+ renderHeader(null, title);
+ renderEmpty(4, "No contexts found. Add secrets to create one.");
+ renderFooter(["any key to go back"]);
+ yield* readKey;
+ return null;
+ }
+
+ let selected = 0;
+
+ const loop = (): Effect.Effect =>
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop
+ Effect.gen(function* () {
+ write(screen.clear);
+ let row = renderHeader(null, title);
+ row++;
+
+ const items = contexts.map((ctx) => ({
+ key: ctx.context,
+ label: ctx.context,
+ icon: icons.folder,
+ hint: `${ctx.count} secrets`,
+ }));
+
+ selected = Math.min(selected, items.length - 1);
+ row = renderMenu(items, selected, row);
+ renderFooter(["↑↓ navigate", "Enter select", "Esc back"]);
+
+ const key = yield* readKey;
+
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
+ return null;
+ }
+ if (key.name === "up") {
+ selected = (selected - 1 + items.length) % items.length;
+ return yield* loop();
+ }
+ if (key.name === "down") {
+ selected = (selected + 1) % items.length;
+ return yield* loop();
+ }
+ if (key.name === "return") {
+ const ctx = contexts[selected];
+ return ctx ? ctx.context : null;
+ }
+ return yield* loop();
+ });
+
+ return yield* loop();
+ });
+
// ── Prompt Context ──────────────────────────────────────────────────
const promptContext = (): Effect.Effect =>
@@ -374,7 +444,7 @@ const confirmDeleteContext = (
writeLine(7, ` ${c.dim("This cannot be undone.")}`);
writeLine(
9,
- ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel`
+ ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel ${c.dim("/")} ${c.dim("Esc")} back`
);
const key = yield* readKey;
@@ -598,7 +668,7 @@ const confirmDelete = (
);
writeLine(
7,
- ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel`
+ ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel ${c.dim("/")} ${c.dim("Esc")} back`
);
const k = yield* readKey;
@@ -882,6 +952,9 @@ const auditView = (
}
}
+ // ── Env file exports ──────────────────────────────────────────────
+ row = yield* renderEnvFileExports(row, context);
+
row += 2;
writeLine(row, ` ${c.dim("Press any key to continue...")}`);
renderFooter(["any key to go back"]);
@@ -889,6 +962,75 @@ const auditView = (
return "back" as ViewResult;
});
+// ── Env file exports (audit subsection) ─────────────────────────────
+
+const renderEnvFileExports = (
+ startRow: number,
+ contextFilter: string | null
+): Effect.Effect =>
+ Effect.gen(function* () {
+ let row = startRow;
+
+ const allExports = yield* SecretStore.listEnvFileExports().pipe(
+ Effect.catchAll(() => Effect.succeed([] as EnvFileExport[]))
+ );
+
+ // Prune stale exports (files no longer on disk)
+ const alive: EnvFileExport[] = [];
+ const stale: EnvFileExport[] = [];
+ for (const e of allExports) {
+ if (existsSync(e.path)) {
+ alive.push(e);
+ } else {
+ stale.push(e);
+ }
+ }
+ for (const e of stale) {
+ yield* SecretStore.removeEnvFileExport(e.path).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+ }
+
+ if (stale.length > 0) {
+ row += 2;
+ writeLine(
+ row,
+ ` ${icons.broom} Removed ${c.bold(String(stale.length))} stale env file record${stale.length === 1 ? "" : "s"} ${c.dim("(files no longer on disk)")}`
+ );
+ }
+
+ const filtered = contextFilter
+ ? alive.filter((e) => e.context === contextFilter)
+ : alive;
+
+ if (filtered.length === 0) {
+ return row;
+ }
+
+ row += 2;
+ writeLine(row, ` ${icons.file} ${c.bold("Generated .env files:")}`);
+ row++;
+
+ for (const e of filtered) {
+ const date = e.created_at.replace("T", " ").slice(0, 19);
+ row++;
+ writeLine(row, ` ${icons.file} ${e.path}`);
+ row++;
+ writeLine(
+ row,
+ ` ${c.dim(`context: ${e.context} generated: ${date}`)}`
+ );
+ }
+
+ row += 2;
+ writeLine(
+ row,
+ ` ${icons.chart} ${c.bold(String(filtered.length))} env file${filtered.length === 1 ? "" : "s"} generated`
+ );
+
+ return row;
+ });
+
// ── Import View ─────────────────────────────────────────────────────
const importView = (
@@ -900,12 +1042,16 @@ const importView = (
row++;
write(cursor.show);
- const filePath = yield* readLine(
- ` ${c.cyan("File path (default: .env):")} `
- );
+ writeLine(row, ` ${c.cyan("File path (default: .env):")}`);
+ row++;
+ const filePath = yield* readLine(` ${c.dim("›")} `);
write(cursor.hide);
- const path = filePath && filePath.trim() !== "" ? filePath.trim() : ".env";
+ if (filePath === null) {
+ return "back" as ViewResult;
+ }
+
+ const path = filePath.trim() === "" ? ".env" : filePath.trim();
row += 2;
writeLine(row, ` ${c.dim("Reading")} ${path}${c.dim("...")}`);
@@ -974,12 +1120,16 @@ const exportView = (
row++;
write(cursor.show);
- const filePath = yield* readLine(
- ` ${c.cyan("Output path (default: .env):")} `
- );
+ writeLine(row, ` ${c.cyan("Output path (default: .env):")}`);
+ row++;
+ const filePath = yield* readLine(` ${c.dim("›")} `);
write(cursor.hide);
- const path = filePath && filePath.trim() !== "" ? filePath.trim() : ".env";
+ if (filePath === null) {
+ return "back" as ViewResult;
+ }
+
+ const path = filePath.trim() === "" ? ".env" : filePath.trim();
row += 2;
writeLine(row, ` ${c.dim("Exporting secrets...")}`);
@@ -1020,6 +1170,11 @@ const exportView = (
})
);
+ const absolutePath = resolve(path);
+ yield* SecretStore.trackEnvFileExport(context, absolutePath).pipe(
+ Effect.catchAll(() => Effect.void)
+ );
+
row++;
renderMessage(
row,
From 04f425c86863908952c96615c8096fd62edd964a Mon Sep 17 00:00:00 2001
From: David Nussio
Date: Wed, 1 Apr 2026 18:47:04 +0200
Subject: [PATCH 4/4] chore: improve navigation and docs
---
README.md | 41 ++++++
apps/website/components/docs-content.tsx | 164 +++++++++++++++++++++++
apps/website/components/docs-sidebar.tsx | 9 ++
apps/website/components/features.tsx | 6 +
packages/tui/src/views.ts | 92 ++++++-------
5 files changed, 266 insertions(+), 46 deletions(-)
diff --git a/README.md b/README.md
index e8a42a8..e1071bb 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Secure environment secrets management using native OS credential stores.
- Export secrets as shell environment variables (`eval $(envsec env)`)
- Load secrets from `.env` files (with conflict detection)
- Share secrets encrypted with GPG for team members
+- Interactive terminal UI (`envsec tui`) for managing secrets without memorizing commands
## Packages
@@ -31,6 +32,7 @@ This is a monorepo containing the following packages:
| [`envsec`](./packages/cli) | CLI tool for managing secrets | [](https://www.npmjs.com/package/envsec) |
| [`@envsec/sdk`](./packages/sdk) | Node.js / Bun SDK for loading secrets programmatically | [](https://www.npmjs.com/package/@envsec/sdk) |
| [`@envsec/core`](./packages/core) | Core engine — OS credential store adapters + metadata DB | [](https://www.npmjs.com/package/@envsec/core) |
+| [`@envsec/tui`](./packages/tui) | Interactive terminal UI for secrets management | [](https://www.npmjs.com/package/@envsec/tui) |
## SDK Quick Start
@@ -383,6 +385,44 @@ Secrets with an `--expires` duration set via `envsec add` are tracked in metadat
The `audit` command also tracks generated `.env` files. Every time `env-file` is used, the output path, context, and timestamp are recorded. The audit output includes a second section listing these files. If a tracked `.env` file no longer exists on disk, audit automatically removes it from the metadata and reports the cleanup.
+### Interactive TUI
+
+envsec includes a full-screen terminal UI for managing secrets interactively — no need to memorize commands.
+
+```bash
+# Launch the TUI
+envsec tui
+
+# Launch with a pre-selected context
+envsec -c myapp.dev tui
+```
+
+The TUI provides eight screens accessible from the main menu:
+
+- **Contexts** — browse all contexts, set active context with `s`, clear context with `x`, view secret counts, delete entire contexts
+- **Secrets** — list secrets in a table, reveal values, add or delete secrets
+- **Add Secret** — interactive form with masked input and optional expiry duration
+- **Search** — glob pattern search across secrets or contexts
+- **Saved Commands** — list, view, and delete saved command templates
+- **Audit** — check for expired/expiring secrets, review tracked `.env` file exports
+- **Import .env** — load secrets from a `.env` file into the current context
+- **Export .env** — export secrets to a `.env` file (tracked for audit)
+
+Keyboard shortcuts:
+
+| Key | Action |
+|-----|--------|
+| `↑` / `↓` | Navigate menu items and table rows |
+| `Enter` | Select / confirm |
+| `c` | Open contexts view (main menu) |
+| `s` | Set selected as active context (contexts view) |
+| `x` | Clear active context (contexts view) |
+| `a` | Add a new secret (secrets view) |
+| `d` | Delete selected item |
+| `r` | Reveal secret value (detail view) |
+| `Esc` | Go back / cancel |
+| `q` | Quit the TUI |
+
### Diagnose your setup
```bash
@@ -496,6 +536,7 @@ packages/
cli/ → envsec CLI (published as `envsec`)
sdk/ → Node.js/Bun SDK (published as `@envsec/sdk`)
core/ → Core engine, shared by CLI and SDK (published as `@envsec/core`)
+ tui/ → Interactive terminal UI (published as `@envsec/tui`)
apps/
website/ → Documentation website
```
diff --git a/apps/website/components/docs-content.tsx b/apps/website/components/docs-content.tsx
index 0a14012..3b3699a 100644
--- a/apps/website/components/docs-content.tsx
+++ b/apps/website/components/docs-content.tsx
@@ -461,6 +461,170 @@ envsec --json doctor`}
+ {/* Interactive TUI */}
+
+ Interactive TUI
+
+ envsec includes a full-screen terminal UI for managing secrets without
+ memorizing commands. Launch it with envsec tui or
+ optionally pass a context to start in.
+
+
+
+ The TUI uses raw ANSI escape sequences with zero external
+ dependencies. It runs in an alternate screen buffer so your terminal
+ history stays clean.
+
+
+
+
+ Views & Screens
+
+ The main menu provides access to eight screens, each covering a core
+ envsec workflow.
+
+ Contexts
+
+ Browse all contexts with their secret counts. Press s to
+ set the selected context as the active context for the session,{" "}
+ x to clear the active context, Enter to view
+ its secrets, or d to delete all secrets in a context
+ (with confirmation).
+
+ Secrets
+
+ Lists all secrets in the current context as a table with key, last
+ updated, and expiry columns. Press Enter to reveal a
+ secret value, a to add a new secret, or d to
+ delete the selected secret.
+
+ Add Secret
+
+ Interactive form to store a new secret. Prompts for key, value (masked
+ input), and an optional expiry duration (e.g. 30d,{" "}
+ 1y, 6mo).
+
+ Search
+
+ Glob pattern search. With a context selected, searches secret keys.
+ Without a context, searches context names.
+
+ Saved Commands
+
+ Lists all saved commands in a table with name, command template, and
+ context. Press d to delete a command.
+
+ Audit
+
+ Scans for secrets expiring within 30 days. Shows expired vs. expiring
+ status with time distance. Also lists tracked .env file
+ exports and cleans up stale records for files that no longer exist on
+ disk.
+
+ Import .env
+
+ Prompts for a file path (defaults to .env) and imports
+ all key-value pairs into the current context. Keys are converted from{" "}
+ UPPER_SNAKE_CASE to dotted.lowercase.
+
+ Export .env
+
+ Prompts for an output path (defaults to .env) and writes
+ all secrets from the current context. The export is tracked in
+ metadata for the audit view.
+
+
+
+
+ Keyboard Shortcuts
+
+
+
+
+ | Key |
+ Action |
+
+
+
+
+ |
+ ↑ / ↓
+ |
+ Navigate menu items and table rows |
+
+
+ |
+ Enter
+ |
+ Select / confirm |
+
+
+ |
+ c
+ |
+ Open contexts view (main menu) |
+
+
+ |
+ s
+ |
+
+ Set selected as active context (contexts view)
+ |
+
+
+ |
+ x
+ |
+ Clear active context (contexts view) |
+
+
+ |
+ a
+ |
+ Add a new secret (secrets view) |
+
+
+ |
+ d
+ |
+ Delete selected item |
+
+
+ |
+ r
+ |
+ Reveal secret value (detail view) |
+
+
+ |
+ Esc
+ |
+ Go back / cancel |
+
+
+ |
+ q
+ |
+ Quit the TUI |
+
+
+ |
+ Ctrl+C
+ |
+ Quit the TUI |
+
+
+
+
+
+
{/* Configuration */}
Contexts
diff --git a/apps/website/components/docs-sidebar.tsx b/apps/website/components/docs-sidebar.tsx
index ff415ed..19fb8c5 100644
--- a/apps/website/components/docs-sidebar.tsx
+++ b/apps/website/components/docs-sidebar.tsx
@@ -34,6 +34,14 @@ const SECTIONS = [
{ id: "doctor", label: "doctor" },
],
},
+ {
+ title: "Interactive TUI",
+ items: [
+ { id: "tui-overview", label: "Overview" },
+ { id: "tui-views", label: "Views & Screens" },
+ { id: "tui-keyboard", label: "Keyboard Shortcuts" },
+ ],
+ },
{
title: "Configuration",
items: [
@@ -66,6 +74,7 @@ export function DocsSidebar() {
const [expanded, setExpanded] = useState>({
"Getting Started": true,
Commands: true,
+ "Interactive TUI": true,
Configuration: true,
SDK: true,
Security: true,
diff --git a/apps/website/components/features.tsx b/apps/website/components/features.tsx
index 5f5f2c9..779f8d1 100644
--- a/apps/website/components/features.tsx
+++ b/apps/website/components/features.tsx
@@ -59,6 +59,12 @@ const FEATURES = [
description:
"Tab completions for bash, zsh, fish, and PowerShell. Feels native in every shell.",
},
+ {
+ icon: Monitor,
+ title: "Interactive TUI",
+ description:
+ "Full-screen terminal UI for browsing contexts, managing secrets, running audits, and importing/exporting — all without memorizing commands.",
+ },
] as const;
export function Features() {
diff --git a/packages/tui/src/views.ts b/packages/tui/src/views.ts
index 22c4ebc..d998426 100644
--- a/packages/tui/src/views.ts
+++ b/packages/tui/src/views.ts
@@ -36,6 +36,7 @@ import {
// ── Types ───────────────────────────────────────────────────────────
type ViewResult = "back" | "quit" | "refresh";
+type ContextsViewResult = ViewResult | { setContext: string } | "clearContext";
// ── Main Menu ───────────────────────────────────────────────────────
@@ -137,9 +138,18 @@ export const mainMenuView = (
} else if (key.name === "down") {
selected = (selected + 1) % mainMenuItems.length;
} else if (key.name === "c") {
- const newCtx = yield* promptContext();
- if (newCtx !== null) {
- ctx = newCtx;
+ const result = yield* contextsView();
+ if (result === "quit") {
+ running = false;
+ } else if (result === "clearContext") {
+ ctx = null;
+ message = { text: "Context cleared", type: "info" };
+ } else if (typeof result === "object" && "setContext" in result) {
+ ctx = result.setContext;
+ message = {
+ text: `Context set to "${result.setContext}"`,
+ type: "success",
+ };
}
} else if (key.name === "return") {
const item = mainMenuItems[selected];
@@ -152,6 +162,15 @@ export const mainMenuView = (
const result = yield* contextsView();
if (result === "quit") {
running = false;
+ } else if (result === "clearContext") {
+ ctx = null;
+ message = { text: "Context cleared", type: "info" };
+ } else if (typeof result === "object" && "setContext" in result) {
+ ctx = result.setContext;
+ message = {
+ text: `Context set to "${result.setContext}"`,
+ type: "success",
+ };
}
break;
}
@@ -307,49 +326,17 @@ const selectContext = (
return yield* loop();
});
-// ── Prompt Context ──────────────────────────────────────────────────
-
-const promptContext = (): Effect.Effect =>
- Effect.gen(function* () {
- write(screen.clear);
- renderHeader(null, "Set Context");
-
- // Show existing contexts as hints
- const contexts = yield* SecretStore.listContexts().pipe(
- Effect.catchAll(() => Effect.succeed([]))
- );
-
- let row = 4;
- if (contexts.length > 0) {
- writeLine(row, ` ${c.dim("Existing contexts:")}`);
- row++;
- for (const ctx of contexts.slice(0, 10)) {
- writeLine(
- row,
- ` ${c.dim("•")} ${ctx.context} ${c.dim(`(${ctx.count} secrets)`)}`
- );
- row++;
- }
- row++;
- }
-
- write(cursor.show);
- const input = yield* readLine(` ${c.cyan("Context name:")} `);
- write(cursor.hide);
-
- if (input === null || input.trim() === "") {
- return null;
- }
- return input.trim();
- });
-
// ── Contexts View ───────────────────────────────────────────────────
-const contextsView = (): Effect.Effect =>
+const contextsView = (): Effect.Effect<
+ ContextsViewResult,
+ never,
+ SecretStore
+> =>
Effect.gen(function* () {
let selected = 0;
- const loop = (): Effect.Effect =>
+ const loop = (): Effect.Effect =>
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: interactive TUI loop
Effect.gen(function* () {
const contexts = yield* SecretStore.listContexts().pipe(
@@ -368,9 +355,9 @@ const contextsView = (): Effect.Effect =>
renderFooter(["Esc back", "q quit"]);
const key = yield* readKey;
if (key.name === "q" || (key.ctrl && key.name === "c")) {
- return "quit" as ViewResult;
+ return "quit" as ContextsViewResult;
}
- return "back" as ViewResult;
+ return "back" as ContextsViewResult;
}
const items = contexts.map((ctx) => ({
@@ -386,6 +373,8 @@ const contextsView = (): Effect.Effect =>
renderFooter([
"↑↓ navigate",
"Enter view secrets",
+ "s set context",
+ "x clear context",
"d delete all",
"Esc back",
"q quit",
@@ -394,10 +383,10 @@ const contextsView = (): Effect.Effect =>
const key = yield* readKey;
if (key.name === "q" || (key.ctrl && key.name === "c")) {
- return "quit" as ViewResult;
+ return "quit" as ContextsViewResult;
}
if (key.name === "escape") {
- return "back" as ViewResult;
+ return "back" as ContextsViewResult;
}
if (key.name === "up") {
selected = (selected - 1 + items.length) % items.length;
@@ -406,12 +395,23 @@ const contextsView = (): Effect.Effect =>
selected = (selected + 1) % items.length;
}
+ if (key.name === "s") {
+ const ctx = contexts[selected];
+ if (ctx) {
+ return { setContext: ctx.context } as ContextsViewResult;
+ }
+ }
+
+ if (key.name === "x") {
+ return "clearContext" as ContextsViewResult;
+ }
+
if (key.name === "return") {
const ctx = contexts[selected];
if (ctx) {
const result = yield* secretsView(ctx.context);
if (result === "quit") {
- return "quit" as ViewResult;
+ return "quit" as ContextsViewResult;
}
}
}