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