Skip to content

Commit fc8f215

Browse files
committed
feat(cli): exec enterprise + deploy + keychain (alpha.7)
Three operator-facing features that close the gap between sealed-env the library and a production-grade deploy workflow. 1. `sealed-env exec` learns enterprise mode When the sealed file is enterprise, exec mints the unseal token in memory (prompting for TOTP if not given via --totp), uses it to decrypt, and injects only the resulting plaintext env vars into the child process. Master/signing/TOTP/token are STRIPPED from the child's environment — the application sees DATABASE_URL etc., never SEALED_ENV_KEY. 2. `sealed-env deploy [-- <command>]` wrapper Production deploy wrapper around exec. Auto-detects deploy_id from `git rev-parse HEAD`, refuses to run with a dirty tree, optionally polls a health URL after the command. Replaces the typical hand-rolled 130-line deploy.sh with one command. 3. `sealed-env keychain push/pull/status/clear` OS-native encrypted storage for SEALED_ENV_* secrets, replacing plaintext .env.local. Cross-platform via shell-out (no native deps): Windows: DPAPI under %LOCALAPPDATA%\sealed-env\*.bin macOS: security CLI (system Keychain) Linux: secret-tool (libsecret) Auto-loader prefers keychain over .env.local. Status prints SHA-256 fingerprints per entry (no values), safe for logs. Plus: - `sealed-env unseal --token-only` for clean shell-script usage: TOKEN=$(sealed-env unseal --token-only ...) - Auto-load source string in stderr hint (keychain vs file). - Fixed init.ts inline-comment-in-.env.local bug that confused the auto-load parser. - Refactored token-mint flow into utils/token.ts shared by exec, deploy, and unseal. Architecture: host-side decrypt. The operator's machine does the full unseal; only plaintext env vars reach the container. Master keys never touch the deploy host (when used with DOCKER_HOST=ssh://...). Bumps to 0.1.0-alpha.7. No wire-format changes.
1 parent e6d6720 commit fc8f215

15 files changed

Lines changed: 1084 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,87 @@ files written today will remain readable forever. See [SPEC.md](./SPEC.md).
1414

1515
---
1616

17+
## [0.1.0-alpha.7] — 2026-05-07
18+
19+
Operator ergonomics + hardened key storage. **No wire-format changes**
20+
files sealed by previous `0.1.0-alpha.x` releases (≥ alpha.4) decrypt
21+
cleanly on `0.1.0-alpha.7`.
22+
23+
### Added
24+
25+
- **`sealed-env exec` now handles enterprise mode end-to-end.** When the
26+
file is enterprise, exec mints the unseal token IN MEMORY (prompting
27+
for the TOTP code if `--totp` not given), uses it to decrypt, and
28+
injects the resulting `KEY=value` pairs into the child process. The
29+
raw token never appears in stdout/stderr/disk. Master/signing/TOTP
30+
credentials are also stripped from the child's environment, so the
31+
application sees only `DATABASE_URL` etc., never `SEALED_ENV_KEY`.
32+
33+
```sh
34+
# Single-line replacement for a 130-line deploy.sh:
35+
sealed-env exec --file .env.sealed --deploy-id $(git rev-parse HEAD) \
36+
-- docker compose up -d --build status
37+
```
38+
39+
- **`sealed-env deploy [-- <command>]`** — production deploy wrapper
40+
around `exec` that auto-detects `deploy_id` from `git rev-parse HEAD`,
41+
refuses to run with a dirty working tree (uncommitted changes would
42+
silently NOT be in the build), and optionally polls a health URL after
43+
the command finishes. Replaces the standard hand-rolled deploy.sh
44+
pattern with a single command.
45+
46+
```sh
47+
sealed-env deploy \
48+
--health-url http://127.0.0.1:8090/actuator/health \
49+
-- docker compose up -d --build status
50+
```
51+
52+
- **`sealed-env keychain push|pull|status|clear`** — store
53+
`SEALED_ENV_*` secrets in the OS-native encrypted keychain instead of
54+
`.env.local` plaintext. Cross-platform via shell-out (no native deps):
55+
- Windows: DPAPI (`%LOCALAPPDATA%\sealed-env\*.bin`, encrypted with
56+
the user login key, inaccessible to other users on the machine).
57+
- macOS: `security` CLI (system Keychain).
58+
- Linux: `secret-tool` (libsecret / GNOME Keyring / KWallet).
59+
60+
After `keychain push`, the auto-loader prefers the keychain over
61+
`.env.local`. The `status` subcommand prints a SHA-256 fingerprint
62+
per entry (no values), safe for logs and support threads.
63+
64+
- **`sealed-env unseal --token-only`** — emit just the token, no
65+
surrounding human-readable text. Designed for shell scripts:
66+
`TOKEN=$(sealed-env unseal --token-only --file ... --totp ...)`.
67+
No more parsing through `grep -oE 'usl_...'`.
68+
69+
### Changed
70+
71+
- **Auto-load priority** is now: `process.env` → OS keychain →
72+
`.env.local`. CI/explicit env vars still win. The startup hint
73+
on stderr now reads `(loaded N SEALED_ENV_* vars from OS keychain)`
74+
or `(... from .env.local)` so users know where their keys came from.
75+
76+
- **`init` no longer writes inline comments** in `.env.local`. The
77+
TOTP-secret-is-base32 hint moved to its own comment line above. The
78+
old inline form (`SECRET=value # base32`) confused the auto-load
79+
parser, treating the comment as part of the value.
80+
81+
### Architecture note: host-side decrypt
82+
83+
This release enables a meaningful security upgrade for production
84+
deploys. With `sealed-env exec` / `sealed-env deploy`, the operator's
85+
machine does the full unseal (master key + signing key + TOTP secret +
86+
mint token) and only injects the resulting plaintext env vars into the
87+
container. The container — and the deploy host, if you deploy from
88+
laptop via `DOCKER_HOST=ssh://...` — never sees the master key,
89+
signing key, TOTP secret, or unseal token.
90+
91+
In contrast, the Spring Boot starter approach (which still works, no
92+
breaking change) requires those credentials on the deploy host. For
93+
single-instance deploys that's fine; for multi-container fleets the
94+
starter is still the right call. Both are documented.
95+
96+
---
97+
1798
## [0.1.0-alpha.6] — 2026-05-07
1899

19100
UX release. **No wire-format changes** — files sealed by previous

docs/07-operational-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ Add the starter to `pom.xml`:
195195
<dependency>
196196
<groupId>io.github.davidalmeidac</groupId>
197197
<artifactId>sealed-env-spring-boot-starter</artifactId>
198-
<version>0.1.0-alpha.6</version>
198+
<version>0.1.0-alpha.7</version>
199199
</dependency>
200200
```
201201

java/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<groupId>io.github.davidalmeidac</groupId>
99
<artifactId>sealed-env-parent</artifactId>
10-
<version>0.1.0-alpha.6</version>
10+
<version>0.1.0-alpha.7</version>
1111
<packaging>pom</packaging>
1212

1313
<name>sealed-env (parent)</name>

java/sealed-env-core/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<parent>
99
<groupId>io.github.davidalmeidac</groupId>
1010
<artifactId>sealed-env-parent</artifactId>
11-
<version>0.1.0-alpha.6</version>
11+
<version>0.1.0-alpha.7</version>
1212
</parent>
1313

1414
<artifactId>sealed-env-core</artifactId>

java/sealed-env-spring-boot-starter/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<parent>
99
<groupId>io.github.davidalmeidac</groupId>
1010
<artifactId>sealed-env-parent</artifactId>
11-
<version>0.1.0-alpha.6</version>
11+
<version>0.1.0-alpha.7</version>
1212
</parent>
1313

1414
<artifactId>sealed-env-spring-boot-starter</artifactId>

node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sealed-env",
3-
"version": "0.1.0-alpha.6",
3+
"version": "0.1.0-alpha.7",
44
"description": "Encrypted .env files with optional TOTP unsealing for production deploys. Cross-stack with the Java port. One minimal CLI dependency.",
55
"keywords": [
66
"dotenv",

node/src/cli/commands/deploy.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* `sealed-env deploy [--health-url <url>] -- <command> [args...]`
3+
*
4+
* Production deploy wrapper around `exec`. Adds the safety rails that
5+
* a hand-rolled deploy.sh would otherwise have to implement:
6+
*
7+
* - Auto-detects `deploy_id` from `git rev-parse HEAD`.
8+
* - Refuses to deploy with a dirty working tree (uncommitted changes
9+
* would silently NOT be in the build, since the deploy_id binds to
10+
* the committed sha).
11+
* - Prompts the operator for the TOTP code (hidden from logs).
12+
* - Mints the unseal token IN MEMORY and injects only the resulting
13+
* plaintext env vars into the child. The master/signing/TOTP
14+
* credentials never reach the child process or stdout.
15+
* - Optionally polls a health endpoint after the command exits.
16+
*
17+
* $ sealed-env deploy -- docker compose up -d --build status
18+
* $ sealed-env deploy --health-url http://127.0.0.1:8090/actuator/health -- ./up.sh
19+
*
20+
* For the file path, defaults to `.env.sealed`. Override with --file.
21+
*
22+
* Compared to a hand-written deploy.sh: ~5 lines instead of ~130, and
23+
* the token never appears in stdout (no fragile grep).
24+
*/
25+
26+
import { execSync } from 'node:child_process';
27+
28+
import { SealedEnvError } from '../../core/errors.js';
29+
import { execCommand } from './exec.js';
30+
import { parseFlags } from '../utils/flags.js';
31+
32+
export async function deployCommand(argv: string[]): Promise<void> {
33+
const sepIndex = argv.indexOf('--');
34+
if (sepIndex === -1) {
35+
throw new SealedEnvError(
36+
'CONFIG_ERROR',
37+
'usage: sealed-env deploy [--file <path>] [--health-url <url>] [--health-timeout <s>] [--allow-dirty] -- <command> [args...]\n' +
38+
'\nThe `--` separator marks where sealed-env flags end.',
39+
);
40+
}
41+
42+
const sealedArgs = argv.slice(0, sepIndex);
43+
const childArgs = argv.slice(sepIndex + 1);
44+
45+
if (childArgs.length === 0) {
46+
throw new SealedEnvError('CONFIG_ERROR', 'no command given after `--`');
47+
}
48+
49+
const { values } = parseFlags(sealedArgs, {
50+
file: { type: 'string', default: '.env.sealed' },
51+
'health-url': { type: 'string', default: '' },
52+
'health-timeout': { type: 'string', default: '30' },
53+
'allow-dirty': { type: 'boolean', default: false },
54+
totp: { type: 'string', default: '' },
55+
'deploy-id': { type: 'string', default: '' },
56+
});
57+
58+
// ── Pre-flight ────────────────────────────────────────────────
59+
let deployId = (values['deploy-id'] as string).trim();
60+
if (!deployId) {
61+
deployId = tryGitHead();
62+
}
63+
64+
const allowDirty = values['allow-dirty'] as boolean;
65+
if (!allowDirty && isInGitRepo() && isWorkingTreeDirty()) {
66+
throw new SealedEnvError(
67+
'CONFIG_ERROR',
68+
'working tree is dirty — refusing to deploy.\n' +
69+
'Commit or stash your changes first, or pass --allow-dirty (NOT recommended for prod).\n' +
70+
'The deploy_id binds to the committed sha; uncommitted changes would silently not be in the build.',
71+
);
72+
}
73+
74+
// ── Banner ────────────────────────────────────────────────────
75+
const banner = buildBanner(deployId);
76+
process.stderr.write(banner);
77+
78+
// ── Delegate to exec ──────────────────────────────────────────
79+
// exec handles: enterprise mode detection, TOTP prompt, token mint
80+
// in memory, plaintext injection, signal forwarding, exit code
81+
// propagation. We just feed it the right arguments.
82+
const execArgs: string[] = [
83+
'--file',
84+
values.file as string,
85+
];
86+
if (deployId) execArgs.push('--deploy-id', deployId);
87+
if (values.totp) execArgs.push('--totp', values.totp as string);
88+
execArgs.push('--', ...childArgs);
89+
90+
await execCommand(execArgs);
91+
92+
// If the child exited non-zero, exec already set process.exitCode.
93+
// No point health-checking a failed deploy.
94+
if (process.exitCode && process.exitCode !== 0) {
95+
return;
96+
}
97+
98+
// ── Optional health check ────────────────────────────────────
99+
const healthUrl = (values['health-url'] as string).trim();
100+
if (healthUrl) {
101+
const timeoutS = Math.max(Number(values['health-timeout']) || 30, 1);
102+
process.stderr.write(`\n▸ Waiting for ${healthUrl} (up to ${timeoutS}s)...\n`);
103+
const ok = await pollHealth(healthUrl, timeoutS * 1000);
104+
if (!ok) {
105+
process.stderr.write(`✗ health check failed after ${timeoutS}s\n`);
106+
process.exitCode = 1;
107+
return;
108+
}
109+
process.stderr.write(`✓ ${healthUrl} returned 200\n`);
110+
}
111+
112+
process.stderr.write(
113+
`\n✓ Deploy successful${deployId ? ` (${deployId.substring(0, 7)})` : ''}\n`,
114+
);
115+
}
116+
117+
function tryGitHead(): string {
118+
try {
119+
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
120+
.trim();
121+
} catch {
122+
return '';
123+
}
124+
}
125+
126+
function isInGitRepo(): boolean {
127+
try {
128+
execSync('git rev-parse --is-inside-work-tree', {
129+
stdio: ['ignore', 'ignore', 'ignore'],
130+
});
131+
return true;
132+
} catch {
133+
return false;
134+
}
135+
}
136+
137+
function isWorkingTreeDirty(): boolean {
138+
try {
139+
execSync('git diff-index --quiet HEAD --', {
140+
stdio: ['ignore', 'ignore', 'ignore'],
141+
});
142+
return false;
143+
} catch {
144+
return true;
145+
}
146+
}
147+
148+
function buildBanner(deployId: string): string {
149+
const short = deployId ? deployId.substring(0, 7) : '(no git)';
150+
let branch = '';
151+
let subject = '';
152+
try {
153+
branch = execSync('git rev-parse --abbrev-ref HEAD', {
154+
encoding: 'utf8',
155+
stdio: ['ignore', 'pipe', 'ignore'],
156+
}).trim();
157+
subject = execSync('git log -1 --pretty=%s', {
158+
encoding: 'utf8',
159+
stdio: ['ignore', 'pipe', 'ignore'],
160+
})
161+
.trim()
162+
.substring(0, 60);
163+
} catch {
164+
/* not a git repo, skip */
165+
}
166+
return [
167+
'',
168+
' ┌─ sealed-env deploy ──────────────────────────────────────┐',
169+
` │ branch: ${branch || '(unknown)'}`,
170+
` │ commit: ${short}`,
171+
...(subject ? [` │ message: ${subject}`] : []),
172+
' └──────────────────────────────────────────────────────────┘',
173+
'',
174+
].join('\n');
175+
}
176+
177+
/**
178+
* Poll an HTTP endpoint until it returns 2xx or the timeout elapses.
179+
* Uses Node's built-in fetch (Node 18+). 1s between attempts.
180+
*/
181+
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
182+
const deadline = Date.now() + timeoutMs;
183+
while (Date.now() < deadline) {
184+
try {
185+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
186+
if (res.ok) return true;
187+
} catch {
188+
/* swallow, keep retrying */
189+
}
190+
await new Promise((r) => setTimeout(r, 1000));
191+
}
192+
return false;
193+
}

0 commit comments

Comments
 (0)