envpkt 0.13.1 → 0.13.3
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 +95 -65
- package/dist/cli.js +201 -43
- package/dist/index.d.ts +27 -1
- package/dist/index.js +55 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,43 +5,38 @@
|
|
|
5
5
|
|
|
6
6
|
**Credentials your agents actually understand.**
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
envpkt gives every credential an `envpkt.toml` entry describing _what service it authenticates to_, _what it's allowed to do_, _when it expires_, and _how to rotate it_ — while the secret values stay in your secrets manager, encrypted at rest, or injected at runtime, never committed in plaintext.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**Day one, with zero agents,** it's an encrypted-at-rest `.env` replacement with scoped loading: scan the credentials already in your shell, seal them into a file that's safe to commit, and load them automatically on `cd`, into a single command, or as a plain `.env` for any tool that wants one.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
**As you add agents,** the same metadata gives them structured awareness of their own credentials over [MCP](#for-agents-and-fleets) — capabilities, expiry, drift, fleet health — without any secret value ever entering the model's context window.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
## Quick Start
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
"mcpServers": {
|
|
19
|
-
"envpkt": {
|
|
20
|
-
"command": "envpkt",
|
|
21
|
-
"args": ["mcp"]
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
```
|
|
16
|
+
The whole loop for a single project — discover, seal, load — with zero agents involved:
|
|
26
17
|
|
|
27
|
-
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g envpkt
|
|
28
20
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
# 1. Discover the credentials already in your shell, and scaffold envpkt.toml from them
|
|
22
|
+
envpkt env scan
|
|
23
|
+
envpkt env scan --write
|
|
24
|
+
|
|
25
|
+
# 2. Generate an age key and seal the secret values into envpkt.toml (the file is safe to commit)
|
|
26
|
+
envpkt keygen
|
|
27
|
+
envpkt seal
|
|
36
28
|
|
|
37
|
-
|
|
29
|
+
# 3. Load them — pick whatever fits the moment:
|
|
30
|
+
envpkt exec -- your-tool # run one command with the secrets injected, scoped to it
|
|
31
|
+
eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc: auto-load on cd into a project, restore on leave
|
|
32
|
+
envpkt env dotenv -o .env # materialize a .env for Docker / Wrangler / Vite / …
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
# Anytime: check health and drift
|
|
35
|
+
envpkt audit
|
|
36
|
+
envpkt env check
|
|
37
|
+
```
|
|
43
38
|
|
|
44
|
-
|
|
39
|
+
Encrypted secrets committed to git, loaded where you need them, with health you can audit — and not an agent in sight. Scaling the same metadata to agents and fleets is [act two](#for-agents-and-fleets).
|
|
45
40
|
|
|
46
41
|
## Security Model
|
|
47
42
|
|
|
@@ -53,30 +48,6 @@ envpkt operates a three-tier trust model. Each tier has different guarantees, an
|
|
|
53
48
|
|
|
54
49
|
**Tier 3: Shell-level agents** — Agents with shell access (Claude Code, Devin, etc.) can read environment variables directly. Prevention isn't possible at this tier. envpkt provides encrypted storage, scoped access, and audit trails — because when prevention isn't possible, visibility is what matters.
|
|
55
50
|
|
|
56
|
-
## Quick Start
|
|
57
|
-
|
|
58
|
-
Start where your credentials already are — environment variables — and graduate to encrypted, per-agent-scoped metadata.
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
# Install
|
|
62
|
-
npm install -g envpkt
|
|
63
|
-
|
|
64
|
-
# Auto-discover credentials from your shell environment
|
|
65
|
-
envpkt env scan
|
|
66
|
-
|
|
67
|
-
# Scaffold envpkt.toml from discovered credentials
|
|
68
|
-
envpkt env scan --write
|
|
69
|
-
|
|
70
|
-
# Audit credential health
|
|
71
|
-
envpkt audit
|
|
72
|
-
|
|
73
|
-
# Check for drift between envpkt.toml and live environment
|
|
74
|
-
envpkt env check
|
|
75
|
-
|
|
76
|
-
# Scan a directory tree of agents
|
|
77
|
-
envpkt fleet
|
|
78
|
-
```
|
|
79
|
-
|
|
80
51
|
## The envpkt.toml File
|
|
81
52
|
|
|
82
53
|
Every project gets one `envpkt.toml` that describes its credentials. Here's a minimal example:
|
|
@@ -236,6 +207,44 @@ A composite action resolves the credentials in `envpkt.toml` into the CI job —
|
|
|
236
207
|
|
|
237
208
|
> Decrypting sealed packets requires the [`age`](https://github.com/FiloSottile/age) CLI on the runner (install it first, as above) — not needed if you only inject plaintext `[env.*]` defaults or resolve via fnox. Pin to a released tag (e.g. `@v0.12.0`); no moving major tag (`@v1`) is published yet. Node is assumed present; add `actions/setup-node` first to pin a version.
|
|
238
209
|
|
|
210
|
+
## For agents and fleets
|
|
211
|
+
|
|
212
|
+
Everything above stands on its own with no agents involved. Once you _do_ have them, the same `envpkt.toml` metadata powers three more capabilities: agents reading their own constraints over MCP, fleet-wide health monitoring, and shared catalogs across many agents.
|
|
213
|
+
|
|
214
|
+
### MCP server
|
|
215
|
+
|
|
216
|
+
envpkt ships an [MCP](https://modelcontextprotocol.io/) server that gives AI agents structured awareness of their credentials. Add it to Claude, Cursor, VS Code, or any MCP-compatible client:
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"mcpServers": {
|
|
221
|
+
"envpkt": {
|
|
222
|
+
"command": "envpkt",
|
|
223
|
+
"args": ["mcp"]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Tools**
|
|
230
|
+
|
|
231
|
+
| Tool | Description |
|
|
232
|
+
| ------------------ | ------------------------------------------------------- |
|
|
233
|
+
| `getPacketHealth` | Get overall health status with per-secret audit results |
|
|
234
|
+
| `listCapabilities` | List agent and per-secret capabilities |
|
|
235
|
+
| `getSecretMeta` | Get metadata for a specific secret by key |
|
|
236
|
+
| `checkExpiration` | Check expiration status and days remaining |
|
|
237
|
+
| `getEnvMeta` | Get metadata for environment defaults and drift status |
|
|
238
|
+
|
|
239
|
+
**Resources**
|
|
240
|
+
|
|
241
|
+
| URI | Description |
|
|
242
|
+
| ----------------------- | --------------------------------- |
|
|
243
|
+
| `envpkt://health` | Current credential health summary |
|
|
244
|
+
| `envpkt://capabilities` | Agent and secret capabilities |
|
|
245
|
+
|
|
246
|
+
The MCP server exposes metadata only — it reads `envpkt.toml` and strips any `encrypted_value` ciphertext from responses, so prompt injection cannot leak what isn't there. See [Security Model](#security-model) for the full trust model.
|
|
247
|
+
|
|
239
248
|
## Fleet Management
|
|
240
249
|
|
|
241
250
|
When you're running multiple agents, `envpkt fleet` scans a directory tree for `envpkt.toml` files and aggregates credential health across your entire fleet.
|
|
@@ -355,6 +364,19 @@ envpkt audit -c path/to/envpkt.toml # Specify config path
|
|
|
355
364
|
|
|
356
365
|
Exit codes: `0` = healthy, `1` = degraded, `2` = critical.
|
|
357
366
|
|
|
367
|
+
### `envpkt doctor`
|
|
368
|
+
|
|
369
|
+
One-shot environment check: is the `age` CLI installed, is a config resolvable here, and do its sealed secrets decrypt with an available key?
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
envpkt doctor
|
|
373
|
+
# ✓ age v1.2.0
|
|
374
|
+
# ✓ config /path/to/envpkt.toml
|
|
375
|
+
# ✓ secrets 5 resolved, 0 skipped
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
If `age` is missing it prints the platform-specific install command; if a sealed packet has no key, it lists the paths it searched. Exits non-zero when a check fails.
|
|
379
|
+
|
|
358
380
|
### `envpkt resolve`
|
|
359
381
|
|
|
360
382
|
Resolve catalog references and output a flat, self-contained config.
|
|
@@ -383,6 +405,25 @@ envpkt inspect --secrets --plaintext # Show secret values in plaintext
|
|
|
383
405
|
|
|
384
406
|
The `--secrets` flag reads values from environment variables matching each secret key. By default values are masked (`pos•••••yapp`). Add `--plaintext` to display full values.
|
|
385
407
|
|
|
408
|
+
### `envpkt diff`
|
|
409
|
+
|
|
410
|
+
Compare two configs — useful for spotting drift between environments (e.g. `dev.envpkt.toml` vs `prod.envpkt.toml`). Reports keys only in each side and field-level metadata changes for shared keys. Sealed ciphertext is ignored (the same secret re-encrypts differently); a sealed↔unsealed change is reported.
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
envpkt diff dev.envpkt.toml prod.envpkt.toml
|
|
414
|
+
# - dev.envpkt.toml
|
|
415
|
+
# + prod.envpkt.toml
|
|
416
|
+
#
|
|
417
|
+
# [secret]
|
|
418
|
+
# - OLD_KEY
|
|
419
|
+
# + NEW_KEY
|
|
420
|
+
# ~ API_KEY
|
|
421
|
+
# expires: 2026-01-01 → 2027-01-01
|
|
422
|
+
|
|
423
|
+
envpkt diff a.toml b.toml --format json # structured diff
|
|
424
|
+
envpkt diff a.toml b.toml --exit-code # exit non-zero on any difference (CI drift gate)
|
|
425
|
+
```
|
|
426
|
+
|
|
386
427
|
### `envpkt exec`
|
|
387
428
|
|
|
388
429
|
Run a pre-flight audit, inject secrets from fnox into the environment, then execute a command.
|
|
@@ -470,7 +511,8 @@ Secret values are emitted **only when the package sets top-level `scope = "shell
|
|
|
470
511
|
Generate a `cd` hook (zsh/bash) that loads a project's credentials when you enter its directory tree and restores your environment when you leave:
|
|
471
512
|
|
|
472
513
|
```bash
|
|
473
|
-
eval "$(envpkt shell-hook zsh)"
|
|
514
|
+
eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc (or: shell-hook bash)
|
|
515
|
+
eval "$(envpkt shell-hook zsh --no-audit)" # …without the per-cd health-check line
|
|
474
516
|
```
|
|
475
517
|
|
|
476
518
|
On each directory change it resolves the **nearest `envpkt.toml`, walking up from the current directory** (like `git`/`direnv` — so it works from any subdirectory, not just the project root), injects that package via `env export --track`, and restores the previous package on leave (prior values, not a blind unset). Env defaults always load; secret values load only for `scope = "shell"` packages. Backed by `envpkt config-path` — a resolve-only command that prints the active config path (no decryption).
|
|
@@ -486,18 +528,6 @@ Inject resolved secrets into a GitHub Actions job. Emits `::add-mask::` for each
|
|
|
486
528
|
npx envpkt env github --strict
|
|
487
529
|
```
|
|
488
530
|
|
|
489
|
-
### `envpkt shell-hook`
|
|
490
|
-
|
|
491
|
-
Output a shell function that runs `envpkt audit --format minimal` whenever you `cd` into a directory. envpkt's config discovery chain automatically finds config files beyond CWD (see [Config Resolution](#config-resolution)), so the hook works even in directories without a local `envpkt.toml`.
|
|
492
|
-
|
|
493
|
-
```bash
|
|
494
|
-
# Add to your .zshrc
|
|
495
|
-
eval "$(envpkt shell-hook zsh)"
|
|
496
|
-
|
|
497
|
-
# Add to your .bashrc
|
|
498
|
-
eval "$(envpkt shell-hook bash)"
|
|
499
|
-
```
|
|
500
|
-
|
|
501
531
|
### `envpkt mcp`
|
|
502
532
|
|
|
503
533
|
Start the envpkt MCP server (stdio transport) for AI agent integration.
|
package/dist/cli.js
CHANGED
|
@@ -8,10 +8,10 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
|
8
8
|
import { Env, Fs, Path, Platform } from "functype-os";
|
|
9
9
|
import { TomlDate, parse, stringify } from "smol-toml";
|
|
10
10
|
import { FormatRegistry, Type } from "@sinclair/typebox";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import { directSilentLogger } from "functype-log/direct";
|
|
14
|
-
import {
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
17
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -557,6 +557,63 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
|
557
557
|
}));
|
|
558
558
|
};
|
|
559
559
|
//#endregion
|
|
560
|
+
//#region src/fnox/identity.ts
|
|
561
|
+
/** Check if the age CLI is available on PATH */
|
|
562
|
+
const ageAvailable = () => Try(() => {
|
|
563
|
+
execFileSync("age", ["--version"], { stdio: "pipe" });
|
|
564
|
+
return true;
|
|
565
|
+
}).fold(() => false, (v) => v);
|
|
566
|
+
/** The age CLI version string (e.g. "v1.2.0"), or None if age isn't on PATH. */
|
|
567
|
+
const ageVersion = () => Try(() => execFileSync("age", ["--version"], {
|
|
568
|
+
stdio: [
|
|
569
|
+
"pipe",
|
|
570
|
+
"pipe",
|
|
571
|
+
"pipe"
|
|
572
|
+
],
|
|
573
|
+
encoding: "utf-8"
|
|
574
|
+
}).trim()).fold(() => Option.none(), (v) => Option(v));
|
|
575
|
+
/** Platform-aware instructions for installing the age CLI. */
|
|
576
|
+
const ageInstallHint = () => {
|
|
577
|
+
return `Install age:\n ${{
|
|
578
|
+
darwin: "brew install age",
|
|
579
|
+
linux: "apt install age (or: apk add age · dnf install age · nix-env -iA nixpkgs.age)",
|
|
580
|
+
win32: "scoop install age (or: winget install FiloSottile.age)"
|
|
581
|
+
}[process.platform] ?? "see the install guide"}\n https://github.com/FiloSottile/age#installation`;
|
|
582
|
+
};
|
|
583
|
+
/**
|
|
584
|
+
* Extract the secret key from an age identity file (plain or encrypted).
|
|
585
|
+
* - Plain identity files (from `age-keygen`) contain `AGE-SECRET-KEY-*` lines directly
|
|
586
|
+
* - Encrypted identity files need `age --decrypt` to unwrap
|
|
587
|
+
*/
|
|
588
|
+
const unwrapAgentKey = (identityPath) => {
|
|
589
|
+
if (!existsSync(identityPath)) return Left({
|
|
590
|
+
_tag: "IdentityNotFound",
|
|
591
|
+
path: identityPath
|
|
592
|
+
});
|
|
593
|
+
return Try(() => readFileSync(identityPath, "utf-8")).fold((err) => Left({
|
|
594
|
+
_tag: "DecryptFailed",
|
|
595
|
+
message: `Failed to read identity file: ${err}`
|
|
596
|
+
}), (content) => {
|
|
597
|
+
const secretKeyLine = content.split("\n").find((l) => l.startsWith("AGE-SECRET-KEY-"));
|
|
598
|
+
if (secretKeyLine) return Right(secretKeyLine.trim());
|
|
599
|
+
if (!ageAvailable()) return Left({
|
|
600
|
+
_tag: "AgeNotFound",
|
|
601
|
+
message: "age CLI not found on PATH"
|
|
602
|
+
});
|
|
603
|
+
return Try(() => execFileSync("age", ["--decrypt", identityPath], {
|
|
604
|
+
stdio: [
|
|
605
|
+
"pipe",
|
|
606
|
+
"pipe",
|
|
607
|
+
"pipe"
|
|
608
|
+
],
|
|
609
|
+
encoding: "utf-8"
|
|
610
|
+
})).fold((err) => Left({
|
|
611
|
+
_tag: "DecryptFailed",
|
|
612
|
+
message: `age decrypt failed: ${err}`
|
|
613
|
+
}), (output) => Right(output.trim()));
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
//#endregion
|
|
560
617
|
//#region src/cli/output.ts
|
|
561
618
|
const RESET = "\x1B[0m";
|
|
562
619
|
const BOLD = "\x1B[1m";
|
|
@@ -659,7 +716,7 @@ const formatError = (error) => {
|
|
|
659
716
|
case "ParseError": return `${RED}Error:${RESET} Failed to parse TOML: ${error.message}`;
|
|
660
717
|
case "ValidationError": return `${RED}Error:${RESET} Config validation failed:\n${String(error.errors)}`;
|
|
661
718
|
case "ReadError": return `${RED}Error:${RESET} Could not read file: ${error.message}`;
|
|
662
|
-
case "AgeNotFound": return `${RED}Error:${RESET} age
|
|
719
|
+
case "AgeNotFound": return `${RED}Error:${RESET} age is required for this operation but was not found on PATH.\n${DIM}${ageInstallHint()}${RESET}`;
|
|
663
720
|
case "DecryptFailed": return `${RED}Error:${RESET} Decrypt failed: ${error.message}`;
|
|
664
721
|
case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
|
|
665
722
|
case "SealKeyUnavailable": {
|
|
@@ -873,6 +930,90 @@ const runConfigPath = (options) => {
|
|
|
873
930
|
});
|
|
874
931
|
};
|
|
875
932
|
//#endregion
|
|
933
|
+
//#region src/core/diff.ts
|
|
934
|
+
/** Normalize a metadata value to a comparable/displayable string (`undefined` = absent). */
|
|
935
|
+
const serialize = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : JSON.stringify(value);
|
|
936
|
+
/**
|
|
937
|
+
* Field-level diff of two entries. `encrypted_value` is excluded from value comparison — the same
|
|
938
|
+
* secret re-encrypts to different ciphertext, so diffing it is noise — but a change in *sealed
|
|
939
|
+
* status* (present ↔ absent) is reported as a synthetic `sealed` field.
|
|
940
|
+
*/
|
|
941
|
+
const metaDiff = (a, b) => {
|
|
942
|
+
const ar = a;
|
|
943
|
+
const br = b;
|
|
944
|
+
const sealedChange = !!ar["encrypted_value"] === !!br["encrypted_value"] ? [] : [{
|
|
945
|
+
field: "sealed",
|
|
946
|
+
a: ar["encrypted_value"] ? "yes" : "no",
|
|
947
|
+
b: br["encrypted_value"] ? "yes" : "no"
|
|
948
|
+
}];
|
|
949
|
+
const fieldChanges = [...Object.keys(ar), ...Object.keys(br)].filter((k, i, arr) => k !== "encrypted_value" && arr.indexOf(k) === i).flatMap((field) => {
|
|
950
|
+
const av = serialize(ar[field]);
|
|
951
|
+
const bv = serialize(br[field]);
|
|
952
|
+
return av === bv ? [] : [{
|
|
953
|
+
field,
|
|
954
|
+
a: av,
|
|
955
|
+
b: bv
|
|
956
|
+
}];
|
|
957
|
+
});
|
|
958
|
+
return [...sealedChange, ...fieldChanges.sort((x, y) => x.field.localeCompare(y.field))];
|
|
959
|
+
};
|
|
960
|
+
const sectionDiff = (a, b) => {
|
|
961
|
+
const aKeys = Object.keys(a);
|
|
962
|
+
const bKeys = Object.keys(b);
|
|
963
|
+
return {
|
|
964
|
+
onlyA: aKeys.filter((k) => !(k in b)).sort(),
|
|
965
|
+
onlyB: bKeys.filter((k) => !(k in a)).sort(),
|
|
966
|
+
changed: aKeys.filter((k) => k in b).sort().flatMap((key) => {
|
|
967
|
+
const changes = metaDiff(a[key], b[key]);
|
|
968
|
+
return changes.length === 0 ? [] : [{
|
|
969
|
+
key,
|
|
970
|
+
changes
|
|
971
|
+
}];
|
|
972
|
+
})
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
const isEmpty = (s) => s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0;
|
|
976
|
+
/** Compare two configs by their `[secret.*]` and `[env.*]` entries (metadata, not ciphertext). */
|
|
977
|
+
const diffConfigs = (a, b) => {
|
|
978
|
+
const secret = sectionDiff(a.secret ?? {}, b.secret ?? {});
|
|
979
|
+
const env = sectionDiff(a.env ?? {}, b.env ?? {});
|
|
980
|
+
return {
|
|
981
|
+
secret,
|
|
982
|
+
env,
|
|
983
|
+
identical: isEmpty(secret) && isEmpty(env)
|
|
984
|
+
};
|
|
985
|
+
};
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/cli/commands/diff.ts
|
|
988
|
+
const formatSection = (name, s) => {
|
|
989
|
+
if (s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0) return [];
|
|
990
|
+
return [
|
|
991
|
+
`${BOLD}[${name}]${RESET}`,
|
|
992
|
+
...s.onlyA.map((k) => ` ${RED}- ${k}${RESET}`),
|
|
993
|
+
...s.onlyB.map((k) => ` ${GREEN}+ ${k}${RESET}`),
|
|
994
|
+
...s.changed.flatMap((c) => [` ${YELLOW}~ ${c.key}${RESET}`, ...c.changes.map((ch) => ` ${ch.field}: ${DIM}${ch.a ?? "∅"}${RESET} → ${DIM}${ch.b ?? "∅"}${RESET}`)])
|
|
995
|
+
];
|
|
996
|
+
};
|
|
997
|
+
const loadOrExit = (path, side) => loadConfig(path).fold((err) => {
|
|
998
|
+
console.error(`${RED}Error${RESET} (${side} = ${path}): ${formatError(err)}`);
|
|
999
|
+
process.exit(2);
|
|
1000
|
+
}, (config) => config);
|
|
1001
|
+
/**
|
|
1002
|
+
* Compare two envpkt.toml files by their `[secret.*]` and `[env.*]` entries. Reports keys only in
|
|
1003
|
+
* each side and field-level metadata changes for shared keys (ciphertext is ignored; sealed-status
|
|
1004
|
+
* changes are reported). With `--exit-code`, exits non-zero when the configs differ.
|
|
1005
|
+
*/
|
|
1006
|
+
const runDiff = (pathA, pathB, options) => {
|
|
1007
|
+
const diff = diffConfigs(loadOrExit(pathA, "a"), loadOrExit(pathB, "b"));
|
|
1008
|
+
if (options.format === "json") console.log(JSON.stringify(diff, null, 2));
|
|
1009
|
+
else if (diff.identical) console.log(`${GREEN}✓${RESET} no differences`);
|
|
1010
|
+
else {
|
|
1011
|
+
const body = [...formatSection("secret", diff.secret), ...formatSection("env", diff.env)];
|
|
1012
|
+
console.log(`${DIM}- ${pathA}\n+ ${pathB}${RESET}\n\n${body.join("\n")}`);
|
|
1013
|
+
}
|
|
1014
|
+
if (options.exitCode && !diff.identical) process.exit(1);
|
|
1015
|
+
};
|
|
1016
|
+
//#endregion
|
|
876
1017
|
//#region src/fnox/cli.ts
|
|
877
1018
|
/** Export all secrets from fnox as key=value pairs for a given profile */
|
|
878
1019
|
const fnoxExport = (profile, agentKey) => {
|
|
@@ -918,46 +1059,6 @@ const fnoxAvailable = () => Try(() => {
|
|
|
918
1059
|
return true;
|
|
919
1060
|
}).fold(() => false, (v) => v);
|
|
920
1061
|
//#endregion
|
|
921
|
-
//#region src/fnox/identity.ts
|
|
922
|
-
/** Check if the age CLI is available on PATH */
|
|
923
|
-
const ageAvailable = () => Try(() => {
|
|
924
|
-
execFileSync("age", ["--version"], { stdio: "pipe" });
|
|
925
|
-
return true;
|
|
926
|
-
}).fold(() => false, (v) => v);
|
|
927
|
-
/**
|
|
928
|
-
* Extract the secret key from an age identity file (plain or encrypted).
|
|
929
|
-
* - Plain identity files (from `age-keygen`) contain `AGE-SECRET-KEY-*` lines directly
|
|
930
|
-
* - Encrypted identity files need `age --decrypt` to unwrap
|
|
931
|
-
*/
|
|
932
|
-
const unwrapAgentKey = (identityPath) => {
|
|
933
|
-
if (!existsSync(identityPath)) return Left({
|
|
934
|
-
_tag: "IdentityNotFound",
|
|
935
|
-
path: identityPath
|
|
936
|
-
});
|
|
937
|
-
return Try(() => readFileSync(identityPath, "utf-8")).fold((err) => Left({
|
|
938
|
-
_tag: "DecryptFailed",
|
|
939
|
-
message: `Failed to read identity file: ${err}`
|
|
940
|
-
}), (content) => {
|
|
941
|
-
const secretKeyLine = content.split("\n").find((l) => l.startsWith("AGE-SECRET-KEY-"));
|
|
942
|
-
if (secretKeyLine) return Right(secretKeyLine.trim());
|
|
943
|
-
if (!ageAvailable()) return Left({
|
|
944
|
-
_tag: "AgeNotFound",
|
|
945
|
-
message: "age CLI not found on PATH"
|
|
946
|
-
});
|
|
947
|
-
return Try(() => execFileSync("age", ["--decrypt", identityPath], {
|
|
948
|
-
stdio: [
|
|
949
|
-
"pipe",
|
|
950
|
-
"pipe",
|
|
951
|
-
"pipe"
|
|
952
|
-
],
|
|
953
|
-
encoding: "utf-8"
|
|
954
|
-
})).fold((err) => Left({
|
|
955
|
-
_tag: "DecryptFailed",
|
|
956
|
-
message: `age decrypt failed: ${err}`
|
|
957
|
-
}), (output) => Right(output.trim()));
|
|
958
|
-
});
|
|
959
|
-
};
|
|
960
|
-
//#endregion
|
|
961
1062
|
//#region src/fnox/parse.ts
|
|
962
1063
|
/** Read and parse fnox.toml, extracting secret keys and profiles */
|
|
963
1064
|
const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((err) => Left({
|
|
@@ -1594,6 +1695,57 @@ const bootSafe = (options) => {
|
|
|
1594
1695
|
}));
|
|
1595
1696
|
};
|
|
1596
1697
|
//#endregion
|
|
1698
|
+
//#region src/cli/commands/doctor.ts
|
|
1699
|
+
const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM}${detail}${RESET}`);
|
|
1700
|
+
const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
|
|
1701
|
+
const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
|
|
1702
|
+
/** Print the resolution/key check, returning whether it passed. */
|
|
1703
|
+
const reportResolution = (configPath) => bootSafe({
|
|
1704
|
+
configPath,
|
|
1705
|
+
inject: false,
|
|
1706
|
+
warnOnly: true
|
|
1707
|
+
}).fold((err) => {
|
|
1708
|
+
if (err._tag === "SealKeyUnavailable") {
|
|
1709
|
+
bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
|
|
1710
|
+
err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
|
|
1711
|
+
} else bad("config", `${err._tag}`);
|
|
1712
|
+
return false;
|
|
1713
|
+
}, (boot) => {
|
|
1714
|
+
const resolved = Object.keys(boot.secrets).length;
|
|
1715
|
+
ok("secrets", `${resolved} resolved, ${boot.skipped.length} skipped`);
|
|
1716
|
+
const auditColor = boot.audit.status === "healthy" ? GREEN : YELLOW;
|
|
1717
|
+
console.log(` ${auditColor}•${RESET} audit ${DIM}${boot.audit.status}${RESET}`);
|
|
1718
|
+
return true;
|
|
1719
|
+
});
|
|
1720
|
+
/**
|
|
1721
|
+
* One-shot environment check: is age installed, is a config resolvable, and do its sealed
|
|
1722
|
+
* secrets decrypt with an available key? Read-only; exits non-zero if any check fails.
|
|
1723
|
+
*/
|
|
1724
|
+
const runDoctor = (options) => {
|
|
1725
|
+
console.log(`${BOLD}envpkt doctor${RESET}\n`);
|
|
1726
|
+
const ageOk = ageVersion().fold(() => {
|
|
1727
|
+
bad("age ", "not found on PATH");
|
|
1728
|
+
console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
|
|
1729
|
+
return false;
|
|
1730
|
+
}, (version) => {
|
|
1731
|
+
ok("age ", version);
|
|
1732
|
+
return true;
|
|
1733
|
+
});
|
|
1734
|
+
const resolveOk = resolveConfigPath(options.config).fold(() => {
|
|
1735
|
+
warn("config", "no envpkt.toml found for this directory");
|
|
1736
|
+
return true;
|
|
1737
|
+
}, ({ path }) => {
|
|
1738
|
+
ok("config", path);
|
|
1739
|
+
return reportResolution(path);
|
|
1740
|
+
});
|
|
1741
|
+
console.log("");
|
|
1742
|
+
if (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
|
|
1743
|
+
else {
|
|
1744
|
+
console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
|
|
1745
|
+
process.exit(1);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
//#endregion
|
|
1597
1749
|
//#region src/core/dotenv.ts
|
|
1598
1750
|
const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
1599
1751
|
/**
|
|
@@ -5078,6 +5230,12 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
|
|
|
5078
5230
|
program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
|
|
5079
5231
|
runUpgrade();
|
|
5080
5232
|
});
|
|
5233
|
+
program.command("diff").description("Compare two envpkt.toml configs by their secret/env entries (keys + metadata)").argument("<a>", "First config path").argument("<b>", "Second config path").option("--format <format>", "Output format: text | json", "text").option("--exit-code", "Exit non-zero when the configs differ (for CI drift gates)").action((a, b, options) => {
|
|
5234
|
+
runDiff(a, b, options);
|
|
5235
|
+
});
|
|
5236
|
+
program.command("doctor").description("Check that age is installed and that the resolved config's sealed secrets can be decrypted").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
|
5237
|
+
runDoctor(options);
|
|
5238
|
+
});
|
|
5081
5239
|
program.command("config-path").description("Print the envpkt.toml path resolved for the current directory (empty if none). Resolve-only — no decryption.").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
|
5082
5240
|
runConfigPath(options);
|
|
5083
5241
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -583,6 +583,32 @@ declare const quoteDotenvValue: (value: string) => string;
|
|
|
583
583
|
/** Serialize entries to dotenv text (no trailing newline). */
|
|
584
584
|
declare const formatDotenv: (entries: ReadonlyArray<DotenvEntry>, options?: FormatDotenvOptions) => string;
|
|
585
585
|
//#endregion
|
|
586
|
+
//#region src/core/diff.d.ts
|
|
587
|
+
/** A single field that differs between two entries. `undefined` means the field is absent on that side. */
|
|
588
|
+
type FieldChange = {
|
|
589
|
+
readonly field: string;
|
|
590
|
+
readonly a: string | undefined;
|
|
591
|
+
readonly b: string | undefined;
|
|
592
|
+
};
|
|
593
|
+
/** An entry present in both configs whose metadata differs. */
|
|
594
|
+
type ChangedEntry = {
|
|
595
|
+
readonly key: string;
|
|
596
|
+
readonly changes: ReadonlyArray<FieldChange>;
|
|
597
|
+
};
|
|
598
|
+
/** Diff of one keyed section (`[secret.*]` or `[env.*]`). Key lists are sorted. */
|
|
599
|
+
type SectionDiff = {
|
|
600
|
+
readonly onlyA: ReadonlyArray<string>;
|
|
601
|
+
readonly onlyB: ReadonlyArray<string>;
|
|
602
|
+
readonly changed: ReadonlyArray<ChangedEntry>;
|
|
603
|
+
};
|
|
604
|
+
type ConfigDiff = {
|
|
605
|
+
readonly secret: SectionDiff;
|
|
606
|
+
readonly env: SectionDiff;
|
|
607
|
+
readonly identical: boolean;
|
|
608
|
+
};
|
|
609
|
+
/** Compare two configs by their `[secret.*]` and `[env.*]` entries (metadata, not ciphertext). */
|
|
610
|
+
declare const diffConfigs: (a: EnvpktConfig, b: EnvpktConfig) => ConfigDiff;
|
|
611
|
+
//#endregion
|
|
586
612
|
//#region src/core/toml-edit.d.ts
|
|
587
613
|
/**
|
|
588
614
|
* Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
|
|
@@ -668,4 +694,4 @@ type ToolDef = {
|
|
|
668
694
|
declare const toolDefinitions: readonly ToolDef[];
|
|
669
695
|
declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
|
|
670
696
|
//#endregion
|
|
671
|
-
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DotenvEntry, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatDotenvOptions, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
697
|
+
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type ChangedEntry, type CheckResult, type ConfidenceLevel, type ConfigDiff, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DotenvEntry, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FieldChange, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatDotenvOptions, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type SectionDiff, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -2306,6 +2306,60 @@ const formatDotenv = (entries, options) => {
|
|
|
2306
2306
|
return header ? `${header}\n\n${body}` : body;
|
|
2307
2307
|
};
|
|
2308
2308
|
//#endregion
|
|
2309
|
+
//#region src/core/diff.ts
|
|
2310
|
+
/** Normalize a metadata value to a comparable/displayable string (`undefined` = absent). */
|
|
2311
|
+
const serialize = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : JSON.stringify(value);
|
|
2312
|
+
/**
|
|
2313
|
+
* Field-level diff of two entries. `encrypted_value` is excluded from value comparison — the same
|
|
2314
|
+
* secret re-encrypts to different ciphertext, so diffing it is noise — but a change in *sealed
|
|
2315
|
+
* status* (present ↔ absent) is reported as a synthetic `sealed` field.
|
|
2316
|
+
*/
|
|
2317
|
+
const metaDiff = (a, b) => {
|
|
2318
|
+
const ar = a;
|
|
2319
|
+
const br = b;
|
|
2320
|
+
const sealedChange = !!ar["encrypted_value"] === !!br["encrypted_value"] ? [] : [{
|
|
2321
|
+
field: "sealed",
|
|
2322
|
+
a: ar["encrypted_value"] ? "yes" : "no",
|
|
2323
|
+
b: br["encrypted_value"] ? "yes" : "no"
|
|
2324
|
+
}];
|
|
2325
|
+
const fieldChanges = [...Object.keys(ar), ...Object.keys(br)].filter((k, i, arr) => k !== "encrypted_value" && arr.indexOf(k) === i).flatMap((field) => {
|
|
2326
|
+
const av = serialize(ar[field]);
|
|
2327
|
+
const bv = serialize(br[field]);
|
|
2328
|
+
return av === bv ? [] : [{
|
|
2329
|
+
field,
|
|
2330
|
+
a: av,
|
|
2331
|
+
b: bv
|
|
2332
|
+
}];
|
|
2333
|
+
});
|
|
2334
|
+
return [...sealedChange, ...fieldChanges.sort((x, y) => x.field.localeCompare(y.field))];
|
|
2335
|
+
};
|
|
2336
|
+
const sectionDiff = (a, b) => {
|
|
2337
|
+
const aKeys = Object.keys(a);
|
|
2338
|
+
const bKeys = Object.keys(b);
|
|
2339
|
+
return {
|
|
2340
|
+
onlyA: aKeys.filter((k) => !(k in b)).sort(),
|
|
2341
|
+
onlyB: bKeys.filter((k) => !(k in a)).sort(),
|
|
2342
|
+
changed: aKeys.filter((k) => k in b).sort().flatMap((key) => {
|
|
2343
|
+
const changes = metaDiff(a[key], b[key]);
|
|
2344
|
+
return changes.length === 0 ? [] : [{
|
|
2345
|
+
key,
|
|
2346
|
+
changes
|
|
2347
|
+
}];
|
|
2348
|
+
})
|
|
2349
|
+
};
|
|
2350
|
+
};
|
|
2351
|
+
const isEmpty = (s) => s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0;
|
|
2352
|
+
/** Compare two configs by their `[secret.*]` and `[env.*]` entries (metadata, not ciphertext). */
|
|
2353
|
+
const diffConfigs = (a, b) => {
|
|
2354
|
+
const secret = sectionDiff(a.secret ?? {}, b.secret ?? {});
|
|
2355
|
+
const env = sectionDiff(a.env ?? {}, b.env ?? {});
|
|
2356
|
+
return {
|
|
2357
|
+
secret,
|
|
2358
|
+
env,
|
|
2359
|
+
identical: isEmpty(secret) && isEmpty(env)
|
|
2360
|
+
};
|
|
2361
|
+
};
|
|
2362
|
+
//#endregion
|
|
2309
2363
|
//#region src/core/toml-edit.ts
|
|
2310
2364
|
const SECTION_RE = /^\[.+\]\s*$/;
|
|
2311
2365
|
const MULTILINE_OPEN = "\"\"\"";
|
|
@@ -2804,4 +2858,4 @@ const startServer = async () => {
|
|
|
2804
2858
|
await server.connect(transport);
|
|
2805
2859
|
};
|
|
2806
2860
|
//#endregion
|
|
2807
|
-
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
2861
|
+
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|