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 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). On copy, `created` is reset to today and `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.
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 afterExternal = options.external ? (() => {
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
- ...afterExternal,
934
- secrets: afterExternal.secrets.filter((s) => s.status === options.status)
935
- } : afterExternal;
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` is reset to today: the entry is new *here*, regardless of the source's age.
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.today
1678
+ created: opts.created
1677
1679
  }), (cipher) => ({
1678
1680
  ...rest,
1679
- created: opts.today,
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 through the next section or EOF.
1758
- * Strips trailing blank lines left behind.
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 after = lines.slice(range.end);
1768
- const beforeAll = lines.slice(0, range.start);
1769
- const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
1770
- const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
1771
- return Either.right(result);
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 today = todayIso$2();
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
- today,
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
- today,
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 baseRaw = overwrite ? orExit(removeSection(raw, header), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : raw;
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
- if (!process.stdin.isTTY) {
4696
- console.error(`${RED}Error:${RESET} --edit requires an interactive terminal`);
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 rl = await import("node:readline").then((m) => m.createInterface({
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("--external", "Show only secrets without encrypted_value").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
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), always prompts interactively").action(async (options) => {
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` is reset to today: the entry is new *here*, regardless of the source's age.
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 today: string;
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 through the next section or EOF.
700
- * Strips trailing blank lines left behind.
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` is reset to today: the entry is new *here*, regardless of the source's age.
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.today
2520
+ created: opts.created
2520
2521
  }), (cipher) => ({
2521
2522
  ...rest,
2522
- created: opts.today,
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 through the next section or EOF.
2601
- * Strips trailing blank lines left behind.
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 after = lines.slice(range.end);
2611
- const beforeAll = lines.slice(0, range.start);
2612
- const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
2613
- const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
2614
- return Either.right(result);
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.15.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.0",
42
+ "@modelcontextprotocol/sdk": "^1.32.1",
43
43
  "@sinclair/typebox": "^0.34.52",
44
44
  "commander": "^15.0.0",
45
- "functype": "^1.13.0",
46
- "functype-log": "^1.13.0",
47
- "functype-os": "^1.13.0",
48
- "smol-toml": "^1.9.0"
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.1",
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"