From 590d3bf9598b5c4626f208855a53f07df6bf983c Mon Sep 17 00:00:00 2001 From: David Nussio Date: Sat, 4 Apr 2026 09:16:49 +0200 Subject: [PATCH] feat: implement `envsec shell` command for secrets-scoped subshell sessions --- README.md | 36 ++++ packages/cli/src/cli-runner.ts | 2 + packages/cli/src/cli/shell.ts | 204 +++++++++++++++++++++ packages/cli/test/e2e-test.sh | 41 ++++- packages/core/src/errors.ts | 8 + packages/core/src/index.ts | 1 + packages/core/src/ui.ts | 1 + shell-command-spec.md | 315 +++++++++++++++++++++++++++++++++ 8 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/cli/shell.ts create mode 100644 shell-command-spec.md diff --git a/README.md b/README.md index e1071bb..d2a1afc 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,42 @@ envsec -c myapp.dev env --unset --shell fish Supported shells: `bash` (default), `zsh`, `fish`, `powershell`. Keys are converted to `UPPER_SNAKE_CASE` (e.g. `api.token` → `API_TOKEN`). Output goes to stdout so it can be piped to `eval` or sourced directly — no file is written to disk. +### Start a secrets-scoped shell session + +Spawn an interactive subshell with all secrets from the context injected as +environment variables. When you `exit`, the secrets are gone — no cleanup needed. + +```bash +envsec -c myapp.dev shell +``` + +``` +▶ envsec shell — context: myapp.dev (8 secrets loaded) +Type 'exit' or press Ctrl+D to leave the session. + +(envsec:myapp.dev) ~ $ echo $DATABASE_URL +postgres://user:pass@localhost/mydb + +(envsec:myapp.dev) ~ $ exit +→ Exiting envsec shell — secrets cleared. +``` + +Options: + +```bash +# Force a specific shell +envsec -c myapp.dev shell --shell zsh + +# Only envsec secrets in env (no parent variables, except PATH) +envsec -c myapp.dev shell --no-inherit + +# Suppress the startup/exit banner +envsec -c myapp.dev shell --quiet +``` + +The variable `ENVSEC_CONTEXT` is always set inside the session, so you can +reference it in scripts or prompt customizations. + ### Load secrets from a .env file ```bash diff --git a/packages/cli/src/cli-runner.ts b/packages/cli/src/cli-runner.ts index 1ec19f9..a1c530a 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 { shellCommand } from "./cli/shell.js"; import { tuiCommand } from "./cli/tui.js"; import { generateCompletions, type ShellType } from "./completions/index.js"; @@ -49,6 +50,7 @@ const command = rootCommand.pipe( envCommand, loadCommand, shareCommand, + shellCommand, tuiCommand, auditCommand, doctorCommand, diff --git a/packages/cli/src/cli/shell.ts b/packages/cli/src/cli/shell.ts new file mode 100644 index 0000000..93215be --- /dev/null +++ b/packages/cli/src/cli/shell.ts @@ -0,0 +1,204 @@ +import { execFileSync, spawn } from "node:child_process"; +import { accessSync, constants } from "node:fs"; +import path from "node:path"; +import { Command, Options } from "@effect/cli"; +import { + badge, + bold, + dim, + icons, + type SecretNotFoundError, + SecretStore, + ShellNotFoundError, +} from "@envsec/core"; +import { Console, Effect } from "effect"; +import { requireContext } from "./root.js"; + +const shellOption = Options.text("shell").pipe( + Options.withAlias("s"), + Options.withDescription( + "Shell to spawn (bash, zsh, fish, powershell). Default: auto-detect" + ), + Options.optional +); + +const noInherit = Options.boolean("no-inherit").pipe( + Options.withDescription("Do not inherit parent environment variables"), + Options.withDefault(false) +); + +const quiet = Options.boolean("quiet").pipe( + Options.withAlias("q"), + Options.withDescription("Suppress startup/exit banner"), + Options.withDefault(false) +); + +const toEnvKey = (key: string): string => + key.toUpperCase().replaceAll(".", "_"); + +const resolveShell = (name: string): { bin: string; args: string[] } => { + switch (name) { + case "bash": + return { bin: "bash", args: ["--norc", "--noprofile"] }; + case "zsh": + return { bin: "zsh", args: ["--no-rcs"] }; + case "fish": + return { bin: "fish", args: [] }; + case "powershell": + case "pwsh": + return { bin: "pwsh", args: ["-NoExit", "-NoProfile"] }; + default: + return { bin: name, args: [] }; + } +}; + +const detectShell = (override?: string): { bin: string; args: string[] } => { + if (override) { + return resolveShell(override); + } + if (process.env.SHELL) { + const shellPath = process.env.SHELL; + const name = path.basename(shellPath); + const resolved = resolveShell(name); + // Use the full path from $SHELL instead of just the name + return { bin: shellPath, args: resolved.args }; + } + if (process.platform === "win32") { + return { bin: "powershell.exe", args: ["-NoExit"] }; + } + return { bin: "/bin/sh", args: [] }; +}; + +const shellExists = (bin: string): Effect.Effect => + Effect.try({ + try: () => { + if (path.isAbsolute(bin)) { + accessSync(bin, constants.X_OK); + } else { + execFileSync("which", [bin], { stdio: "ignore" }); + } + }, + catch: () => + new ShellNotFoundError({ + shell: bin, + message: `Shell "${bin}" not found in PATH.`, + }), + }); + +const fetchSecrets = (ctx: string) => + Effect.gen(function* () { + const secrets = yield* SecretStore.list(ctx); + const secretEnv: Record = {}; + + if (secrets.length === 0) { + return secretEnv; + } + + const results = yield* Effect.forEach( + secrets, + (item) => + SecretStore.get(ctx, item.key).pipe( + Effect.map((value) => ({ + key: item.key, + found: true as const, + value: String(value), + })), + Effect.catchTag("SecretNotFoundError", (_: SecretNotFoundError) => + Effect.succeed({ + key: item.key, + found: false as const, + value: "", + }) + ) + ), + { concurrency: 10 } + ); + + for (const result of results) { + if (result.found) { + secretEnv[toEnvKey(result.key)] = result.value; + } + } + + return secretEnv; + }); + +const buildChildEnv = ( + ctx: string, + secretEnv: Record, + bin: string, + inherit: boolean +): Record => { + const parentEnv = inherit + ? { ...process.env } + : { PATH: process.env.PATH ?? "" }; + + const childEnv: Record = { + ...(parentEnv as Record), + ...secretEnv, + ENVSEC_CONTEXT: ctx, + }; + + const shellName = path.basename(bin); + if (shellName === "bash" || shellName === "zsh") { + childEnv.PS1 = `(envsec:${ctx}) ${process.env.PS1 ?? "\\u@\\h:\\w\\$ "}`; + } + + return childEnv; +}; + +export const shellCommand = Command.make( + "shell", + { shell: shellOption, noInherit, quiet }, + ({ shell: shellOpt, noInherit, quiet }) => + Effect.gen(function* () { + const ctx = yield* requireContext; + + const existingCtx = process.env.ENVSEC_CONTEXT; + if (existingCtx) { + yield* Console.error( + `${icons.warning} Already inside an envsec shell (context: ${bold(existingCtx)}). Nesting is allowed but may cause confusion.` + ); + } + + const secretEnv = yield* fetchSecrets(ctx); + + const { bin, args } = detectShell( + shellOpt._tag === "Some" ? shellOpt.value : undefined + ); + yield* shellExists(bin); + + const childEnv = buildChildEnv(ctx, secretEnv, bin, !noInherit); + + const count = Object.keys(secretEnv).length; + if (!quiet) { + yield* Console.error( + `${icons.shell} envsec shell ${dim("—")} context: ${bold(ctx)} (${badge(count, "secret")} loaded)` + ); + yield* Console.error( + `${dim("Type 'exit' or press Ctrl+D to leave the session.")}` + ); + } + + yield* Effect.async((resume) => { + const child = spawn(bin, args, { + env: childEnv, + stdio: "inherit", + }); + + child.on("error", () => { + resume(Effect.void); + }); + + child.on("close", (code) => { + if (!quiet) { + process.stderr.write( + `${icons.arrow} Exiting envsec shell ${dim("—")} secrets cleared.\n` + ); + } + process.exitCode = code ?? 0; + resume(Effect.void); + }); + }); + }) +); diff --git a/packages/cli/test/e2e-test.sh b/packages/cli/test/e2e-test.sh index ebb4b3b..4dcd217 100755 --- a/packages/cli/test/e2e-test.sh +++ b/packages/cli/test/e2e-test.sh @@ -967,9 +967,46 @@ out=$(node "$CLI" --completions fish 2>/dev/null) assert_contains "completions fish: complete" "complete -c envsec" "$out" assert_contains "completions fish: __complete" "__complete" "$out" -# ─── 22. CLEANUP & VERIFY ──────────────────────────────────────────────────── +# ─── 22. SHELL ──────────────────────────────────────────────────────────────── echo "" -echo "── 22. CLEANUP ──" +echo "── 22. SHELL ──" + +CTX_SHELL="test.e2e" + +# shell command: ENVSEC_CONTEXT is set inside subshell +result=$(echo 'echo $ENVSEC_CONTEXT' | node "$CLI" -c "$CTX_SHELL" shell --quiet --shell bash 2>/dev/null) +assert_eq "shell: ENVSEC_CONTEXT set" "$CTX_SHELL" "$result" + +# shell command: secret is visible inside subshell (use api.token which is stable) +expected_val=$(run_ok -c "$CTX_SHELL" get api.token) +result=$(echo 'echo $API_TOKEN' | node "$CLI" -c "$CTX_SHELL" shell --quiet --shell bash 2>/dev/null) +assert_eq "shell: secret visible" "$expected_val" "$result" + +# shell command: --no-inherit hides parent vars +result=$(echo 'echo ${HOME:-UNSET}' | node "$CLI" -c "$CTX_SHELL" shell --quiet --no-inherit --shell bash 2>/dev/null) +assert_eq "shell: --no-inherit hides HOME" "UNSET" "$result" + +# shell command: --no-inherit preserves PATH +result=$(echo 'echo ${PATH:-EMPTY}' | node "$CLI" -c "$CTX_SHELL" shell --quiet --no-inherit --shell bash 2>/dev/null) +assert_not_contains "shell: --no-inherit keeps PATH" "EMPTY" "$result" + +# shell command: banner appears on stderr without --quiet +out=$(echo 'exit 0' | node "$CLI" -c "$CTX_SHELL" shell --shell bash 2>&1 >/dev/null) +assert_contains "shell: banner on stderr" "envsec shell" "$out" +assert_contains "shell: banner shows context" "$CTX_SHELL" "$out" +assert_contains "shell: exit message" "Exiting" "$out" + +# shell command: --quiet suppresses banner +out=$(echo 'exit 0' | node "$CLI" -c "$CTX_SHELL" shell --quiet --shell bash 2>&1 >/dev/null) +assert_not_contains "shell: --quiet no banner" "envsec shell" "$out" + +# shell command: nesting warning when ENVSEC_CONTEXT is already set +out=$(ENVSEC_CONTEXT="other.ctx" node "$CLI" -c "$CTX_SHELL" shell --quiet --shell bash 2>&1 < /dev/null) +assert_contains "shell: nesting warning" "Already inside" "$out" + +# ─── 23. CLEANUP & VERIFY ──────────────────────────────────────────────────── +echo "" +echo "── 23. CLEANUP ──" for key in db.password api.token special.emoji special.utf8; do run_ok -c "$CTX" delete -y "$key" >/dev/null || true diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 555e512..8dc70c1 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -116,3 +116,11 @@ export class GPGEncryptionError extends Schema.TaggedError() message: Schema.String, } ) {} + +export class ShellNotFoundError extends Schema.TaggedError()( + "ShellNotFoundError", + { + shell: Schema.String, + message: Schema.String, + } +) {} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f63b7..2fdd7a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export { MetadataStoreError, MissingSecretsError, SecretNotFoundError, + ShellNotFoundError, UnsupportedPlatformError, } from "./errors.js"; export { LinuxSecretServiceAccessLive } from "./implementations/linux-secret-service-access.js"; diff --git a/packages/core/src/ui.ts b/packages/core/src/ui.ts index eaac17c..19417c3 100644 --- a/packages/core/src/ui.ts +++ b/packages/core/src/ui.ts @@ -57,6 +57,7 @@ export const icons = { cancel: dim("⊘"), // U+2298 broom: yellow("~"), // tilde env: cyan("$"), // env var + shell: green("▶"), // U+25B6 } as const; // ── Formatting Helpers ────────────────────────────────────────────── diff --git a/shell-command-spec.md b/shell-command-spec.md new file mode 100644 index 0000000..08d08da --- /dev/null +++ b/shell-command-spec.md @@ -0,0 +1,315 @@ +# `envsec shell` — Implementation Spec + +## Overview + +`envsec -c shell` spawns a subshell with all secrets from the given context injected as environment variables. When the user exits the subshell, the secrets vanish with it — no cleanup required. + +## CLI Interface + +```bash +envsec -c myapp.dev shell [options] +``` + +### Options + +| Flag | Alias | Type | Default | Description | +|---|---|---|---|---| +| `--shell` | `-s` | `string` | auto-detect | Shell to spawn (`bash`, `zsh`, `fish`, `powershell`) | +| `--no-inherit` | | `boolean` | `false` | Do not inherit parent environment variables | +| `--quiet` | `-q` | `boolean` | `false` | Suppress startup/exit banner | + +### Examples + +```bash +# Interactive session with all secrets from myapp.dev +envsec -c myapp.dev shell + +# Force a specific shell +envsec -c myapp.dev shell --shell zsh + +# Maximum isolation — only envsec secrets in env +envsec -c myapp.dev shell --no-inherit + +# No banner output (useful in scripts) +envsec -c myapp.dev shell --quiet +``` + +--- + +## Behavior + +### Startup sequence + +1. Resolve context secrets via the existing store adapter (same path as `env` command) +2. Convert keys to `UPPER_SNAKE_CASE` (same logic as `env` and `env-file`) +3. Build child environment: + - Default: `{ ...process.env, ...secrets, ENVSEC_CONTEXT: context }` + - With `--no-inherit`: `{ ...secrets, ENVSEC_CONTEXT: context, PATH: process.env.PATH }` + (`PATH` is always preserved — without it the shell is unusable) +4. Set prompt indicator (see Shell-specific behavior below) +5. Spawn shell with `stdio: 'inherit'` +6. Wait for exit +7. Print exit message (unless `--quiet`) +8. Exit with the same exit code as the subshell + +### Exit + +- `envsec shell` exits with the same code as the subshell process +- Secrets are never written to disk — they exist only in the child process memory +- No explicit cleanup needed: process exit is the cleanup + +### `ENVSEC_CONTEXT` variable + +Always injected. Lets users and scripts know they are inside an envsec session: + +```bash +echo $ENVSEC_CONTEXT # myapp.dev +``` + +Also useful for prompt customization and shell integrations. + +--- + +## Shell Detection + +Auto-detection order: + +1. `--shell` flag (explicit override) +2. `$SHELL` environment variable (Unix) +3. `$PSVersionTable` presence (PowerShell detection on Windows) +4. Fallback: `/bin/sh` + +```ts +function detectShell(override?: string): { bin: string; args: string[] } { + if (override) return resolveShell(override); + if (process.env.SHELL) return resolveShell(path.basename(process.env.SHELL)); + if (process.platform === 'win32') return { bin: 'powershell.exe', args: ['-NoExit'] }; + return { bin: '/bin/sh', args: [] }; +} + +function resolveShell(name: string): { bin: string; args: string[] } { + switch (name) { + case 'bash': return { bin: 'bash', args: ['--norc', '--noprofile'] }; // see note below + case 'zsh': return { bin: 'zsh', args: ['--no-rcs'] }; + case 'fish': return { bin: 'fish', args: [] }; + case 'powershell': + case 'pwsh': return { bin: 'pwsh', args: ['-NoExit', '-NoProfile'] }; + default: return { bin: name, args: [] }; + } +} +``` + +> **Note on startup files**: Using `--norc`/`--no-rcs` avoids startup files overwriting `PS1` or re-exporting variables. Consider making this configurable if users report issues with aliases or functions they rely on. Default: skip startup files. + +--- + +## Shell-specific Prompt Behavior + +### bash / zsh + +Set `PS1` in the child environment: + +```ts +const contextLabel = `(envsec:${context})`; +env.PS1 = `${contextLabel} ${process.env.PS1 || '\\u@\\h:\\w\\$ '}`; +``` + +### fish + +Fish ignores `PS1`. Instead, set only `ENVSEC_CONTEXT` and document that users can reference it in their `fish_prompt` function. Optionally print a reminder at startup: + +``` +🔐 envsec shell — context: myapp.dev +Type 'exit' to leave the session. +``` + +### PowerShell + +Inject `$env:ENVSEC_CONTEXT`. Prompt modification requires a function override — skip for now, rely on banner only. + +--- + +## Startup / Exit Banner + +Unless `--quiet`: + +**Startup:** +``` +🔐 envsec shell — context: myapp.dev (12 secrets loaded) +Type 'exit' to leave the session. +``` + +**Exit:** +``` +👋 Exiting envsec shell — secrets cleared. +``` + +Banner goes to `stderr` so it does not pollute stdout pipelines. + +--- + +## Implementation Location + +### File structure + +``` +src/ + commands/ + shell.ts ← new file + cmd-shell.ts ← wires up the yargs command (follow existing pattern) +``` + +### Core logic sketch (`shell.ts`) + +```ts +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { getAll } from '../store.js'; // existing store method +import { toUpperSnakeCase } from '../utils.js'; // existing util + +export async function runShellCommand(options: { + context: string; + shell?: string; + noInherit?: boolean; + quiet?: boolean; + db: string; +}): Promise { + const { context, shell: shellOverride, noInherit = false, quiet = false, db } = options; + + // 1. Fetch secrets + const secrets = await getAll({ context, db }); + const secretEnv: Record = {}; + for (const [key, value] of Object.entries(secrets)) { + secretEnv[toUpperSnakeCase(key)] = value; + } + + // 2. Build env + const parentEnv = noInherit + ? { PATH: process.env.PATH ?? '' } + : { ...process.env }; + + const childEnv: Record = { + ...parentEnv, + ...secretEnv, + ENVSEC_CONTEXT: context, + }; + + // 3. Prompt indicator (bash/zsh only) + const { bin, args } = detectShell(shellOverride); + const shellName = path.basename(bin); + if (['bash', 'zsh'].includes(shellName)) { + childEnv.PS1 = `(envsec:${context}) ${process.env.PS1 ?? '\\u@\\h:\\w\\$ '}`; + } + + // 4. Banner + if (!quiet) { + const count = Object.keys(secrets).length; + process.stderr.write( + `🔐 envsec shell — context: ${context} (${count} secret${count !== 1 ? 's' : ''} loaded)\n` + + `Type 'exit' to leave the session.\n` + ); + } + + // 5. Spawn + const child = spawn(bin, args, { env: childEnv, stdio: 'inherit' }); + + await new Promise((resolve) => { + child.on('close', (code) => { + if (!quiet) { + process.stderr.write('👋 Exiting envsec shell — secrets cleared.\n'); + } + process.exit(code ?? 0); + resolve(); + }); + }); +} +``` + +--- + +## Error Cases + +| Condition | Behavior | +|---|---| +| Context has no secrets | Warn and proceed (empty env injected) | +| Context does not exist | Hard fail with clear message: `Context "x" not found.` | +| Requested shell binary not found | Hard fail: `Shell "fish" not found in PATH.` | +| Already inside an envsec shell (`ENVSEC_CONTEXT` set) | Warn: `⚠ Already inside an envsec shell (context: myapp.dev). Nesting is allowed but may cause confusion.` | + +--- + +## Tests (e2e) + +Extend `test/e2e-test.sh` with: + +```bash +# shell command: exits with correct code +echo "exit 42" | envsec -c $TEST_CONTEXT shell --quiet +assert_exit_code 42 + +# shell command: secret is visible inside subshell +result=$(echo "echo \$TEST_KEY_UPPER" | envsec -c $TEST_CONTEXT shell --quiet) +assert_equals "$result" "expected_value" + +# shell command: ENVSEC_CONTEXT is set +result=$(echo "echo \$ENVSEC_CONTEXT" | envsec -c $TEST_CONTEXT shell --quiet) +assert_equals "$result" "$TEST_CONTEXT" + +# shell command: --no-inherit hides parent vars +result=$(echo "echo \${HOME:-UNSET}" | envsec -c $TEST_CONTEXT shell --quiet --no-inherit) +assert_equals "$result" "UNSET" +``` + +--- + +## README additions + +### New section under "Usage" + +````markdown +### Start a secrets-scoped shell session + +Spawn an interactive subshell with all secrets from the context injected as +environment variables. When you `exit`, the secrets are gone — no cleanup needed. + +```bash +envsec -c myapp.dev shell +``` + +``` +🔐 envsec shell — context: myapp.dev (8 secrets loaded) +Type 'exit' to leave the session. + +(envsec:myapp.dev) ~ $ echo $DATABASE_URL +postgres://user:pass@localhost/mydb + +(envsec:myapp.dev) ~ $ exit +👋 Exiting envsec shell — secrets cleared. +``` + +Options: + +```bash +# Force a specific shell +envsec -c myapp.dev shell --shell zsh + +# Only envsec secrets in env (no parent variables, except PATH) +envsec -c myapp.dev shell --no-inherit + +# Suppress the startup/exit banner +envsec -c myapp.dev shell --quiet +``` + +The variable `ENVSEC_CONTEXT` is always set inside the session, so you can +reference it in scripts or prompt customizations. +```` + +--- + +## Open decisions + +- [ ] **Startup files**: default to `--norc`/`--no-rcs` or load them? Leaning toward skip, make configurable. +- [ ] **fish prompt**: auto-detect and patch `fish_prompt` via `--init-command`, or document manual setup? +- [ ] **Windows PowerShell**: `$Profile` loading — skip for now, revisit. +- [ ] **Nesting detection**: warn only, or hard-fail? Leaning toward warn. +- [ ] **`--exec` variant**: `envsec -c ctx shell --exec "node server.js"` as alias for `run`? Probably out of scope — `run` already handles it.