envpkt 0.15.0 → 0.16.0
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 +12 -1
- package/dist/cli.js +127 -37
- package/dist/index.d.ts +11 -5
- package/dist/index.js +51 -11
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -478,7 +478,7 @@ envpkt copy API_KEY --to b.toml --force # overwrite if it already exist
|
|
|
478
478
|
envpkt copy API_KEY --to b.toml --dry-run # preview without writing
|
|
479
479
|
```
|
|
480
480
|
|
|
481
|
-
`--from`/`--to` default to the config resolved for the current directory (and must already exist).
|
|
481
|
+
`--from`/`--to` default to the config resolved for the current directory (and must already exist). A copy into a new key gets today's `created`. With `--force` onto an existing entry, the entry is replaced in place and keeps the destination's `created`. Either way, `last_rotated_at` is dropped (it's the source's rotation history). Copying a sealed secret needs the source key to unseal and the destination's `identity.recipient` to reseal.
|
|
482
482
|
|
|
483
483
|
### `envpkt exec`
|
|
484
484
|
|
|
@@ -499,6 +499,8 @@ Encrypt secret values into `envpkt.toml` using [age](https://age-encryption.org/
|
|
|
499
499
|
envpkt seal # Seal all secrets in envpkt.toml
|
|
500
500
|
envpkt seal -c path/to/envpkt.toml # Specify config path
|
|
501
501
|
envpkt seal --profile staging # Use a specific fnox profile for value resolution
|
|
502
|
+
envpkt seal --edit OPENAI_API_KEY # Re-seal one key with a new value (prompts in a terminal)
|
|
503
|
+
pbpaste | envpkt seal --edit OPENAI_API_KEY # No terminal (agent, CI): read the value from stdin
|
|
502
504
|
```
|
|
503
505
|
|
|
504
506
|
Requires `identity.recipient` (age public key) in your config. Values are resolved via cascade:
|
|
@@ -507,6 +509,15 @@ Requires `identity.recipient` (age public key) in your config. Values are resolv
|
|
|
507
509
|
2. **Environment variables** (e.g. `OPENAI_API_KEY` in your shell)
|
|
508
510
|
3. **Interactive prompt** (asks you to paste each value)
|
|
509
511
|
|
|
512
|
+
Plain `envpkt seal` skips keys that are already sealed, so it only covers a first seal. `OPENAI_API_KEY=... envpkt seal` works for that.
|
|
513
|
+
|
|
514
|
+
To rotate one sealed value, use `--edit <key>`. In a terminal it prompts, and asks before replacing a sealed value. Without a terminal (an agent shell, CI, Claude Code's `!` prompt), it reads the value from stdin instead and replaces it without asking. Pass `--stdin` to read stdin on purpose, even in a terminal. Stdin rules:
|
|
515
|
+
|
|
516
|
+
- It carries one value, so `--edit` takes exactly one key.
|
|
517
|
+
- One trailing newline is stripped (`\n` or `\r\n`).
|
|
518
|
+
- Empty or whitespace-only stdin is an error, and the file is left unchanged.
|
|
519
|
+
- envpkt never prints the value.
|
|
520
|
+
|
|
510
521
|
After sealing, each secret gets an `encrypted_value` field. At boot time, `envpkt exec` or `boot()` automatically decrypts sealed values using the `identity.key_file` path (or the default `~/.envpkt/age-key.txt`).
|
|
511
522
|
|
|
512
523
|
See [`examples/sealed-agent.toml`](./examples/sealed-agent.toml) for a complete example.
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ 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";
|
|
18
|
+
import { text } from "node:stream/consumers";
|
|
18
19
|
import { createInterface } from "node:readline";
|
|
19
20
|
//#region src/core/namespace.ts
|
|
20
21
|
const DEFAULT_SEPARATOR = "__";
|
|
@@ -922,7 +923,7 @@ const runAuditOnConfig = (config, options) => {
|
|
|
922
923
|
secrets: audit.secrets.filter((s) => !!secretEntries[s.key].encrypted_value)
|
|
923
924
|
};
|
|
924
925
|
})() : audit;
|
|
925
|
-
const
|
|
926
|
+
const afterUnsealed = options.unsealed ? (() => {
|
|
926
927
|
const secretEntries = config.secret ?? {};
|
|
927
928
|
return {
|
|
928
929
|
...afterSealed,
|
|
@@ -930,9 +931,9 @@ const runAuditOnConfig = (config, options) => {
|
|
|
930
931
|
};
|
|
931
932
|
})() : afterSealed;
|
|
932
933
|
const afterStatus = options.status ? {
|
|
933
|
-
...
|
|
934
|
-
secrets:
|
|
935
|
-
} :
|
|
934
|
+
...afterUnsealed,
|
|
935
|
+
secrets: afterUnsealed.secrets.filter((s) => s.status === options.status)
|
|
936
|
+
} : afterUnsealed;
|
|
936
937
|
const filtered = Option(options.expiring).fold(() => afterStatus, (expiring) => ({
|
|
937
938
|
...afterStatus,
|
|
938
939
|
secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
|
|
@@ -1664,7 +1665,8 @@ const tomlInlineTable = (rec) => {
|
|
|
1664
1665
|
};
|
|
1665
1666
|
/**
|
|
1666
1667
|
* The SecretMeta to write into the destination on copy.
|
|
1667
|
-
* - `created`
|
|
1668
|
+
* - `created` comes from the caller, never the source: today for an entry new to the
|
|
1669
|
+
* destination, or the destination's own `created` when the copy overwrites it.
|
|
1668
1670
|
* - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
|
|
1669
1671
|
* - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
|
|
1670
1672
|
* `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
|
|
@@ -1673,10 +1675,10 @@ const copyableSecretMeta = (meta, opts) => {
|
|
|
1673
1675
|
const { last_rotated_at: _lra, encrypted_value: _ev, ...rest } = meta;
|
|
1674
1676
|
return opts.encryptedValue.fold(() => ({
|
|
1675
1677
|
...rest,
|
|
1676
|
-
created: opts.
|
|
1678
|
+
created: opts.created
|
|
1677
1679
|
}), (cipher) => ({
|
|
1678
1680
|
...rest,
|
|
1679
|
-
created: opts.
|
|
1681
|
+
created: opts.created,
|
|
1680
1682
|
encrypted_value: cipher
|
|
1681
1683
|
}));
|
|
1682
1684
|
};
|
|
@@ -1751,11 +1753,28 @@ const findSectionRange = (lines, sectionHeader) => {
|
|
|
1751
1753
|
end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
|
|
1752
1754
|
};
|
|
1753
1755
|
};
|
|
1756
|
+
/**
|
|
1757
|
+
* End (exclusive) of a section's own lines: through its last field, before the blank and
|
|
1758
|
+
* comment lines that run up to the next header. Those trailing lines separate it from its
|
|
1759
|
+
* neighbour (or are the neighbour's doc comment), so edits to this section leave them alone.
|
|
1760
|
+
*/
|
|
1761
|
+
const sectionBodyEnd = (lines, range) => {
|
|
1762
|
+
const lastContent = lines.slice(range.start, range.end).findLastIndex((l) => {
|
|
1763
|
+
const t = l.trim();
|
|
1764
|
+
return t !== "" && !t.startsWith("#");
|
|
1765
|
+
});
|
|
1766
|
+
return range.start + lastContent + 1;
|
|
1767
|
+
};
|
|
1768
|
+
const dropTrailingBlanks = (xs) => xs.slice(0, xs.findLastIndex((l) => l.trim() !== "") + 1);
|
|
1769
|
+
const dropLeadingBlanks = (xs) => {
|
|
1770
|
+
const first = xs.findIndex((l) => l.trim() !== "");
|
|
1771
|
+
return first === -1 ? [] : xs.slice(first);
|
|
1772
|
+
};
|
|
1754
1773
|
/** Check whether a section header exists in the raw TOML */
|
|
1755
1774
|
const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
|
|
1756
1775
|
/**
|
|
1757
|
-
* Remove a TOML section (e.g. `[secret.X]`) and all its fields
|
|
1758
|
-
*
|
|
1776
|
+
* Remove a TOML section (e.g. `[secret.X]`) and all its fields. A comment above the next
|
|
1777
|
+
* section stays. The sections on either side end up one blank line apart.
|
|
1759
1778
|
*/
|
|
1760
1779
|
const removeSection = (raw, sectionHeader) => {
|
|
1761
1780
|
const lines = raw.split("\n");
|
|
@@ -1764,11 +1783,33 @@ const removeSection = (raw, sectionHeader) => {
|
|
|
1764
1783
|
_tag: "SectionNotFound",
|
|
1765
1784
|
section: sectionHeader
|
|
1766
1785
|
});
|
|
1767
|
-
const
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
return Either.right(
|
|
1786
|
+
const before = dropTrailingBlanks(lines.slice(0, range.start));
|
|
1787
|
+
const after = dropLeadingBlanks(lines.slice(sectionBodyEnd(lines, range)));
|
|
1788
|
+
if (after.length === 0) return Either.right([...before, ...raw.endsWith("\n") ? [""] : []].join("\n"));
|
|
1789
|
+
if (before.length === 0) return Either.right(after.join("\n"));
|
|
1790
|
+
return Either.right([
|
|
1791
|
+
...before,
|
|
1792
|
+
"",
|
|
1793
|
+
...after
|
|
1794
|
+
].join("\n"));
|
|
1795
|
+
};
|
|
1796
|
+
/**
|
|
1797
|
+
* Replace a TOML section with `block`, in place. Lines outside the section, including the
|
|
1798
|
+
* blank and comment lines between it and the next header, are left as they were.
|
|
1799
|
+
*/
|
|
1800
|
+
const replaceSection = (raw, sectionHeader, block) => {
|
|
1801
|
+
const lines = raw.split("\n");
|
|
1802
|
+
const range = findSectionRange(lines, sectionHeader);
|
|
1803
|
+
if (!range) return Either.left({
|
|
1804
|
+
_tag: "SectionNotFound",
|
|
1805
|
+
section: sectionHeader
|
|
1806
|
+
});
|
|
1807
|
+
const replaced = [
|
|
1808
|
+
...lines.slice(0, range.start),
|
|
1809
|
+
...block.trimEnd().split("\n"),
|
|
1810
|
+
...lines.slice(sectionBodyEnd(lines, range))
|
|
1811
|
+
];
|
|
1812
|
+
return Either.right(replaced.join("\n"));
|
|
1772
1813
|
};
|
|
1773
1814
|
/**
|
|
1774
1815
|
* Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
|
|
@@ -2173,14 +2214,15 @@ const todayIso$2 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0
|
|
|
2173
2214
|
* Build the `[secret.<destName>]` block for a copy: unseal the source value with the
|
|
2174
2215
|
* source identity and reseal it for the destination recipient. Secrets with no sealed
|
|
2175
2216
|
* value are copied as metadata only (with a warning) — there's nothing to reseal.
|
|
2217
|
+
* Overwriting an existing destination entry keeps its `created`; a new entry gets today.
|
|
2176
2218
|
*/
|
|
2177
2219
|
const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
2178
2220
|
const meta = srcConfig.secret[key];
|
|
2179
|
-
const
|
|
2221
|
+
const created = destConfig.secret?.[destName]?.created ?? todayIso$2();
|
|
2180
2222
|
if (meta.encrypted_value === void 0 || meta.encrypted_value === "") {
|
|
2181
2223
|
console.error(`${YELLOW}Warning:${RESET} secret "${key}" has no sealed value — copying metadata only.`);
|
|
2182
2224
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2183
|
-
|
|
2225
|
+
created,
|
|
2184
2226
|
encryptedValue: Option.none()
|
|
2185
2227
|
}));
|
|
2186
2228
|
}
|
|
@@ -2205,14 +2247,13 @@ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
|
2205
2247
|
}
|
|
2206
2248
|
const cipher = orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
|
|
2207
2249
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2208
|
-
|
|
2250
|
+
created,
|
|
2209
2251
|
encryptedValue: Option(cipher)
|
|
2210
2252
|
}));
|
|
2211
2253
|
};
|
|
2212
2254
|
const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
|
|
2213
2255
|
const raw = readFileSync(destPath, "utf-8");
|
|
2214
|
-
const
|
|
2215
|
-
const updated = appendSection(baseRaw, block);
|
|
2256
|
+
const updated = overwrite ? orExit(replaceSection(raw, header, block), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : appendSection(raw, block);
|
|
2216
2257
|
if (dryRun) {
|
|
2217
2258
|
previewIfValid(updated, block);
|
|
2218
2259
|
return;
|
|
@@ -4593,13 +4634,18 @@ const applySealedToml = (raw, sealedMeta) => {
|
|
|
4593
4634
|
};
|
|
4594
4635
|
return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
|
|
4595
4636
|
};
|
|
4637
|
+
/**
|
|
4638
|
+
* A value with no non-whitespace content. Unsealing trims, so such a value would come back as
|
|
4639
|
+
* `""` — it is treated as empty on both the prompt and stdin paths.
|
|
4640
|
+
*/
|
|
4641
|
+
const isBlank = (value) => value.trim() === "";
|
|
4596
4642
|
/** Affirmative answer to a `[y/N]` confirm prompt. */
|
|
4597
4643
|
const isAffirmative = (answer) => /^y(es)?$/i.test(answer.trim());
|
|
4598
4644
|
/**
|
|
4599
4645
|
* Collect new plaintext values for the `--edit`ed keys via the injected `prompt`. Before
|
|
4600
4646
|
* overwriting an entry that is **already sealed** (`encrypted_value` present), require an
|
|
4601
4647
|
* explicit confirmation — replacing it discards the only ciphertext, and the prior value
|
|
4602
|
-
* can't be recovered without the original key. Declined or empty entries are skipped (reported
|
|
4648
|
+
* can't be recovered without the original key. Declined or empty (whitespace-only) entries are skipped (reported
|
|
4603
4649
|
* via `onSkip`). I/O flows only through `prompt`/`onSkip`, so the confirm/skip logic is
|
|
4604
4650
|
* unit-testable without a TTY.
|
|
4605
4651
|
*/
|
|
@@ -4614,7 +4660,7 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4614
4660
|
}
|
|
4615
4661
|
}
|
|
4616
4662
|
const value = await prompt(`Enter new value for ${key}: `);
|
|
4617
|
-
if (value
|
|
4663
|
+
if (isBlank(value)) {
|
|
4618
4664
|
onSkip?.(key, "empty");
|
|
4619
4665
|
continue;
|
|
4620
4666
|
}
|
|
@@ -4622,6 +4668,23 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4622
4668
|
}
|
|
4623
4669
|
return values;
|
|
4624
4670
|
};
|
|
4671
|
+
/**
|
|
4672
|
+
* Pick the `--edit` input source. Stdin is used when `--stdin` is passed or no terminal is
|
|
4673
|
+
* attached; it carries one value, so it needs exactly one key. Left holds the error message.
|
|
4674
|
+
*/
|
|
4675
|
+
const resolveEditInput = (keyCount, opts) => {
|
|
4676
|
+
if (!opts.stdin && opts.isTTY) return Right("prompt");
|
|
4677
|
+
if (keyCount !== 1) {
|
|
4678
|
+
const why = opts.stdin ? "--stdin" : "Without an interactive terminal, --edit";
|
|
4679
|
+
return Left(`${why} reads one value from stdin, so it takes exactly one key (got ${keyCount}). Seal one key at a time.`);
|
|
4680
|
+
}
|
|
4681
|
+
return Right("stdin");
|
|
4682
|
+
};
|
|
4683
|
+
/** Strip one trailing newline (`\n` or `\r\n`) from piped input. None when nothing but whitespace is left. */
|
|
4684
|
+
const parseStdinValue = (raw) => {
|
|
4685
|
+
const value = raw.replace(/\r?\n$/, "");
|
|
4686
|
+
return isBlank(value) ? Option.none() : Option(value);
|
|
4687
|
+
};
|
|
4625
4688
|
/** Write sealed values back into the TOML file, preserving structure. */
|
|
4626
4689
|
const writeSealedToml = (configPath, sealedMeta) => {
|
|
4627
4690
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -4629,6 +4692,35 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
4629
4692
|
validateOrExit(finalContent);
|
|
4630
4693
|
writeFileSync(configPath, finalContent);
|
|
4631
4694
|
};
|
|
4695
|
+
/**
|
|
4696
|
+
* Read one `--edit` value from stdin. The value is never echoed or logged. Piping a value is
|
|
4697
|
+
* an explicit request to replace it, so a sealed entry is replaced without the confirm prompt.
|
|
4698
|
+
*/
|
|
4699
|
+
const readEditedValueFromStdin = async (key) => {
|
|
4700
|
+
const endHint = process.stdin.isTTY ? " (end with Ctrl-D)" : "";
|
|
4701
|
+
console.error(`${DIM}Reading the value for ${key} from stdin${endHint}${RESET}`);
|
|
4702
|
+
return parseStdinValue(await text(process.stdin)).fold(() => {
|
|
4703
|
+
console.error(`${RED}Error:${RESET} No value on stdin for ${key}`);
|
|
4704
|
+
console.error(`${DIM}Pipe the value in, e.g. ${CYAN}pbpaste | envpkt seal --edit ${key}${RESET}${DIM}, or run in a terminal to be prompted.${RESET}`);
|
|
4705
|
+
process.exit(2);
|
|
4706
|
+
return {};
|
|
4707
|
+
}, (value) => ({ [key]: value }));
|
|
4708
|
+
};
|
|
4709
|
+
/** Prompt for each `--edit` value on the terminal, confirming before replacing a sealed one. */
|
|
4710
|
+
const promptEditedValues = async (editKeys, secretEntries) => {
|
|
4711
|
+
const rl = await import("node:readline").then((m) => m.createInterface({
|
|
4712
|
+
input: process.stdin,
|
|
4713
|
+
output: process.stderr
|
|
4714
|
+
}));
|
|
4715
|
+
const prompt = (question) => new Promise((resolve) => {
|
|
4716
|
+
rl.question(question, (answer) => resolve(answer));
|
|
4717
|
+
});
|
|
4718
|
+
const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
|
|
4719
|
+
console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
|
|
4720
|
+
});
|
|
4721
|
+
rl.close();
|
|
4722
|
+
return values;
|
|
4723
|
+
};
|
|
4632
4724
|
const runSeal = async (options) => {
|
|
4633
4725
|
const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
|
|
4634
4726
|
console.error(formatError(err));
|
|
@@ -4672,6 +4764,10 @@ const runSeal = async (options) => {
|
|
|
4672
4764
|
}, (k) => Option(k));
|
|
4673
4765
|
});
|
|
4674
4766
|
const editKeys = options.edit ? options.edit.split(",").map((k) => k.trim()).filter((k) => k.length > 0) : [];
|
|
4767
|
+
if (options.stdin && editKeys.length === 0) {
|
|
4768
|
+
console.error(`${RED}Error:${RESET} --stdin needs --edit <key> to name the secret the value is for`);
|
|
4769
|
+
process.exit(2);
|
|
4770
|
+
}
|
|
4675
4771
|
if (editKeys.length > 0) {
|
|
4676
4772
|
const allSecretEntries = config.secret ?? {};
|
|
4677
4773
|
const unknownKeys = editKeys.filter((k) => !(k in allSecretEntries));
|
|
@@ -4692,24 +4788,18 @@ const runSeal = async (options) => {
|
|
|
4692
4788
|
console.error(`${DIM}external = true means the value is managed outside envpkt — set it in that store instead.${RESET}`);
|
|
4693
4789
|
process.exit(2);
|
|
4694
4790
|
}
|
|
4695
|
-
|
|
4696
|
-
|
|
4791
|
+
const input = resolveEditInput(editKeys.length, {
|
|
4792
|
+
stdin: options.stdin === true,
|
|
4793
|
+
isTTY: process.stdin.isTTY === true
|
|
4794
|
+
}).fold((msg) => {
|
|
4795
|
+
console.error(`${RED}Error:${RESET} ${msg}`);
|
|
4697
4796
|
process.exit(2);
|
|
4698
|
-
|
|
4797
|
+
return "prompt";
|
|
4798
|
+
}, (i) => i);
|
|
4699
4799
|
const secretEntries = Object.fromEntries(editKeys.map((k) => [k, allSecretEntries[k]]));
|
|
4700
4800
|
console.log(`${BOLD}Editing ${editKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
|
|
4701
4801
|
console.log("");
|
|
4702
|
-
const
|
|
4703
|
-
input: process.stdin,
|
|
4704
|
-
output: process.stderr
|
|
4705
|
-
}));
|
|
4706
|
-
const prompt = (question) => new Promise((resolve) => {
|
|
4707
|
-
rl.question(question, (answer) => resolve(answer));
|
|
4708
|
-
});
|
|
4709
|
-
const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
|
|
4710
|
-
console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
|
|
4711
|
-
});
|
|
4712
|
-
rl.close();
|
|
4802
|
+
const values = input === "stdin" ? await readEditedValueFromStdin(editKeys[0]) : await promptEditedValues(editKeys, secretEntries);
|
|
4713
4803
|
if (Object.keys(values).length === 0) {
|
|
4714
4804
|
console.error(`${RED}Error:${RESET} No values provided`);
|
|
4715
4805
|
process.exit(2);
|
|
@@ -5588,7 +5678,7 @@ program.command("init").description("Initialize a new envpkt.toml in the current
|
|
|
5588
5678
|
program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
|
|
5589
5679
|
runKeygen(options);
|
|
5590
5680
|
});
|
|
5591
|
-
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--
|
|
5681
|
+
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing | missing_metadata | external").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--unsealed", "Show only secrets without encrypted_value (resolved at runtime or external)").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
|
|
5592
5682
|
runAudit(options);
|
|
5593
5683
|
});
|
|
5594
5684
|
program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
|
|
@@ -5609,7 +5699,7 @@ program.command("headers").description("Print HTTP headers for an MCP server as
|
|
|
5609
5699
|
program.command("resolve").description("Resolve catalog references and output a flat, self-contained config").option("-c, --config <path>", "Path to envpkt.toml").option("-o, --output <path>", "Write resolved config to file (default: stdout)").option("--format <format>", "Output format: toml | json", "toml").option("--dry-run", "Show what would be resolved without writing").action((options) => {
|
|
5610
5700
|
runResolve(options);
|
|
5611
5701
|
});
|
|
5612
|
-
program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated),
|
|
5702
|
+
program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated). Prompts in a terminal; without one, reads a single key's value from stdin").option("--stdin", "With --edit <key>, read the new value from stdin even in a terminal (one key; one trailing newline is stripped)").action(async (options) => {
|
|
5613
5703
|
await runSeal(options);
|
|
5614
5704
|
});
|
|
5615
5705
|
program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -680,13 +680,14 @@ declare const diffConfigs: (a: EnvpktConfig, b: EnvpktConfig) => ConfigDiff;
|
|
|
680
680
|
//#region src/core/copy.d.ts
|
|
681
681
|
/**
|
|
682
682
|
* The SecretMeta to write into the destination on copy.
|
|
683
|
-
* - `created`
|
|
683
|
+
* - `created` comes from the caller, never the source: today for an entry new to the
|
|
684
|
+
* destination, or the destination's own `created` when the copy overwrites it.
|
|
684
685
|
* - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
|
|
685
686
|
* - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
|
|
686
687
|
* `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
|
|
687
688
|
*/
|
|
688
689
|
declare const copyableSecretMeta: (meta: SecretMeta, opts: {
|
|
689
|
-
readonly
|
|
690
|
+
readonly created: string;
|
|
690
691
|
readonly encryptedValue: Option<string>;
|
|
691
692
|
}) => SecretMeta;
|
|
692
693
|
/** Serialize a `[secret.<name>]` block from its metadata, round-trippable by the TOML parser. */
|
|
@@ -696,10 +697,15 @@ declare const serializeEnvBlock: (name: string, meta: EnvMeta) => string;
|
|
|
696
697
|
//#endregion
|
|
697
698
|
//#region src/core/toml-edit.d.ts
|
|
698
699
|
/**
|
|
699
|
-
* Remove a TOML section (e.g. `[secret.X]`) and all its fields
|
|
700
|
-
*
|
|
700
|
+
* Remove a TOML section (e.g. `[secret.X]`) and all its fields. A comment above the next
|
|
701
|
+
* section stays. The sections on either side end up one blank line apart.
|
|
701
702
|
*/
|
|
702
703
|
declare const removeSection: (raw: string, sectionHeader: string) => Either<TomlEditError, string>;
|
|
704
|
+
/**
|
|
705
|
+
* Replace a TOML section with `block`, in place. Lines outside the section, including the
|
|
706
|
+
* blank and comment lines between it and the next header, are left as they were.
|
|
707
|
+
*/
|
|
708
|
+
declare const replaceSection: (raw: string, sectionHeader: string, block: string) => Either<TomlEditError, string>;
|
|
703
709
|
/**
|
|
704
710
|
* Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
|
|
705
711
|
* Errors if old doesn't exist or new already exists.
|
|
@@ -779,4 +785,4 @@ type ToolDef = {
|
|
|
779
785
|
declare const toolDefinitions: readonly ToolDef[];
|
|
780
786
|
declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
|
|
781
787
|
//#endregion
|
|
782
|
-
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 McpConfigError, type McpHeader, McpHeaderSchema, type McpHeadersError, 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, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, 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, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
|
788
|
+
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 McpConfigError, type McpHeader, McpHeaderSchema, type McpHeadersError, 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, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, replaceSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
package/dist/index.js
CHANGED
|
@@ -2507,7 +2507,8 @@ const tomlInlineTable = (rec) => {
|
|
|
2507
2507
|
};
|
|
2508
2508
|
/**
|
|
2509
2509
|
* The SecretMeta to write into the destination on copy.
|
|
2510
|
-
* - `created`
|
|
2510
|
+
* - `created` comes from the caller, never the source: today for an entry new to the
|
|
2511
|
+
* destination, or the destination's own `created` when the copy overwrites it.
|
|
2511
2512
|
* - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
|
|
2512
2513
|
* - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
|
|
2513
2514
|
* `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
|
|
@@ -2516,10 +2517,10 @@ const copyableSecretMeta = (meta, opts) => {
|
|
|
2516
2517
|
const { last_rotated_at: _lra, encrypted_value: _ev, ...rest } = meta;
|
|
2517
2518
|
return opts.encryptedValue.fold(() => ({
|
|
2518
2519
|
...rest,
|
|
2519
|
-
created: opts.
|
|
2520
|
+
created: opts.created
|
|
2520
2521
|
}), (cipher) => ({
|
|
2521
2522
|
...rest,
|
|
2522
|
-
created: opts.
|
|
2523
|
+
created: opts.created,
|
|
2523
2524
|
encrypted_value: cipher
|
|
2524
2525
|
}));
|
|
2525
2526
|
};
|
|
@@ -2594,11 +2595,28 @@ const findSectionRange = (lines, sectionHeader) => {
|
|
|
2594
2595
|
end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
|
|
2595
2596
|
};
|
|
2596
2597
|
};
|
|
2598
|
+
/**
|
|
2599
|
+
* End (exclusive) of a section's own lines: through its last field, before the blank and
|
|
2600
|
+
* comment lines that run up to the next header. Those trailing lines separate it from its
|
|
2601
|
+
* neighbour (or are the neighbour's doc comment), so edits to this section leave them alone.
|
|
2602
|
+
*/
|
|
2603
|
+
const sectionBodyEnd = (lines, range) => {
|
|
2604
|
+
const lastContent = lines.slice(range.start, range.end).findLastIndex((l) => {
|
|
2605
|
+
const t = l.trim();
|
|
2606
|
+
return t !== "" && !t.startsWith("#");
|
|
2607
|
+
});
|
|
2608
|
+
return range.start + lastContent + 1;
|
|
2609
|
+
};
|
|
2610
|
+
const dropTrailingBlanks = (xs) => xs.slice(0, xs.findLastIndex((l) => l.trim() !== "") + 1);
|
|
2611
|
+
const dropLeadingBlanks = (xs) => {
|
|
2612
|
+
const first = xs.findIndex((l) => l.trim() !== "");
|
|
2613
|
+
return first === -1 ? [] : xs.slice(first);
|
|
2614
|
+
};
|
|
2597
2615
|
/** Check whether a section header exists in the raw TOML */
|
|
2598
2616
|
const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
|
|
2599
2617
|
/**
|
|
2600
|
-
* Remove a TOML section (e.g. `[secret.X]`) and all its fields
|
|
2601
|
-
*
|
|
2618
|
+
* Remove a TOML section (e.g. `[secret.X]`) and all its fields. A comment above the next
|
|
2619
|
+
* section stays. The sections on either side end up one blank line apart.
|
|
2602
2620
|
*/
|
|
2603
2621
|
const removeSection = (raw, sectionHeader) => {
|
|
2604
2622
|
const lines = raw.split("\n");
|
|
@@ -2607,11 +2625,33 @@ const removeSection = (raw, sectionHeader) => {
|
|
|
2607
2625
|
_tag: "SectionNotFound",
|
|
2608
2626
|
section: sectionHeader
|
|
2609
2627
|
});
|
|
2610
|
-
const
|
|
2611
|
-
const
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
return Either.right(
|
|
2628
|
+
const before = dropTrailingBlanks(lines.slice(0, range.start));
|
|
2629
|
+
const after = dropLeadingBlanks(lines.slice(sectionBodyEnd(lines, range)));
|
|
2630
|
+
if (after.length === 0) return Either.right([...before, ...raw.endsWith("\n") ? [""] : []].join("\n"));
|
|
2631
|
+
if (before.length === 0) return Either.right(after.join("\n"));
|
|
2632
|
+
return Either.right([
|
|
2633
|
+
...before,
|
|
2634
|
+
"",
|
|
2635
|
+
...after
|
|
2636
|
+
].join("\n"));
|
|
2637
|
+
};
|
|
2638
|
+
/**
|
|
2639
|
+
* Replace a TOML section with `block`, in place. Lines outside the section, including the
|
|
2640
|
+
* blank and comment lines between it and the next header, are left as they were.
|
|
2641
|
+
*/
|
|
2642
|
+
const replaceSection = (raw, sectionHeader, block) => {
|
|
2643
|
+
const lines = raw.split("\n");
|
|
2644
|
+
const range = findSectionRange(lines, sectionHeader);
|
|
2645
|
+
if (!range) return Either.left({
|
|
2646
|
+
_tag: "SectionNotFound",
|
|
2647
|
+
section: sectionHeader
|
|
2648
|
+
});
|
|
2649
|
+
const replaced = [
|
|
2650
|
+
...lines.slice(0, range.start),
|
|
2651
|
+
...block.trimEnd().split("\n"),
|
|
2652
|
+
...lines.slice(sectionBodyEnd(lines, range))
|
|
2653
|
+
];
|
|
2654
|
+
return Either.right(replaced.join("\n"));
|
|
2615
2655
|
};
|
|
2616
2656
|
/**
|
|
2617
2657
|
* Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
|
|
@@ -3056,4 +3096,4 @@ const startServer = async () => {
|
|
|
3056
3096
|
await server.connect(transport);
|
|
3057
3097
|
};
|
|
3058
3098
|
//#endregion
|
|
3059
|
-
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, McpHeaderSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, 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, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
|
3099
|
+
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, McpHeaderSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, replaceSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "envpkt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Credential lifecycle and fleet management for AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credentials",
|
|
@@ -39,16 +39,16 @@
|
|
|
39
39
|
"prepublishOnly": "pnpm validate"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@modelcontextprotocol/sdk": "^1.32.
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
43
43
|
"@sinclair/typebox": "^0.34.52",
|
|
44
44
|
"commander": "^15.0.0",
|
|
45
|
-
"functype": "^1.
|
|
46
|
-
"functype-log": "^1.
|
|
47
|
-
"functype-os": "^1.
|
|
48
|
-
"smol-toml": "^1.9.
|
|
45
|
+
"functype": "^1.15.0",
|
|
46
|
+
"functype-log": "^1.15.0",
|
|
47
|
+
"functype-os": "^1.15.0",
|
|
48
|
+
"smol-toml": "^1.9.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/node": "^24.19.
|
|
51
|
+
"@types/node": "^24.19.2",
|
|
52
52
|
"ts-builds": "^3.4.3",
|
|
53
53
|
"tsdown": "^0.22.14",
|
|
54
54
|
"tsx": "^4.23.15"
|