envpkt 0.6.2 → 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 +312 -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) => {
|
|
@@ -2950,7 +3053,30 @@ const runSeal = async (options) => {
|
|
|
2950
3053
|
const metaKeys = targetKeys;
|
|
2951
3054
|
console.log(`${BOLD}Sealing ${metaKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
|
|
2952
3055
|
console.log("");
|
|
2953
|
-
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
|
+
})();
|
|
2954
3080
|
const resolved = Object.keys(values).length;
|
|
2955
3081
|
const skipped = metaKeys.length - resolved;
|
|
2956
3082
|
if (resolved === 0) {
|
|
@@ -3023,6 +3149,12 @@ program.name("envpkt").description("Credential lifecycle and fleet management fo
|
|
|
3023
3149
|
const pkgPath = findPkgJson(dirname(fileURLToPath(import.meta.url)));
|
|
3024
3150
|
return pkgPath ? JSON.parse(readFileSync(pkgPath, "utf-8")).version : "0.0.0";
|
|
3025
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
|
+
});
|
|
3026
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) => {
|
|
3027
3159
|
runInit(process.cwd(), options);
|
|
3028
3160
|
});
|