envsec 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,27 +1,54 @@
1
- # envsec
1
+ # secenv
2
2
 
3
- Secure environment secrets management for macOS using the native Keychain.
3
+ Secure environment secrets management using native OS credential stores.
4
4
 
5
5
  ## Features
6
6
 
7
- - Store secrets in macOS Keychain (not plain text files)
7
+ - Store secrets in your OS native credential store (not plain text files)
8
+ - Cross-platform: macOS, Linux, Windows
8
9
  - Organize secrets by environment (dev, staging, prod, etc.)
9
10
  - Track secret types (string, number, boolean) and metadata via SQLite
10
11
  - Search secrets with glob patterns
12
+ - Run commands with secret interpolation
13
+ - Export secrets to `.env` files
11
14
 
12
15
  ## Requirements
13
16
 
14
- - macOS
15
17
  - Node.js >= 18
16
18
 
19
+ ### macOS
20
+
21
+ No extra dependencies. Uses the built-in Keychain via the `security` CLI tool.
22
+
23
+ ### Linux
24
+
25
+ Requires `libsecret-tools` (provides the `secret-tool` command), which talks to GNOME Keyring, KDE Wallet, or any Secret Service API provider via D-Bus.
26
+
27
+ ```bash
28
+ # Debian / Ubuntu
29
+ sudo apt install libsecret-tools
30
+
31
+ # Fedora
32
+ sudo dnf install libsecret
33
+
34
+ # Arch
35
+ sudo pacman -S libsecret
36
+ ```
37
+
38
+ A running D-Bus session and a keyring daemon (e.g. `gnome-keyring-daemon`) must be active. Most desktop environments handle this automatically.
39
+
40
+ ### Windows
41
+
42
+ No extra dependencies. Uses the built-in Windows Credential Manager via `cmdkey` and PowerShell.
43
+
17
44
  ## Installation
18
45
 
19
46
  ```bash
20
- npm install -g envsec
47
+ npm install -g secenv
21
48
  ```
22
49
 
23
50
  ```bash
24
- npx envsec
51
+ npx secenv
25
52
  ```
26
53
 
27
54
  ## Usage
@@ -59,6 +86,28 @@ secenv -e dev list
59
86
  secenv -e dev search "api.*"
60
87
  ```
61
88
 
89
+ ### Generate a .env file
90
+
91
+ ```bash
92
+ # Creates .env with all secrets from the environment
93
+ secenv -e dev env-file
94
+
95
+ # Specify a custom output path
96
+ secenv -e dev env-file --output .env.local
97
+ ```
98
+
99
+ Keys are converted to `UPPER_SNAKE_CASE` (e.g. `api.token` → `API_TOKEN`).
100
+
101
+ ### Run a command with secrets
102
+
103
+ ```bash
104
+ # Placeholders {key} are resolved with secret values before execution
105
+ secenv -e dev run 'curl {api.url} -H "Authorization: Bearer {api.token}"'
106
+
107
+ # Any {dotted.key} in the command string is replaced with its value
108
+ secenv -e prod run 'psql {db.connection_string}'
109
+ ```
110
+
62
111
  ### Delete a secret
63
112
 
64
113
  ```bash
@@ -70,7 +119,15 @@ secenv -e dev del api.key
70
119
 
71
120
  ## How it works
72
121
 
73
- Secrets are stored in the macOS Keychain using the `security` command-line tool. Metadata (key names, types, timestamps) is kept in a SQLite database at `~/.secenv/store.sqlite`. Keys must contain at least one dot separator (e.g., `service.account`) which maps to the Keychain service/account structure.
122
+ Secrets are stored in the native OS credential store. The backend is selected automatically based on the platform:
123
+
124
+ | OS | Backend | Tool / API |
125
+ |---------|--------------------------------|-------------------------------------|
126
+ | macOS | Keychain | `security` CLI |
127
+ | Linux | Secret Service API (D-Bus) | `secret-tool` (libsecret) |
128
+ | Windows | Credential Manager | `cmdkey` + PowerShell (advapi32) |
129
+
130
+ Metadata (key names, types, timestamps) is kept in a SQLite database at `~/.secenv/store.sqlite`. Keys must contain at least one dot separator (e.g., `service.account`) which maps to the credential store's service/account structure.
74
131
 
75
132
  ## License
76
133
 
@@ -0,0 +1,22 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+ import { SecretStore } from "../services/SecretStore.js";
4
+ import { rootCommand } from "./root.js";
5
+ import { writeFileSync } from "node:fs";
6
+ const output = Options.text("output").pipe(Options.withAlias("o"), Options.withDescription("Output file path (default: .env)"), Options.withDefault(".env"));
7
+ export const envFileCommand = Command.make("env-file", { output }, ({ output }) => Effect.gen(function* () {
8
+ const { env } = yield* rootCommand;
9
+ const secrets = yield* SecretStore.list(env);
10
+ if (secrets.length === 0) {
11
+ yield* Effect.log(`No secrets found for env "${env}"`);
12
+ return;
13
+ }
14
+ const lines = [];
15
+ for (const item of secrets) {
16
+ const value = yield* SecretStore.get(env, item.key);
17
+ const envKey = item.key.toUpperCase().replaceAll(".", "_");
18
+ lines.push(`${envKey}=${String(value)}`);
19
+ }
20
+ writeFileSync(output, lines.join("\n") + "\n", "utf-8");
21
+ yield* Effect.log(`Written ${secrets.length} secret(s) to ${output}`);
22
+ }));
@@ -0,0 +1,29 @@
1
+ import { Command, Args } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+ import { SecretStore } from "../services/SecretStore.js";
4
+ import { rootCommand } from "./root.js";
5
+ import { execSync } from "node:child_process";
6
+ const cmd = Args.text({ name: "command" }).pipe(Args.withDescription("Command to execute. Use {key} placeholders for secret interpolation"));
7
+ export const runCommand = Command.make("run", { cmd }, ({ cmd }) => Effect.gen(function* () {
8
+ const { env } = yield* rootCommand;
9
+ // Find all {key} placeholders
10
+ const placeholders = [...cmd.matchAll(/\{([^}]+)\}/g)];
11
+ let resolved = cmd;
12
+ for (const match of placeholders) {
13
+ const key = match[1];
14
+ const value = yield* SecretStore.get(env, key);
15
+ resolved = resolved.replaceAll(`{${key}}`, String(value));
16
+ }
17
+ if (placeholders.length > 0) {
18
+ yield* Effect.log(`Resolved ${placeholders.length} secret(s)`);
19
+ }
20
+ try {
21
+ execSync(resolved, {
22
+ stdio: "inherit",
23
+ shell: process.platform === "win32" ? "cmd.exe" : "/bin/sh",
24
+ });
25
+ }
26
+ catch (e) {
27
+ yield* Effect.fail(new Error(`Command exited with code ${e.status ?? 1}`));
28
+ }
29
+ }));
@@ -0,0 +1,96 @@
1
+ import { execFile } from "node:child_process";
2
+ import { Effect, Layer } from "effect";
3
+ import { KeychainAccess } from "../services/KeychainAccess.js";
4
+ import { KeychainError, SecretNotFoundError } from "../errors.js";
5
+ /**
6
+ * Linux implementation using `secret-tool` (libsecret).
7
+ *
8
+ * Stores secrets via the freedesktop.org Secret Service API (D-Bus),
9
+ * backed by GNOME Keyring, KDE Wallet, or any compatible provider.
10
+ *
11
+ * Requires: `libsecret-tools` package
12
+ * - Debian/Ubuntu: sudo apt install libsecret-tools
13
+ * - Fedora: sudo dnf install libsecret
14
+ * - Arch: sudo pacman -S libsecret
15
+ */
16
+ const run = (args, stdin) => Effect.async((resume) => {
17
+ const child = execFile("secret-tool", args, (error, stdout, stderr) => {
18
+ if (error && error.code === "ENOENT") {
19
+ resume(Effect.fail(new KeychainError({
20
+ command: args[0] ?? "unknown",
21
+ stderr: "secret-tool not found. Install libsecret-tools.",
22
+ message: "secret-tool is not installed. Install it with your package manager (e.g. apt install libsecret-tools).",
23
+ })));
24
+ return;
25
+ }
26
+ resume(Effect.succeed({
27
+ exitCode: error ? error.code ?? 1 : 0,
28
+ stdout,
29
+ stderr,
30
+ }));
31
+ });
32
+ // secret-tool store reads the password from stdin
33
+ if (stdin !== undefined) {
34
+ child.stdin?.write(stdin);
35
+ child.stdin?.end();
36
+ }
37
+ });
38
+ const make = KeychainAccess.of({
39
+ set: Effect.fn("LinuxSecretServiceAccess.set")(function* (service, account, password) {
40
+ // secret-tool store --label="<label>" <attribute> <value> ...
41
+ // Password is read from stdin
42
+ const result = yield* run([
43
+ "store",
44
+ "--label",
45
+ `secenv:${service}/${account}`,
46
+ "service",
47
+ service,
48
+ "account",
49
+ account,
50
+ ], password);
51
+ if (result.exitCode !== 0) {
52
+ return yield* new KeychainError({
53
+ command: "store",
54
+ stderr: result.stderr,
55
+ message: `Failed to store secret: ${service}/${account}`,
56
+ });
57
+ }
58
+ }),
59
+ get: Effect.fn("LinuxSecretServiceAccess.get")(function* (service, account) {
60
+ // secret-tool lookup <attribute> <value> ...
61
+ const result = yield* run([
62
+ "lookup",
63
+ "service",
64
+ service,
65
+ "account",
66
+ account,
67
+ ]);
68
+ // secret-tool returns exit 0 with empty stdout when not found
69
+ if (result.exitCode !== 0 || result.stdout === "") {
70
+ return yield* new SecretNotFoundError({
71
+ key: account,
72
+ env: service,
73
+ message: `Secret not found: ${service}/${account}`,
74
+ });
75
+ }
76
+ return result.stdout.trimEnd();
77
+ }),
78
+ remove: Effect.fn("LinuxSecretServiceAccess.remove")(function* (service, account) {
79
+ // secret-tool clear <attribute> <value> ...
80
+ const result = yield* run([
81
+ "clear",
82
+ "service",
83
+ service,
84
+ "account",
85
+ account,
86
+ ]);
87
+ if (result.exitCode !== 0) {
88
+ return yield* new KeychainError({
89
+ command: "clear",
90
+ stderr: result.stderr,
91
+ message: `Failed to remove secret: ${service}/${account}`,
92
+ });
93
+ }
94
+ }),
95
+ });
96
+ export const LinuxSecretServiceAccessLive = Layer.succeed(KeychainAccess, make);
@@ -0,0 +1,23 @@
1
+ import { platform } from "node:os";
2
+ import { MacOsKeychainAccessLive } from "./MacOsKeychainAccess.js";
3
+ import { LinuxSecretServiceAccessLive } from "./LinuxSecretServiceAccess.js";
4
+ import { WindowsCredentialManagerAccessLive } from "./WindowsCredentialManagerAccess.js";
5
+ /**
6
+ * Auto-detects the current OS and provides the appropriate KeychainAccess layer.
7
+ *
8
+ * - macOS: uses `security` CLI (Keychain)
9
+ * - Linux: uses `secret-tool` (libsecret / Secret Service API)
10
+ * - Windows: uses PowerShell + Credential Manager (advapi32 CredRead/cmdkey)
11
+ */
12
+ export const PlatformKeychainAccessLive = (() => {
13
+ switch (platform()) {
14
+ case "darwin":
15
+ return MacOsKeychainAccessLive;
16
+ case "linux":
17
+ return LinuxSecretServiceAccessLive;
18
+ case "win32":
19
+ return WindowsCredentialManagerAccessLive;
20
+ default:
21
+ throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
22
+ }
23
+ })();
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import { Effect, Layer } from "effect";
3
+ import { KeychainAccess } from "../services/KeychainAccess.js";
4
+ import { KeychainError, SecretNotFoundError } from "../errors.js";
5
+ /**
6
+ * Windows implementation using PowerShell + Windows Credential Manager.
7
+ *
8
+ * Uses the built-in `cmdkey` for basic operations and PowerShell's
9
+ * `System.Net.NetworkCredential` / `CredentialManager` for read/write.
10
+ *
11
+ * No extra dependencies required — uses only built-in Windows APIs via PowerShell.
12
+ *
13
+ * Credential target format: "secenv:<service>/<account>"
14
+ */
15
+ const runPowerShell = (script) => Effect.async((resume) => {
16
+ execFile("powershell.exe", [
17
+ "-NoProfile",
18
+ "-NonInteractive",
19
+ "-Command",
20
+ script,
21
+ ], (error, stdout, stderr) => {
22
+ if (error && error.code === "ENOENT") {
23
+ resume(Effect.fail(new KeychainError({
24
+ command: "powershell",
25
+ stderr: "powershell.exe not found",
26
+ message: "PowerShell is not available. Ensure you are running on Windows.",
27
+ })));
28
+ return;
29
+ }
30
+ resume(Effect.succeed({
31
+ exitCode: error ? error.code ?? 1 : 0,
32
+ stdout,
33
+ stderr,
34
+ }));
35
+ });
36
+ });
37
+ const escapePS = (s) => s.replaceAll("'", "''");
38
+ const targetName = (service, account) => `secenv:${service}/${account}`;
39
+ const make = KeychainAccess.of({
40
+ set: Effect.fn("WindowsCredentialManagerAccess.set")(function* (service, account, password) {
41
+ const target = escapePS(targetName(service, account));
42
+ const user = escapePS(account);
43
+ const pass = escapePS(password);
44
+ // Use cmdkey for simplicity — it's built-in and handles generic credentials
45
+ const script = `cmdkey /generic:'${target}' /user:'${user}' /pass:'${pass}'`;
46
+ const result = yield* runPowerShell(script);
47
+ if (result.exitCode !== 0) {
48
+ return yield* new KeychainError({
49
+ command: "cmdkey /add",
50
+ stderr: result.stderr || result.stdout,
51
+ message: `Failed to store credential: ${service}/${account}`,
52
+ });
53
+ }
54
+ }),
55
+ get: Effect.fn("WindowsCredentialManagerAccess.get")(function* (service, account) {
56
+ const target = escapePS(targetName(service, account));
57
+ // Read credential using .NET CredentialManager API via PowerShell
58
+ // This is the only reliable way to read the password back from Credential Manager
59
+ const script = [
60
+ `Add-Type -AssemblyName System.Runtime.InteropServices`,
61
+ `$cred = [System.Runtime.InteropServices.Marshal]`,
62
+ `$target = '${target}'`,
63
+ // Use P/Invoke to call CredReadW
64
+ `Add-Type @'`,
65
+ `using System;`,
66
+ `using System.Runtime.InteropServices;`,
67
+ `public class CredManager {`,
68
+ ` [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]`,
69
+ ` public static extern bool CredRead(string target, int type, int flags, out IntPtr cred);`,
70
+ ` [DllImport("advapi32.dll")]`,
71
+ ` public static extern void CredFree(IntPtr cred);`,
72
+ ` [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]`,
73
+ ` public struct CREDENTIAL {`,
74
+ ` public int Flags; public int Type;`,
75
+ ` public string TargetName; public string Comment;`,
76
+ ` public long LastWritten; public int CredentialBlobSize;`,
77
+ ` public IntPtr CredentialBlob; public int Persist;`,
78
+ ` public int AttributeCount; public IntPtr Attributes;`,
79
+ ` public string TargetAlias; public string UserName;`,
80
+ ` }`,
81
+ ` public static string Read(string target) {`,
82
+ ` IntPtr ptr;`,
83
+ ` if (!CredRead(target, 1, 0, out ptr)) return null;`,
84
+ ` var c = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL));`,
85
+ ` var pw = Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);`,
86
+ ` CredFree(ptr);`,
87
+ ` return pw;`,
88
+ ` }`,
89
+ `}`,
90
+ `'@`,
91
+ `$result = [CredManager]::Read('${target}')`,
92
+ `if ($result -eq $null) { exit 1 }`,
93
+ `Write-Output $result`,
94
+ ].join("\n");
95
+ const result = yield* runPowerShell(script);
96
+ if (result.exitCode !== 0) {
97
+ return yield* new SecretNotFoundError({
98
+ key: account,
99
+ env: service,
100
+ message: `Secret not found: ${service}/${account}`,
101
+ });
102
+ }
103
+ return result.stdout.trim();
104
+ }),
105
+ remove: Effect.fn("WindowsCredentialManagerAccess.remove")(function* (service, account) {
106
+ const target = escapePS(targetName(service, account));
107
+ const script = `cmdkey /delete:'${target}'`;
108
+ const result = yield* runPowerShell(script);
109
+ if (result.exitCode !== 0) {
110
+ return yield* new KeychainError({
111
+ command: "cmdkey /delete",
112
+ stderr: result.stderr || result.stdout,
113
+ message: `Failed to remove credential: ${service}/${account}`,
114
+ });
115
+ }
116
+ }),
117
+ });
118
+ export const WindowsCredentialManagerAccessLive = Layer.succeed(KeychainAccess, make);
package/dist/main.js CHANGED
@@ -9,10 +9,12 @@ import { getCommand } from "./cli/read.js";
9
9
  import { deleteCommand, delCommand } from "./cli/delete.js";
10
10
  import { searchCommand } from "./cli/search.js";
11
11
  import { listCommand } from "./cli/list.js";
12
+ import { runCommand } from "./cli/run.js";
13
+ import { envFileCommand } from "./cli/env-file.js";
12
14
  import { SecretStore } from "./services/SecretStore.js";
13
15
  const require = createRequire(import.meta.url);
14
16
  const pkg = require("../package.json");
15
- const command = rootCommand.pipe(Command.withSubcommands([addCommand, getCommand, deleteCommand, delCommand, searchCommand, listCommand]));
17
+ const command = rootCommand.pipe(Command.withSubcommands([addCommand, getCommand, deleteCommand, delCommand, searchCommand, listCommand, runCommand, envFileCommand]));
16
18
  const cli = Command.run(command, {
17
19
  name: "secenv",
18
20
  version: pkg.version,
@@ -2,11 +2,11 @@ import { Effect } from "effect";
2
2
  import { KeychainAccess } from "./KeychainAccess.js";
3
3
  import { MetadataStore } from "./MetadataStore.js";
4
4
  import * as SecretKey from "../domain/SecretKey.js";
5
- import { MacOsKeychainAccessLive } from "../implementations/MacOsKeychainAccess.js";
5
+ import { PlatformKeychainAccessLive } from "../implementations/PlatformKeychainAccess.js";
6
6
  import { SqliteMetadataStoreLive } from "../implementations/SqliteMetadataStore.js";
7
7
  export class SecretStore extends Effect.Service()("SecretStore", {
8
8
  accessors: true,
9
- dependencies: [MacOsKeychainAccessLive, SqliteMetadataStoreLive],
9
+ dependencies: [PlatformKeychainAccessLive, SqliteMetadataStoreLive],
10
10
  effect: Effect.gen(function* () {
11
11
  const keychain = yield* KeychainAccess;
12
12
  const metadata = yield* MetadataStore;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "envsec",
3
- "version": "0.1.4",
4
- "description": "Secure environment secrets management using macOS Keychain",
3
+ "version": "0.1.6",
4
+ "description": "Secure environment secrets management using native OS credential stores",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "secenv": "./dist/main.js"
@@ -12,9 +12,6 @@
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
14
14
  },
15
- "os": [
16
- "darwin"
17
- ],
18
15
  "scripts": {
19
16
  "build": "tsc",
20
17
  "prepublishOnly": "npm run build"
@@ -23,7 +20,12 @@
23
20
  "secrets",
24
21
  "environment-variables",
25
22
  "keychain",
23
+ "credential-manager",
24
+ "libsecret",
25
+ "cross-platform",
26
26
  "macos",
27
+ "linux",
28
+ "windows",
27
29
  "effect",
28
30
  "cli"
29
31
  ],