envpkt 0.13.6 → 0.14.1

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
@@ -141,6 +141,10 @@ See [`examples/`](./examples/) for more configurations.
141
141
 
142
142
  Sealed packets embed age-encrypted secret values directly in `envpkt.toml`. This makes your config fully self-contained — no external secrets backend needed at runtime.
143
143
 
144
+ > **Requires the [`age`](https://github.com/FiloSottile/age) CLI.** envpkt shells out to `age` to seal and unseal `encrypted_value` — it's a runtime dependency, not bundled, and there is **no built-in / JS fallback**. Every path that decrypts a sealed value (`boot()`, `exec`, `env export`, `env github`, the GitHub Action) needs `age` on `PATH` plus the private key.
145
+ >
146
+ > **Gotcha — partial resolution hides a missing `age`.** Plaintext `[env.*]` defaults and fnox-backed values resolve _without_ `age`, so a config can look like it's working while every sealed secret silently fails to decrypt. If sealed values aren't showing up, confirm `age` is installed — `envpkt doctor` reports its presence.
147
+
144
148
  ### Setup
145
149
 
146
150
  ```bash
@@ -214,7 +218,7 @@ A composite action resolves the credentials in `envpkt.toml` into the CI job —
214
218
 
215
219
  **Inputs:** `config`, `version` (npm version to run, default `latest`), `strict`, `profile`.
216
220
 
217
- > 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. Reference `@v0` for the moving major tag (re-pointed to each `0.x` release), or pin an exact release like `@v0.13.4` for immutability. `@v1` ships when envpkt reaches 1.0. Node is assumed present; add `actions/setup-node` first to pin a version.
221
+ > **The [`age`](https://github.com/FiloSottile/age) CLI is required on the runner to unseal `encrypted_value` packets** (install it first, as above) — envpkt shells out to `age`, with no bundled/JS fallback. Without it, sealed secrets won't decrypt or be injected, even though plaintext `[env.*]` defaults and fnox-backed values still resolve — which can mask the missing dependency until a sealed value turns up empty downstream. Reference `@v0` for the moving major tag (re-pointed to each `0.x` release), or pin an exact release like `@v0.13.6` for immutability. `@v1` ships when envpkt reaches 1.0. Node is assumed present; add `actions/setup-node` first to pin a version.
218
222
 
219
223
  ### Anti-rot CI gate
220
224
 
package/dist/cli.js CHANGED
@@ -70,7 +70,8 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
70
70
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
71
71
  const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
72
72
  const hasSealed = !!meta.encrypted_value;
73
- const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
73
+ const isExternal = meta.external === true;
74
+ const isMissing = !isExternal && fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
74
75
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
75
76
  if (isExpired) issues.push("Secret has expired");
76
77
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
@@ -87,7 +88,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
87
88
  return {
88
89
  key,
89
90
  service,
90
- status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").else("healthy"),
91
+ status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").elseWhen(isExternal, "external").else("healthy"),
91
92
  days_remaining: daysRemaining,
92
93
  rotation_url: rotationUrl,
93
94
  purpose,
@@ -126,7 +127,6 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
126
127
  const secretEntries = config.secret ?? {};
127
128
  const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
128
129
  const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
129
- const nonAliasMetaKeys = Set$1(nonAliasEntries.map(([k]) => k));
130
130
  const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
131
131
  const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
132
132
  const parseTargetKey = (from_key) => {
@@ -152,13 +152,14 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
152
152
  }), (health) => classifyAlias(key, meta, health, targetRef));
153
153
  });
154
154
  const secrets = List([...nonAliasHealth, ...aliasHealth]);
155
- const orphaned = keys.size > 0 ? nonAliasMetaKeys.toArray().filter((k) => !keys.has(k)).length : 0;
155
+ const orphaned = keys.size > 0 ? nonAliasEntries.filter(([k, meta]) => !keys.has(k) && meta.external !== true).length : 0;
156
156
  const total = secrets.size;
157
157
  const expired = secrets.count((s) => s.status === "expired");
158
158
  const missing = secrets.count((s) => s.status === "missing");
159
159
  const missing_metadata = secrets.count((s) => s.status === "missing_metadata");
160
160
  const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
161
161
  const stale = secrets.count((s) => s.status === "stale");
162
+ const external = secrets.count((s) => s.status === "external");
162
163
  const healthy = secrets.count((s) => s.status === "healthy");
163
164
  const aliases = aliasHealth.length;
164
165
  return {
@@ -171,6 +172,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
171
172
  stale,
172
173
  missing,
173
174
  missing_metadata,
175
+ external,
174
176
  orphaned,
175
177
  aliases,
176
178
  identity: config.identity
@@ -182,7 +184,8 @@ const computeEnvAudit = (config, env = process.env) => {
182
184
  const envWire = (key) => namer(key, envEntries[key]?.namespace);
183
185
  const resolveEffectiveDefault = (entry) => {
184
186
  return Do(function* () {
185
- return yield* $(Option((yield* $(Option(envEntries[yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]))]))).value));
187
+ const targetKey = yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]));
188
+ return yield* $(Option((yield* $(Option(envEntries[targetKey]))).value));
186
189
  }).orElse(entry.value ?? "");
187
190
  };
188
191
  const entries = Object.entries(envEntries).map(([key, entry]) => {
@@ -266,6 +269,7 @@ const SecretMetaSchema = Type.Object({
266
269
  encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
267
270
  from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value." })),
268
271
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
272
+ external: Type.Optional(Type.Boolean({ description: "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'." })),
269
273
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" })),
270
274
  namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." }))
271
275
  }, { description: "Metadata about a single secret" });
@@ -385,7 +389,8 @@ const walkUpForConfig = (dir) => {
385
389
  };
386
390
  /** Discover config by walking up from CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
387
391
  const discoverConfig = (cwd) => {
388
- const walked = walkUpForConfig(cwd ?? process.cwd()).orUndefined();
392
+ const dir = cwd ?? process.cwd();
393
+ const walked = walkUpForConfig(dir).orUndefined();
389
394
  if (walked) return Option({
390
395
  path: walked,
391
396
  source: "cwd"
@@ -646,6 +651,7 @@ const secretStatusIcon = (status) => {
646
651
  case "stale": return `${YELLOW}○${RESET}`;
647
652
  case "missing": return `${RED}?${RESET}`;
648
653
  case "missing_metadata": return `${YELLOW}!${RESET}`;
654
+ case "external": return `${CYAN}↗${RESET}`;
649
655
  default: return " ";
650
656
  }
651
657
  };
@@ -667,9 +673,10 @@ const formatAudit = (audit) => {
667
673
  audit.stale > 0 ? ` ${YELLOW}${audit.stale}${RESET} stale` : null,
668
674
  audit.missing > 0 ? ` ${RED}${audit.missing}${RESET} missing` : null,
669
675
  audit.missing_metadata > 0 ? ` ${YELLOW}${audit.missing_metadata}${RESET} missing metadata` : null,
676
+ audit.external > 0 ? ` ${CYAN}${audit.external}${RESET} external` : null,
670
677
  audit.orphaned > 0 ? ` ${YELLOW}${audit.orphaned}${RESET} orphaned` : null
671
678
  ].filter(Boolean).join("\n"),
672
- audit.secrets.filter((s) => s.status !== "healthy").map(formatSecretRow).toArray().join("\n")
679
+ audit.secrets.filter((s) => s.status !== "healthy" && s.status !== "external").map(formatSecretRow).toArray().join("\n")
673
680
  ].filter((s) => s.length > 0).join("\n\n");
674
681
  };
675
682
  const formatAuditJson = (audit) => JSON.stringify({
@@ -721,12 +728,13 @@ const formatError = (error) => {
721
728
  case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
722
729
  case "SealKeyUnavailable": {
723
730
  const e = error;
731
+ const restoreLine = e.configuredKeyFile ? ` • Restore the configured key to ${e.configuredKeyFile} (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)` : ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`;
724
732
  return [
725
733
  `${RED}Error:${RESET} ${e.sealedKeys.length} sealed secret(s) can't be decrypted — no age key found.`,
726
734
  `${DIM}Searched (in order):${RESET}`,
727
735
  e.searched.map((l) => ` • ${l}`).join("\n"),
728
736
  `${DIM}Fix one:${RESET}`,
729
- ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`,
737
+ restoreLine,
730
738
  ` • Re-provision from source: envpkt seal --edit <KEY>`
731
739
  ].join("\n");
732
740
  }
@@ -757,8 +765,9 @@ const confidenceIcon = (confidence) => {
757
765
  }
758
766
  };
759
767
  const formatScanTable = (scan) => {
768
+ const total = scan.discovered.size;
760
769
  return [
761
- `${BOLD}Scan Results${RESET} — ${scan.discovered.size} credential(s) found in ${scan.total_scanned} env vars`,
770
+ `${BOLD}Scan Results${RESET} — ${total} credential(s) found in ${scan.total_scanned} env vars`,
762
771
  [
763
772
  scan.high_confidence > 0 ? ` ${GREEN}${scan.high_confidence}${RESET} high confidence` : null,
764
773
  scan.medium_confidence > 0 ? ` ${YELLOW}${scan.medium_confidence}${RESET} medium confidence` : null,
@@ -955,7 +964,8 @@ const fnoxExport = (profile, agentKey) => {
955
964
  const eq = line.indexOf("=");
956
965
  if (eq > 0) {
957
966
  const key = line.slice(0, eq).trim();
958
- entries[key] = line.slice(eq + 1).trim();
967
+ const value = line.slice(eq + 1).trim();
968
+ entries[key] = value;
959
969
  }
960
970
  });
961
971
  return Right(entries);
@@ -1395,11 +1405,13 @@ const resolveSealIdentity = (config, configDir) => {
1395
1405
  });
1396
1406
  const inlineKey = resolveInlineKey().orUndefined();
1397
1407
  if (inlineKey) return Option(materializeInlineKey(inlineKey));
1398
- const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1399
- if (existsSync(defaultPath)) return Option({
1400
- path: defaultPath,
1401
- dispose: noop
1402
- });
1408
+ if (!config.identity?.key_file) {
1409
+ const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1410
+ if (existsSync(defaultPath)) return Option({
1411
+ path: defaultPath,
1412
+ dispose: noop
1413
+ });
1414
+ }
1403
1415
  return Option(void 0);
1404
1416
  };
1405
1417
  /** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
@@ -1414,7 +1426,7 @@ const describeSealKeySearch = (config, configDir) => {
1414
1426
  keyFileLine,
1415
1427
  envFileLine,
1416
1428
  inlineLine,
1417
- `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
1429
+ keyFile ? `${defaultPath} (skipped — identity.key_file is set)` : `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
1418
1430
  ];
1419
1431
  };
1420
1432
  const resolveIdentityKey = (config, configDir) => {
@@ -1490,7 +1502,8 @@ const bootSafe = (options) => {
1490
1502
  if (hasSealedValues && sealIdentity.isEmpty) return Left({
1491
1503
  _tag: "SealKeyUnavailable",
1492
1504
  sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
1493
- searched: describeSealKeySearch(config, configDir)
1505
+ searched: describeSealKeySearch(config, configDir),
1506
+ configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
1494
1507
  });
1495
1508
  return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
1496
1509
  const secrets = {};
@@ -1585,7 +1598,8 @@ const bootSafe = (options) => {
1585
1598
  }
1586
1599
  const targetEntry = envEntries[entry.targetKey];
1587
1600
  if (targetEntry?.value === void 0) return;
1588
- envDefaults[aliasKey] = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
1601
+ const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
1602
+ envDefaults[aliasKey] = resolvedTarget;
1589
1603
  });
1590
1604
  if (inject) {
1591
1605
  Object.entries(envDefaults).forEach(([key, value]) => {
@@ -4419,7 +4433,8 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
4419
4433
  const values = {};
4420
4434
  for (const key of editKeys) {
4421
4435
  if (entries[key]?.encrypted_value) {
4422
- if (!isAffirmative(await prompt(`Replace the sealed value for ${key}? The previous value can't be recovered without the original key. [y/N] `))) {
4436
+ const confirm = await prompt(`Replace the sealed value for ${key}? The previous value can't be recovered without the original key. [y/N] `);
4437
+ if (!isAffirmative(confirm)) {
4423
4438
  onSkip?.(key, "declined");
4424
4439
  continue;
4425
4440
  }
@@ -4435,7 +4450,8 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
4435
4450
  };
4436
4451
  /** Write sealed values back into the TOML file, preserving structure. */
4437
4452
  const writeSealedToml = (configPath, sealedMeta) => {
4438
- const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
4453
+ const raw = readFileSync(configPath, "utf-8");
4454
+ const finalContent = applySealedToml(raw, sealedMeta);
4439
4455
  validateOrExit(finalContent);
4440
4456
  writeFileSync(configPath, finalContent);
4441
4457
  };
@@ -4495,6 +4511,12 @@ const runSeal = async (options) => {
4495
4511
  console.error(`${DIM}Aliases reference another secret's value via from_key — seal the target instead.${RESET}`);
4496
4512
  process.exit(2);
4497
4513
  }
4514
+ const externalKeys = editKeys.filter((k) => allSecretEntries[k].external === true);
4515
+ if (externalKeys.length > 0) {
4516
+ console.error(`${RED}Error:${RESET} Cannot seal external entries: ${externalKeys.join(", ")}`);
4517
+ console.error(`${DIM}external = true means the value is managed outside envpkt — set it in that store instead.${RESET}`);
4518
+ process.exit(2);
4519
+ }
4498
4520
  if (!process.stdin.isTTY) {
4499
4521
  console.error(`${RED}Error:${RESET} --edit requires an interactive terminal`);
4500
4522
  process.exit(2);
@@ -4527,14 +4549,16 @@ const runSeal = async (options) => {
4527
4549
  return;
4528
4550
  }
4529
4551
  const allSecretEntries = config.secret ?? {};
4530
- const allKeys = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0);
4552
+ const allKeys = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0 && allSecretEntries[k].external !== true);
4531
4553
  const skippedAliases = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key !== void 0);
4554
+ const skippedExternal = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0 && allSecretEntries[k].external === true);
4532
4555
  const alreadySealed = allKeys.filter((k) => allSecretEntries[k].encrypted_value);
4533
4556
  const unsealed = allKeys.filter((k) => !allSecretEntries[k].encrypted_value);
4534
4557
  if (skippedAliases.length > 0) {
4535
4558
  const noun = skippedAliases.length === 1 ? "alias" : "aliases";
4536
4559
  console.error(`${DIM}Skipping ${skippedAliases.length} ${noun}: ${skippedAliases.join(", ")}${RESET}`);
4537
4560
  }
4561
+ if (skippedExternal.length > 0) console.error(`${DIM}Skipping ${skippedExternal.length} external (value managed outside envpkt): ${skippedExternal.join(", ")}${RESET}`);
4538
4562
  if (!options.reseal && alreadySealed.length > 0) {
4539
4563
  if (unsealed.length === 0) {
4540
4564
  console.log(`${GREEN}✓${RESET} All ${BOLD}${alreadySealed.length}${RESET} secret(s) already sealed. Use ${CYAN}--reseal${RESET} to re-encrypt.`);
@@ -4937,11 +4961,13 @@ const runSecretRotate = async (name, options) => {
4937
4961
  const addSecretFlags = (cmd) => cmd.option("--service <service>", "Service this secret authenticates to").option("--purpose <purpose>", "Why this secret exists").option("--comment <comment>", "Free-form annotation").option("--expires <date>", "Expiration date (YYYY-MM-DD)").option("--capabilities <caps>", "Comma-separated capabilities (e.g. read,write)").option("--rotates <schedule>", "Rotation schedule (e.g. 90d, quarterly)").option("--rate-limit <limit>", "Rate limit info (e.g. 1000/min)").option("--model-hint <hint>", "Suggested model or tier").option("--source <source>", "Where the value originates (e.g. vault, ci)").option("--rotation-url <url>", "URL for secret rotation procedure").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)");
4938
4962
  const registerSecretCommands = (program) => {
4939
4963
  const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
4940
- addSecretFlags(secret.command("add").description("Add a new secret entry to envpkt.toml").argument("<name>", "Secret name (becomes the env var key)").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--dry-run", "Preview the TOML block without writing")).action((name, options) => {
4964
+ const addCmd = secret.command("add").description("Add a new secret entry to envpkt.toml").argument("<name>", "Secret name (becomes the env var key)").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--dry-run", "Preview the TOML block without writing");
4965
+ addSecretFlags(addCmd).action((name, options) => {
4941
4966
  runSecretAdd(name, options);
4942
4967
  });
4943
4968
  const collect = (value, previous) => [...previous, value];
4944
- addSecretFlags(secret.command("edit").description("Update metadata fields on an existing secret").argument("<name>", "Secret name to edit").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--no-required", "Mark this secret as not required").option("--unset <field>", "Remove an optional field (repeatable). Field names match TOML keys, e.g. expires, rate_limit", collect, []).option("--dry-run", "Preview the changes without writing")).action((name, options) => {
4969
+ const editCmd = secret.command("edit").description("Update metadata fields on an existing secret").argument("<name>", "Secret name to edit").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--no-required", "Mark this secret as not required").option("--unset <field>", "Remove an optional field (repeatable). Field names match TOML keys, e.g. expires, rate_limit", collect, []).option("--dry-run", "Preview the changes without writing");
4970
+ addSecretFlags(editCmd).action((name, options) => {
4945
4971
  runSecretEdit(name, options);
4946
4972
  });
4947
4973
  secret.command("rm").description("Remove a secret entry from envpkt.toml").argument("<name>", "Secret name to remove").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((name, options) => {
@@ -5223,35 +5249,39 @@ const buildReport = (path, raw) => {
5223
5249
  skipped(CHECK_NAMES.sealed)
5224
5250
  ]
5225
5251
  }), (config) => {
5252
+ const schemaOk = {
5253
+ name: CHECK_NAMES.schema,
5254
+ status: "ok"
5255
+ };
5256
+ const catalogCheck = config.catalog ? resolveConfig(config, dirname(path)).fold((err) => ({
5257
+ name: CHECK_NAMES.catalog,
5258
+ status: "failed",
5259
+ error: formatError(err)
5260
+ }), () => ({
5261
+ name: CHECK_NAMES.catalog,
5262
+ status: "ok",
5263
+ detail: config.catalog
5264
+ })) : {
5265
+ name: CHECK_NAMES.catalog,
5266
+ status: "na",
5267
+ detail: "no catalog declared"
5268
+ };
5269
+ const aliasCheck = validateAliases(config).fold((err) => ({
5270
+ name: CHECK_NAMES.aliases,
5271
+ status: "failed",
5272
+ error: formatValidationError(err)
5273
+ }), (table) => ({
5274
+ name: CHECK_NAMES.aliases,
5275
+ status: "ok",
5276
+ detail: `${table.entries.size} alias(es)`
5277
+ }));
5278
+ const sealedCheck = checkSealedBlocks(config);
5226
5279
  const checks = [
5227
5280
  tomlOk,
5228
- {
5229
- name: CHECK_NAMES.schema,
5230
- status: "ok"
5231
- },
5232
- config.catalog ? resolveConfig(config, dirname(path)).fold((err) => ({
5233
- name: CHECK_NAMES.catalog,
5234
- status: "failed",
5235
- error: formatError(err)
5236
- }), () => ({
5237
- name: CHECK_NAMES.catalog,
5238
- status: "ok",
5239
- detail: config.catalog
5240
- })) : {
5241
- name: CHECK_NAMES.catalog,
5242
- status: "na",
5243
- detail: "no catalog declared"
5244
- },
5245
- validateAliases(config).fold((err) => ({
5246
- name: CHECK_NAMES.aliases,
5247
- status: "failed",
5248
- error: formatValidationError(err)
5249
- }), (table) => ({
5250
- name: CHECK_NAMES.aliases,
5251
- status: "ok",
5252
- detail: `${table.entries.size} alias(es)`
5253
- })),
5254
- checkSealedBlocks(config)
5281
+ schemaOk,
5282
+ catalogCheck,
5283
+ aliasCheck,
5284
+ sealedCheck
5255
5285
  ];
5256
5286
  return {
5257
5287
  ok: checks.every((c) => c.status === "ok" || c.status === "na"),
@@ -5316,7 +5346,7 @@ const runValidate = (options) => {
5316
5346
  }, ({ path, source }) => {
5317
5347
  const sourceMsg = formatConfigSource(path, source);
5318
5348
  Try(() => readFileSync(path, "utf-8")).fold((e) => {
5319
- emit({
5349
+ const report = {
5320
5350
  ok: false,
5321
5351
  configPath: path,
5322
5352
  checks: [{
@@ -5324,7 +5354,8 @@ const runValidate = (options) => {
5324
5354
  status: "failed",
5325
5355
  error: e.message
5326
5356
  }]
5327
- }, sourceMsg, options.json === true);
5357
+ };
5358
+ emit(report, sourceMsg, options.json === true);
5328
5359
  process.exit(2);
5329
5360
  }, (raw) => {
5330
5361
  const report = buildReport(path, raw);
package/dist/index.d.ts CHANGED
@@ -4,7 +4,6 @@ import { DirectLogger, DirectTestLoggerHandle, createDirectConsoleLogger, create
4
4
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { CallToolResult, ReadResourceResult, Resource } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { DirectLogger as DirectLogger$1, LogEntry, LogLevel, LogMetadata } from "functype-log";
7
-
8
7
  //#region src/core/schema.d.ts
9
8
  declare const ConsumerType: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"agent">, import("@sinclair/typebox").TLiteral<"service">, import("@sinclair/typebox").TLiteral<"developer">, import("@sinclair/typebox").TLiteral<"ci">]>;
10
9
  type ConsumerType = Static<typeof ConsumerType>;
@@ -48,6 +47,7 @@ declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
48
47
  encrypted_value: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
49
48
  from_key: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
50
49
  required: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
50
+ external: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
51
51
  tags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
52
52
  namespace: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
53
53
  }>;
@@ -110,6 +110,7 @@ declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
110
110
  encrypted_value: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
111
111
  from_key: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
112
112
  required: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
113
+ external: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
113
114
  tags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
114
115
  namespace: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
115
116
  }>>>;
@@ -139,7 +140,7 @@ type EnvpktConfig = Static<typeof EnvpktConfigSchema>;
139
140
  /** @deprecated Use `Identity` instead */
140
141
  type AgentIdentity = Identity;
141
142
  type HealthStatus = "healthy" | "degraded" | "critical";
142
- type SecretStatus = "healthy" | "expiring_soon" | "expired" | "stale" | "missing" | "missing_metadata";
143
+ type SecretStatus = "healthy" | "expiring_soon" | "expired" | "stale" | "missing" | "missing_metadata" | "external";
143
144
  type SecretHealth = {
144
145
  readonly key: string;
145
146
  readonly service: Option<string>;
@@ -150,7 +151,8 @@ type SecretHealth = {
150
151
  readonly created: Option<string>;
151
152
  readonly expires: Option<string>;
152
153
  readonly last_rotated_at: Option<string>;
153
- readonly issues: List<string>; /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
154
+ readonly issues: List<string>;
155
+ /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
154
156
  readonly alias_of: Option<string>;
155
157
  };
156
158
  type AuditResult = {
@@ -163,7 +165,10 @@ type AuditResult = {
163
165
  readonly stale: number;
164
166
  readonly missing: number;
165
167
  readonly missing_metadata: number;
166
- readonly orphaned: number; /** Count of entries that are aliases (from_key). Included in `secrets` but reported separately for visibility. */
168
+ /** Count of entries whose value is managed outside envpkt (external = true). Reported, not a health problem. */
169
+ readonly external: number;
170
+ readonly orphaned: number;
171
+ /** Count of entries that are aliases (from_key). Included in `secrets` but reported separately for visibility. */
167
172
  readonly aliases: number;
168
173
  readonly identity?: Identity;
169
174
  };
@@ -173,7 +178,8 @@ type EnvDriftEntry = {
173
178
  readonly defaultValue: string;
174
179
  readonly currentValue: string | undefined;
175
180
  readonly status: EnvDriftStatus;
176
- readonly purpose: string | undefined; /** If this entry is an alias (from_key), the reference it points at (e.g. "env.X") */
181
+ readonly purpose: string | undefined;
182
+ /** If this entry is an alias (from_key), the reference it points at (e.g. "env.X") */
177
183
  readonly alias_of: Option<string>;
178
184
  };
179
185
  type EnvAuditResult = {
@@ -259,7 +265,8 @@ type CatalogError = {
259
265
  readonly message: string;
260
266
  };
261
267
  type AliasTable = {
262
- /** key → { type: "secret"|"env", targetType, targetKey } for every alias entry */readonly entries: ReadonlyMap<string, {
268
+ /** key → { type: "secret"|"env", targetType, targetKey } for every alias entry */
269
+ readonly entries: ReadonlyMap<string, {
263
270
  readonly kind: "secret" | "env";
264
271
  readonly targetKind: "secret" | "env";
265
272
  readonly targetKey: string;
@@ -332,6 +339,7 @@ type BootError = ConfigError | FnoxError | CatalogError | AliasError | {
332
339
  readonly _tag: "SealKeyUnavailable";
333
340
  readonly sealedKeys: ReadonlyArray<string>;
334
341
  readonly searched: ReadonlyArray<string>;
342
+ readonly configuredKeyFile?: string;
335
343
  } | IdentityError;
336
344
  type IdentityError = {
337
345
  readonly _tag: "AgeNotFound";
@@ -571,7 +579,9 @@ type DotenvEntry = {
571
579
  readonly secret: boolean;
572
580
  };
573
581
  type FormatDotenvOptions = {
574
- /** Write secret values into the output. Default true (matches `env export`/`env github`). */readonly includeSecrets?: boolean; /** A pre-formatted comment block (each line `#`-prefixed) placed at the top. */
582
+ /** Write secret values into the output. Default true (matches `env export`/`env github`). */
583
+ readonly includeSecrets?: boolean;
584
+ /** A pre-formatted comment block (each line `#`-prefixed) placed at the top. */
575
585
  readonly header?: string;
576
586
  };
577
587
  /**
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ const SecretMetaSchema = Type.Object({
74
74
  encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
75
75
  from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value." })),
76
76
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
77
+ external: Type.Optional(Type.Boolean({ description: "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'." })),
77
78
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" })),
78
79
  namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." }))
79
80
  }, { description: "Metadata about a single secret" });
@@ -198,7 +199,8 @@ const walkUpForConfig = (dir) => {
198
199
  };
199
200
  /** Discover config by walking up from CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
200
201
  const discoverConfig = (cwd) => {
201
- const walked = walkUpForConfig(cwd ?? process.cwd()).orUndefined();
202
+ const dir = cwd ?? process.cwd();
203
+ const walked = walkUpForConfig(dir).orUndefined();
202
204
  if (walked) return Option({
203
205
  path: walked,
204
206
  source: "cwd"
@@ -635,7 +637,8 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
635
637
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
636
638
  const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
637
639
  const hasSealed = !!meta.encrypted_value;
638
- const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
640
+ const isExternal = meta.external === true;
641
+ const isMissing = !isExternal && fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
639
642
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
640
643
  if (isExpired) issues.push("Secret has expired");
641
644
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
@@ -652,7 +655,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
652
655
  return {
653
656
  key,
654
657
  service,
655
- status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").else("healthy"),
658
+ status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").elseWhen(isExternal, "external").else("healthy"),
656
659
  days_remaining: daysRemaining,
657
660
  rotation_url: rotationUrl,
658
661
  purpose,
@@ -691,7 +694,6 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
691
694
  const secretEntries = config.secret ?? {};
692
695
  const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
693
696
  const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
694
- const nonAliasMetaKeys = Set$1(nonAliasEntries.map(([k]) => k));
695
697
  const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
696
698
  const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
697
699
  const parseTargetKey = (from_key) => {
@@ -717,13 +719,14 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
717
719
  }), (health) => classifyAlias(key, meta, health, targetRef));
718
720
  });
719
721
  const secrets = List([...nonAliasHealth, ...aliasHealth]);
720
- const orphaned = keys.size > 0 ? nonAliasMetaKeys.toArray().filter((k) => !keys.has(k)).length : 0;
722
+ const orphaned = keys.size > 0 ? nonAliasEntries.filter(([k, meta]) => !keys.has(k) && meta.external !== true).length : 0;
721
723
  const total = secrets.size;
722
724
  const expired = secrets.count((s) => s.status === "expired");
723
725
  const missing = secrets.count((s) => s.status === "missing");
724
726
  const missing_metadata = secrets.count((s) => s.status === "missing_metadata");
725
727
  const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
726
728
  const stale = secrets.count((s) => s.status === "stale");
729
+ const external = secrets.count((s) => s.status === "external");
727
730
  const healthy = secrets.count((s) => s.status === "healthy");
728
731
  const aliases = aliasHealth.length;
729
732
  return {
@@ -736,6 +739,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
736
739
  stale,
737
740
  missing,
738
741
  missing_metadata,
742
+ external,
739
743
  orphaned,
740
744
  aliases,
741
745
  identity: config.identity
@@ -747,7 +751,8 @@ const computeEnvAudit = (config, env = process.env) => {
747
751
  const envWire = (key) => namer(key, envEntries[key]?.namespace);
748
752
  const resolveEffectiveDefault = (entry) => {
749
753
  return Do(function* () {
750
- return yield* $(Option((yield* $(Option(envEntries[yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]))]))).value));
754
+ const targetKey = yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]));
755
+ return yield* $(Option((yield* $(Option(envEntries[targetKey]))).value));
751
756
  }).orElse(entry.value ?? "");
752
757
  };
753
758
  const entries = Object.entries(envEntries).map(([key, entry]) => {
@@ -1596,7 +1601,8 @@ const fnoxExport = (profile, agentKey) => {
1596
1601
  const eq = line.indexOf("=");
1597
1602
  if (eq > 0) {
1598
1603
  const key = line.slice(0, eq).trim();
1599
- entries[key] = line.slice(eq + 1).trim();
1604
+ const value = line.slice(eq + 1).trim();
1605
+ entries[key] = value;
1600
1606
  }
1601
1607
  });
1602
1608
  return Right(entries);
@@ -1977,11 +1983,13 @@ const resolveSealIdentity = (config, configDir) => {
1977
1983
  });
1978
1984
  const inlineKey = resolveInlineKey().orUndefined();
1979
1985
  if (inlineKey) return Option(materializeInlineKey(inlineKey));
1980
- const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1981
- if (existsSync(defaultPath)) return Option({
1982
- path: defaultPath,
1983
- dispose: noop
1984
- });
1986
+ if (!config.identity?.key_file) {
1987
+ const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1988
+ if (existsSync(defaultPath)) return Option({
1989
+ path: defaultPath,
1990
+ dispose: noop
1991
+ });
1992
+ }
1985
1993
  return Option(void 0);
1986
1994
  };
1987
1995
  /** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
@@ -1996,7 +2004,7 @@ const describeSealKeySearch = (config, configDir) => {
1996
2004
  keyFileLine,
1997
2005
  envFileLine,
1998
2006
  inlineLine,
1999
- `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
2007
+ keyFile ? `${defaultPath} (skipped — identity.key_file is set)` : `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
2000
2008
  ];
2001
2009
  };
2002
2010
  const resolveIdentityKey = (config, configDir) => {
@@ -2072,7 +2080,8 @@ const bootSafe = (options) => {
2072
2080
  if (hasSealedValues && sealIdentity.isEmpty) return Left({
2073
2081
  _tag: "SealKeyUnavailable",
2074
2082
  sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
2075
- searched: describeSealKeySearch(config, configDir)
2083
+ searched: describeSealKeySearch(config, configDir),
2084
+ configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
2076
2085
  });
2077
2086
  return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
2078
2087
  const secrets = {};
@@ -2167,7 +2176,8 @@ const bootSafe = (options) => {
2167
2176
  }
2168
2177
  const targetEntry = envEntries[entry.targetKey];
2169
2178
  if (targetEntry?.value === void 0) return;
2170
- envDefaults[aliasKey] = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
2179
+ const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
2180
+ envDefaults[aliasKey] = resolvedTarget;
2171
2181
  });
2172
2182
  if (inject) {
2173
2183
  Object.entries(envDefaults).forEach(([key, value]) => {
@@ -2225,12 +2235,13 @@ const formatBootError = (error) => {
2225
2235
  case "SealKeyUnavailable": {
2226
2236
  const keys = error.sealedKeys.length;
2227
2237
  const searched = error.searched.map((line) => ` • ${line}`).join("\n");
2238
+ const restoreLine = error.configuredKeyFile ? ` • Restore the configured key to ${error.configuredKeyFile} (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)` : ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`;
2228
2239
  return [
2229
2240
  `${keys} sealed secret(s) can't be decrypted — no age key found.`,
2230
2241
  `Searched (in order):`,
2231
2242
  searched,
2232
2243
  `Fix one:`,
2233
- ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`,
2244
+ restoreLine,
2234
2245
  ` • Re-provision from source: envpkt seal --edit <KEY>`,
2235
2246
  `Refusing to inject empty values for sealed secrets.`
2236
2247
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.13.6",
3
+ "version": "0.14.1",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -39,19 +39,19 @@
39
39
  "prepublishOnly": "pnpm validate"
40
40
  },
41
41
  "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.29.0",
43
- "@sinclair/typebox": "^0.34.49",
42
+ "@modelcontextprotocol/sdk": "^1.30.0",
43
+ "@sinclair/typebox": "^0.34.52",
44
44
  "commander": "^15.0.0",
45
- "functype": "^1.5.0",
46
- "functype-log": "^1.5.0",
47
- "functype-os": "^1.5.0",
48
- "smol-toml": "^1.7.0"
45
+ "functype": "^1.8.0",
46
+ "functype-log": "^1.8.0",
47
+ "functype-os": "^1.8.0",
48
+ "smol-toml": "^1.7.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^24.13.2",
52
- "ts-builds": "^3.2.1",
53
- "tsdown": "^0.22.3",
54
- "tsx": "^4.22.4"
51
+ "@types/node": "^24.13.3",
52
+ "ts-builds": "^3.2.3",
53
+ "tsdown": "^0.22.14",
54
+ "tsx": "^4.23.1"
55
55
  },
56
56
  "type": "module",
57
57
  "main": "./dist/index.js",
@@ -71,5 +71,5 @@
71
71
  "schemas"
72
72
  ],
73
73
  "prettier": "ts-builds/prettier",
74
- "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
74
+ "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
75
75
  }
@@ -195,6 +195,10 @@
195
195
  "description": "Whether this secret is required for operation",
196
196
  "type": "boolean"
197
197
  },
198
+ "external": {
199
+ "description": "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'.",
200
+ "type": "boolean"
201
+ },
198
202
  "tags": {
199
203
  "description": "Key-value tags for grouping and filtering",
200
204
  "type": "object",