envpkt 0.11.1 → 0.11.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 CHANGED
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync,
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
- import { Cond, Either, Left, List, Option, Right, Try } from "functype";
6
+ import { $, Cond, Do, Either, Left, List, Map as Map$1, Option, Right, Set as Set$1, Try } from "functype";
7
7
  import { TypeCompiler } from "@sinclair/typebox/compiler";
8
8
  import { Env, Fs, Path, Platform } from "functype-os";
9
9
  import { TomlDate, parse, stringify } from "smol-toml";
@@ -27,20 +27,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
27
27
  const issues = [];
28
28
  const created = Option(meta.created).flatMap(parseDate);
29
29
  const expires = Option(meta.expires).flatMap(parseDate);
30
+ const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
30
31
  const rotationUrl = Option(meta.rotation_url);
31
32
  const purpose = Option(meta.purpose);
32
33
  const service = Option(meta.service);
33
34
  const daysRemaining = expires.map((exp) => daysBetween(today, exp));
34
- const daysSinceCreated = created.map((c) => daysBetween(c, today));
35
+ const staleFromRotation = lastRotated.isSome();
36
+ const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
35
37
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
36
38
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
37
- const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
39
+ const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
38
40
  const hasSealed = !!meta.encrypted_value;
39
41
  const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
40
42
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
41
43
  if (isExpired) issues.push("Secret has expired");
42
44
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
43
- if (isStale) issues.push("Secret is stale (no rotation detected)");
45
+ if (isStale) {
46
+ const since = daysSinceRotation.fold(() => "?", (d) => String(d));
47
+ const label = staleFromRotation ? "last rotated" : "created";
48
+ issues.push(`Secret is stale (${label} ${since} days ago)`);
49
+ }
44
50
  if (isMissing) issues.push("Key not found in fnox");
45
51
  if (isMissingMetadata) {
46
52
  if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
@@ -55,6 +61,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
55
61
  purpose,
56
62
  created: Option(meta.created),
57
63
  expires: Option(meta.expires),
64
+ last_rotated_at: Option(meta.last_rotated_at),
58
65
  issues: List(issues),
59
66
  alias_of: Option(void 0)
60
67
  };
@@ -70,9 +77,10 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
70
77
  status: targetHealth.status,
71
78
  days_remaining: targetHealth.days_remaining,
72
79
  rotation_url: targetHealth.rotation_url,
73
- purpose: meta.purpose !== void 0 ? Option(meta.purpose) : targetHealth.purpose,
80
+ purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
74
81
  created: targetHealth.created,
75
82
  expires: targetHealth.expires,
83
+ last_rotated_at: targetHealth.last_rotated_at,
76
84
  issues: List([]),
77
85
  alias_of: Option(targetRef)
78
86
  });
@@ -86,17 +94,18 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
86
94
  const secretEntries = config.secret ?? {};
87
95
  const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
88
96
  const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
89
- const nonAliasMetaKeys = new Set(nonAliasEntries.map(([k]) => k));
97
+ const nonAliasMetaKeys = Set$1(nonAliasEntries.map(([k]) => k));
90
98
  const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
91
- const healthByKey = new Map(nonAliasHealth.map((h) => [h.key, h]));
99
+ const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
92
100
  const parseTargetKey = (from_key) => {
93
- return /^secret\.(.+)$/.exec(from_key)?.[1];
101
+ return Option(from_key.match(/^secret\.(.+)$/)?.[1]);
94
102
  };
95
103
  const aliasHealth = aliasEntries.map(([key, meta]) => {
96
- const targetKey = (aliasTable?.entries.get(`secret.${key}`))?.targetKey ?? (meta.from_key !== void 0 ? parseTargetKey(meta.from_key) : void 0);
97
- const targetHealth = targetKey !== void 0 ? healthByKey.get(targetKey) : void 0;
98
- const targetRef = meta.from_key ?? (targetKey !== void 0 ? `secret.${targetKey}` : "");
99
- if (!targetHealth) return {
104
+ const tableEntry = aliasTable?.entries.get(`secret.${key}`);
105
+ const targetKey = Option(tableEntry?.targetKey).fold(() => Option(meta.from_key).flatMap(parseTargetKey), (k) => Option(k));
106
+ const targetHealth = targetKey.flatMap((k) => healthByKey.get(k));
107
+ const targetRef = Option(meta.from_key).fold(() => targetKey.map((k) => `secret.${k}`), (v) => Option(v)).orElse("");
108
+ return targetHealth.fold(() => ({
100
109
  key,
101
110
  service: Option(meta.service),
102
111
  status: "missing",
@@ -105,13 +114,13 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
105
114
  purpose: Option(meta.purpose),
106
115
  created: Option(meta.created),
107
116
  expires: Option(meta.expires),
117
+ last_rotated_at: Option(meta.last_rotated_at),
108
118
  issues: List(["Alias target not resolvable"]),
109
119
  alias_of: Option(targetRef)
110
- };
111
- return classifyAlias(key, meta, targetHealth, targetRef);
120
+ }), (health) => classifyAlias(key, meta, health, targetRef));
112
121
  });
113
122
  const secrets = List([...nonAliasHealth, ...aliasHealth]);
114
- const orphaned = keys.size > 0 ? [...nonAliasMetaKeys].filter((k) => !keys.has(k)).length : 0;
123
+ const orphaned = keys.size > 0 ? nonAliasMetaKeys.toArray().filter((k) => !keys.has(k)).length : 0;
115
124
  const total = secrets.size;
116
125
  const expired = secrets.count((s) => s.status === "expired");
117
126
  const missing = secrets.count((s) => s.status === "missing");
@@ -137,12 +146,14 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
137
146
  };
138
147
  const computeEnvAudit = (config, env = process.env) => {
139
148
  const envEntries = config.env ?? {};
149
+ const resolveEffectiveDefault = (entry) => {
150
+ return Do(function* () {
151
+ return yield* $(Option((yield* $(Option(envEntries[yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]))]))).value));
152
+ }).orElse(entry.value ?? "");
153
+ };
140
154
  const entries = Object.entries(envEntries).map(([key, entry]) => {
141
155
  const currentValue = env[key];
142
- const effectiveDefault = entry.from_key !== void 0 ? (() => {
143
- const targetKey = /^env\.(.+)$/.exec(entry.from_key)?.[1];
144
- return (targetKey !== void 0 ? envEntries[targetKey] : void 0)?.value ?? "";
145
- })() : entry.value ?? "";
156
+ const effectiveDefault = resolveEffectiveDefault(entry);
146
157
  return {
147
158
  key,
148
159
  defaultValue: effectiveDefault,
@@ -203,6 +214,10 @@ const SecretMetaSchema = Type.Object({
203
214
  format: "date",
204
215
  description: "Date the secret was provisioned (YYYY-MM-DD)"
205
216
  })),
217
+ last_rotated_at: Type.Optional(Type.String({
218
+ format: "date",
219
+ description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
220
+ })),
206
221
  rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
207
222
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
208
223
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
@@ -427,18 +442,19 @@ const loadCatalog = (catalogPath) => loadConfig(catalogPath).fold((err) => {
427
442
  /** Resolve secrets by merging catalog meta with agent overrides (shallow merge) */
428
443
  const resolveSecrets = (agentMeta, catalogMeta, agentSecrets, catalogPath) => {
429
444
  return agentSecrets.reduce((acc, key) => acc.flatMap((resolved) => {
430
- if (!(key in catalogMeta)) return Left({
445
+ const catalogEntry = catalogMeta[key];
446
+ if (catalogEntry === void 0) return Left({
431
447
  _tag: "SecretNotInCatalog",
432
448
  key,
433
449
  catalogPath
434
450
  });
435
- const catalogEntry = catalogMeta[key];
451
+ const merged = Option(agentMeta[key]).fold(() => catalogEntry, (override) => ({
452
+ ...catalogEntry,
453
+ ...override
454
+ }));
436
455
  return Right({
437
456
  ...resolved,
438
- [key]: key in agentMeta ? {
439
- ...catalogEntry,
440
- ...agentMeta[key]
441
- } : catalogEntry
457
+ [key]: merged
442
458
  });
443
459
  }), Right({}));
444
460
  };
@@ -898,12 +914,12 @@ const validateOneSecret = (key, meta, secretEntries) => {
898
914
  kind: "secret",
899
915
  field: "encrypted_value"
900
916
  });
901
- return parseRef(ref).fold(() => Left({
917
+ return parseRef(ref).toEither({
902
918
  _tag: "AliasInvalidSyntax",
903
919
  key,
904
920
  kind: "secret",
905
921
  value: ref
906
- }), (parsed) => {
922
+ }).flatMap((parsed) => {
907
923
  if (parsed.kind !== "secret") return Left({
908
924
  _tag: "AliasCrossType",
909
925
  key,
@@ -914,23 +930,23 @@ const validateOneSecret = (key, meta, secretEntries) => {
914
930
  _tag: "AliasSelfReference",
915
931
  key: `secret.${key}`
916
932
  });
917
- return Option(secretEntries[parsed.key]).fold(() => Left({
933
+ return Option(secretEntries[parsed.key]).toEither({
918
934
  _tag: "AliasTargetMissing",
919
935
  key: `secret.${key}`,
920
936
  target: ref
921
- }), (target) => {
937
+ }).flatMap((target) => {
922
938
  if (target.from_key !== void 0) return Left({
923
939
  _tag: "AliasChained",
924
940
  key: `secret.${key}`,
925
941
  target: ref
926
942
  });
927
- return Right(Option({
943
+ return Right({
928
944
  kind: "secret",
929
945
  targetKind: "secret",
930
946
  targetKey: parsed.key
931
- }));
947
+ });
932
948
  });
933
- });
949
+ }).map((entry) => Option(entry));
934
950
  };
935
951
  const validateOneEnv = (key, meta, envEntries) => {
936
952
  if (meta.from_key === void 0) return Right(Option(void 0));
@@ -941,12 +957,12 @@ const validateOneEnv = (key, meta, envEntries) => {
941
957
  kind: "env",
942
958
  field: "value"
943
959
  });
944
- return parseRef(ref).fold(() => Left({
960
+ return parseRef(ref).toEither({
945
961
  _tag: "AliasInvalidSyntax",
946
962
  key,
947
963
  kind: "env",
948
964
  value: ref
949
- }), (parsed) => {
965
+ }).flatMap((parsed) => {
950
966
  if (parsed.kind !== "env") return Left({
951
967
  _tag: "AliasCrossType",
952
968
  key,
@@ -957,57 +973,43 @@ const validateOneEnv = (key, meta, envEntries) => {
957
973
  _tag: "AliasSelfReference",
958
974
  key: `env.${key}`
959
975
  });
960
- return Option(envEntries[parsed.key]).fold(() => Left({
976
+ return Option(envEntries[parsed.key]).toEither({
961
977
  _tag: "AliasTargetMissing",
962
978
  key: `env.${key}`,
963
979
  target: ref
964
- }), (target) => {
980
+ }).flatMap((target) => {
965
981
  if (target.from_key !== void 0) return Left({
966
982
  _tag: "AliasChained",
967
983
  key: `env.${key}`,
968
984
  target: ref
969
985
  });
970
- return Right(Option({
986
+ return Right({
971
987
  kind: "env",
972
988
  targetKind: "env",
973
989
  targetKey: parsed.key
974
- }));
990
+ });
975
991
  });
976
- });
992
+ }).map((entry) => Option(entry));
977
993
  };
978
- /**
979
- * Validate all `from_key` references in a resolved config. Produces an
980
- * AliasTable mapping each alias to its target, or an AliasError describing
981
- * the first failure.
982
- *
983
- * Rules:
984
- * - Ref must be "secret.<KEY>" or "env.<KEY>"
985
- * - Target must exist in the same resolved config
986
- * - Target must be the same type (secret→secret, env→env only)
987
- * - Target must not itself be a from_key entry (single hop only)
988
- * - Self-reference is rejected
989
- * - An alias entry cannot also carry a value field (encrypted_value for
990
- * secrets, value for env)
991
- */
994
+ /** Fail-fast reduction: accumulates validated entries, short-circuits on first AliasError */
995
+ const collectValidated = (items, validate, prefix) => items.reduce((acc, [key, meta]) => acc.flatMap((entries) => validate(key, meta).map((opt) => opt.fold(() => entries, (entry) => [...entries, [`${prefix}.${key}`, entry]]))), Right([]));
992
996
  const validateAliases = (config) => {
993
997
  const secretEntries = config.secret ?? {};
994
998
  const envEntries = config.env ?? {};
995
- const entries = /* @__PURE__ */ new Map();
996
- const secretResults = Object.entries(secretEntries).map(([key, meta]) => [key, validateOneSecret(key, meta, secretEntries)]);
997
- for (const [key, result] of secretResults) {
998
- const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
999
- if (outcome === void 0) continue;
1000
- if ("_tag" in outcome) return Left(outcome);
1001
- entries.set(`secret.${key}`, outcome);
1002
- }
1003
- const envResults = Object.entries(envEntries).map(([key, meta]) => [key, validateOneEnv(key, meta, envEntries)]);
1004
- for (const [key, result] of envResults) {
1005
- const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
1006
- if (outcome === void 0) continue;
1007
- if ("_tag" in outcome) return Left(outcome);
1008
- entries.set(`env.${key}`, outcome);
999
+ const secretPairs = collectValidated(Object.entries(secretEntries), (k, m) => validateOneSecret(k, m, secretEntries), "secret");
1000
+ const envPairs = collectValidated(Object.entries(envEntries), (k, m) => validateOneEnv(k, m, envEntries), "env");
1001
+ return secretPairs.flatMap((secrets) => envPairs.map((envs) => [...secrets, ...envs])).map((allEntries) => ({ entries: new Map(allEntries) }));
1002
+ };
1003
+ /** Format an alias error into a human-readable message */
1004
+ const formatAliasError = (error) => {
1005
+ switch (error._tag) {
1006
+ case "AliasInvalidSyntax": return `[${error.kind}.${error.key}] from_key = "${error.value}" — expected "secret.<KEY>" or "env.<KEY>"`;
1007
+ case "AliasTargetMissing": return `[${error.key}] from_key target "${error.target}" not found in config`;
1008
+ case "AliasSelfReference": return `[${error.key}] from_key cannot reference itself`;
1009
+ case "AliasChained": return `[${error.key}] from_key target "${error.target}" is itself an alias; chained aliases are not supported`;
1010
+ case "AliasCrossType": return `[${error.kind}.${error.key}] cannot alias a ${error.targetKind} entry; same-type aliasing only (secret→secret, env→env)`;
1011
+ case "AliasValueConflict": return `[${error.kind}.${error.key}] cannot declare both from_key and ${error.field}; an alias has no value of its own`;
1009
1012
  }
1010
- return Right({ entries });
1011
1013
  };
1012
1014
  //#endregion
1013
1015
  //#region src/core/keygen.ts
@@ -1197,11 +1199,12 @@ const sealSecrets = (meta, values, recipient) => {
1197
1199
  message: "age CLI not found on PATH"
1198
1200
  });
1199
1201
  return Object.entries(meta).reduce((acc, [key, secretMeta]) => acc.flatMap((result) => {
1200
- if (!(key in values)) return Right({
1202
+ const value = values[key];
1203
+ if (value === void 0) return Right({
1201
1204
  ...result,
1202
1205
  [key]: secretMeta
1203
1206
  });
1204
- return ageEncrypt(values[key], recipient).mapLeft((err) => ({
1207
+ return ageEncrypt(value, recipient).mapLeft((err) => ({
1205
1208
  _tag: "EncryptFailed",
1206
1209
  key,
1207
1210
  message: err.message
@@ -1297,11 +1300,11 @@ const bootSafe = (options) => {
1297
1300
  const secretEntries = config.secret ?? {};
1298
1301
  const envEntries = config.env ?? {};
1299
1302
  const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
1300
- const aliasSecretKeys = Object.keys(secretEntries).filter((k) => secretEntries[k].from_key !== void 0);
1303
+ const aliasSecretKeys = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1301
1304
  const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
1302
- const aliasEnvKeys = Object.keys(envEntries).filter((k) => envEntries[k].from_key !== void 0);
1305
+ const aliasEnvKeys = Object.entries(envEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1303
1306
  const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
1304
- const hasSealedValues = nonAliasMetaKeys.some((k) => !!nonAliasSecretEntries[k].encrypted_value);
1307
+ const hasSealedValues = Object.values(nonAliasSecretEntries).some((meta) => !!meta.encrypted_value);
1305
1308
  const identityKeyResult = resolveIdentityKey(config, configDir);
1306
1309
  const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
1307
1310
  if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
@@ -1314,7 +1317,7 @@ const bootSafe = (options) => {
1314
1317
  const injected = [];
1315
1318
  const skipped = [];
1316
1319
  warnings.push(...checkEnvMisclassification(config));
1317
- const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => entry.value !== void 0 ? [[key, entry.value]] : [], () => [])));
1320
+ const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => Option(entry.value).fold(() => [], (v) => [[key, v]]), () => [])));
1318
1321
  const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
1319
1322
  if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
1320
1323
  process.env[key] = value;
@@ -1374,21 +1377,20 @@ const bootSafe = (options) => {
1374
1377
  aliasSecretKeys.forEach((aliasKey) => {
1375
1378
  const entry = aliasTable.entries.get(`secret.${aliasKey}`);
1376
1379
  if (!entry) return;
1377
- const targetValue = secrets[entry.targetKey];
1378
- if (targetValue !== void 0) {
1379
- secrets[aliasKey] = targetValue;
1380
- injected.push(aliasKey);
1381
- log.debug("phase.alias.copied", {
1380
+ Option(secrets[entry.targetKey]).fold(() => {
1381
+ skipped.push(aliasKey);
1382
+ log.debug("phase.alias.target_unresolved", {
1382
1383
  alias: aliasKey,
1383
1384
  target: entry.targetKey
1384
1385
  });
1385
- } else {
1386
- skipped.push(aliasKey);
1387
- log.debug("phase.alias.target_unresolved", {
1386
+ }, (targetValue) => {
1387
+ secrets[aliasKey] = targetValue;
1388
+ injected.push(aliasKey);
1389
+ log.debug("phase.alias.copied", {
1388
1390
  alias: aliasKey,
1389
1391
  target: entry.targetKey
1390
1392
  });
1391
- }
1393
+ });
1392
1394
  });
1393
1395
  aliasEnvKeys.forEach((aliasKey) => {
1394
1396
  const entry = aliasTable.entries.get(`env.${aliasKey}`);
@@ -1425,7 +1427,7 @@ const bootSafe = (options) => {
1425
1427
  };
1426
1428
  //#endregion
1427
1429
  //#region src/core/patterns.ts
1428
- const EXCLUDED_VARS = new Set([
1430
+ const EXCLUDED_VARS = Set$1([
1429
1431
  "PATH",
1430
1432
  "HOME",
1431
1433
  "USER",
@@ -2138,25 +2140,22 @@ const envScan = (env, options) => {
2138
2140
  };
2139
2141
  };
2140
2142
  const parseAliasRef = (raw, expectedKind) => {
2141
- const match = /^(secret|env)\.(.+)$/.exec(raw);
2142
- if (!match) return void 0;
2143
- if (match[1] !== expectedKind) return void 0;
2144
- return match[2];
2143
+ const match = raw.match(/^(secret|env)\.(.+)$/);
2144
+ if (match?.[1] !== expectedKind) return Option(void 0);
2145
+ return Option(match[2]);
2145
2146
  };
2146
2147
  /** Bidirectional drift detection between config and live environment */
2147
2148
  const envCheck = (config, env) => {
2148
2149
  const secretEntries = config.secret ?? {};
2149
2150
  const metaKeys = Object.keys(secretEntries);
2150
- const trackedSet = new Set(metaKeys);
2151
+ const metaKeysSet = Set$1(metaKeys);
2151
2152
  const isSecretPresent = (key) => {
2152
2153
  if (env[key] !== void 0 && env[key] !== "") return true;
2153
2154
  const meta = secretEntries[key];
2154
2155
  if (meta?.from_key === void 0) return false;
2155
- const targetKey = parseAliasRef(meta.from_key, "secret");
2156
- return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
2156
+ return parseAliasRef(meta.from_key, "secret").fold(() => false, (targetKey) => env[targetKey] !== void 0 && env[targetKey] !== "");
2157
2157
  };
2158
- const secretDriftEntries = metaKeys.map((key) => {
2159
- const meta = secretEntries[key];
2158
+ const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
2160
2159
  const present = isSecretPresent(key);
2161
2160
  return {
2162
2161
  envVar: key,
@@ -2170,14 +2169,9 @@ const envCheck = (config, env) => {
2170
2169
  if (env[key] !== void 0 && env[key] !== "") return true;
2171
2170
  const meta = envDefaults[key];
2172
2171
  if (meta?.from_key === void 0) return false;
2173
- const targetKey = parseAliasRef(meta.from_key, "env");
2174
- return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
2172
+ return parseAliasRef(meta.from_key, "env").fold(() => false, (targetKey) => env[targetKey] !== void 0 && env[targetKey] !== "");
2175
2173
  };
2176
- const envDefaultEntries = Object.keys(envDefaults).filter((key) => {
2177
- if (trackedSet.has(key)) return false;
2178
- trackedSet.add(key);
2179
- return true;
2180
- }).map((key) => {
2174
+ const envDefaultEntries = Object.keys(envDefaults).filter((key) => !metaKeysSet.has(key)).map((key) => {
2181
2175
  const present = isEnvPresent(key);
2182
2176
  return {
2183
2177
  envVar: key,
@@ -2186,7 +2180,8 @@ const envCheck = (config, env) => {
2186
2180
  confidence: Option(void 0)
2187
2181
  };
2188
2182
  });
2189
- const untrackedEntries = scanEnv(env).filter((match) => !trackedSet.has(match.envVar)).map((match) => ({
2183
+ const trackedKeys = Set$1([...metaKeys, ...envDefaultEntries.map((e) => e.envVar)]);
2184
+ const untrackedEntries = scanEnv(env).filter((match) => !trackedKeys.has(match.envVar)).map((match) => ({
2190
2185
  envVar: match.envVar,
2191
2186
  service: match.service,
2192
2187
  status: "untracked",
@@ -2229,6 +2224,22 @@ created = "${todayIso$1()}"
2229
2224
  //#region src/core/toml-edit.ts
2230
2225
  const SECTION_RE = /^\[.+\]\s*$/;
2231
2226
  const MULTILINE_OPEN = "\"\"\"";
2227
+ const scanSectionBoundary = (state, line, i) => {
2228
+ if (state.done) return state;
2229
+ if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2230
+ ...state,
2231
+ inMultiline: false
2232
+ } : state;
2233
+ if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2234
+ ...state,
2235
+ inMultiline: true
2236
+ } : state;
2237
+ return SECTION_RE.test(line) ? {
2238
+ ...state,
2239
+ end: i,
2240
+ done: true
2241
+ } : state;
2242
+ };
2232
2243
  /**
2233
2244
  * Find the line range [start, end) of a TOML section by its header string.
2234
2245
  * The range includes the header line through to (but not including) the next section header or EOF.
@@ -2237,26 +2248,14 @@ const MULTILINE_OPEN = "\"\"\"";
2237
2248
  const findSectionRange = (lines, sectionHeader) => {
2238
2249
  const start = lines.findIndex((l) => l.trim() === sectionHeader);
2239
2250
  if (start === -1) return void 0;
2240
- let end = lines.length;
2241
- let inMultiline = false;
2242
- for (let i = start + 1; i < lines.length; i++) {
2243
- const line = lines[i];
2244
- if (inMultiline) {
2245
- if (line.includes(MULTILINE_OPEN)) inMultiline = false;
2246
- continue;
2247
- }
2248
- if (line.includes(MULTILINE_OPEN)) {
2249
- if ((line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1) inMultiline = true;
2250
- continue;
2251
- }
2252
- if (SECTION_RE.test(line)) {
2253
- end = i;
2254
- break;
2255
- }
2256
- }
2251
+ const initial = {
2252
+ end: lines.length,
2253
+ inMultiline: false,
2254
+ done: false
2255
+ };
2257
2256
  return {
2258
2257
  start,
2259
- end
2258
+ end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
2260
2259
  };
2261
2260
  };
2262
2261
  /** Check whether a section header exists in the raw TOML */
@@ -2272,12 +2271,10 @@ const removeSection = (raw, sectionHeader) => {
2272
2271
  _tag: "SectionNotFound",
2273
2272
  section: sectionHeader
2274
2273
  });
2275
- let removeEnd = range.end;
2276
- while (removeEnd > range.start && removeEnd - 1 >= range.start && lines[removeEnd - 1].trim() === "") removeEnd--;
2277
- const before = lines.slice(0, range.start);
2278
2274
  const after = lines.slice(range.end);
2279
- while (before.length > 0 && before[before.length - 1].trim() === "") before.pop();
2280
- const result = [...before, ...after].join("\n");
2275
+ const beforeAll = lines.slice(0, range.start);
2276
+ const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
2277
+ const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
2281
2278
  return Either.right(result);
2282
2279
  };
2283
2280
  /**
@@ -2313,55 +2310,47 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2313
2310
  const before = lines.slice(0, range.start + 1);
2314
2311
  const after = lines.slice(range.end);
2315
2312
  const sectionBody = lines.slice(range.start + 1, range.end);
2316
- const remaining = [];
2317
- const updatedKeys = /* @__PURE__ */ new Set();
2318
- let inMultiline = false;
2319
- let multilineKey = "";
2320
- for (let i = 0; i < sectionBody.length; i++) {
2321
- const line = sectionBody[i];
2322
- if (inMultiline) {
2323
- if (line.includes(MULTILINE_OPEN)) {
2324
- inMultiline = false;
2325
- if (updates[multilineKey] === null) continue;
2326
- if (multilineKey in updates) continue;
2327
- } else {
2328
- if (updates[multilineKey] === null) continue;
2329
- if (multilineKey in updates) continue;
2330
- }
2331
- remaining.push(line);
2332
- continue;
2333
- }
2313
+ const findClosingMultiline = (fromIdx) => {
2314
+ const idx = sectionBody.findIndex((l, j) => j > fromIdx && l.includes(MULTILINE_OPEN));
2315
+ return idx === -1 ? sectionBody.length : idx;
2316
+ };
2317
+ const initial = {
2318
+ remaining: [],
2319
+ updatedKeys: Set$1.empty(),
2320
+ skipUntil: -1
2321
+ };
2322
+ const step = (state, line, i) => {
2323
+ if (i <= state.skipUntil) return state;
2334
2324
  const eqIdx = line.indexOf("=");
2335
- if (eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[")) {
2336
- const key = line.slice(0, eqIdx).trim();
2337
- if (key in updates) {
2338
- updatedKeys.add(key);
2339
- const afterEquals = line.slice(eqIdx + 1).trim();
2340
- if (afterEquals.includes(MULTILINE_OPEN)) {
2341
- if ((afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1) {
2342
- inMultiline = true;
2343
- multilineKey = key;
2344
- }
2345
- }
2346
- if (updates[key] === null) continue;
2347
- remaining.push(`${key} = ${updates[key]}`);
2348
- if (inMultiline) {
2349
- for (let j = i + 1; j < sectionBody.length; j++) if (sectionBody[j].includes(MULTILINE_OPEN)) {
2350
- i = j;
2351
- inMultiline = false;
2352
- break;
2353
- }
2354
- }
2355
- continue;
2356
- }
2325
+ const isFieldLine = eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[");
2326
+ const key = isFieldLine ? line.slice(0, eqIdx).trim() : "";
2327
+ if (isFieldLine && key in updates) {
2328
+ const afterEquals = line.slice(eqIdx + 1).trim();
2329
+ const skipUntil = afterEquals.includes(MULTILINE_OPEN) && (afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? findClosingMultiline(i) : state.skipUntil;
2330
+ const updatedKeys = state.updatedKeys.add(key);
2331
+ const value = updates[key];
2332
+ if (value === null) return {
2333
+ ...state,
2334
+ updatedKeys,
2335
+ skipUntil
2336
+ };
2337
+ return {
2338
+ remaining: [...state.remaining, `${key} = ${value}`],
2339
+ updatedKeys,
2340
+ skipUntil
2341
+ };
2357
2342
  }
2358
- remaining.push(line);
2359
- }
2360
- const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
2361
- remaining.push(...newFields);
2343
+ return {
2344
+ ...state,
2345
+ remaining: [...state.remaining, line]
2346
+ };
2347
+ };
2348
+ const final = List(sectionBody).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]));
2349
+ const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !final.updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
2362
2350
  const result = [
2363
2351
  ...before,
2364
- ...remaining,
2352
+ ...final.remaining,
2353
+ ...newFields,
2365
2354
  ...after
2366
2355
  ].join("\n");
2367
2356
  return Either.right(result);
@@ -2372,6 +2361,60 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2372
2361
  */
2373
2362
  const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
2374
2363
  //#endregion
2364
+ //#region src/core/validate.ts
2365
+ /**
2366
+ * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
2367
+ *
2368
+ * Used by write-path CLI commands to verify the post-edit file would still be
2369
+ * structurally valid before persisting. Catalog resolution is intentionally
2370
+ * excluded — catalog issues depend on external files, not on the local edit,
2371
+ * and `envpkt validate` covers them as a separate explicit check.
2372
+ */
2373
+ const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
2374
+ /** Human-readable one-liner for any ValidationError tag. */
2375
+ const formatValidationError = (err) => {
2376
+ switch (err._tag) {
2377
+ case "FileNotFound": return `Config file not found: ${err.path}`;
2378
+ case "ParseError": return `TOML parse error: ${err.message}`;
2379
+ case "ValidationError": return `Schema validation failed: ${err.errors.toArray().join("; ")}`;
2380
+ case "ReadError": return `Read error: ${err.message}`;
2381
+ case "AliasInvalidSyntax":
2382
+ case "AliasTargetMissing":
2383
+ case "AliasSelfReference":
2384
+ case "AliasChained":
2385
+ case "AliasCrossType":
2386
+ case "AliasValueConflict": return formatAliasError(err);
2387
+ }
2388
+ };
2389
+ //#endregion
2390
+ //#region src/cli/write-gate.ts
2391
+ /**
2392
+ * Run structural validation against an in-memory TOML string.
2393
+ * On failure, prints the error, leaves the file untouched, and exits with code 1.
2394
+ * On success, returns — caller is responsible for writing.
2395
+ *
2396
+ * Use this when the write step has bespoke logic (e.g. wraps writeFileSync in Try,
2397
+ * has multi-line post-write output). Otherwise prefer `writeIfValid`.
2398
+ */
2399
+ const validateOrExit = (updated) => {
2400
+ validateRawConfig(updated).fold((err) => {
2401
+ console.error(`${RED}Error:${RESET} Aborted — change would produce an invalid config:`);
2402
+ console.error(` ${formatValidationError(err)}`);
2403
+ console.error(`${DIM}File unchanged.${RESET}`);
2404
+ process.exit(1);
2405
+ }, () => {});
2406
+ };
2407
+ /**
2408
+ * Validate then persist. Most mutating CLI commands use this — it bundles the
2409
+ * validate-or-exit gate with the writeFileSync + success log so each call site
2410
+ * stays two lines instead of five.
2411
+ */
2412
+ const writeIfValid = (configPath, updated, successMsg) => {
2413
+ validateOrExit(updated);
2414
+ writeFileSync(configPath, updated, "utf-8");
2415
+ console.log(successMsg);
2416
+ };
2417
+ //#endregion
2375
2418
  //#region src/cli/commands/env.ts
2376
2419
  const printPostWriteGuidance = () => {
2377
2420
  console.log(`\n${DIM}Note: Secret values are NOT stored — only metadata.${RESET}`);
@@ -2403,7 +2446,9 @@ const runEnvScan = (options) => {
2403
2446
  return;
2404
2447
  }
2405
2448
  const newToml = generateTomlFromScan(newEntries);
2406
- Try(() => writeFileSync(configPath, `${existing.trimEnd()}\n\n${newToml}`, "utf-8")).fold((err) => {
2449
+ const combined = `${existing.trimEnd()}\n\n${newToml}`;
2450
+ validateOrExit(combined);
2451
+ Try(() => writeFileSync(configPath, combined, "utf-8")).fold((err) => {
2407
2452
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
2408
2453
  process.exit(1);
2409
2454
  }, () => {
@@ -2411,8 +2456,9 @@ const runEnvScan = (options) => {
2411
2456
  printPostWriteGuidance();
2412
2457
  });
2413
2458
  } else {
2414
- const header = `#:schema https://raw.githubusercontent.com/jordanburke/envpkt/main/schemas/envpkt.schema.json\n\nversion = 1\n\n[lifecycle]\nstale_warning_days = 90\n\n`;
2415
- Try(() => writeFileSync(configPath, header + toml, "utf-8")).fold((err) => {
2459
+ const combined = `#:schema https://raw.githubusercontent.com/jordanburke/envpkt/main/schemas/envpkt.schema.json\n\nversion = 1\n\n[lifecycle]\nstale_warning_days = 90\n\n` + toml;
2460
+ validateOrExit(combined);
2461
+ Try(() => writeFileSync(configPath, combined, "utf-8")).fold((err) => {
2416
2462
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
2417
2463
  process.exit(1);
2418
2464
  }, () => {
@@ -2521,8 +2567,7 @@ const runEnvAdd = (name, value, options) => {
2521
2567
  console.log(block);
2522
2568
  return;
2523
2569
  }
2524
- writeFileSync(configPath, appendSection(readFileSync(configPath, "utf-8"), block), "utf-8");
2525
- console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
2570
+ writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
2526
2571
  });
2527
2572
  });
2528
2573
  };
@@ -2557,8 +2602,7 @@ const runEnvEdit = (name, options) => {
2557
2602
  console.log(updated);
2558
2603
  return;
2559
2604
  }
2560
- writeFileSync(configPath, updated, "utf-8");
2561
- console.log(`${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
2605
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
2562
2606
  });
2563
2607
  });
2564
2608
  });
@@ -2574,8 +2618,7 @@ const runEnvRm = (name, options) => {
2574
2618
  console.log(updated);
2575
2619
  return;
2576
2620
  }
2577
- writeFileSync(configPath, updated, "utf-8");
2578
- console.log(`${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
2621
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
2579
2622
  });
2580
2623
  });
2581
2624
  };
@@ -2645,8 +2688,7 @@ const runEnvAlias = (name, options) => {
2645
2688
  return;
2646
2689
  }
2647
2690
  const raw = readFileSync(configPath, "utf-8");
2648
- writeFileSync(configPath, appendSection(existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw, block), "utf-8");
2649
- console.log(`${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
2691
+ writeIfValid(configPath, appendSection(existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw, block), `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
2650
2692
  });
2651
2693
  });
2652
2694
  };
@@ -2661,8 +2703,7 @@ const runEnvRename = (oldName, newName, options) => {
2661
2703
  console.log(updated);
2662
2704
  return;
2663
2705
  }
2664
- writeFileSync(configPath, updated, "utf-8");
2665
- console.log(`${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
2706
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
2666
2707
  });
2667
2708
  });
2668
2709
  };
@@ -2753,31 +2794,7 @@ const runExec = (args, options) => {
2753
2794
  //#endregion
2754
2795
  //#region src/core/fleet.ts
2755
2796
  const CONFIG_FILENAME$1 = "envpkt.toml";
2756
- const SKIP_DIRS = new Set([
2757
- "node_modules",
2758
- ".git",
2759
- ".hg",
2760
- ".svn",
2761
- "dist",
2762
- "build",
2763
- "lib",
2764
- ".claude",
2765
- "__pycache__",
2766
- "target",
2767
- "out",
2768
- "tmp",
2769
- ".terraform",
2770
- ".gradle",
2771
- ".cargo",
2772
- ".venv",
2773
- ".next",
2774
- ".cache",
2775
- ".tox",
2776
- "vendor",
2777
- "coverage",
2778
- ".nyc_output",
2779
- ".turbo"
2780
- ]);
2797
+ const SKIP_DIRS = Set$1.of("node_modules", ".git", ".hg", ".svn", "dist", "build", "lib", ".claude", "__pycache__", "target", "out", "tmp", ".terraform", ".gradle", ".cargo", ".venv", ".next", ".cache", ".tox", "vendor", "coverage", ".nyc_output", ".turbo");
2781
2798
  function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
2782
2799
  if (currentDepth > maxDepth) return;
2783
2800
  const configPath = join(dir, CONFIG_FILENAME$1);
@@ -2942,6 +2959,7 @@ const runInit = (dir, options) => {
2942
2959
  }, (keys) => keys);
2943
2960
  });
2944
2961
  const content = generateTemplate(options, fnoxKeys.orUndefined());
2962
+ validateOrExit(content);
2945
2963
  Try(() => writeFileSync(outPath, content, "utf-8")).fold((err) => {
2946
2964
  console.error(`${RED}Error:${RESET} Failed to write ${CONFIG_FILENAME}: ${err}`);
2947
2965
  process.exit(1);
@@ -3364,24 +3382,19 @@ const handleGetSecretMeta = (args) => {
3364
3382
  const secretEntries = config.secret ?? {};
3365
3383
  return Option(secretEntries[key]).fold(() => errorResult(`Secret not found: ${key}`), (meta) => {
3366
3384
  const { encrypted_value: _, from_key: fromKey, ...rest } = meta;
3367
- if (fromKey !== void 0) {
3368
- const targetKey = /^secret\.(.+)$/.exec(fromKey)?.[1];
3369
- const target = targetKey !== void 0 ? secretEntries[targetKey] : void 0;
3370
- if (target) {
3371
- const { encrypted_value: __, from_key: ___, ...targetRest } = target;
3372
- return textResult(JSON.stringify({
3373
- key,
3374
- ...targetRest,
3375
- ...rest,
3376
- alias_of: fromKey
3377
- }, null, 2));
3378
- }
3385
+ if (fromKey !== void 0) return Option(fromKey.match(/^secret\.(.+)$/)?.[1]).flatMap((k) => Option(secretEntries[k])).fold(() => textResult(JSON.stringify({
3386
+ key,
3387
+ ...rest,
3388
+ alias_of: fromKey
3389
+ }, null, 2)), (t) => {
3390
+ const { encrypted_value: __, from_key: ___, ...targetRest } = t;
3379
3391
  return textResult(JSON.stringify({
3380
3392
  key,
3393
+ ...targetRest,
3381
3394
  ...rest,
3382
3395
  alias_of: fromKey
3383
3396
  }, null, 2));
3384
- }
3397
+ });
3385
3398
  return textResult(JSON.stringify({
3386
3399
  key,
3387
3400
  ...rest
@@ -3540,75 +3553,105 @@ const resolveValues = async (keys, profile, agentKey) => {
3540
3553
  };
3541
3554
  //#endregion
3542
3555
  //#region src/cli/commands/seal.ts
3543
- /** Write sealed values back into the TOML file, preserving structure */
3544
- const writeSealedToml = (configPath, sealedMeta) => {
3545
- const lines = readFileSync(configPath, "utf-8").split("\n");
3546
- const output = [];
3547
- let currentMetaKey = Option.none();
3548
- let insideMetaBlock = false;
3549
- let hasEncryptedValue = false;
3550
- const pendingSeals = /* @__PURE__ */ new Map();
3551
- Object.entries(sealedMeta).forEach(([key, meta]) => {
3552
- if (meta.encrypted_value) pendingSeals.set(key, meta.encrypted_value);
3556
+ const META_SECTION_RE = /^\[secret\.(.+)\]\s*$/;
3557
+ const ENCRYPTED_VALUE_RE = /^encrypted_value\s*=/;
3558
+ const NEW_SECTION_RE = /^\[/;
3559
+ const MULTILINE_DELIM = "\"\"\"";
3560
+ /**
3561
+ * In-memory transform: inject sealed encrypted_value blocks into raw TOML,
3562
+ * replacing existing encrypted_value lines (single or multiline) or appending
3563
+ * a new block at the end of the section. Pure — no I/O.
3564
+ */
3565
+ const applySealedToml = (raw, sealedMeta) => {
3566
+ const lines = raw.split("\n");
3567
+ const getSeal = (key) => Option(sealedMeta[key]?.encrypted_value);
3568
+ const isPending = (state, key) => !getSeal(key).isEmpty && !state.consumedKeys.has(key);
3569
+ const sealLinesFor = (key) => getSeal(key).fold(() => [], (v) => [
3570
+ `encrypted_value = """`,
3571
+ v,
3572
+ `"""`
3573
+ ]);
3574
+ /** Append a seal block to `output` if the current section is pending and hasn't already got one. */
3575
+ const flushPending = (state) => state.currentMetaKey.fold(() => state, (key) => {
3576
+ if (state.hasEncryptedValue || !isPending(state, key)) return state;
3577
+ return {
3578
+ ...state,
3579
+ output: [
3580
+ ...state.output,
3581
+ ...sealLinesFor(key),
3582
+ ""
3583
+ ],
3584
+ consumedKeys: state.consumedKeys.add(key)
3585
+ };
3553
3586
  });
3554
- const metaSectionRe = /^\[secret\.(.+)\]\s*$/;
3555
- const encryptedValueRe = /^encrypted_value\s*=/;
3556
- const newSectionRe = /^\[/;
3557
- const flushPending = () => {
3558
- currentMetaKey.forEach((key) => {
3559
- if (!hasEncryptedValue && pendingSeals.has(key)) {
3560
- output.push(`encrypted_value = """`);
3561
- output.push(pendingSeals.get(key));
3562
- output.push(`"""`);
3563
- output.push("");
3564
- pendingSeals.delete(key);
3565
- }
3566
- });
3567
- };
3568
- for (let i = 0; i < lines.length; i++) {
3569
- const line = lines[i];
3570
- const metaMatch = metaSectionRe.exec(line);
3587
+ const step = (state, line, i) => {
3588
+ if (i <= state.skipUntil) return state;
3589
+ const metaMatch = line.match(META_SECTION_RE);
3571
3590
  if (metaMatch) {
3572
- flushPending();
3573
- currentMetaKey = Option(metaMatch[1]);
3574
- insideMetaBlock = true;
3575
- hasEncryptedValue = false;
3576
- output.push(line);
3577
- continue;
3591
+ const flushed = flushPending(state);
3592
+ return {
3593
+ ...flushed,
3594
+ output: [...flushed.output, line],
3595
+ currentMetaKey: Option(metaMatch[1]),
3596
+ insideMetaBlock: true,
3597
+ hasEncryptedValue: false
3598
+ };
3578
3599
  }
3579
- if (insideMetaBlock && newSectionRe.test(line) && !metaSectionRe.test(line)) {
3580
- flushPending();
3581
- insideMetaBlock = false;
3582
- currentMetaKey = Option.none();
3583
- output.push(line);
3584
- continue;
3600
+ if (state.insideMetaBlock && NEW_SECTION_RE.test(line) && !META_SECTION_RE.test(line)) {
3601
+ const flushed = flushPending(state);
3602
+ return {
3603
+ ...flushed,
3604
+ output: [...flushed.output, line],
3605
+ insideMetaBlock: false,
3606
+ currentMetaKey: Option.none()
3607
+ };
3585
3608
  }
3586
- if (insideMetaBlock && encryptedValueRe.test(line)) {
3587
- hasEncryptedValue = true;
3588
- const replacing = currentMetaKey.fold(() => false, (key) => pendingSeals.has(key));
3589
- if (replacing) currentMetaKey.forEach((key) => {
3590
- output.push(`encrypted_value = """`);
3591
- output.push(pendingSeals.get(key));
3592
- output.push(`"""`);
3593
- pendingSeals.delete(key);
3594
- });
3595
- else output.push(line);
3596
- if (line.slice(line.indexOf("=") + 1).trim().includes("\"\"\"")) {
3597
- while (i + 1 < lines.length && !lines[i + 1].includes("\"\"\"")) {
3598
- if (!replacing) output.push(lines[i + 1]);
3599
- i++;
3600
- }
3601
- if (i + 1 < lines.length) {
3602
- if (!replacing) output.push(lines[i + 1]);
3603
- i++;
3604
- }
3605
- }
3606
- continue;
3609
+ if (state.insideMetaBlock && ENCRYPTED_VALUE_RE.test(line)) {
3610
+ const replacingKey = state.currentMetaKey.filter((k) => isPending(state, k));
3611
+ const replacement = replacingKey.fold(() => [line], (key) => sealLinesFor(key));
3612
+ const consumedKeys = replacingKey.fold(() => state.consumedKeys, (key) => state.consumedKeys.add(key));
3613
+ const replacing = !replacingKey.isEmpty;
3614
+ if (!line.slice(line.indexOf("=") + 1).trim().includes(MULTILINE_DELIM)) return {
3615
+ ...state,
3616
+ output: [...state.output, ...replacement],
3617
+ hasEncryptedValue: true,
3618
+ consumedKeys
3619
+ };
3620
+ const closingIdx = lines.findIndex((l, j) => j > i && l.includes(MULTILINE_DELIM));
3621
+ const effectiveEnd = closingIdx === -1 ? lines.length - 1 : closingIdx;
3622
+ const continuation = replacing ? [] : lines.slice(i + 1, effectiveEnd + 1);
3623
+ return {
3624
+ ...state,
3625
+ output: [
3626
+ ...state.output,
3627
+ ...replacement,
3628
+ ...continuation
3629
+ ],
3630
+ hasEncryptedValue: true,
3631
+ consumedKeys,
3632
+ skipUntil: effectiveEnd
3633
+ };
3607
3634
  }
3608
- output.push(line);
3609
- }
3610
- flushPending();
3611
- writeFileSync(configPath, output.join("\n"));
3635
+ return {
3636
+ ...state,
3637
+ output: [...state.output, line]
3638
+ };
3639
+ };
3640
+ const initial = {
3641
+ output: [],
3642
+ currentMetaKey: Option.none(),
3643
+ insideMetaBlock: false,
3644
+ hasEncryptedValue: false,
3645
+ consumedKeys: Set$1.empty(),
3646
+ skipUntil: -1
3647
+ };
3648
+ return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3649
+ };
3650
+ /** Write sealed values back into the TOML file, preserving structure. */
3651
+ const writeSealedToml = (configPath, sealedMeta) => {
3652
+ const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
3653
+ validateOrExit(finalContent);
3654
+ writeFileSync(configPath, finalContent);
3612
3655
  };
3613
3656
  const runSeal = async (options) => {
3614
3657
  const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
@@ -3846,8 +3889,7 @@ const runSecretAdd = (name, options) => {
3846
3889
  console.log(block);
3847
3890
  return;
3848
3891
  }
3849
- writeFileSync(configPath, appendSection(readFileSync(configPath, "utf-8"), block), "utf-8");
3850
- console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3892
+ writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3851
3893
  });
3852
3894
  });
3853
3895
  };
@@ -3879,8 +3921,7 @@ const runSecretEdit = (name, options) => {
3879
3921
  console.log(updated);
3880
3922
  return;
3881
3923
  }
3882
- writeFileSync(configPath, updated, "utf-8");
3883
- console.log(`${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
3924
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
3884
3925
  });
3885
3926
  });
3886
3927
  });
@@ -3896,8 +3937,7 @@ const runSecretRm = (name, options) => {
3896
3937
  console.log(updated);
3897
3938
  return;
3898
3939
  }
3899
- writeFileSync(configPath, updated, "utf-8");
3900
- console.log(`${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
3940
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
3901
3941
  });
3902
3942
  });
3903
3943
  };
@@ -3912,8 +3952,7 @@ const runSecretRename = (oldName, newName, options) => {
3912
3952
  console.log(updated);
3913
3953
  return;
3914
3954
  }
3915
- writeFileSync(configPath, updated, "utf-8");
3916
- console.log(`${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
3955
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
3917
3956
  });
3918
3957
  });
3919
3958
  };
@@ -3983,11 +4022,102 @@ const runSecretAlias = (name, options) => {
3983
4022
  return;
3984
4023
  }
3985
4024
  const raw = readFileSync(configPath, "utf-8");
3986
- writeFileSync(configPath, appendSection(existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw, block), "utf-8");
3987
- console.log(`${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
4025
+ writeIfValid(configPath, appendSection(existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw, block), `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
4026
+ });
4027
+ });
4028
+ };
4029
+ const readNewValue = async (key) => {
4030
+ if (!process.stdin.isTTY) {
4031
+ const chunks = [];
4032
+ return new Promise((resolveP, rejectP) => {
4033
+ process.stdin.on("data", (c) => chunks.push(Buffer.from(c)));
4034
+ process.stdin.on("end", () => resolveP(Buffer.concat(chunks).toString("utf-8").replace(/\r?\n$/, "")));
4035
+ process.stdin.on("error", rejectP);
4036
+ });
4037
+ }
4038
+ const rl = createInterface({
4039
+ input: process.stdin,
4040
+ output: process.stderr
4041
+ });
4042
+ return new Promise((resolveP) => {
4043
+ rl.question(`Enter new value for ${key}: `, (answer) => {
4044
+ rl.close();
4045
+ resolveP(answer);
3988
4046
  });
3989
4047
  });
3990
4048
  };
4049
+ const runSecretRotate = async (name, options) => {
4050
+ const { configPath, source } = resolveConfigPath(options.config).fold((err) => {
4051
+ console.error(formatError(err));
4052
+ process.exit(2);
4053
+ return {
4054
+ configPath: "",
4055
+ source: "flag"
4056
+ };
4057
+ }, ({ path, source: s }) => ({
4058
+ configPath: path,
4059
+ source: s
4060
+ }));
4061
+ const sourceMsg = formatConfigSource(configPath, source);
4062
+ if (sourceMsg) console.error(sourceMsg);
4063
+ const config = loadConfig(configPath).fold((err) => {
4064
+ console.error(formatError(err));
4065
+ process.exit(2);
4066
+ }, (c) => c);
4067
+ const meta = config.secret?.[name];
4068
+ if (!meta) {
4069
+ console.error(`${RED}Error:${RESET} Secret "${name}" not found in ${configPath}`);
4070
+ process.exit(1);
4071
+ }
4072
+ if (meta.from_key) {
4073
+ console.error(`${RED}Error:${RESET} "${name}" is an alias (from_key = "${meta.from_key}"). Rotate the target secret instead.`);
4074
+ process.exit(1);
4075
+ }
4076
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4077
+ const wasSealed = !!meta.encrypted_value;
4078
+ const raw = readFileSync(configPath, "utf-8");
4079
+ if (!wasSealed) {
4080
+ updateSectionFields(raw, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4081
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4082
+ process.exit(2);
4083
+ }, (result) => {
4084
+ if (options.dryRun) {
4085
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4086
+ console.log(result);
4087
+ return;
4088
+ }
4089
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
4090
+ });
4091
+ return;
4092
+ }
4093
+ if (!config.identity?.recipient) {
4094
+ console.error(`${RED}Error:${RESET} identity.recipient is required to rotate a sealed secret`);
4095
+ console.error(`${DIM}Run ${CYAN}envpkt keygen${DIM} to configure one.${RESET}`);
4096
+ process.exit(2);
4097
+ }
4098
+ const { recipient } = config.identity;
4099
+ const value = await readNewValue(name);
4100
+ if (value === "") {
4101
+ console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
4102
+ process.exit(1);
4103
+ }
4104
+ const ciphertext = ageEncrypt(value, recipient).fold((err) => {
4105
+ console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
4106
+ process.exit(2);
4107
+ return "";
4108
+ }, (ct) => ct);
4109
+ updateSectionFields(applySealedToml(raw, { [name]: { encrypted_value: ciphertext } }), `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4110
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4111
+ process.exit(2);
4112
+ }, (result) => {
4113
+ if (options.dryRun) {
4114
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4115
+ console.log(result);
4116
+ return;
4117
+ }
4118
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
4119
+ });
4120
+ };
3991
4121
  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)");
3992
4122
  const registerSecretCommands = (program) => {
3993
4123
  const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
@@ -4003,6 +4133,9 @@ const registerSecretCommands = (program) => {
4003
4133
  secret.command("rename").description("Rename a secret entry, preserving all metadata").argument("<old>", "Current secret name").argument("<new>", "New secret name").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((oldName, newName, options) => {
4004
4134
  runSecretRename(oldName, newName, options);
4005
4135
  });
4136
+ secret.command("rotate").description("Rotate a secret's value (sealed: reseal + stamp; unsealed: stamp only)").argument("<name>", "Secret name to rotate").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action(async (name, options) => {
4137
+ await runSecretRotate(name, options);
4138
+ });
4006
4139
  secret.command("alias").description("Create an alias entry that reuses another secret's resolved value").argument("<name>", "Alias name (becomes the env var key)").requiredOption("--from <ref>", "Target reference — must be \"secret.<KEY>\"").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this alias exists (local metadata)").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--force", "Overwrite the entry if <name> already exists").option("--dry-run", "Preview the TOML block without writing").action((name, options) => {
4007
4140
  runSecretAlias(name, options);
4008
4141
  });
@@ -4102,6 +4235,192 @@ const runUpgrade = () => {
4102
4235
  else console.log(`\n${GREEN}✓${RESET} Upgraded ${YELLOW}${before}${RESET} → ${BOLD}${after}${RESET}`);
4103
4236
  };
4104
4237
  //#endregion
4238
+ //#region src/cli/commands/validate.ts
4239
+ const SEAL_BEGIN = "-----BEGIN AGE ENCRYPTED FILE-----";
4240
+ const SEAL_END = "-----END AGE ENCRYPTED FILE-----";
4241
+ const CHECK_NAMES = {
4242
+ toml: "TOML syntax",
4243
+ schema: "Schema",
4244
+ catalog: "Catalog",
4245
+ aliases: "Aliases",
4246
+ sealed: "Sealed blocks"
4247
+ };
4248
+ /** Structural sanity scan over each [secret.*].encrypted_value PEM block. No decryption. */
4249
+ const checkSealedBlocks = (config) => {
4250
+ const secrets = config.secret ?? {};
4251
+ const sealed = Object.entries(secrets).filter(([, meta]) => typeof meta.encrypted_value === "string");
4252
+ if (sealed.length === 0) return {
4253
+ name: CHECK_NAMES.sealed,
4254
+ status: "na",
4255
+ detail: "no sealed values"
4256
+ };
4257
+ const broken = sealed.flatMap(([key, meta]) => {
4258
+ const raw = (meta.encrypted_value ?? "").trim();
4259
+ if (!raw.startsWith(SEAL_BEGIN)) return [`[secret.${key}] encrypted_value missing BEGIN marker`];
4260
+ if (!raw.endsWith(SEAL_END)) return [`[secret.${key}] encrypted_value missing END marker`];
4261
+ return [];
4262
+ });
4263
+ if (broken.length === 0) return {
4264
+ name: CHECK_NAMES.sealed,
4265
+ status: "ok",
4266
+ detail: `${sealed.length} sealed value(s)`
4267
+ };
4268
+ return {
4269
+ name: CHECK_NAMES.sealed,
4270
+ status: "failed",
4271
+ error: broken.join("; ")
4272
+ };
4273
+ };
4274
+ const skipped = (name) => ({
4275
+ name,
4276
+ status: "skipped"
4277
+ });
4278
+ const buildReport = (path, raw) => {
4279
+ return parseToml(raw).fold((err) => ({
4280
+ ok: false,
4281
+ configPath: path,
4282
+ checks: [
4283
+ {
4284
+ name: CHECK_NAMES.toml,
4285
+ status: "failed",
4286
+ error: formatValidationError(err)
4287
+ },
4288
+ skipped(CHECK_NAMES.schema),
4289
+ skipped(CHECK_NAMES.catalog),
4290
+ skipped(CHECK_NAMES.aliases),
4291
+ skipped(CHECK_NAMES.sealed)
4292
+ ]
4293
+ }), (data) => {
4294
+ const tomlOk = {
4295
+ name: CHECK_NAMES.toml,
4296
+ status: "ok"
4297
+ };
4298
+ return validateConfig(data).fold((err) => ({
4299
+ ok: false,
4300
+ configPath: path,
4301
+ checks: [
4302
+ tomlOk,
4303
+ {
4304
+ name: CHECK_NAMES.schema,
4305
+ status: "failed",
4306
+ error: formatValidationError(err)
4307
+ },
4308
+ skipped(CHECK_NAMES.catalog),
4309
+ skipped(CHECK_NAMES.aliases),
4310
+ skipped(CHECK_NAMES.sealed)
4311
+ ]
4312
+ }), (config) => {
4313
+ const checks = [
4314
+ tomlOk,
4315
+ {
4316
+ name: CHECK_NAMES.schema,
4317
+ status: "ok"
4318
+ },
4319
+ config.catalog ? resolveConfig(config, dirname(path)).fold((err) => ({
4320
+ name: CHECK_NAMES.catalog,
4321
+ status: "failed",
4322
+ error: formatError(err)
4323
+ }), () => ({
4324
+ name: CHECK_NAMES.catalog,
4325
+ status: "ok",
4326
+ detail: config.catalog
4327
+ })) : {
4328
+ name: CHECK_NAMES.catalog,
4329
+ status: "na",
4330
+ detail: "no catalog declared"
4331
+ },
4332
+ validateAliases(config).fold((err) => ({
4333
+ name: CHECK_NAMES.aliases,
4334
+ status: "failed",
4335
+ error: formatValidationError(err)
4336
+ }), (table) => ({
4337
+ name: CHECK_NAMES.aliases,
4338
+ status: "ok",
4339
+ detail: `${table.entries.size} alias(es)`
4340
+ })),
4341
+ checkSealedBlocks(config)
4342
+ ];
4343
+ return {
4344
+ ok: checks.every((c) => c.status === "ok" || c.status === "na"),
4345
+ configPath: path,
4346
+ checks
4347
+ };
4348
+ });
4349
+ });
4350
+ };
4351
+ const renderCheckLines = (check) => {
4352
+ switch (check.status) {
4353
+ case "ok": {
4354
+ const detail = check.detail ? ` ${DIM}(${check.detail})${RESET}` : "";
4355
+ return [` ${GREEN}✓${RESET} ${check.name}${detail}`];
4356
+ }
4357
+ case "failed": return check.error ? [` ${RED}✗${RESET} ${check.name}`, ` ${RED}${check.error}${RESET}`] : [` ${RED}✗${RESET} ${check.name}`];
4358
+ case "skipped": return [` ${DIM}○ ${check.name} (skipped — prior check failed)${RESET}`];
4359
+ case "na": return [` ${DIM}— ${check.name} (${check.detail ?? "not applicable"})${RESET}`];
4360
+ }
4361
+ };
4362
+ const formatTextReport = (report, sourceMsg) => {
4363
+ const header = `${report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`} ${BOLD}${report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`}${RESET} — ${CYAN}${report.configPath}${RESET}`;
4364
+ const checkLines = report.checks.flatMap((c) => [...renderCheckLines(c)]);
4365
+ return [
4366
+ ...sourceMsg ? [sourceMsg] : [],
4367
+ header,
4368
+ "",
4369
+ ...checkLines
4370
+ ].join("\n");
4371
+ };
4372
+ const formatJsonReport = (report) => JSON.stringify({
4373
+ ok: report.ok,
4374
+ configPath: report.configPath,
4375
+ checks: report.checks.map((c) => ({
4376
+ name: c.name,
4377
+ status: c.status,
4378
+ error: c.error ?? null,
4379
+ detail: c.detail ?? null
4380
+ }))
4381
+ }, null, 2);
4382
+ const emit = (report, sourceMsg, asJson) => {
4383
+ if (asJson) {
4384
+ console.log(formatJsonReport(report));
4385
+ return;
4386
+ }
4387
+ if (report.ok) console.log(formatTextReport(report, sourceMsg));
4388
+ else console.error(formatTextReport(report, sourceMsg));
4389
+ };
4390
+ const runValidate = (options) => {
4391
+ resolveConfigPath(options.config).fold((err) => {
4392
+ if (options.json) console.log(JSON.stringify({
4393
+ ok: false,
4394
+ configPath: "path" in err ? err.path : null,
4395
+ checks: [{
4396
+ name: "Config file",
4397
+ status: "failed",
4398
+ error: formatValidationError(err)
4399
+ }]
4400
+ }, null, 2));
4401
+ else console.error(formatError(err));
4402
+ process.exit(2);
4403
+ }, ({ path, source }) => {
4404
+ const sourceMsg = formatConfigSource(path, source);
4405
+ Try(() => readFileSync(path, "utf-8")).fold((e) => {
4406
+ emit({
4407
+ ok: false,
4408
+ configPath: path,
4409
+ checks: [{
4410
+ name: "Read",
4411
+ status: "failed",
4412
+ error: e.message
4413
+ }]
4414
+ }, sourceMsg, options.json === true);
4415
+ process.exit(2);
4416
+ }, (raw) => {
4417
+ const report = buildReport(path, raw);
4418
+ emit(report, sourceMsg, options.json === true);
4419
+ process.exit(report.ok ? 0 : 1);
4420
+ });
4421
+ });
4422
+ };
4423
+ //#endregion
4105
4424
  //#region src/cli/index.ts
4106
4425
  const program = new Command();
4107
4426
  program.name("envpkt").description("Credential lifecycle and fleet management for AI agents\n\n Developer workflow: env scan → keygen → seal → eval $(envpkt env export)\n Agent / CI workflow: catalog → audit --strict → seal → exec --strict → fleet").version((() => {
@@ -4125,6 +4444,9 @@ program.command("audit").description("Audit credential health from envpkt.toml (
4125
4444
  program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
4126
4445
  runFleet(options);
4127
4446
  });
4447
+ program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
4448
+ runValidate(options);
4449
+ });
4128
4450
  program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").action((options) => {
4129
4451
  runInspect(options);
4130
4452
  });