envpkt 0.11.0 → 0.11.2

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,12 +3,12 @@ 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";
10
10
  import { FormatRegistry, Type } from "@sinclair/typebox";
11
- import { directSilentLogger } from "functype-log";
11
+ import { directSilentLogger } from "functype-log/direct";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { homedir } from "node:os";
14
14
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -70,7 +70,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
70
70
  status: targetHealth.status,
71
71
  days_remaining: targetHealth.days_remaining,
72
72
  rotation_url: targetHealth.rotation_url,
73
- purpose: meta.purpose !== void 0 ? Option(meta.purpose) : targetHealth.purpose,
73
+ purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
74
74
  created: targetHealth.created,
75
75
  expires: targetHealth.expires,
76
76
  issues: List([]),
@@ -86,17 +86,18 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
86
86
  const secretEntries = config.secret ?? {};
87
87
  const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
88
88
  const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
89
- const nonAliasMetaKeys = new Set(nonAliasEntries.map(([k]) => k));
89
+ const nonAliasMetaKeys = Set$1(nonAliasEntries.map(([k]) => k));
90
90
  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]));
91
+ const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
92
92
  const parseTargetKey = (from_key) => {
93
- return /^secret\.(.+)$/.exec(from_key)?.[1];
93
+ return Option(from_key.match(/^secret\.(.+)$/)?.[1]);
94
94
  };
95
95
  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 {
96
+ const tableEntry = aliasTable?.entries.get(`secret.${key}`);
97
+ const targetKey = Option(tableEntry?.targetKey).fold(() => Option(meta.from_key).flatMap(parseTargetKey), (k) => Option(k));
98
+ const targetHealth = targetKey.flatMap((k) => healthByKey.get(k));
99
+ const targetRef = Option(meta.from_key).fold(() => targetKey.map((k) => `secret.${k}`), (v) => Option(v)).orElse("");
100
+ return targetHealth.fold(() => ({
100
101
  key,
101
102
  service: Option(meta.service),
102
103
  status: "missing",
@@ -107,11 +108,10 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
107
108
  expires: Option(meta.expires),
108
109
  issues: List(["Alias target not resolvable"]),
109
110
  alias_of: Option(targetRef)
110
- };
111
- return classifyAlias(key, meta, targetHealth, targetRef);
111
+ }), (health) => classifyAlias(key, meta, health, targetRef));
112
112
  });
113
113
  const secrets = List([...nonAliasHealth, ...aliasHealth]);
114
- const orphaned = keys.size > 0 ? [...nonAliasMetaKeys].filter((k) => !keys.has(k)).length : 0;
114
+ const orphaned = keys.size > 0 ? nonAliasMetaKeys.toArray().filter((k) => !keys.has(k)).length : 0;
115
115
  const total = secrets.size;
116
116
  const expired = secrets.count((s) => s.status === "expired");
117
117
  const missing = secrets.count((s) => s.status === "missing");
@@ -137,12 +137,14 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
137
137
  };
138
138
  const computeEnvAudit = (config, env = process.env) => {
139
139
  const envEntries = config.env ?? {};
140
+ const resolveEffectiveDefault = (entry) => {
141
+ return Do(function* () {
142
+ return yield* $(Option((yield* $(Option(envEntries[yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]))]))).value));
143
+ }).orElse(entry.value ?? "");
144
+ };
140
145
  const entries = Object.entries(envEntries).map(([key, entry]) => {
141
146
  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 ?? "";
147
+ const effectiveDefault = resolveEffectiveDefault(entry);
146
148
  return {
147
149
  key,
148
150
  defaultValue: effectiveDefault,
@@ -427,18 +429,19 @@ const loadCatalog = (catalogPath) => loadConfig(catalogPath).fold((err) => {
427
429
  /** Resolve secrets by merging catalog meta with agent overrides (shallow merge) */
428
430
  const resolveSecrets = (agentMeta, catalogMeta, agentSecrets, catalogPath) => {
429
431
  return agentSecrets.reduce((acc, key) => acc.flatMap((resolved) => {
430
- if (!(key in catalogMeta)) return Left({
432
+ const catalogEntry = catalogMeta[key];
433
+ if (catalogEntry === void 0) return Left({
431
434
  _tag: "SecretNotInCatalog",
432
435
  key,
433
436
  catalogPath
434
437
  });
435
- const catalogEntry = catalogMeta[key];
438
+ const merged = Option(agentMeta[key]).fold(() => catalogEntry, (override) => ({
439
+ ...catalogEntry,
440
+ ...override
441
+ }));
436
442
  return Right({
437
443
  ...resolved,
438
- [key]: key in agentMeta ? {
439
- ...catalogEntry,
440
- ...agentMeta[key]
441
- } : catalogEntry
444
+ [key]: merged
442
445
  });
443
446
  }), Right({}));
444
447
  };
@@ -898,12 +901,12 @@ const validateOneSecret = (key, meta, secretEntries) => {
898
901
  kind: "secret",
899
902
  field: "encrypted_value"
900
903
  });
901
- return parseRef(ref).fold(() => Left({
904
+ return parseRef(ref).toEither({
902
905
  _tag: "AliasInvalidSyntax",
903
906
  key,
904
907
  kind: "secret",
905
908
  value: ref
906
- }), (parsed) => {
909
+ }).flatMap((parsed) => {
907
910
  if (parsed.kind !== "secret") return Left({
908
911
  _tag: "AliasCrossType",
909
912
  key,
@@ -914,23 +917,23 @@ const validateOneSecret = (key, meta, secretEntries) => {
914
917
  _tag: "AliasSelfReference",
915
918
  key: `secret.${key}`
916
919
  });
917
- return Option(secretEntries[parsed.key]).fold(() => Left({
920
+ return Option(secretEntries[parsed.key]).toEither({
918
921
  _tag: "AliasTargetMissing",
919
922
  key: `secret.${key}`,
920
923
  target: ref
921
- }), (target) => {
924
+ }).flatMap((target) => {
922
925
  if (target.from_key !== void 0) return Left({
923
926
  _tag: "AliasChained",
924
927
  key: `secret.${key}`,
925
928
  target: ref
926
929
  });
927
- return Right(Option({
930
+ return Right({
928
931
  kind: "secret",
929
932
  targetKind: "secret",
930
933
  targetKey: parsed.key
931
- }));
934
+ });
932
935
  });
933
- });
936
+ }).map((entry) => Option(entry));
934
937
  };
935
938
  const validateOneEnv = (key, meta, envEntries) => {
936
939
  if (meta.from_key === void 0) return Right(Option(void 0));
@@ -941,12 +944,12 @@ const validateOneEnv = (key, meta, envEntries) => {
941
944
  kind: "env",
942
945
  field: "value"
943
946
  });
944
- return parseRef(ref).fold(() => Left({
947
+ return parseRef(ref).toEither({
945
948
  _tag: "AliasInvalidSyntax",
946
949
  key,
947
950
  kind: "env",
948
951
  value: ref
949
- }), (parsed) => {
952
+ }).flatMap((parsed) => {
950
953
  if (parsed.kind !== "env") return Left({
951
954
  _tag: "AliasCrossType",
952
955
  key,
@@ -957,57 +960,43 @@ const validateOneEnv = (key, meta, envEntries) => {
957
960
  _tag: "AliasSelfReference",
958
961
  key: `env.${key}`
959
962
  });
960
- return Option(envEntries[parsed.key]).fold(() => Left({
963
+ return Option(envEntries[parsed.key]).toEither({
961
964
  _tag: "AliasTargetMissing",
962
965
  key: `env.${key}`,
963
966
  target: ref
964
- }), (target) => {
967
+ }).flatMap((target) => {
965
968
  if (target.from_key !== void 0) return Left({
966
969
  _tag: "AliasChained",
967
970
  key: `env.${key}`,
968
971
  target: ref
969
972
  });
970
- return Right(Option({
973
+ return Right({
971
974
  kind: "env",
972
975
  targetKind: "env",
973
976
  targetKey: parsed.key
974
- }));
977
+ });
975
978
  });
976
- });
979
+ }).map((entry) => Option(entry));
977
980
  };
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
- */
981
+ /** Fail-fast reduction: accumulates validated entries, short-circuits on first AliasError */
982
+ 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
983
  const validateAliases = (config) => {
993
984
  const secretEntries = config.secret ?? {};
994
985
  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);
986
+ const secretPairs = collectValidated(Object.entries(secretEntries), (k, m) => validateOneSecret(k, m, secretEntries), "secret");
987
+ const envPairs = collectValidated(Object.entries(envEntries), (k, m) => validateOneEnv(k, m, envEntries), "env");
988
+ return secretPairs.flatMap((secrets) => envPairs.map((envs) => [...secrets, ...envs])).map((allEntries) => ({ entries: new Map(allEntries) }));
989
+ };
990
+ /** Format an alias error into a human-readable message */
991
+ const formatAliasError = (error) => {
992
+ switch (error._tag) {
993
+ case "AliasInvalidSyntax": return `[${error.kind}.${error.key}] from_key = "${error.value}" — expected "secret.<KEY>" or "env.<KEY>"`;
994
+ case "AliasTargetMissing": return `[${error.key}] from_key target "${error.target}" not found in config`;
995
+ case "AliasSelfReference": return `[${error.key}] from_key cannot reference itself`;
996
+ case "AliasChained": return `[${error.key}] from_key target "${error.target}" is itself an alias; chained aliases are not supported`;
997
+ case "AliasCrossType": return `[${error.kind}.${error.key}] cannot alias a ${error.targetKind} entry; same-type aliasing only (secret→secret, env→env)`;
998
+ case "AliasValueConflict": return `[${error.kind}.${error.key}] cannot declare both from_key and ${error.field}; an alias has no value of its own`;
1009
999
  }
1010
- return Right({ entries });
1011
1000
  };
1012
1001
  //#endregion
1013
1002
  //#region src/core/keygen.ts
@@ -1197,11 +1186,12 @@ const sealSecrets = (meta, values, recipient) => {
1197
1186
  message: "age CLI not found on PATH"
1198
1187
  });
1199
1188
  return Object.entries(meta).reduce((acc, [key, secretMeta]) => acc.flatMap((result) => {
1200
- if (!(key in values)) return Right({
1189
+ const value = values[key];
1190
+ if (value === void 0) return Right({
1201
1191
  ...result,
1202
1192
  [key]: secretMeta
1203
1193
  });
1204
- return ageEncrypt(values[key], recipient).mapLeft((err) => ({
1194
+ return ageEncrypt(value, recipient).mapLeft((err) => ({
1205
1195
  _tag: "EncryptFailed",
1206
1196
  key,
1207
1197
  message: err.message
@@ -1297,11 +1287,11 @@ const bootSafe = (options) => {
1297
1287
  const secretEntries = config.secret ?? {};
1298
1288
  const envEntries = config.env ?? {};
1299
1289
  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);
1290
+ const aliasSecretKeys = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1301
1291
  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);
1292
+ const aliasEnvKeys = Object.entries(envEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1303
1293
  const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
1304
- const hasSealedValues = nonAliasMetaKeys.some((k) => !!nonAliasSecretEntries[k].encrypted_value);
1294
+ const hasSealedValues = Object.values(nonAliasSecretEntries).some((meta) => !!meta.encrypted_value);
1305
1295
  const identityKeyResult = resolveIdentityKey(config, configDir);
1306
1296
  const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
1307
1297
  if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
@@ -1314,7 +1304,7 @@ const bootSafe = (options) => {
1314
1304
  const injected = [];
1315
1305
  const skipped = [];
1316
1306
  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]] : [], () => [])));
1307
+ const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => Option(entry.value).fold(() => [], (v) => [[key, v]]), () => [])));
1318
1308
  const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
1319
1309
  if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
1320
1310
  process.env[key] = value;
@@ -1374,21 +1364,20 @@ const bootSafe = (options) => {
1374
1364
  aliasSecretKeys.forEach((aliasKey) => {
1375
1365
  const entry = aliasTable.entries.get(`secret.${aliasKey}`);
1376
1366
  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", {
1367
+ Option(secrets[entry.targetKey]).fold(() => {
1368
+ skipped.push(aliasKey);
1369
+ log.debug("phase.alias.target_unresolved", {
1382
1370
  alias: aliasKey,
1383
1371
  target: entry.targetKey
1384
1372
  });
1385
- } else {
1386
- skipped.push(aliasKey);
1387
- log.debug("phase.alias.target_unresolved", {
1373
+ }, (targetValue) => {
1374
+ secrets[aliasKey] = targetValue;
1375
+ injected.push(aliasKey);
1376
+ log.debug("phase.alias.copied", {
1388
1377
  alias: aliasKey,
1389
1378
  target: entry.targetKey
1390
1379
  });
1391
- }
1380
+ });
1392
1381
  });
1393
1382
  aliasEnvKeys.forEach((aliasKey) => {
1394
1383
  const entry = aliasTable.entries.get(`env.${aliasKey}`);
@@ -1425,7 +1414,7 @@ const bootSafe = (options) => {
1425
1414
  };
1426
1415
  //#endregion
1427
1416
  //#region src/core/patterns.ts
1428
- const EXCLUDED_VARS = new Set([
1417
+ const EXCLUDED_VARS = Set$1([
1429
1418
  "PATH",
1430
1419
  "HOME",
1431
1420
  "USER",
@@ -2138,25 +2127,22 @@ const envScan = (env, options) => {
2138
2127
  };
2139
2128
  };
2140
2129
  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];
2130
+ const match = raw.match(/^(secret|env)\.(.+)$/);
2131
+ if (match?.[1] !== expectedKind) return Option(void 0);
2132
+ return Option(match[2]);
2145
2133
  };
2146
2134
  /** Bidirectional drift detection between config and live environment */
2147
2135
  const envCheck = (config, env) => {
2148
2136
  const secretEntries = config.secret ?? {};
2149
2137
  const metaKeys = Object.keys(secretEntries);
2150
- const trackedSet = new Set(metaKeys);
2138
+ const metaKeysSet = Set$1(metaKeys);
2151
2139
  const isSecretPresent = (key) => {
2152
2140
  if (env[key] !== void 0 && env[key] !== "") return true;
2153
2141
  const meta = secretEntries[key];
2154
2142
  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] !== "";
2143
+ return parseAliasRef(meta.from_key, "secret").fold(() => false, (targetKey) => env[targetKey] !== void 0 && env[targetKey] !== "");
2157
2144
  };
2158
- const secretDriftEntries = metaKeys.map((key) => {
2159
- const meta = secretEntries[key];
2145
+ const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
2160
2146
  const present = isSecretPresent(key);
2161
2147
  return {
2162
2148
  envVar: key,
@@ -2170,14 +2156,9 @@ const envCheck = (config, env) => {
2170
2156
  if (env[key] !== void 0 && env[key] !== "") return true;
2171
2157
  const meta = envDefaults[key];
2172
2158
  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] !== "";
2159
+ return parseAliasRef(meta.from_key, "env").fold(() => false, (targetKey) => env[targetKey] !== void 0 && env[targetKey] !== "");
2175
2160
  };
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) => {
2161
+ const envDefaultEntries = Object.keys(envDefaults).filter((key) => !metaKeysSet.has(key)).map((key) => {
2181
2162
  const present = isEnvPresent(key);
2182
2163
  return {
2183
2164
  envVar: key,
@@ -2186,7 +2167,8 @@ const envCheck = (config, env) => {
2186
2167
  confidence: Option(void 0)
2187
2168
  };
2188
2169
  });
2189
- const untrackedEntries = scanEnv(env).filter((match) => !trackedSet.has(match.envVar)).map((match) => ({
2170
+ const trackedKeys = Set$1([...metaKeys, ...envDefaultEntries.map((e) => e.envVar)]);
2171
+ const untrackedEntries = scanEnv(env).filter((match) => !trackedKeys.has(match.envVar)).map((match) => ({
2190
2172
  envVar: match.envVar,
2191
2173
  service: match.service,
2192
2174
  status: "untracked",
@@ -2229,6 +2211,22 @@ created = "${todayIso$1()}"
2229
2211
  //#region src/core/toml-edit.ts
2230
2212
  const SECTION_RE = /^\[.+\]\s*$/;
2231
2213
  const MULTILINE_OPEN = "\"\"\"";
2214
+ const scanSectionBoundary = (state, line, i) => {
2215
+ if (state.done) return state;
2216
+ if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2217
+ ...state,
2218
+ inMultiline: false
2219
+ } : state;
2220
+ if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2221
+ ...state,
2222
+ inMultiline: true
2223
+ } : state;
2224
+ return SECTION_RE.test(line) ? {
2225
+ ...state,
2226
+ end: i,
2227
+ done: true
2228
+ } : state;
2229
+ };
2232
2230
  /**
2233
2231
  * Find the line range [start, end) of a TOML section by its header string.
2234
2232
  * The range includes the header line through to (but not including) the next section header or EOF.
@@ -2237,26 +2235,14 @@ const MULTILINE_OPEN = "\"\"\"";
2237
2235
  const findSectionRange = (lines, sectionHeader) => {
2238
2236
  const start = lines.findIndex((l) => l.trim() === sectionHeader);
2239
2237
  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
- }
2238
+ const initial = {
2239
+ end: lines.length,
2240
+ inMultiline: false,
2241
+ done: false
2242
+ };
2257
2243
  return {
2258
2244
  start,
2259
- end
2245
+ end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
2260
2246
  };
2261
2247
  };
2262
2248
  /** Check whether a section header exists in the raw TOML */
@@ -2272,12 +2258,10 @@ const removeSection = (raw, sectionHeader) => {
2272
2258
  _tag: "SectionNotFound",
2273
2259
  section: sectionHeader
2274
2260
  });
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
2261
  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");
2262
+ const beforeAll = lines.slice(0, range.start);
2263
+ const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
2264
+ const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
2281
2265
  return Either.right(result);
2282
2266
  };
2283
2267
  /**
@@ -2313,55 +2297,47 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2313
2297
  const before = lines.slice(0, range.start + 1);
2314
2298
  const after = lines.slice(range.end);
2315
2299
  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
- }
2300
+ const findClosingMultiline = (fromIdx) => {
2301
+ const idx = sectionBody.findIndex((l, j) => j > fromIdx && l.includes(MULTILINE_OPEN));
2302
+ return idx === -1 ? sectionBody.length : idx;
2303
+ };
2304
+ const initial = {
2305
+ remaining: [],
2306
+ updatedKeys: Set$1.empty(),
2307
+ skipUntil: -1
2308
+ };
2309
+ const step = (state, line, i) => {
2310
+ if (i <= state.skipUntil) return state;
2334
2311
  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
- }
2312
+ const isFieldLine = eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[");
2313
+ const key = isFieldLine ? line.slice(0, eqIdx).trim() : "";
2314
+ if (isFieldLine && key in updates) {
2315
+ const afterEquals = line.slice(eqIdx + 1).trim();
2316
+ const skipUntil = afterEquals.includes(MULTILINE_OPEN) && (afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? findClosingMultiline(i) : state.skipUntil;
2317
+ const updatedKeys = state.updatedKeys.add(key);
2318
+ const value = updates[key];
2319
+ if (value === null) return {
2320
+ ...state,
2321
+ updatedKeys,
2322
+ skipUntil
2323
+ };
2324
+ return {
2325
+ remaining: [...state.remaining, `${key} = ${value}`],
2326
+ updatedKeys,
2327
+ skipUntil
2328
+ };
2357
2329
  }
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);
2330
+ return {
2331
+ ...state,
2332
+ remaining: [...state.remaining, line]
2333
+ };
2334
+ };
2335
+ const final = List(sectionBody).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]));
2336
+ const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !final.updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
2362
2337
  const result = [
2363
2338
  ...before,
2364
- ...remaining,
2339
+ ...final.remaining,
2340
+ ...newFields,
2365
2341
  ...after
2366
2342
  ].join("\n");
2367
2343
  return Either.right(result);
@@ -2372,6 +2348,60 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2372
2348
  */
2373
2349
  const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
2374
2350
  //#endregion
2351
+ //#region src/core/validate.ts
2352
+ /**
2353
+ * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
2354
+ *
2355
+ * Used by write-path CLI commands to verify the post-edit file would still be
2356
+ * structurally valid before persisting. Catalog resolution is intentionally
2357
+ * excluded — catalog issues depend on external files, not on the local edit,
2358
+ * and `envpkt validate` covers them as a separate explicit check.
2359
+ */
2360
+ const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
2361
+ /** Human-readable one-liner for any ValidationError tag. */
2362
+ const formatValidationError = (err) => {
2363
+ switch (err._tag) {
2364
+ case "FileNotFound": return `Config file not found: ${err.path}`;
2365
+ case "ParseError": return `TOML parse error: ${err.message}`;
2366
+ case "ValidationError": return `Schema validation failed: ${err.errors.toArray().join("; ")}`;
2367
+ case "ReadError": return `Read error: ${err.message}`;
2368
+ case "AliasInvalidSyntax":
2369
+ case "AliasTargetMissing":
2370
+ case "AliasSelfReference":
2371
+ case "AliasChained":
2372
+ case "AliasCrossType":
2373
+ case "AliasValueConflict": return formatAliasError(err);
2374
+ }
2375
+ };
2376
+ //#endregion
2377
+ //#region src/cli/write-gate.ts
2378
+ /**
2379
+ * Run structural validation against an in-memory TOML string.
2380
+ * On failure, prints the error, leaves the file untouched, and exits with code 1.
2381
+ * On success, returns — caller is responsible for writing.
2382
+ *
2383
+ * Use this when the write step has bespoke logic (e.g. wraps writeFileSync in Try,
2384
+ * has multi-line post-write output). Otherwise prefer `writeIfValid`.
2385
+ */
2386
+ const validateOrExit = (updated) => {
2387
+ validateRawConfig(updated).fold((err) => {
2388
+ console.error(`${RED}Error:${RESET} Aborted — change would produce an invalid config:`);
2389
+ console.error(` ${formatValidationError(err)}`);
2390
+ console.error(`${DIM}File unchanged.${RESET}`);
2391
+ process.exit(1);
2392
+ }, () => {});
2393
+ };
2394
+ /**
2395
+ * Validate then persist. Most mutating CLI commands use this — it bundles the
2396
+ * validate-or-exit gate with the writeFileSync + success log so each call site
2397
+ * stays two lines instead of five.
2398
+ */
2399
+ const writeIfValid = (configPath, updated, successMsg) => {
2400
+ validateOrExit(updated);
2401
+ writeFileSync(configPath, updated, "utf-8");
2402
+ console.log(successMsg);
2403
+ };
2404
+ //#endregion
2375
2405
  //#region src/cli/commands/env.ts
2376
2406
  const printPostWriteGuidance = () => {
2377
2407
  console.log(`\n${DIM}Note: Secret values are NOT stored — only metadata.${RESET}`);
@@ -2403,7 +2433,9 @@ const runEnvScan = (options) => {
2403
2433
  return;
2404
2434
  }
2405
2435
  const newToml = generateTomlFromScan(newEntries);
2406
- Try(() => writeFileSync(configPath, `${existing.trimEnd()}\n\n${newToml}`, "utf-8")).fold((err) => {
2436
+ const combined = `${existing.trimEnd()}\n\n${newToml}`;
2437
+ validateOrExit(combined);
2438
+ Try(() => writeFileSync(configPath, combined, "utf-8")).fold((err) => {
2407
2439
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
2408
2440
  process.exit(1);
2409
2441
  }, () => {
@@ -2411,8 +2443,9 @@ const runEnvScan = (options) => {
2411
2443
  printPostWriteGuidance();
2412
2444
  });
2413
2445
  } 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) => {
2446
+ 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;
2447
+ validateOrExit(combined);
2448
+ Try(() => writeFileSync(configPath, combined, "utf-8")).fold((err) => {
2416
2449
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
2417
2450
  process.exit(1);
2418
2451
  }, () => {
@@ -2521,8 +2554,7 @@ const runEnvAdd = (name, value, options) => {
2521
2554
  console.log(block);
2522
2555
  return;
2523
2556
  }
2524
- writeFileSync(configPath, appendSection(readFileSync(configPath, "utf-8"), block), "utf-8");
2525
- console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
2557
+ writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
2526
2558
  });
2527
2559
  });
2528
2560
  };
@@ -2557,8 +2589,7 @@ const runEnvEdit = (name, options) => {
2557
2589
  console.log(updated);
2558
2590
  return;
2559
2591
  }
2560
- writeFileSync(configPath, updated, "utf-8");
2561
- console.log(`${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
2592
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
2562
2593
  });
2563
2594
  });
2564
2595
  });
@@ -2574,8 +2605,7 @@ const runEnvRm = (name, options) => {
2574
2605
  console.log(updated);
2575
2606
  return;
2576
2607
  }
2577
- writeFileSync(configPath, updated, "utf-8");
2578
- console.log(`${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
2608
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
2579
2609
  });
2580
2610
  });
2581
2611
  };
@@ -2645,8 +2675,7 @@ const runEnvAlias = (name, options) => {
2645
2675
  return;
2646
2676
  }
2647
2677
  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}`);
2678
+ 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
2679
  });
2651
2680
  });
2652
2681
  };
@@ -2661,8 +2690,7 @@ const runEnvRename = (oldName, newName, options) => {
2661
2690
  console.log(updated);
2662
2691
  return;
2663
2692
  }
2664
- writeFileSync(configPath, updated, "utf-8");
2665
- console.log(`${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
2693
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
2666
2694
  });
2667
2695
  });
2668
2696
  };
@@ -2753,31 +2781,7 @@ const runExec = (args, options) => {
2753
2781
  //#endregion
2754
2782
  //#region src/core/fleet.ts
2755
2783
  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
- ]);
2784
+ 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
2785
  function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
2782
2786
  if (currentDepth > maxDepth) return;
2783
2787
  const configPath = join(dir, CONFIG_FILENAME$1);
@@ -2942,6 +2946,7 @@ const runInit = (dir, options) => {
2942
2946
  }, (keys) => keys);
2943
2947
  });
2944
2948
  const content = generateTemplate(options, fnoxKeys.orUndefined());
2949
+ validateOrExit(content);
2945
2950
  Try(() => writeFileSync(outPath, content, "utf-8")).fold((err) => {
2946
2951
  console.error(`${RED}Error:${RESET} Failed to write ${CONFIG_FILENAME}: ${err}`);
2947
2952
  process.exit(1);
@@ -3364,24 +3369,19 @@ const handleGetSecretMeta = (args) => {
3364
3369
  const secretEntries = config.secret ?? {};
3365
3370
  return Option(secretEntries[key]).fold(() => errorResult(`Secret not found: ${key}`), (meta) => {
3366
3371
  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
- }
3372
+ if (fromKey !== void 0) return Option(fromKey.match(/^secret\.(.+)$/)?.[1]).flatMap((k) => Option(secretEntries[k])).fold(() => textResult(JSON.stringify({
3373
+ key,
3374
+ ...rest,
3375
+ alias_of: fromKey
3376
+ }, null, 2)), (t) => {
3377
+ const { encrypted_value: __, from_key: ___, ...targetRest } = t;
3379
3378
  return textResult(JSON.stringify({
3380
3379
  key,
3380
+ ...targetRest,
3381
3381
  ...rest,
3382
3382
  alias_of: fromKey
3383
3383
  }, null, 2));
3384
- }
3384
+ });
3385
3385
  return textResult(JSON.stringify({
3386
3386
  key,
3387
3387
  ...rest
@@ -3540,75 +3540,97 @@ const resolveValues = async (keys, profile, agentKey) => {
3540
3540
  };
3541
3541
  //#endregion
3542
3542
  //#region src/cli/commands/seal.ts
3543
- /** Write sealed values back into the TOML file, preserving structure */
3543
+ const META_SECTION_RE = /^\[secret\.(.+)\]\s*$/;
3544
+ const ENCRYPTED_VALUE_RE = /^encrypted_value\s*=/;
3545
+ const NEW_SECTION_RE = /^\[/;
3546
+ const MULTILINE_DELIM = "\"\"\"";
3547
+ /** Write sealed values back into the TOML file, preserving structure. */
3544
3548
  const writeSealedToml = (configPath, sealedMeta) => {
3545
3549
  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);
3550
+ const getSeal = (key) => Option(sealedMeta[key]?.encrypted_value);
3551
+ const isPending = (state, key) => !getSeal(key).isEmpty && !state.consumedKeys.has(key);
3552
+ const sealLinesFor = (key) => getSeal(key).fold(() => [], (v) => [
3553
+ `encrypted_value = """`,
3554
+ v,
3555
+ `"""`
3556
+ ]);
3557
+ /** Append a seal block to `output` if the current section is pending and hasn't already got one. */
3558
+ const flushPending = (state) => state.currentMetaKey.fold(() => state, (key) => {
3559
+ if (state.hasEncryptedValue || !isPending(state, key)) return state;
3560
+ return {
3561
+ ...state,
3562
+ output: [
3563
+ ...state.output,
3564
+ ...sealLinesFor(key),
3565
+ ""
3566
+ ],
3567
+ consumedKeys: state.consumedKeys.add(key)
3568
+ };
3553
3569
  });
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);
3570
+ const step = (state, line, i) => {
3571
+ if (i <= state.skipUntil) return state;
3572
+ const metaMatch = line.match(META_SECTION_RE);
3571
3573
  if (metaMatch) {
3572
- flushPending();
3573
- currentMetaKey = Option(metaMatch[1]);
3574
- insideMetaBlock = true;
3575
- hasEncryptedValue = false;
3576
- output.push(line);
3577
- continue;
3574
+ const flushed = flushPending(state);
3575
+ return {
3576
+ ...flushed,
3577
+ output: [...flushed.output, line],
3578
+ currentMetaKey: Option(metaMatch[1]),
3579
+ insideMetaBlock: true,
3580
+ hasEncryptedValue: false
3581
+ };
3578
3582
  }
3579
- if (insideMetaBlock && newSectionRe.test(line) && !metaSectionRe.test(line)) {
3580
- flushPending();
3581
- insideMetaBlock = false;
3582
- currentMetaKey = Option.none();
3583
- output.push(line);
3584
- continue;
3583
+ if (state.insideMetaBlock && NEW_SECTION_RE.test(line) && !META_SECTION_RE.test(line)) {
3584
+ const flushed = flushPending(state);
3585
+ return {
3586
+ ...flushed,
3587
+ output: [...flushed.output, line],
3588
+ insideMetaBlock: false,
3589
+ currentMetaKey: Option.none()
3590
+ };
3585
3591
  }
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;
3592
+ if (state.insideMetaBlock && ENCRYPTED_VALUE_RE.test(line)) {
3593
+ const replacingKey = state.currentMetaKey.filter((k) => isPending(state, k));
3594
+ const replacement = replacingKey.fold(() => [line], (key) => sealLinesFor(key));
3595
+ const consumedKeys = replacingKey.fold(() => state.consumedKeys, (key) => state.consumedKeys.add(key));
3596
+ const replacing = !replacingKey.isEmpty;
3597
+ if (!line.slice(line.indexOf("=") + 1).trim().includes(MULTILINE_DELIM)) return {
3598
+ ...state,
3599
+ output: [...state.output, ...replacement],
3600
+ hasEncryptedValue: true,
3601
+ consumedKeys
3602
+ };
3603
+ const closingIdx = lines.findIndex((l, j) => j > i && l.includes(MULTILINE_DELIM));
3604
+ const effectiveEnd = closingIdx === -1 ? lines.length - 1 : closingIdx;
3605
+ const continuation = replacing ? [] : lines.slice(i + 1, effectiveEnd + 1);
3606
+ return {
3607
+ ...state,
3608
+ output: [
3609
+ ...state.output,
3610
+ ...replacement,
3611
+ ...continuation
3612
+ ],
3613
+ hasEncryptedValue: true,
3614
+ consumedKeys,
3615
+ skipUntil: effectiveEnd
3616
+ };
3607
3617
  }
3608
- output.push(line);
3609
- }
3610
- flushPending();
3611
- writeFileSync(configPath, output.join("\n"));
3618
+ return {
3619
+ ...state,
3620
+ output: [...state.output, line]
3621
+ };
3622
+ };
3623
+ const initial = {
3624
+ output: [],
3625
+ currentMetaKey: Option.none(),
3626
+ insideMetaBlock: false,
3627
+ hasEncryptedValue: false,
3628
+ consumedKeys: Set$1.empty(),
3629
+ skipUntil: -1
3630
+ };
3631
+ const finalContent = flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3632
+ validateOrExit(finalContent);
3633
+ writeFileSync(configPath, finalContent);
3612
3634
  };
3613
3635
  const runSeal = async (options) => {
3614
3636
  const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
@@ -3846,8 +3868,7 @@ const runSecretAdd = (name, options) => {
3846
3868
  console.log(block);
3847
3869
  return;
3848
3870
  }
3849
- writeFileSync(configPath, appendSection(readFileSync(configPath, "utf-8"), block), "utf-8");
3850
- console.log(`${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3871
+ writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3851
3872
  });
3852
3873
  });
3853
3874
  };
@@ -3879,8 +3900,7 @@ const runSecretEdit = (name, options) => {
3879
3900
  console.log(updated);
3880
3901
  return;
3881
3902
  }
3882
- writeFileSync(configPath, updated, "utf-8");
3883
- console.log(`${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
3903
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
3884
3904
  });
3885
3905
  });
3886
3906
  });
@@ -3896,8 +3916,7 @@ const runSecretRm = (name, options) => {
3896
3916
  console.log(updated);
3897
3917
  return;
3898
3918
  }
3899
- writeFileSync(configPath, updated, "utf-8");
3900
- console.log(`${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
3919
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
3901
3920
  });
3902
3921
  });
3903
3922
  };
@@ -3912,8 +3931,7 @@ const runSecretRename = (oldName, newName, options) => {
3912
3931
  console.log(updated);
3913
3932
  return;
3914
3933
  }
3915
- writeFileSync(configPath, updated, "utf-8");
3916
- console.log(`${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
3934
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
3917
3935
  });
3918
3936
  });
3919
3937
  };
@@ -3983,8 +4001,7 @@ const runSecretAlias = (name, options) => {
3983
4001
  return;
3984
4002
  }
3985
4003
  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}`);
4004
+ 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}`);
3988
4005
  });
3989
4006
  });
3990
4007
  };
@@ -4102,6 +4119,192 @@ const runUpgrade = () => {
4102
4119
  else console.log(`\n${GREEN}✓${RESET} Upgraded ${YELLOW}${before}${RESET} → ${BOLD}${after}${RESET}`);
4103
4120
  };
4104
4121
  //#endregion
4122
+ //#region src/cli/commands/validate.ts
4123
+ const SEAL_BEGIN = "-----BEGIN AGE ENCRYPTED FILE-----";
4124
+ const SEAL_END = "-----END AGE ENCRYPTED FILE-----";
4125
+ const CHECK_NAMES = {
4126
+ toml: "TOML syntax",
4127
+ schema: "Schema",
4128
+ catalog: "Catalog",
4129
+ aliases: "Aliases",
4130
+ sealed: "Sealed blocks"
4131
+ };
4132
+ /** Structural sanity scan over each [secret.*].encrypted_value PEM block. No decryption. */
4133
+ const checkSealedBlocks = (config) => {
4134
+ const secrets = config.secret ?? {};
4135
+ const sealed = Object.entries(secrets).filter(([, meta]) => typeof meta.encrypted_value === "string");
4136
+ if (sealed.length === 0) return {
4137
+ name: CHECK_NAMES.sealed,
4138
+ status: "na",
4139
+ detail: "no sealed values"
4140
+ };
4141
+ const broken = sealed.flatMap(([key, meta]) => {
4142
+ const raw = (meta.encrypted_value ?? "").trim();
4143
+ if (!raw.startsWith(SEAL_BEGIN)) return [`[secret.${key}] encrypted_value missing BEGIN marker`];
4144
+ if (!raw.endsWith(SEAL_END)) return [`[secret.${key}] encrypted_value missing END marker`];
4145
+ return [];
4146
+ });
4147
+ if (broken.length === 0) return {
4148
+ name: CHECK_NAMES.sealed,
4149
+ status: "ok",
4150
+ detail: `${sealed.length} sealed value(s)`
4151
+ };
4152
+ return {
4153
+ name: CHECK_NAMES.sealed,
4154
+ status: "failed",
4155
+ error: broken.join("; ")
4156
+ };
4157
+ };
4158
+ const skipped = (name) => ({
4159
+ name,
4160
+ status: "skipped"
4161
+ });
4162
+ const buildReport = (path, raw) => {
4163
+ return parseToml(raw).fold((err) => ({
4164
+ ok: false,
4165
+ configPath: path,
4166
+ checks: [
4167
+ {
4168
+ name: CHECK_NAMES.toml,
4169
+ status: "failed",
4170
+ error: formatValidationError(err)
4171
+ },
4172
+ skipped(CHECK_NAMES.schema),
4173
+ skipped(CHECK_NAMES.catalog),
4174
+ skipped(CHECK_NAMES.aliases),
4175
+ skipped(CHECK_NAMES.sealed)
4176
+ ]
4177
+ }), (data) => {
4178
+ const tomlOk = {
4179
+ name: CHECK_NAMES.toml,
4180
+ status: "ok"
4181
+ };
4182
+ return validateConfig(data).fold((err) => ({
4183
+ ok: false,
4184
+ configPath: path,
4185
+ checks: [
4186
+ tomlOk,
4187
+ {
4188
+ name: CHECK_NAMES.schema,
4189
+ status: "failed",
4190
+ error: formatValidationError(err)
4191
+ },
4192
+ skipped(CHECK_NAMES.catalog),
4193
+ skipped(CHECK_NAMES.aliases),
4194
+ skipped(CHECK_NAMES.sealed)
4195
+ ]
4196
+ }), (config) => {
4197
+ const checks = [
4198
+ tomlOk,
4199
+ {
4200
+ name: CHECK_NAMES.schema,
4201
+ status: "ok"
4202
+ },
4203
+ config.catalog ? resolveConfig(config, dirname(path)).fold((err) => ({
4204
+ name: CHECK_NAMES.catalog,
4205
+ status: "failed",
4206
+ error: formatError(err)
4207
+ }), () => ({
4208
+ name: CHECK_NAMES.catalog,
4209
+ status: "ok",
4210
+ detail: config.catalog
4211
+ })) : {
4212
+ name: CHECK_NAMES.catalog,
4213
+ status: "na",
4214
+ detail: "no catalog declared"
4215
+ },
4216
+ validateAliases(config).fold((err) => ({
4217
+ name: CHECK_NAMES.aliases,
4218
+ status: "failed",
4219
+ error: formatValidationError(err)
4220
+ }), (table) => ({
4221
+ name: CHECK_NAMES.aliases,
4222
+ status: "ok",
4223
+ detail: `${table.entries.size} alias(es)`
4224
+ })),
4225
+ checkSealedBlocks(config)
4226
+ ];
4227
+ return {
4228
+ ok: checks.every((c) => c.status === "ok" || c.status === "na"),
4229
+ configPath: path,
4230
+ checks
4231
+ };
4232
+ });
4233
+ });
4234
+ };
4235
+ const renderCheckLines = (check) => {
4236
+ switch (check.status) {
4237
+ case "ok": {
4238
+ const detail = check.detail ? ` ${DIM}(${check.detail})${RESET}` : "";
4239
+ return [` ${GREEN}✓${RESET} ${check.name}${detail}`];
4240
+ }
4241
+ case "failed": return check.error ? [` ${RED}✗${RESET} ${check.name}`, ` ${RED}${check.error}${RESET}`] : [` ${RED}✗${RESET} ${check.name}`];
4242
+ case "skipped": return [` ${DIM}○ ${check.name} (skipped — prior check failed)${RESET}`];
4243
+ case "na": return [` ${DIM}— ${check.name} (${check.detail ?? "not applicable"})${RESET}`];
4244
+ }
4245
+ };
4246
+ const formatTextReport = (report, sourceMsg) => {
4247
+ const header = `${report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`} ${BOLD}${report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`}${RESET} — ${CYAN}${report.configPath}${RESET}`;
4248
+ const checkLines = report.checks.flatMap((c) => [...renderCheckLines(c)]);
4249
+ return [
4250
+ ...sourceMsg ? [sourceMsg] : [],
4251
+ header,
4252
+ "",
4253
+ ...checkLines
4254
+ ].join("\n");
4255
+ };
4256
+ const formatJsonReport = (report) => JSON.stringify({
4257
+ ok: report.ok,
4258
+ configPath: report.configPath,
4259
+ checks: report.checks.map((c) => ({
4260
+ name: c.name,
4261
+ status: c.status,
4262
+ error: c.error ?? null,
4263
+ detail: c.detail ?? null
4264
+ }))
4265
+ }, null, 2);
4266
+ const emit = (report, sourceMsg, asJson) => {
4267
+ if (asJson) {
4268
+ console.log(formatJsonReport(report));
4269
+ return;
4270
+ }
4271
+ if (report.ok) console.log(formatTextReport(report, sourceMsg));
4272
+ else console.error(formatTextReport(report, sourceMsg));
4273
+ };
4274
+ const runValidate = (options) => {
4275
+ resolveConfigPath(options.config).fold((err) => {
4276
+ if (options.json) console.log(JSON.stringify({
4277
+ ok: false,
4278
+ configPath: "path" in err ? err.path : null,
4279
+ checks: [{
4280
+ name: "Config file",
4281
+ status: "failed",
4282
+ error: formatValidationError(err)
4283
+ }]
4284
+ }, null, 2));
4285
+ else console.error(formatError(err));
4286
+ process.exit(2);
4287
+ }, ({ path, source }) => {
4288
+ const sourceMsg = formatConfigSource(path, source);
4289
+ Try(() => readFileSync(path, "utf-8")).fold((e) => {
4290
+ emit({
4291
+ ok: false,
4292
+ configPath: path,
4293
+ checks: [{
4294
+ name: "Read",
4295
+ status: "failed",
4296
+ error: e.message
4297
+ }]
4298
+ }, sourceMsg, options.json === true);
4299
+ process.exit(2);
4300
+ }, (raw) => {
4301
+ const report = buildReport(path, raw);
4302
+ emit(report, sourceMsg, options.json === true);
4303
+ process.exit(report.ok ? 0 : 1);
4304
+ });
4305
+ });
4306
+ };
4307
+ //#endregion
4105
4308
  //#region src/cli/index.ts
4106
4309
  const program = new Command();
4107
4310
  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 +4328,9 @@ program.command("audit").description("Audit credential health from envpkt.toml (
4125
4328
  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
4329
  runFleet(options);
4127
4330
  });
4331
+ 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) => {
4332
+ runValidate(options);
4333
+ });
4128
4334
  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
4335
  runInspect(options);
4130
4336
  });