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 +496 -290
- package/dist/index.d.ts +99 -113
- package/dist/index.js +143 -207
- package/package.json +8 -8
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:
|
|
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 =
|
|
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 =
|
|
91
|
+
const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
|
|
92
92
|
const parseTargetKey = (from_key) => {
|
|
93
|
-
return /^secret\.(.+)
|
|
93
|
+
return Option(from_key.match(/^secret\.(.+)$/)?.[1]);
|
|
94
94
|
};
|
|
95
95
|
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
|
438
|
+
const merged = Option(agentMeta[key]).fold(() => catalogEntry, (override) => ({
|
|
439
|
+
...catalogEntry,
|
|
440
|
+
...override
|
|
441
|
+
}));
|
|
436
442
|
return Right({
|
|
437
443
|
...resolved,
|
|
438
|
-
[key]:
|
|
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).
|
|
904
|
+
return parseRef(ref).toEither({
|
|
902
905
|
_tag: "AliasInvalidSyntax",
|
|
903
906
|
key,
|
|
904
907
|
kind: "secret",
|
|
905
908
|
value: ref
|
|
906
|
-
})
|
|
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]).
|
|
920
|
+
return Option(secretEntries[parsed.key]).toEither({
|
|
918
921
|
_tag: "AliasTargetMissing",
|
|
919
922
|
key: `secret.${key}`,
|
|
920
923
|
target: ref
|
|
921
|
-
})
|
|
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(
|
|
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).
|
|
947
|
+
return parseRef(ref).toEither({
|
|
945
948
|
_tag: "AliasInvalidSyntax",
|
|
946
949
|
key,
|
|
947
950
|
kind: "env",
|
|
948
951
|
value: ref
|
|
949
|
-
})
|
|
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]).
|
|
963
|
+
return Option(envEntries[parsed.key]).toEither({
|
|
961
964
|
_tag: "AliasTargetMissing",
|
|
962
965
|
key: `env.${key}`,
|
|
963
966
|
target: ref
|
|
964
|
-
})
|
|
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(
|
|
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
|
-
|
|
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
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1189
|
+
const value = values[key];
|
|
1190
|
+
if (value === void 0) return Right({
|
|
1201
1191
|
...result,
|
|
1202
1192
|
[key]: secretMeta
|
|
1203
1193
|
});
|
|
1204
|
-
return ageEncrypt(
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
-
}
|
|
1386
|
-
|
|
1387
|
-
|
|
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 =
|
|
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)\.(.+)
|
|
2142
|
-
if (
|
|
2143
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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
|
-
|
|
2280
|
-
const
|
|
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
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
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
|
-
|
|
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
|
|
2415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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
|
-
|
|
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
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
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
|
|
3555
|
-
|
|
3556
|
-
|
|
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
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
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 &&
|
|
3580
|
-
flushPending();
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
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 &&
|
|
3587
|
-
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
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
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|