envpkt 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +315 -180
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync,
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { Cond, Left, List, Option, Right, Try } from "functype";
|
|
7
6
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
7
|
+
import { Cond, Left, List, Option, Right, Try } from "functype";
|
|
8
8
|
import { Env, Fs, Path } from "functype-os";
|
|
9
9
|
import { TomlDate, parse, stringify } from "smol-toml";
|
|
10
10
|
import { FormatRegistry, Type } from "@sinclair/typebox";
|
|
@@ -15,109 +15,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
15
15
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
16
16
|
import { createInterface } from "node:readline";
|
|
17
17
|
|
|
18
|
-
//#region src/core/audit.ts
|
|
19
|
-
const MS_PER_DAY = 864e5;
|
|
20
|
-
const WARN_BEFORE_DAYS = 30;
|
|
21
|
-
const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / MS_PER_DAY);
|
|
22
|
-
const parseDate = (dateStr) => {
|
|
23
|
-
const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00Z`);
|
|
24
|
-
return Number.isNaN(d.getTime()) ? Option(void 0) : Option(d);
|
|
25
|
-
};
|
|
26
|
-
const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration, requireService, today) => {
|
|
27
|
-
const issues = [];
|
|
28
|
-
const created = Option(meta?.created).flatMap(parseDate);
|
|
29
|
-
const expires = Option(meta?.expires).flatMap(parseDate);
|
|
30
|
-
const rotationUrl = Option(meta?.rotation_url);
|
|
31
|
-
const purpose = Option(meta?.purpose);
|
|
32
|
-
const service = Option(meta?.service);
|
|
33
|
-
const daysRemaining = expires.map((exp) => daysBetween(today, exp));
|
|
34
|
-
const daysSinceCreated = created.map((c) => daysBetween(c, today));
|
|
35
|
-
const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
|
|
36
|
-
const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
|
|
37
|
-
const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
|
|
38
|
-
const hasSealed = !!meta?.encrypted_value;
|
|
39
|
-
const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
|
|
40
|
-
const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
|
|
41
|
-
if (isExpired) issues.push("Secret has expired");
|
|
42
|
-
if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
|
|
43
|
-
if (isStale) issues.push("Secret is stale (no rotation detected)");
|
|
44
|
-
if (isMissing) issues.push("Key not found in fnox");
|
|
45
|
-
if (isMissingMetadata) {
|
|
46
|
-
if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
|
|
47
|
-
if (requireService && service.isNone()) issues.push("Missing required service");
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
key,
|
|
51
|
-
service,
|
|
52
|
-
status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").else("healthy"),
|
|
53
|
-
days_remaining: daysRemaining,
|
|
54
|
-
rotation_url: rotationUrl,
|
|
55
|
-
purpose,
|
|
56
|
-
created: Option(meta?.created),
|
|
57
|
-
expires: Option(meta?.expires),
|
|
58
|
-
issues: List(issues)
|
|
59
|
-
};
|
|
60
|
-
};
|
|
61
|
-
const computeAudit = (config, fnoxKeys, today) => {
|
|
62
|
-
const now = today ?? /* @__PURE__ */ new Date();
|
|
63
|
-
const lifecycle = config.lifecycle ?? {};
|
|
64
|
-
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
65
|
-
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
66
|
-
const requireService = lifecycle.require_service ?? false;
|
|
67
|
-
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
68
|
-
const secretEntries = config.secret ?? {};
|
|
69
|
-
const metaKeys = new Set(Object.keys(secretEntries));
|
|
70
|
-
const secrets = List(Object.entries(secretEntries).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
|
|
71
|
-
const orphaned = keys.size > 0 ? [...metaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
72
|
-
const total = secrets.size;
|
|
73
|
-
const expired = secrets.count((s) => s.status === "expired");
|
|
74
|
-
const missing = secrets.count((s) => s.status === "missing");
|
|
75
|
-
const missing_metadata = secrets.count((s) => s.status === "missing_metadata");
|
|
76
|
-
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
77
|
-
const stale = secrets.count((s) => s.status === "stale");
|
|
78
|
-
const healthy = secrets.count((s) => s.status === "healthy");
|
|
79
|
-
return {
|
|
80
|
-
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
81
|
-
secrets,
|
|
82
|
-
total,
|
|
83
|
-
healthy,
|
|
84
|
-
expiring_soon,
|
|
85
|
-
expired,
|
|
86
|
-
stale,
|
|
87
|
-
missing,
|
|
88
|
-
missing_metadata,
|
|
89
|
-
orphaned,
|
|
90
|
-
identity: config.identity
|
|
91
|
-
};
|
|
92
|
-
};
|
|
93
|
-
const computeEnvAudit = (config, env = process.env) => {
|
|
94
|
-
const envEntries = config.env ?? {};
|
|
95
|
-
const entries = [];
|
|
96
|
-
for (const [key, entry] of Object.entries(envEntries)) {
|
|
97
|
-
const currentValue = env[key];
|
|
98
|
-
const status = Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== entry.value, "overridden").else("default");
|
|
99
|
-
entries.push({
|
|
100
|
-
key,
|
|
101
|
-
defaultValue: entry.value,
|
|
102
|
-
currentValue,
|
|
103
|
-
status,
|
|
104
|
-
purpose: entry.purpose
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
entries,
|
|
109
|
-
total: entries.length,
|
|
110
|
-
defaults_applied: entries.filter((e) => e.status === "default").length,
|
|
111
|
-
overridden: entries.filter((e) => e.status === "overridden").length,
|
|
112
|
-
missing: entries.filter((e) => e.status === "missing").length
|
|
113
|
-
};
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
//#endregion
|
|
117
18
|
//#region src/core/schema.ts
|
|
118
|
-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
|
+
const DATE_RE$1 = /^\d{4}-\d{2}-\d{2}$/;
|
|
119
20
|
const URI_RE = /^https?:\/\/.+/;
|
|
120
|
-
if (!FormatRegistry.Has("date")) FormatRegistry.Set("date", (v) => DATE_RE.test(v));
|
|
21
|
+
if (!FormatRegistry.Has("date")) FormatRegistry.Set("date", (v) => DATE_RE$1.test(v));
|
|
121
22
|
if (!FormatRegistry.Has("uri")) FormatRegistry.Set("uri", (v) => URI_RE.test(v));
|
|
122
23
|
const ConsumerType = Type.Union([
|
|
123
24
|
Type.Literal("agent"),
|
|
@@ -359,83 +260,6 @@ const resolveConfigPath = (flagPath, envVar, cwd) => {
|
|
|
359
260
|
}));
|
|
360
261
|
};
|
|
361
262
|
|
|
362
|
-
//#endregion
|
|
363
|
-
//#region src/core/catalog.ts
|
|
364
|
-
/** Load and validate a catalog file, mapping ConfigError → CatalogError */
|
|
365
|
-
const loadCatalog = (catalogPath) => loadConfig(catalogPath).fold((err) => {
|
|
366
|
-
if (err._tag === "FileNotFound") return Left({
|
|
367
|
-
_tag: "CatalogNotFound",
|
|
368
|
-
path: err.path
|
|
369
|
-
});
|
|
370
|
-
return Left({
|
|
371
|
-
_tag: "CatalogLoadError",
|
|
372
|
-
message: `${err._tag}: ${"message" in err ? err.message : String(err)}`
|
|
373
|
-
});
|
|
374
|
-
}, (config) => Right(config));
|
|
375
|
-
/** Resolve secrets by merging catalog meta with agent overrides (shallow merge) */
|
|
376
|
-
const resolveSecrets = (agentMeta, catalogMeta, agentSecrets, catalogPath) => {
|
|
377
|
-
const resolved = {};
|
|
378
|
-
for (const key of agentSecrets) {
|
|
379
|
-
const catalogEntry = catalogMeta[key];
|
|
380
|
-
if (!catalogEntry) return Left({
|
|
381
|
-
_tag: "SecretNotInCatalog",
|
|
382
|
-
key,
|
|
383
|
-
catalogPath
|
|
384
|
-
});
|
|
385
|
-
const agentOverride = agentMeta[key];
|
|
386
|
-
if (agentOverride) resolved[key] = {
|
|
387
|
-
...catalogEntry,
|
|
388
|
-
...agentOverride
|
|
389
|
-
};
|
|
390
|
-
else resolved[key] = catalogEntry;
|
|
391
|
-
}
|
|
392
|
-
return Right(resolved);
|
|
393
|
-
};
|
|
394
|
-
/** Resolve an agent config against its catalog (if any), producing a flat self-contained config */
|
|
395
|
-
const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
396
|
-
if (!agentConfig.catalog) return Right({
|
|
397
|
-
config: agentConfig,
|
|
398
|
-
merged: [],
|
|
399
|
-
overridden: [],
|
|
400
|
-
warnings: []
|
|
401
|
-
});
|
|
402
|
-
if (!agentConfig.identity?.secrets || agentConfig.identity.secrets.length === 0) return Left({
|
|
403
|
-
_tag: "MissingSecretsList",
|
|
404
|
-
message: "Config has 'catalog' but identity.secrets is missing — declare which catalog secrets this agent needs"
|
|
405
|
-
});
|
|
406
|
-
const catalogPath = resolve(agentConfigDir, agentConfig.catalog);
|
|
407
|
-
const agentSecrets = agentConfig.identity.secrets;
|
|
408
|
-
const agentSecretEntries = agentConfig.secret ?? {};
|
|
409
|
-
return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentSecretEntries, catalogConfig.secret ?? {}, agentSecrets, catalogPath).map((resolvedMeta) => {
|
|
410
|
-
const merged = [];
|
|
411
|
-
const overridden = [];
|
|
412
|
-
const warnings = [];
|
|
413
|
-
for (const key of agentSecrets) {
|
|
414
|
-
merged.push(key);
|
|
415
|
-
if (agentSecretEntries[key]) overridden.push(key);
|
|
416
|
-
}
|
|
417
|
-
const { catalog: _catalog, ...agentWithoutCatalog } = agentConfig;
|
|
418
|
-
const identityData = agentConfig.identity ? (() => {
|
|
419
|
-
const { secrets: _secrets, ...rest } = agentConfig.identity;
|
|
420
|
-
return rest;
|
|
421
|
-
})() : void 0;
|
|
422
|
-
return {
|
|
423
|
-
config: {
|
|
424
|
-
...agentWithoutCatalog,
|
|
425
|
-
identity: identityData ? {
|
|
426
|
-
...identityData,
|
|
427
|
-
name: identityData.name
|
|
428
|
-
} : void 0,
|
|
429
|
-
secret: resolvedMeta
|
|
430
|
-
},
|
|
431
|
-
catalogPath,
|
|
432
|
-
merged,
|
|
433
|
-
overridden,
|
|
434
|
-
warnings
|
|
435
|
-
};
|
|
436
|
-
}));
|
|
437
|
-
};
|
|
438
|
-
|
|
439
263
|
//#endregion
|
|
440
264
|
//#region src/cli/output.ts
|
|
441
265
|
const RESET = "\x1B[0m";
|
|
@@ -643,6 +467,285 @@ const formatConfigSource = (path, source) => {
|
|
|
643
467
|
return `${DIM}envpkt: loaded ${path}${RESET}`;
|
|
644
468
|
};
|
|
645
469
|
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/cli/commands/add.ts
|
|
472
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
473
|
+
const buildSecretBlock = (name, options) => {
|
|
474
|
+
const lines = [`[secret.${name}]`];
|
|
475
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
476
|
+
if (options.service) lines.push(`service = "${options.service}"`);
|
|
477
|
+
if (options.purpose) lines.push(`purpose = "${options.purpose}"`);
|
|
478
|
+
if (options.comment) lines.push(`comment = "${options.comment}"`);
|
|
479
|
+
lines.push(`created = "${today}"`);
|
|
480
|
+
if (options.expires) lines.push(`expires = "${options.expires}"`);
|
|
481
|
+
if (options.rotates) lines.push(`rotates = "${options.rotates}"`);
|
|
482
|
+
if (options.rateLimit) lines.push(`rate_limit = "${options.rateLimit}"`);
|
|
483
|
+
if (options.modelHint) lines.push(`model_hint = "${options.modelHint}"`);
|
|
484
|
+
if (options.source) lines.push(`source = "${options.source}"`);
|
|
485
|
+
if (options.rotationUrl) lines.push(`rotation_url = "${options.rotationUrl}"`);
|
|
486
|
+
if (options.required) lines.push(`required = true`);
|
|
487
|
+
if (options.capabilities) {
|
|
488
|
+
const caps = options.capabilities.split(",").map((c) => `"${c.trim()}"`).join(", ");
|
|
489
|
+
lines.push(`capabilities = [${caps}]`);
|
|
490
|
+
}
|
|
491
|
+
if (options.tags) {
|
|
492
|
+
const pairs = options.tags.split(",").map((pair) => {
|
|
493
|
+
const [k, v] = pair.split("=").map((s) => s.trim());
|
|
494
|
+
return `${k} = "${v}"`;
|
|
495
|
+
});
|
|
496
|
+
lines.push(`tags = { ${pairs.join(", ")} }`);
|
|
497
|
+
}
|
|
498
|
+
return `${lines.join("\n")}\n`;
|
|
499
|
+
};
|
|
500
|
+
const runAdd = (name, options) => {
|
|
501
|
+
if (options.expires && !DATE_RE.test(options.expires)) {
|
|
502
|
+
console.error(`${RED}Error:${RESET} Invalid date format for --expires: "${options.expires}" (expected YYYY-MM-DD)`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
506
|
+
console.error(formatError(err));
|
|
507
|
+
process.exit(2);
|
|
508
|
+
}, ({ path: configPath, source }) => {
|
|
509
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
510
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
511
|
+
loadConfig(configPath).fold((err) => {
|
|
512
|
+
console.error(formatError(err));
|
|
513
|
+
process.exit(2);
|
|
514
|
+
}, (config) => {
|
|
515
|
+
if (config.secret?.[name]) {
|
|
516
|
+
console.error(`${RED}Error:${RESET} Secret "${name}" already exists in ${configPath}`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
const block = buildSecretBlock(name, options);
|
|
520
|
+
if (options.dryRun) {
|
|
521
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
522
|
+
console.log(block);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
writeFileSync(configPath, `${readFileSync(configPath, "utf-8").trimEnd()}\n\n${block}`, "utf-8");
|
|
526
|
+
console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/cli/commands/add-env.ts
|
|
533
|
+
const buildEnvBlock = (name, value, options) => {
|
|
534
|
+
const lines = [`[env.${name}]`, `value = "${value}"`];
|
|
535
|
+
if (options.purpose) lines.push(`purpose = "${options.purpose}"`);
|
|
536
|
+
if (options.comment) lines.push(`comment = "${options.comment}"`);
|
|
537
|
+
if (options.tags) {
|
|
538
|
+
const pairs = options.tags.split(",").map((pair) => {
|
|
539
|
+
const [k, v] = pair.split("=").map((s) => s.trim());
|
|
540
|
+
return `${k} = "${v}"`;
|
|
541
|
+
});
|
|
542
|
+
lines.push(`tags = { ${pairs.join(", ")} }`);
|
|
543
|
+
}
|
|
544
|
+
return `${lines.join("\n")}\n`;
|
|
545
|
+
};
|
|
546
|
+
const runAddEnv = (name, value, options) => {
|
|
547
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
548
|
+
console.error(formatError(err));
|
|
549
|
+
process.exit(2);
|
|
550
|
+
}, ({ path: configPath, source }) => {
|
|
551
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
552
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
553
|
+
loadConfig(configPath).fold((err) => {
|
|
554
|
+
console.error(formatError(err));
|
|
555
|
+
process.exit(2);
|
|
556
|
+
}, (config) => {
|
|
557
|
+
if (config.env?.[name]) {
|
|
558
|
+
console.error(`${RED}Error:${RESET} Env entry "${name}" already exists in ${configPath}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
const block = buildEnvBlock(name, value, options);
|
|
562
|
+
if (options.dryRun) {
|
|
563
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
564
|
+
console.log(block);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
writeFileSync(configPath, `${readFileSync(configPath, "utf-8").trimEnd()}\n\n${block}`, "utf-8");
|
|
568
|
+
console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/core/audit.ts
|
|
575
|
+
const MS_PER_DAY = 864e5;
|
|
576
|
+
const WARN_BEFORE_DAYS = 30;
|
|
577
|
+
const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / MS_PER_DAY);
|
|
578
|
+
const parseDate = (dateStr) => {
|
|
579
|
+
const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00Z`);
|
|
580
|
+
return Number.isNaN(d.getTime()) ? Option(void 0) : Option(d);
|
|
581
|
+
};
|
|
582
|
+
const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration, requireService, today) => {
|
|
583
|
+
const issues = [];
|
|
584
|
+
const created = Option(meta?.created).flatMap(parseDate);
|
|
585
|
+
const expires = Option(meta?.expires).flatMap(parseDate);
|
|
586
|
+
const rotationUrl = Option(meta?.rotation_url);
|
|
587
|
+
const purpose = Option(meta?.purpose);
|
|
588
|
+
const service = Option(meta?.service);
|
|
589
|
+
const daysRemaining = expires.map((exp) => daysBetween(today, exp));
|
|
590
|
+
const daysSinceCreated = created.map((c) => daysBetween(c, today));
|
|
591
|
+
const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
|
|
592
|
+
const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
|
|
593
|
+
const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
|
|
594
|
+
const hasSealed = !!meta?.encrypted_value;
|
|
595
|
+
const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
|
|
596
|
+
const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
|
|
597
|
+
if (isExpired) issues.push("Secret has expired");
|
|
598
|
+
if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
|
|
599
|
+
if (isStale) issues.push("Secret is stale (no rotation detected)");
|
|
600
|
+
if (isMissing) issues.push("Key not found in fnox");
|
|
601
|
+
if (isMissingMetadata) {
|
|
602
|
+
if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
|
|
603
|
+
if (requireService && service.isNone()) issues.push("Missing required service");
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
key,
|
|
607
|
+
service,
|
|
608
|
+
status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").else("healthy"),
|
|
609
|
+
days_remaining: daysRemaining,
|
|
610
|
+
rotation_url: rotationUrl,
|
|
611
|
+
purpose,
|
|
612
|
+
created: Option(meta?.created),
|
|
613
|
+
expires: Option(meta?.expires),
|
|
614
|
+
issues: List(issues)
|
|
615
|
+
};
|
|
616
|
+
};
|
|
617
|
+
const computeAudit = (config, fnoxKeys, today) => {
|
|
618
|
+
const now = today ?? /* @__PURE__ */ new Date();
|
|
619
|
+
const lifecycle = config.lifecycle ?? {};
|
|
620
|
+
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
621
|
+
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
622
|
+
const requireService = lifecycle.require_service ?? false;
|
|
623
|
+
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
624
|
+
const secretEntries = config.secret ?? {};
|
|
625
|
+
const metaKeys = new Set(Object.keys(secretEntries));
|
|
626
|
+
const secrets = List(Object.entries(secretEntries).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
|
|
627
|
+
const orphaned = keys.size > 0 ? [...metaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
628
|
+
const total = secrets.size;
|
|
629
|
+
const expired = secrets.count((s) => s.status === "expired");
|
|
630
|
+
const missing = secrets.count((s) => s.status === "missing");
|
|
631
|
+
const missing_metadata = secrets.count((s) => s.status === "missing_metadata");
|
|
632
|
+
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
633
|
+
const stale = secrets.count((s) => s.status === "stale");
|
|
634
|
+
const healthy = secrets.count((s) => s.status === "healthy");
|
|
635
|
+
return {
|
|
636
|
+
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
637
|
+
secrets,
|
|
638
|
+
total,
|
|
639
|
+
healthy,
|
|
640
|
+
expiring_soon,
|
|
641
|
+
expired,
|
|
642
|
+
stale,
|
|
643
|
+
missing,
|
|
644
|
+
missing_metadata,
|
|
645
|
+
orphaned,
|
|
646
|
+
identity: config.identity
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
const computeEnvAudit = (config, env = process.env) => {
|
|
650
|
+
const envEntries = config.env ?? {};
|
|
651
|
+
const entries = [];
|
|
652
|
+
for (const [key, entry] of Object.entries(envEntries)) {
|
|
653
|
+
const currentValue = env[key];
|
|
654
|
+
const status = Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== entry.value, "overridden").else("default");
|
|
655
|
+
entries.push({
|
|
656
|
+
key,
|
|
657
|
+
defaultValue: entry.value,
|
|
658
|
+
currentValue,
|
|
659
|
+
status,
|
|
660
|
+
purpose: entry.purpose
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
entries,
|
|
665
|
+
total: entries.length,
|
|
666
|
+
defaults_applied: entries.filter((e) => e.status === "default").length,
|
|
667
|
+
overridden: entries.filter((e) => e.status === "overridden").length,
|
|
668
|
+
missing: entries.filter((e) => e.status === "missing").length
|
|
669
|
+
};
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/core/catalog.ts
|
|
674
|
+
/** Load and validate a catalog file, mapping ConfigError → CatalogError */
|
|
675
|
+
const loadCatalog = (catalogPath) => loadConfig(catalogPath).fold((err) => {
|
|
676
|
+
if (err._tag === "FileNotFound") return Left({
|
|
677
|
+
_tag: "CatalogNotFound",
|
|
678
|
+
path: err.path
|
|
679
|
+
});
|
|
680
|
+
return Left({
|
|
681
|
+
_tag: "CatalogLoadError",
|
|
682
|
+
message: `${err._tag}: ${"message" in err ? err.message : String(err)}`
|
|
683
|
+
});
|
|
684
|
+
}, (config) => Right(config));
|
|
685
|
+
/** Resolve secrets by merging catalog meta with agent overrides (shallow merge) */
|
|
686
|
+
const resolveSecrets = (agentMeta, catalogMeta, agentSecrets, catalogPath) => {
|
|
687
|
+
const resolved = {};
|
|
688
|
+
for (const key of agentSecrets) {
|
|
689
|
+
const catalogEntry = catalogMeta[key];
|
|
690
|
+
if (!catalogEntry) return Left({
|
|
691
|
+
_tag: "SecretNotInCatalog",
|
|
692
|
+
key,
|
|
693
|
+
catalogPath
|
|
694
|
+
});
|
|
695
|
+
const agentOverride = agentMeta[key];
|
|
696
|
+
if (agentOverride) resolved[key] = {
|
|
697
|
+
...catalogEntry,
|
|
698
|
+
...agentOverride
|
|
699
|
+
};
|
|
700
|
+
else resolved[key] = catalogEntry;
|
|
701
|
+
}
|
|
702
|
+
return Right(resolved);
|
|
703
|
+
};
|
|
704
|
+
/** Resolve an agent config against its catalog (if any), producing a flat self-contained config */
|
|
705
|
+
const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
706
|
+
if (!agentConfig.catalog) return Right({
|
|
707
|
+
config: agentConfig,
|
|
708
|
+
merged: [],
|
|
709
|
+
overridden: [],
|
|
710
|
+
warnings: []
|
|
711
|
+
});
|
|
712
|
+
if (!agentConfig.identity?.secrets || agentConfig.identity.secrets.length === 0) return Left({
|
|
713
|
+
_tag: "MissingSecretsList",
|
|
714
|
+
message: "Config has 'catalog' but identity.secrets is missing — declare which catalog secrets this agent needs"
|
|
715
|
+
});
|
|
716
|
+
const catalogPath = resolve(agentConfigDir, agentConfig.catalog);
|
|
717
|
+
const agentSecrets = agentConfig.identity.secrets;
|
|
718
|
+
const agentSecretEntries = agentConfig.secret ?? {};
|
|
719
|
+
return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentSecretEntries, catalogConfig.secret ?? {}, agentSecrets, catalogPath).map((resolvedMeta) => {
|
|
720
|
+
const merged = [];
|
|
721
|
+
const overridden = [];
|
|
722
|
+
const warnings = [];
|
|
723
|
+
for (const key of agentSecrets) {
|
|
724
|
+
merged.push(key);
|
|
725
|
+
if (agentSecretEntries[key]) overridden.push(key);
|
|
726
|
+
}
|
|
727
|
+
const { catalog: _catalog, ...agentWithoutCatalog } = agentConfig;
|
|
728
|
+
const identityData = agentConfig.identity ? (() => {
|
|
729
|
+
const { secrets: _secrets, ...rest } = agentConfig.identity;
|
|
730
|
+
return rest;
|
|
731
|
+
})() : void 0;
|
|
732
|
+
return {
|
|
733
|
+
config: {
|
|
734
|
+
...agentWithoutCatalog,
|
|
735
|
+
identity: identityData ? {
|
|
736
|
+
...identityData,
|
|
737
|
+
name: identityData.name
|
|
738
|
+
} : void 0,
|
|
739
|
+
secret: resolvedMeta
|
|
740
|
+
},
|
|
741
|
+
catalogPath,
|
|
742
|
+
merged,
|
|
743
|
+
overridden,
|
|
744
|
+
warnings
|
|
745
|
+
};
|
|
746
|
+
}));
|
|
747
|
+
};
|
|
748
|
+
|
|
646
749
|
//#endregion
|
|
647
750
|
//#region src/cli/commands/audit.ts
|
|
648
751
|
const runAudit = (options) => {
|
|
@@ -2842,6 +2945,7 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
2842
2945
|
output.push(`encrypted_value = """`);
|
|
2843
2946
|
output.push(pendingSeals.get(currentMetaKey));
|
|
2844
2947
|
output.push(`"""`);
|
|
2948
|
+
output.push("");
|
|
2845
2949
|
pendingSeals.delete(currentMetaKey);
|
|
2846
2950
|
}
|
|
2847
2951
|
currentMetaKey = metaMatch[1];
|
|
@@ -2855,6 +2959,7 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
2855
2959
|
output.push(`encrypted_value = """`);
|
|
2856
2960
|
output.push(pendingSeals.get(currentMetaKey));
|
|
2857
2961
|
output.push(`"""`);
|
|
2962
|
+
output.push("");
|
|
2858
2963
|
pendingSeals.delete(currentMetaKey);
|
|
2859
2964
|
}
|
|
2860
2965
|
insideMetaBlock = false;
|
|
@@ -2869,6 +2974,7 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
2869
2974
|
output.push(`encrypted_value = """`);
|
|
2870
2975
|
output.push(pendingSeals.get(currentMetaKey));
|
|
2871
2976
|
output.push(`"""`);
|
|
2977
|
+
output.push("");
|
|
2872
2978
|
pendingSeals.delete(currentMetaKey);
|
|
2873
2979
|
} else output.push(line);
|
|
2874
2980
|
if (line.slice(line.indexOf("=") + 1).trim().includes("\"\"\"")) {
|
|
@@ -2947,7 +3053,30 @@ const runSeal = async (options) => {
|
|
|
2947
3053
|
const metaKeys = targetKeys;
|
|
2948
3054
|
console.log(`${BOLD}Sealing ${metaKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
|
|
2949
3055
|
console.log("");
|
|
2950
|
-
const values = await
|
|
3056
|
+
const values = await (async () => {
|
|
3057
|
+
if (options.reseal && alreadySealed.length > 0) {
|
|
3058
|
+
const identityPath = config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0;
|
|
3059
|
+
if (!identityPath) {
|
|
3060
|
+
console.error(`${RED}Error:${RESET} identity.key_file is required for --reseal (needed to decrypt existing secrets)`);
|
|
3061
|
+
console.error("");
|
|
3062
|
+
console.error(`${DIM}Add to your envpkt.toml:${RESET}`);
|
|
3063
|
+
console.error(`${DIM} [identity]${RESET}`);
|
|
3064
|
+
console.error(`${DIM} key_file = "path/to/identity.txt"${RESET}`);
|
|
3065
|
+
process.exit(2);
|
|
3066
|
+
}
|
|
3067
|
+
const decrypted = unsealSecrets(Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]])), identityPath).fold((err) => {
|
|
3068
|
+
console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
|
|
3069
|
+
process.exit(2);
|
|
3070
|
+
return {};
|
|
3071
|
+
}, (d) => d);
|
|
3072
|
+
const newValues = unsealed.length > 0 ? await resolveValues(unsealed, options.profile, identityKey) : {};
|
|
3073
|
+
return {
|
|
3074
|
+
...decrypted,
|
|
3075
|
+
...newValues
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
return resolveValues(metaKeys, options.profile, identityKey);
|
|
3079
|
+
})();
|
|
2951
3080
|
const resolved = Object.keys(values).length;
|
|
2952
3081
|
const skipped = metaKeys.length - resolved;
|
|
2953
3082
|
if (resolved === 0) {
|
|
@@ -3020,6 +3149,12 @@ program.name("envpkt").description("Credential lifecycle and fleet management fo
|
|
|
3020
3149
|
const pkgPath = findPkgJson(dirname(fileURLToPath(import.meta.url)));
|
|
3021
3150
|
return pkgPath ? JSON.parse(readFileSync(pkgPath, "utf-8")).version : "0.0.0";
|
|
3022
3151
|
})());
|
|
3152
|
+
program.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("--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("--required", "Mark this secret as required").option("--rotation-url <url>", "URL for secret rotation procedure").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--dry-run", "Preview the TOML block without writing").action((name, options) => {
|
|
3153
|
+
runAdd(name, options);
|
|
3154
|
+
});
|
|
3155
|
+
program.command("add-env").description("Add a new environment default entry to envpkt.toml").argument("<name>", "Environment variable name").argument("<value>", "Default value").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this env var exists").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--dry-run", "Preview the TOML block without writing").action((name, value, options) => {
|
|
3156
|
+
runAddEnv(name, value, options);
|
|
3157
|
+
});
|
|
3023
3158
|
program.command("init").description("Initialize a new envpkt.toml in the current directory").option("--from-fnox [path]", "Scaffold from fnox.toml (optionally specify path)").option("--catalog <path>", "Path to shared secret catalog").option("--identity", "Include [identity] section").option("--name <name>", "Identity name (requires --identity)").option("--capabilities <caps>", "Comma-separated capabilities (requires --identity)").option("--expires <date>", "Credential expiration YYYY-MM-DD (requires --identity)").option("--force", "Overwrite existing envpkt.toml").action((options) => {
|
|
3024
3159
|
runInit(process.cwd(), options);
|
|
3025
3160
|
});
|