Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -49,6 +50,7 @@ const command = rootCommand.pipe(
envCommand,
loadCommand,
shareCommand,
shellCommand,
tuiCommand,
auditCommand,
doctorCommand,
Expand Down
204 changes: 204 additions & 0 deletions packages/cli/src/cli/shell.ts
Original file line number Diff line number Diff line change
@@ -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<void, ShellNotFoundError> =>
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<string, string> = {};

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<string, string>,
bin: string,
inherit: boolean
): Record<string, string> => {
const parentEnv = inherit
? { ...process.env }
: { PATH: process.env.PATH ?? "" };

const childEnv: Record<string, string> = {
...(parentEnv as Record<string, string>),
...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<void, never>((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);
});
});
})
);
41 changes: 39 additions & 2 deletions packages/cli/test/e2e-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,11 @@ export class GPGEncryptionError extends Schema.TaggedError<GPGEncryptionError>()
message: Schema.String,
}
) {}

export class ShellNotFoundError extends Schema.TaggedError<ShellNotFoundError>()(
"ShellNotFoundError",
{
shell: Schema.String,
message: Schema.String,
}
) {}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {
MetadataStoreError,
MissingSecretsError,
SecretNotFoundError,
ShellNotFoundError,
UnsupportedPlatformError,
} from "./errors.js";
export { LinuxSecretServiceAccessLive } from "./implementations/linux-secret-service-access.js";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down
Loading
Loading