envpkt 0.13.5 → 0.14.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 +5 -1
- package/dist/cli.js +71 -44
- package/dist/index.d.ts +16 -7
- package/dist/index.js +15 -8
- package/package.json +10 -10
- package/schemas/envpkt.schema.json +4 -0
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
|
-
>
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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({
|
|
@@ -757,8 +764,9 @@ const confidenceIcon = (confidence) => {
|
|
|
757
764
|
}
|
|
758
765
|
};
|
|
759
766
|
const formatScanTable = (scan) => {
|
|
767
|
+
const total = scan.discovered.size;
|
|
760
768
|
return [
|
|
761
|
-
`${BOLD}Scan Results${RESET} — ${
|
|
769
|
+
`${BOLD}Scan Results${RESET} — ${total} credential(s) found in ${scan.total_scanned} env vars`,
|
|
762
770
|
[
|
|
763
771
|
scan.high_confidence > 0 ? ` ${GREEN}${scan.high_confidence}${RESET} high confidence` : null,
|
|
764
772
|
scan.medium_confidence > 0 ? ` ${YELLOW}${scan.medium_confidence}${RESET} medium confidence` : null,
|
|
@@ -955,7 +963,8 @@ const fnoxExport = (profile, agentKey) => {
|
|
|
955
963
|
const eq = line.indexOf("=");
|
|
956
964
|
if (eq > 0) {
|
|
957
965
|
const key = line.slice(0, eq).trim();
|
|
958
|
-
|
|
966
|
+
const value = line.slice(eq + 1).trim();
|
|
967
|
+
entries[key] = value;
|
|
959
968
|
}
|
|
960
969
|
});
|
|
961
970
|
return Right(entries);
|
|
@@ -1585,7 +1594,8 @@ const bootSafe = (options) => {
|
|
|
1585
1594
|
}
|
|
1586
1595
|
const targetEntry = envEntries[entry.targetKey];
|
|
1587
1596
|
if (targetEntry?.value === void 0) return;
|
|
1588
|
-
|
|
1597
|
+
const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
|
|
1598
|
+
envDefaults[aliasKey] = resolvedTarget;
|
|
1589
1599
|
});
|
|
1590
1600
|
if (inject) {
|
|
1591
1601
|
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
@@ -4419,7 +4429,8 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4419
4429
|
const values = {};
|
|
4420
4430
|
for (const key of editKeys) {
|
|
4421
4431
|
if (entries[key]?.encrypted_value) {
|
|
4422
|
-
|
|
4432
|
+
const confirm = await prompt(`Replace the sealed value for ${key}? The previous value can't be recovered without the original key. [y/N] `);
|
|
4433
|
+
if (!isAffirmative(confirm)) {
|
|
4423
4434
|
onSkip?.(key, "declined");
|
|
4424
4435
|
continue;
|
|
4425
4436
|
}
|
|
@@ -4435,7 +4446,8 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4435
4446
|
};
|
|
4436
4447
|
/** Write sealed values back into the TOML file, preserving structure. */
|
|
4437
4448
|
const writeSealedToml = (configPath, sealedMeta) => {
|
|
4438
|
-
const
|
|
4449
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4450
|
+
const finalContent = applySealedToml(raw, sealedMeta);
|
|
4439
4451
|
validateOrExit(finalContent);
|
|
4440
4452
|
writeFileSync(configPath, finalContent);
|
|
4441
4453
|
};
|
|
@@ -4495,6 +4507,12 @@ const runSeal = async (options) => {
|
|
|
4495
4507
|
console.error(`${DIM}Aliases reference another secret's value via from_key — seal the target instead.${RESET}`);
|
|
4496
4508
|
process.exit(2);
|
|
4497
4509
|
}
|
|
4510
|
+
const externalKeys = editKeys.filter((k) => allSecretEntries[k].external === true);
|
|
4511
|
+
if (externalKeys.length > 0) {
|
|
4512
|
+
console.error(`${RED}Error:${RESET} Cannot seal external entries: ${externalKeys.join(", ")}`);
|
|
4513
|
+
console.error(`${DIM}external = true means the value is managed outside envpkt — set it in that store instead.${RESET}`);
|
|
4514
|
+
process.exit(2);
|
|
4515
|
+
}
|
|
4498
4516
|
if (!process.stdin.isTTY) {
|
|
4499
4517
|
console.error(`${RED}Error:${RESET} --edit requires an interactive terminal`);
|
|
4500
4518
|
process.exit(2);
|
|
@@ -4527,14 +4545,16 @@ const runSeal = async (options) => {
|
|
|
4527
4545
|
return;
|
|
4528
4546
|
}
|
|
4529
4547
|
const allSecretEntries = config.secret ?? {};
|
|
4530
|
-
const allKeys = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0);
|
|
4548
|
+
const allKeys = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0 && allSecretEntries[k].external !== true);
|
|
4531
4549
|
const skippedAliases = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key !== void 0);
|
|
4550
|
+
const skippedExternal = Object.keys(allSecretEntries).filter((k) => allSecretEntries[k].from_key === void 0 && allSecretEntries[k].external === true);
|
|
4532
4551
|
const alreadySealed = allKeys.filter((k) => allSecretEntries[k].encrypted_value);
|
|
4533
4552
|
const unsealed = allKeys.filter((k) => !allSecretEntries[k].encrypted_value);
|
|
4534
4553
|
if (skippedAliases.length > 0) {
|
|
4535
4554
|
const noun = skippedAliases.length === 1 ? "alias" : "aliases";
|
|
4536
4555
|
console.error(`${DIM}Skipping ${skippedAliases.length} ${noun}: ${skippedAliases.join(", ")}${RESET}`);
|
|
4537
4556
|
}
|
|
4557
|
+
if (skippedExternal.length > 0) console.error(`${DIM}Skipping ${skippedExternal.length} external (value managed outside envpkt): ${skippedExternal.join(", ")}${RESET}`);
|
|
4538
4558
|
if (!options.reseal && alreadySealed.length > 0) {
|
|
4539
4559
|
if (unsealed.length === 0) {
|
|
4540
4560
|
console.log(`${GREEN}✓${RESET} All ${BOLD}${alreadySealed.length}${RESET} secret(s) already sealed. Use ${CYAN}--reseal${RESET} to re-encrypt.`);
|
|
@@ -4937,11 +4957,13 @@ const runSecretRotate = async (name, options) => {
|
|
|
4937
4957
|
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
4958
|
const registerSecretCommands = (program) => {
|
|
4939
4959
|
const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
|
|
4940
|
-
|
|
4960
|
+
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");
|
|
4961
|
+
addSecretFlags(addCmd).action((name, options) => {
|
|
4941
4962
|
runSecretAdd(name, options);
|
|
4942
4963
|
});
|
|
4943
4964
|
const collect = (value, previous) => [...previous, value];
|
|
4944
|
-
|
|
4965
|
+
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");
|
|
4966
|
+
addSecretFlags(editCmd).action((name, options) => {
|
|
4945
4967
|
runSecretEdit(name, options);
|
|
4946
4968
|
});
|
|
4947
4969
|
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 +5245,39 @@ const buildReport = (path, raw) => {
|
|
|
5223
5245
|
skipped(CHECK_NAMES.sealed)
|
|
5224
5246
|
]
|
|
5225
5247
|
}), (config) => {
|
|
5248
|
+
const schemaOk = {
|
|
5249
|
+
name: CHECK_NAMES.schema,
|
|
5250
|
+
status: "ok"
|
|
5251
|
+
};
|
|
5252
|
+
const catalogCheck = config.catalog ? resolveConfig(config, dirname(path)).fold((err) => ({
|
|
5253
|
+
name: CHECK_NAMES.catalog,
|
|
5254
|
+
status: "failed",
|
|
5255
|
+
error: formatError(err)
|
|
5256
|
+
}), () => ({
|
|
5257
|
+
name: CHECK_NAMES.catalog,
|
|
5258
|
+
status: "ok",
|
|
5259
|
+
detail: config.catalog
|
|
5260
|
+
})) : {
|
|
5261
|
+
name: CHECK_NAMES.catalog,
|
|
5262
|
+
status: "na",
|
|
5263
|
+
detail: "no catalog declared"
|
|
5264
|
+
};
|
|
5265
|
+
const aliasCheck = validateAliases(config).fold((err) => ({
|
|
5266
|
+
name: CHECK_NAMES.aliases,
|
|
5267
|
+
status: "failed",
|
|
5268
|
+
error: formatValidationError(err)
|
|
5269
|
+
}), (table) => ({
|
|
5270
|
+
name: CHECK_NAMES.aliases,
|
|
5271
|
+
status: "ok",
|
|
5272
|
+
detail: `${table.entries.size} alias(es)`
|
|
5273
|
+
}));
|
|
5274
|
+
const sealedCheck = checkSealedBlocks(config);
|
|
5226
5275
|
const checks = [
|
|
5227
5276
|
tomlOk,
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
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)
|
|
5277
|
+
schemaOk,
|
|
5278
|
+
catalogCheck,
|
|
5279
|
+
aliasCheck,
|
|
5280
|
+
sealedCheck
|
|
5255
5281
|
];
|
|
5256
5282
|
return {
|
|
5257
5283
|
ok: checks.every((c) => c.status === "ok" || c.status === "na"),
|
|
@@ -5316,7 +5342,7 @@ const runValidate = (options) => {
|
|
|
5316
5342
|
}, ({ path, source }) => {
|
|
5317
5343
|
const sourceMsg = formatConfigSource(path, source);
|
|
5318
5344
|
Try(() => readFileSync(path, "utf-8")).fold((e) => {
|
|
5319
|
-
|
|
5345
|
+
const report = {
|
|
5320
5346
|
ok: false,
|
|
5321
5347
|
configPath: path,
|
|
5322
5348
|
checks: [{
|
|
@@ -5324,7 +5350,8 @@ const runValidate = (options) => {
|
|
|
5324
5350
|
status: "failed",
|
|
5325
5351
|
error: e.message
|
|
5326
5352
|
}]
|
|
5327
|
-
}
|
|
5353
|
+
};
|
|
5354
|
+
emit(report, sourceMsg, options.json === true);
|
|
5328
5355
|
process.exit(2);
|
|
5329
5356
|
}, (raw) => {
|
|
5330
5357
|
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>;
|
|
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
|
-
|
|
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;
|
|
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 */
|
|
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;
|
|
@@ -571,7 +578,9 @@ type DotenvEntry = {
|
|
|
571
578
|
readonly secret: boolean;
|
|
572
579
|
};
|
|
573
580
|
type FormatDotenvOptions = {
|
|
574
|
-
/** Write secret values into the output. Default true (matches `env export`/`env github`). */
|
|
581
|
+
/** Write secret values into the output. Default true (matches `env export`/`env github`). */
|
|
582
|
+
readonly includeSecrets?: boolean;
|
|
583
|
+
/** A pre-formatted comment block (each line `#`-prefixed) placed at the top. */
|
|
575
584
|
readonly header?: string;
|
|
576
585
|
};
|
|
577
586
|
/**
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
1604
|
+
const value = line.slice(eq + 1).trim();
|
|
1605
|
+
entries[key] = value;
|
|
1600
1606
|
}
|
|
1601
1607
|
});
|
|
1602
1608
|
return Right(entries);
|
|
@@ -2167,7 +2173,8 @@ const bootSafe = (options) => {
|
|
|
2167
2173
|
}
|
|
2168
2174
|
const targetEntry = envEntries[entry.targetKey];
|
|
2169
2175
|
if (targetEntry?.value === void 0) return;
|
|
2170
|
-
|
|
2176
|
+
const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
|
|
2177
|
+
envDefaults[aliasKey] = resolvedTarget;
|
|
2171
2178
|
});
|
|
2172
2179
|
if (inject) {
|
|
2173
2180
|
Object.entries(envDefaults).forEach(([key, value]) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "envpkt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Credential lifecycle and fleet management for AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credentials",
|
|
@@ -40,18 +40,18 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
|
-
"@sinclair/typebox": "^0.34.
|
|
43
|
+
"@sinclair/typebox": "^0.34.52",
|
|
44
44
|
"commander": "^15.0.0",
|
|
45
|
-
"functype": "^1.
|
|
46
|
-
"functype-log": "^1.
|
|
47
|
-
"functype-os": "^1.
|
|
45
|
+
"functype": "^1.6.3",
|
|
46
|
+
"functype-log": "^1.6.3",
|
|
47
|
+
"functype-os": "^1.6.3",
|
|
48
48
|
"smol-toml": "^1.7.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/node": "^24.13.
|
|
52
|
-
"ts-builds": "^3.2.
|
|
53
|
-
"tsdown": "^0.22.
|
|
54
|
-
"tsx": "^4.
|
|
51
|
+
"@types/node": "^24.13.3",
|
|
52
|
+
"ts-builds": "^3.2.2",
|
|
53
|
+
"tsdown": "^0.22.9",
|
|
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.
|
|
74
|
+
"packageManager": "pnpm@11.15.0+sha512.266f8957a30d2be6e9468e5e66bcdedd35a794175f71b067ba8504d686cce1d0c0f429b33c323c3c569ad4891e667574a49ff71d1b89a22cc66f13c65818c578"
|
|
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",
|