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 | [![npm](https://img.shields.io/npm/v/envsec)](https://www.npmjs.com/package/envsec) | | [`@envsec/sdk`](./packages/sdk) | Node.js / Bun SDK for loading secrets programmatically | [![npm](https://img.shields.io/npm/v/@envsec/sdk)](https://www.npmjs.com/package/@envsec/sdk) | | [`@envsec/core`](./packages/core) | Core engine — OS credential store adapters + metadata DB | [![npm](https://img.shields.io/npm/v/@envsec/core)](https://www.npmjs.com/package/@envsec/core) | +| [`@envsec/tui`](./packages/tui) | Interactive terminal UI for secrets management | [![npm](https://img.shields.io/npm/v/@envsec/tui)](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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyAction
+ ↑ / ↓ + 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/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 new file mode 100644 index 0000000..161d986 --- /dev/null +++ b/packages/cli/src/cli/tui.ts @@ -0,0 +1,12 @@ +import { Command } from "@effect/cli"; +import { runTUI } from "@envsec/tui"; +import { Effect, Option } from "effect"; +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/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/packages/tui/src/components.ts b/packages/tui/src/components.ts new file mode 100644 index 0000000..a1c54ae --- /dev/null +++ b/packages/tui/src/components.ts @@ -0,0 +1,193 @@ +/** + * Reusable TUI components: menus, lists, status bar, etc. + */ + +import { icons } from "@envsec/core"; +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(`${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; +}; + +// ── 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 => { + const iconMap = { + success: icons.success, + error: icons.error, + warning: icons.warning, + info: icons.info, + }; + writeLine(row, ` ${iconMap[type]} ${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, ` ${icons.empty} ${c.dim(message)}`); + writeLine(row + 2, ""); + return row + 3; +}; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts new file mode 100644 index 0000000..2cb63b7 --- /dev/null +++ b/packages/tui/src/index.ts @@ -0,0 +1,23 @@ +/** + * @envsec/tui — Interactive terminal UI for envsec secrets management. + */ + +import type { SecretStore } from "@envsec/core"; +import { Effect } from "effect"; +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/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts new file mode 100644 index 0000000..3c38b4d --- /dev/null +++ b/packages/tui/src/terminal.ts @@ -0,0 +1,239 @@ +/** + * 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) => { + // 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(); + 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/packages/tui/src/views.ts b/packages/tui/src/views.ts new file mode 100644 index 0000000..d998426 --- /dev/null +++ b/packages/tui/src/views.ts @@ -0,0 +1,1188 @@ +/** + * TUI views — each view is a self-contained interactive screen. + * All views consume SecretStore via Effect dependency injection. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + type EnvFileExport, + expiresAtFromNow, + formatTimeDistance, + icons, + parseDuration, + type SecretMetadata, + SecretStore, +} from "@envsec/core"; +import { Effect } from "effect"; +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"; +type ContextsViewResult = ViewResult | { setContext: string } | "clearContext"; + +// ── Main Menu ─────────────────────────────────────────────────────── + +const mainMenuItems = [ + { + key: "contexts", + label: "Contexts", + icon: icons.folder, + hint: "Browse & manage contexts", + }, + { + key: "secrets", + label: "Secrets", + icon: icons.key, + hint: "View secrets in current context", + }, + { + key: "add", + label: "Add Secret", + icon: icons.save, + hint: "Store a new secret", + }, + { + key: "search", + label: "Search", + icon: icons.search, + hint: "Search secrets or contexts", + }, + { + key: "commands", + label: "Saved Commands", + icon: icons.bolt, + hint: "Manage saved commands", + }, + { + key: "audit", + label: "Audit", + icon: icons.chart, + hint: "Check expiring secrets", + }, + { + key: "import", + label: "Import .env", + icon: icons.upload, + hint: "Load secrets from .env file", + }, + { + key: "export", + label: "Export .env", + icon: icons.download, + 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 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]; + if (!item) { + continue; + } + + switch (item.key) { + case "contexts": { + 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; + } + case "secrets": { + let secretsCtx = ctx; + if (!secretsCtx) { + const picked = yield* selectContext("Secrets — Select Context"); + if (!picked) { + break; + } + secretsCtx = picked; + } + const result = yield* secretsView(secretsCtx); + if (result === "quit") { + running = false; + } + break; + } + case "add": { + let addCtx = ctx; + if (!addCtx) { + const picked = yield* selectContext( + "Add Secret — Select Context" + ); + if (!picked) { + break; + } + addCtx = picked; + } + const result = yield* addSecretView(addCtx); + 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": { + let importCtx = ctx; + if (!importCtx) { + const picked = yield* selectContext("Import — Select Context"); + if (!picked) { + break; + } + importCtx = picked; + } + const result = yield* importView(importCtx); + if (result === "quit") { + running = false; + } + break; + } + case "export": { + let exportCtx = ctx; + if (!exportCtx) { + const picked = yield* selectContext("Export — Select Context"); + if (!picked) { + break; + } + exportCtx = picked; + } + const result = yield* exportView(exportCtx); + if (result === "quit") { + running = false; + } + break; + } + default: + break; + } + } + } + }); + +// ── 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(); + }); + +// ── Contexts View ─────────────────────────────────────────────────── + +const contextsView = (): Effect.Effect< + ContextsViewResult, + never, + SecretStore +> => + 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 ContextsViewResult; + } + return "back" as ContextsViewResult; + } + + 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); + row++; + renderFooter([ + "↑↓ navigate", + "Enter view secrets", + "s set context", + "x clear context", + "d delete all", + "Esc back", + "q quit", + ]); + + const key = yield* readKey; + + if (key.name === "q" || (key.ctrl && key.name === "c")) { + return "quit" as ContextsViewResult; + } + if (key.name === "escape") { + return "back" as ContextsViewResult; + } + if (key.name === "up") { + selected = (selected - 1 + items.length) % items.length; + } + if (key.name === "down") { + 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 ContextsViewResult; + } + } + } + + 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, + ` ${icons.warning} ${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 ${c.dim("/")} ${c.dim("Esc")} back` + ); + + 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, + ` ${icons.warning} Delete secret ${c.bold(c.cyan(`"${key}"`))}?` + ); + writeLine( + 7, + ` ${c.green("y")} confirm ${c.dim("/")} ${c.red("n")} cancel ${c.dim("/")} ${c.dim("Esc")} back` + ); + + 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); + writeLine(row, ` ${c.cyan("Pattern (glob):")}`); + row++; + const pattern = yield* readLine(` ${c.dim("›")} `); + 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, ` ${icons.key} ${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, + ` ${icons.folder} ${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 ? 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} ${s.key} ${status} ${c.dim(distance)}`); + 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 ? 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(distance)}` + ); + row++; + } + } + } + + // ── 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"]); + yield* readKey; + 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 = ( + context: string +): Effect.Effect => + Effect.gen(function* () { + write(screen.clear); + let row = renderHeader(context, "Import .env File"); + row++; + + write(cursor.show); + writeLine(row, ` ${c.cyan("File path (default: .env):")}`); + row++; + const filePath = yield* readLine(` ${c.dim("›")} `); + write(cursor.hide); + + if (filePath === null) { + return "back" as ViewResult; + } + + const path = filePath.trim() === "" ? ".env" : filePath.trim(); + + 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); + writeLine(row, ` ${c.cyan("Output path (default: .env):")}`); + row++; + const filePath = yield* readLine(` ${c.dim("›")} `); + write(cursor.hide); + + if (filePath === null) { + return "back" as ViewResult; + } + + const path = filePath.trim() === "" ? ".env" : filePath.trim(); + + 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; + }) + ); + + const absolutePath = resolve(path); + yield* SecretStore.trackEnvFileExport(context, absolutePath).pipe( + Effect.catchAll(() => 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; + }); 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':