envpkt 0.8.1 → 0.10.1
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/README.md +30 -6
- package/dist/cli.js +431 -30
- package/dist/index.d.ts +76 -8
- package/dist/index.js +312 -31
- package/package.json +25 -23
- package/schemas/envpkt.schema.json +9 -4
package/dist/cli.js
CHANGED
|
@@ -54,10 +54,28 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
54
54
|
purpose,
|
|
55
55
|
created: Option(meta.created),
|
|
56
56
|
expires: Option(meta.expires),
|
|
57
|
-
issues: List(issues)
|
|
57
|
+
issues: List(issues),
|
|
58
|
+
alias_of: Option(void 0)
|
|
58
59
|
};
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Build a SecretHealth row for an alias entry. Status is inherited from the
|
|
63
|
+
* target; metadata (purpose, tags) comes from the alias entry itself where
|
|
64
|
+
* set, otherwise falls through to the target so operators see context.
|
|
65
|
+
*/
|
|
66
|
+
const classifyAlias = (key, meta, targetHealth, targetRef) => ({
|
|
67
|
+
key,
|
|
68
|
+
service: targetHealth.service,
|
|
69
|
+
status: targetHealth.status,
|
|
70
|
+
days_remaining: targetHealth.days_remaining,
|
|
71
|
+
rotation_url: targetHealth.rotation_url,
|
|
72
|
+
purpose: meta.purpose !== void 0 ? Option(meta.purpose) : targetHealth.purpose,
|
|
73
|
+
created: targetHealth.created,
|
|
74
|
+
expires: targetHealth.expires,
|
|
75
|
+
issues: List([]),
|
|
76
|
+
alias_of: Option(targetRef)
|
|
77
|
+
});
|
|
78
|
+
const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
61
79
|
const now = today ?? /* @__PURE__ */ new Date();
|
|
62
80
|
const lifecycle = config.lifecycle ?? {};
|
|
63
81
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
@@ -65,9 +83,31 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
65
83
|
const requireService = lifecycle.require_service ?? false;
|
|
66
84
|
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
67
85
|
const secretEntries = config.secret ?? {};
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
86
|
+
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
87
|
+
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
88
|
+
const nonAliasMetaKeys = new Set(nonAliasEntries.map(([k]) => k));
|
|
89
|
+
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
90
|
+
const healthByKey = new Map(nonAliasHealth.map((h) => [h.key, h]));
|
|
91
|
+
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
92
|
+
const targetKey = (aliasTable?.entries.get(`secret.${key}`))?.targetKey;
|
|
93
|
+
const targetHealth = targetKey !== void 0 ? healthByKey.get(targetKey) : void 0;
|
|
94
|
+
const targetRef = meta.from_key ?? (targetKey !== void 0 ? `secret.${targetKey}` : "");
|
|
95
|
+
if (!targetHealth) return {
|
|
96
|
+
key,
|
|
97
|
+
service: Option(meta.service),
|
|
98
|
+
status: "missing",
|
|
99
|
+
days_remaining: Option(void 0),
|
|
100
|
+
rotation_url: Option(meta.rotation_url),
|
|
101
|
+
purpose: Option(meta.purpose),
|
|
102
|
+
created: Option(meta.created),
|
|
103
|
+
expires: Option(meta.expires),
|
|
104
|
+
issues: List(["Alias target not resolvable"]),
|
|
105
|
+
alias_of: Option(targetRef)
|
|
106
|
+
};
|
|
107
|
+
return classifyAlias(key, meta, targetHealth, targetRef);
|
|
108
|
+
});
|
|
109
|
+
const secrets = List([...nonAliasHealth, ...aliasHealth]);
|
|
110
|
+
const orphaned = keys.size > 0 ? [...nonAliasMetaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
71
111
|
const total = secrets.size;
|
|
72
112
|
const expired = secrets.count((s) => s.status === "expired");
|
|
73
113
|
const missing = secrets.count((s) => s.status === "missing");
|
|
@@ -75,6 +115,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
75
115
|
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
76
116
|
const stale = secrets.count((s) => s.status === "stale");
|
|
77
117
|
const healthy = secrets.count((s) => s.status === "healthy");
|
|
118
|
+
const aliases = aliasHealth.length;
|
|
78
119
|
return {
|
|
79
120
|
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
80
121
|
secrets,
|
|
@@ -86,6 +127,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
86
127
|
missing,
|
|
87
128
|
missing_metadata,
|
|
88
129
|
orphaned,
|
|
130
|
+
aliases,
|
|
89
131
|
identity: config.identity
|
|
90
132
|
};
|
|
91
133
|
};
|
|
@@ -93,13 +135,17 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
93
135
|
const envEntries = config.env ?? {};
|
|
94
136
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
95
137
|
const currentValue = env[key];
|
|
96
|
-
const
|
|
138
|
+
const effectiveDefault = entry.from_key !== void 0 ? (() => {
|
|
139
|
+
const targetKey = /^env\.(.+)$/.exec(entry.from_key)?.[1];
|
|
140
|
+
return (targetKey !== void 0 ? envEntries[targetKey] : void 0)?.value ?? "";
|
|
141
|
+
})() : entry.value ?? "";
|
|
97
142
|
return {
|
|
98
143
|
key,
|
|
99
|
-
defaultValue:
|
|
144
|
+
defaultValue: effectiveDefault,
|
|
100
145
|
currentValue,
|
|
101
|
-
status,
|
|
102
|
-
purpose: entry.purpose
|
|
146
|
+
status: Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== effectiveDefault, "overridden").else("default"),
|
|
147
|
+
purpose: entry.purpose,
|
|
148
|
+
alias_of: Option(entry.from_key)
|
|
103
149
|
};
|
|
104
150
|
});
|
|
105
151
|
return {
|
|
@@ -158,6 +204,7 @@ const SecretMetaSchema = Type.Object({
|
|
|
158
204
|
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
159
205
|
source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
|
|
160
206
|
encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
|
|
207
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value." })),
|
|
161
208
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
162
209
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
163
210
|
}, { description: "Metadata about a single secret" });
|
|
@@ -182,7 +229,8 @@ const CallbackConfigSchema = Type.Object({
|
|
|
182
229
|
}, { description: "Automation callbacks for lifecycle events" });
|
|
183
230
|
const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
|
|
184
231
|
const EnvMetaSchema = Type.Object({
|
|
185
|
-
value: Type.String({ description: "Default value for this environment variable" }),
|
|
232
|
+
value: Type.Optional(Type.String({ description: "Default value for this environment variable. Optional when from_key is set; required otherwise." })),
|
|
233
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'env.<KEY>') whose resolved value this alias reuses. Mutually exclusive with value." })),
|
|
186
234
|
purpose: Type.Optional(Type.String({ description: "Why this env var exists" })),
|
|
187
235
|
comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
|
|
188
236
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
@@ -822,6 +870,140 @@ const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((er
|
|
|
822
870
|
/** Extract the set of secret key names from a parsed fnox config */
|
|
823
871
|
const extractFnoxKeys = (config) => new Set(Object.keys(config.secrets));
|
|
824
872
|
//#endregion
|
|
873
|
+
//#region src/core/alias.ts
|
|
874
|
+
const ALIAS_REF_RE$2 = /^(secret|env)\.(.+)$/;
|
|
875
|
+
const parseRef = (raw) => {
|
|
876
|
+
const match = ALIAS_REF_RE$2.exec(raw);
|
|
877
|
+
if (!match) return Option(void 0);
|
|
878
|
+
const kind = match[1];
|
|
879
|
+
const key = match[2];
|
|
880
|
+
if (!key) return Option(void 0);
|
|
881
|
+
return Option({
|
|
882
|
+
kind,
|
|
883
|
+
key
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
const validateOneSecret = (key, meta, secretEntries) => {
|
|
887
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
888
|
+
const ref = meta.from_key;
|
|
889
|
+
if (meta.encrypted_value !== void 0) return Left({
|
|
890
|
+
_tag: "AliasValueConflict",
|
|
891
|
+
key,
|
|
892
|
+
kind: "secret",
|
|
893
|
+
field: "encrypted_value"
|
|
894
|
+
});
|
|
895
|
+
return parseRef(ref).fold(() => Left({
|
|
896
|
+
_tag: "AliasInvalidSyntax",
|
|
897
|
+
key,
|
|
898
|
+
kind: "secret",
|
|
899
|
+
value: ref
|
|
900
|
+
}), (parsed) => {
|
|
901
|
+
if (parsed.kind !== "secret") return Left({
|
|
902
|
+
_tag: "AliasCrossType",
|
|
903
|
+
key,
|
|
904
|
+
kind: "secret",
|
|
905
|
+
targetKind: parsed.kind
|
|
906
|
+
});
|
|
907
|
+
if (parsed.key === key) return Left({
|
|
908
|
+
_tag: "AliasSelfReference",
|
|
909
|
+
key: `secret.${key}`
|
|
910
|
+
});
|
|
911
|
+
return Option(secretEntries[parsed.key]).fold(() => Left({
|
|
912
|
+
_tag: "AliasTargetMissing",
|
|
913
|
+
key: `secret.${key}`,
|
|
914
|
+
target: ref
|
|
915
|
+
}), (target) => {
|
|
916
|
+
if (target.from_key !== void 0) return Left({
|
|
917
|
+
_tag: "AliasChained",
|
|
918
|
+
key: `secret.${key}`,
|
|
919
|
+
target: ref
|
|
920
|
+
});
|
|
921
|
+
return Right(Option({
|
|
922
|
+
kind: "secret",
|
|
923
|
+
targetKind: "secret",
|
|
924
|
+
targetKey: parsed.key
|
|
925
|
+
}));
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
};
|
|
929
|
+
const validateOneEnv = (key, meta, envEntries) => {
|
|
930
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
931
|
+
const ref = meta.from_key;
|
|
932
|
+
if (meta.value !== void 0) return Left({
|
|
933
|
+
_tag: "AliasValueConflict",
|
|
934
|
+
key,
|
|
935
|
+
kind: "env",
|
|
936
|
+
field: "value"
|
|
937
|
+
});
|
|
938
|
+
return parseRef(ref).fold(() => Left({
|
|
939
|
+
_tag: "AliasInvalidSyntax",
|
|
940
|
+
key,
|
|
941
|
+
kind: "env",
|
|
942
|
+
value: ref
|
|
943
|
+
}), (parsed) => {
|
|
944
|
+
if (parsed.kind !== "env") return Left({
|
|
945
|
+
_tag: "AliasCrossType",
|
|
946
|
+
key,
|
|
947
|
+
kind: "env",
|
|
948
|
+
targetKind: parsed.kind
|
|
949
|
+
});
|
|
950
|
+
if (parsed.key === key) return Left({
|
|
951
|
+
_tag: "AliasSelfReference",
|
|
952
|
+
key: `env.${key}`
|
|
953
|
+
});
|
|
954
|
+
return Option(envEntries[parsed.key]).fold(() => Left({
|
|
955
|
+
_tag: "AliasTargetMissing",
|
|
956
|
+
key: `env.${key}`,
|
|
957
|
+
target: ref
|
|
958
|
+
}), (target) => {
|
|
959
|
+
if (target.from_key !== void 0) return Left({
|
|
960
|
+
_tag: "AliasChained",
|
|
961
|
+
key: `env.${key}`,
|
|
962
|
+
target: ref
|
|
963
|
+
});
|
|
964
|
+
return Right(Option({
|
|
965
|
+
kind: "env",
|
|
966
|
+
targetKind: "env",
|
|
967
|
+
targetKey: parsed.key
|
|
968
|
+
}));
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
};
|
|
972
|
+
/**
|
|
973
|
+
* Validate all `from_key` references in a resolved config. Produces an
|
|
974
|
+
* AliasTable mapping each alias to its target, or an AliasError describing
|
|
975
|
+
* the first failure.
|
|
976
|
+
*
|
|
977
|
+
* Rules:
|
|
978
|
+
* - Ref must be "secret.<KEY>" or "env.<KEY>"
|
|
979
|
+
* - Target must exist in the same resolved config
|
|
980
|
+
* - Target must be the same type (secret→secret, env→env only)
|
|
981
|
+
* - Target must not itself be a from_key entry (single hop only)
|
|
982
|
+
* - Self-reference is rejected
|
|
983
|
+
* - An alias entry cannot also carry a value field (encrypted_value for
|
|
984
|
+
* secrets, value for env)
|
|
985
|
+
*/
|
|
986
|
+
const validateAliases = (config) => {
|
|
987
|
+
const secretEntries = config.secret ?? {};
|
|
988
|
+
const envEntries = config.env ?? {};
|
|
989
|
+
const entries = /* @__PURE__ */ new Map();
|
|
990
|
+
const secretResults = Object.entries(secretEntries).map(([key, meta]) => [key, validateOneSecret(key, meta, secretEntries)]);
|
|
991
|
+
for (const [key, result] of secretResults) {
|
|
992
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
993
|
+
if (outcome === void 0) continue;
|
|
994
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
995
|
+
entries.set(`secret.${key}`, outcome);
|
|
996
|
+
}
|
|
997
|
+
const envResults = Object.entries(envEntries).map(([key, meta]) => [key, validateOneEnv(key, meta, envEntries)]);
|
|
998
|
+
for (const [key, result] of envResults) {
|
|
999
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
1000
|
+
if (outcome === void 0) continue;
|
|
1001
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
1002
|
+
entries.set(`env.${key}`, outcome);
|
|
1003
|
+
}
|
|
1004
|
+
return Right({ entries });
|
|
1005
|
+
};
|
|
1006
|
+
//#endregion
|
|
825
1007
|
//#region src/core/keygen.ts
|
|
826
1008
|
/** Resolve the age identity file path: ENVPKT_AGE_KEY_FILE env var > ~/.envpkt/age-key.txt */
|
|
827
1009
|
const resolveKeyPath = () => process.env["ENVPKT_AGE_KEY_FILE"] ?? join(homedir(), ".envpkt", "age-key.txt");
|
|
@@ -1089,7 +1271,7 @@ const looksLikeSecret = (value) => {
|
|
|
1089
1271
|
};
|
|
1090
1272
|
const checkEnvMisclassification = (config) => {
|
|
1091
1273
|
const envEntries = config.env ?? {};
|
|
1092
|
-
return Object.entries(envEntries).filter(([, entry]) => looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1274
|
+
return Object.entries(envEntries).filter(([, entry]) => entry.value !== void 0 && looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1093
1275
|
};
|
|
1094
1276
|
/** Programmatic boot — returns Either<BootError, BootResult> */
|
|
1095
1277
|
const bootSafe = (options) => {
|
|
@@ -1097,26 +1279,29 @@ const bootSafe = (options) => {
|
|
|
1097
1279
|
const inject = opts.inject !== false;
|
|
1098
1280
|
const failOnExpired = opts.failOnExpired !== false;
|
|
1099
1281
|
const warnOnly = opts.warnOnly ?? false;
|
|
1100
|
-
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => {
|
|
1282
|
+
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => validateAliases(config).fold((err) => Left(err), (aliasTable) => {
|
|
1101
1283
|
const secretEntries = config.secret ?? {};
|
|
1102
|
-
const
|
|
1103
|
-
const
|
|
1284
|
+
const envEntries = config.env ?? {};
|
|
1285
|
+
const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
|
|
1286
|
+
const aliasSecretKeys = Object.keys(secretEntries).filter((k) => secretEntries[k].from_key !== void 0);
|
|
1287
|
+
const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
1288
|
+
const aliasEnvKeys = Object.keys(envEntries).filter((k) => envEntries[k].from_key !== void 0);
|
|
1289
|
+
const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
|
|
1290
|
+
const hasSealedValues = nonAliasMetaKeys.some((k) => !!nonAliasSecretEntries[k].encrypted_value);
|
|
1104
1291
|
const identityKeyResult = resolveIdentityKey(config, configDir);
|
|
1105
1292
|
const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
|
|
1106
1293
|
if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
|
|
1107
1294
|
_tag: "ReadError",
|
|
1108
1295
|
message: "unexpected"
|
|
1109
1296
|
}));
|
|
1110
|
-
const audit = computeAudit(config, detectFnoxKeys(configDir));
|
|
1297
|
+
const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
|
|
1111
1298
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1112
1299
|
const secrets = {};
|
|
1113
1300
|
const injected = [];
|
|
1114
1301
|
const skipped = [];
|
|
1115
1302
|
warnings.push(...checkEnvMisclassification(config));
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
const envDefaults = Object.fromEntries(envEntriesArr.flatMap(([key, entry]) => Option(process.env[key]).fold(() => [[key, entry.value]], () => [])));
|
|
1119
|
-
const overridden = envEntriesArr.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1303
|
+
const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => entry.value !== void 0 ? [[key, entry.value]] : [], () => [])));
|
|
1304
|
+
const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1120
1305
|
if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1121
1306
|
process.env[key] = value;
|
|
1122
1307
|
});
|
|
@@ -1125,7 +1310,7 @@ const bootSafe = (options) => {
|
|
|
1125
1310
|
if (hasSealedValues) identityFilePath.fold(() => {
|
|
1126
1311
|
warnings.push("Sealed values found but no identity file available for decryption");
|
|
1127
1312
|
}, (idPath) => {
|
|
1128
|
-
unsealSecrets(
|
|
1313
|
+
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
1129
1314
|
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1130
1315
|
}, (unsealed) => {
|
|
1131
1316
|
const unsealedEntries = Object.entries(unsealed);
|
|
@@ -1134,7 +1319,7 @@ const bootSafe = (options) => {
|
|
|
1134
1319
|
unsealedEntries.map(([key]) => key).forEach((key) => sealedKeys.add(key));
|
|
1135
1320
|
});
|
|
1136
1321
|
});
|
|
1137
|
-
const remainingKeys =
|
|
1322
|
+
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1138
1323
|
if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1139
1324
|
warnings.push(`fnox export failed: ${err.message}`);
|
|
1140
1325
|
skipped.push(...remainingKeys);
|
|
@@ -1152,9 +1337,34 @@ const bootSafe = (options) => {
|
|
|
1152
1337
|
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1153
1338
|
skipped.push(...remainingKeys);
|
|
1154
1339
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1340
|
+
aliasSecretKeys.forEach((aliasKey) => {
|
|
1341
|
+
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
1342
|
+
if (!entry) return;
|
|
1343
|
+
const targetValue = secrets[entry.targetKey];
|
|
1344
|
+
if (targetValue !== void 0) {
|
|
1345
|
+
secrets[aliasKey] = targetValue;
|
|
1346
|
+
injected.push(aliasKey);
|
|
1347
|
+
} else skipped.push(aliasKey);
|
|
1157
1348
|
});
|
|
1349
|
+
aliasEnvKeys.forEach((aliasKey) => {
|
|
1350
|
+
const entry = aliasTable.entries.get(`env.${aliasKey}`);
|
|
1351
|
+
if (!entry) return;
|
|
1352
|
+
if (process.env[aliasKey] !== void 0) {
|
|
1353
|
+
overridden.push(aliasKey);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const targetEntry = envEntries[entry.targetKey];
|
|
1357
|
+
if (targetEntry?.value === void 0) return;
|
|
1358
|
+
envDefaults[aliasKey] = process.env[entry.targetKey] ?? targetEntry.value;
|
|
1359
|
+
});
|
|
1360
|
+
if (inject) {
|
|
1361
|
+
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1362
|
+
process.env[key] ??= value;
|
|
1363
|
+
});
|
|
1364
|
+
Object.entries(secrets).forEach(([key, value]) => {
|
|
1365
|
+
process.env[key] = value;
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1158
1368
|
return {
|
|
1159
1369
|
audit,
|
|
1160
1370
|
injected,
|
|
@@ -1167,7 +1377,7 @@ const bootSafe = (options) => {
|
|
|
1167
1377
|
configSource
|
|
1168
1378
|
};
|
|
1169
1379
|
});
|
|
1170
|
-
});
|
|
1380
|
+
}));
|
|
1171
1381
|
};
|
|
1172
1382
|
//#endregion
|
|
1173
1383
|
//#region src/core/patterns.ts
|
|
@@ -1883,14 +2093,27 @@ const envScan = (env, options) => {
|
|
|
1883
2093
|
low_confidence
|
|
1884
2094
|
};
|
|
1885
2095
|
};
|
|
2096
|
+
const parseAliasRef = (raw, expectedKind) => {
|
|
2097
|
+
const match = /^(secret|env)\.(.+)$/.exec(raw);
|
|
2098
|
+
if (!match) return void 0;
|
|
2099
|
+
if (match[1] !== expectedKind) return void 0;
|
|
2100
|
+
return match[2];
|
|
2101
|
+
};
|
|
1886
2102
|
/** Bidirectional drift detection between config and live environment */
|
|
1887
2103
|
const envCheck = (config, env) => {
|
|
1888
2104
|
const secretEntries = config.secret ?? {};
|
|
1889
2105
|
const metaKeys = Object.keys(secretEntries);
|
|
1890
2106
|
const trackedSet = new Set(metaKeys);
|
|
2107
|
+
const isSecretPresent = (key) => {
|
|
2108
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
2109
|
+
const meta = secretEntries[key];
|
|
2110
|
+
if (meta?.from_key === void 0) return false;
|
|
2111
|
+
const targetKey = parseAliasRef(meta.from_key, "secret");
|
|
2112
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
2113
|
+
};
|
|
1891
2114
|
const secretDriftEntries = metaKeys.map((key) => {
|
|
1892
2115
|
const meta = secretEntries[key];
|
|
1893
|
-
const present =
|
|
2116
|
+
const present = isSecretPresent(key);
|
|
1894
2117
|
return {
|
|
1895
2118
|
envVar: key,
|
|
1896
2119
|
service: Option(meta.service),
|
|
@@ -1899,12 +2122,19 @@ const envCheck = (config, env) => {
|
|
|
1899
2122
|
};
|
|
1900
2123
|
});
|
|
1901
2124
|
const envDefaults = config.env ?? {};
|
|
2125
|
+
const isEnvPresent = (key) => {
|
|
2126
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
2127
|
+
const meta = envDefaults[key];
|
|
2128
|
+
if (meta?.from_key === void 0) return false;
|
|
2129
|
+
const targetKey = parseAliasRef(meta.from_key, "env");
|
|
2130
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
2131
|
+
};
|
|
1902
2132
|
const envDefaultEntries = Object.keys(envDefaults).filter((key) => {
|
|
1903
2133
|
if (trackedSet.has(key)) return false;
|
|
1904
2134
|
trackedSet.add(key);
|
|
1905
2135
|
return true;
|
|
1906
2136
|
}).map((key) => {
|
|
1907
|
-
const present =
|
|
2137
|
+
const present = isEnvPresent(key);
|
|
1908
2138
|
return {
|
|
1909
2139
|
envVar: key,
|
|
1910
2140
|
service: Option(void 0),
|
|
@@ -2193,7 +2423,9 @@ const runEnvExport = (options) => {
|
|
|
2193
2423
|
if (boot.overridden.length > 0) loadConfig(boot.configPath).fold(() => {}, (config) => {
|
|
2194
2424
|
const envEntries = config.env ?? {};
|
|
2195
2425
|
boot.overridden.forEach((key) => {
|
|
2196
|
-
if (key in envEntries)
|
|
2426
|
+
if (!(key in envEntries)) return;
|
|
2427
|
+
const value = envEntries[key].value ?? process.env[key] ?? "";
|
|
2428
|
+
console.log(`export ${key}='${shellEscape(value)}'`);
|
|
2197
2429
|
});
|
|
2198
2430
|
});
|
|
2199
2431
|
Object.entries(boot.secrets).forEach(([key, value]) => {
|
|
@@ -2303,6 +2535,77 @@ const runEnvRm = (name, options) => {
|
|
|
2303
2535
|
});
|
|
2304
2536
|
});
|
|
2305
2537
|
};
|
|
2538
|
+
const ALIAS_REF_RE$1 = /^(secret|env)\.(.+)$/;
|
|
2539
|
+
const buildEnvAliasBlock = (name, options) => {
|
|
2540
|
+
const lines = [`[env.${name}]`, `from_key = "${options.from}"`];
|
|
2541
|
+
if (options.purpose) lines.push(`purpose = "${options.purpose}"`);
|
|
2542
|
+
if (options.comment) lines.push(`comment = "${options.comment}"`);
|
|
2543
|
+
if (options.tags) {
|
|
2544
|
+
const pairs = options.tags.split(",").map((pair) => {
|
|
2545
|
+
const [k, v] = pair.split("=").map((s) => s.trim());
|
|
2546
|
+
return `${k} = "${v}"`;
|
|
2547
|
+
});
|
|
2548
|
+
lines.push(`tags = { ${pairs.join(", ")} }`);
|
|
2549
|
+
}
|
|
2550
|
+
return `${lines.join("\n")}\n`;
|
|
2551
|
+
};
|
|
2552
|
+
const runEnvAlias = (name, options) => {
|
|
2553
|
+
const match = ALIAS_REF_RE$1.exec(options.from);
|
|
2554
|
+
if (!match) {
|
|
2555
|
+
console.error(`${RED}Error:${RESET} --from "${options.from}" must be formatted as "secret.<KEY>" or "env.<KEY>"`);
|
|
2556
|
+
process.exit(1);
|
|
2557
|
+
}
|
|
2558
|
+
const [, targetKind, targetKey] = match;
|
|
2559
|
+
if (targetKind !== "env") {
|
|
2560
|
+
console.error(`${RED}Error:${RESET} env alias must point at another env entry — got "${options.from}". Use \`envpkt secret alias\` for secret→secret aliases.`);
|
|
2561
|
+
process.exit(1);
|
|
2562
|
+
}
|
|
2563
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
2564
|
+
console.error(formatError(err));
|
|
2565
|
+
process.exit(2);
|
|
2566
|
+
}, ({ path: configPath, source }) => {
|
|
2567
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
2568
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
2569
|
+
loadConfig(configPath).fold((err) => {
|
|
2570
|
+
console.error(formatError(err));
|
|
2571
|
+
process.exit(2);
|
|
2572
|
+
}, (config) => {
|
|
2573
|
+
const envEntries = config.env ?? {};
|
|
2574
|
+
if (name === targetKey) {
|
|
2575
|
+
console.error(`${RED}Error:${RESET} alias "${name}" cannot reference itself`);
|
|
2576
|
+
process.exit(1);
|
|
2577
|
+
}
|
|
2578
|
+
const target = envEntries[targetKey];
|
|
2579
|
+
if (!target) {
|
|
2580
|
+
console.error(`${RED}Error:${RESET} alias target "${options.from}" not found in ${configPath}. Add the target env entry first.`);
|
|
2581
|
+
process.exit(1);
|
|
2582
|
+
}
|
|
2583
|
+
if (target.from_key !== void 0) {
|
|
2584
|
+
console.error(`${RED}Error:${RESET} alias target "${options.from}" is itself an alias. Chained aliases are not supported — point at the canonical entry instead.`);
|
|
2585
|
+
process.exit(1);
|
|
2586
|
+
}
|
|
2587
|
+
const existing = envEntries[name];
|
|
2588
|
+
if (existing) {
|
|
2589
|
+
if (!options.force) {
|
|
2590
|
+
console.error(`${YELLOW}Warning:${RESET} env entry "${name}" already exists in ${configPath} (${existing.from_key ? `currently alias → ${existing.from_key}` : "currently a regular entry"}).`);
|
|
2591
|
+
console.error(` Pass ${BOLD}--force${RESET} to overwrite, or use a different name.`);
|
|
2592
|
+
process.exit(1);
|
|
2593
|
+
}
|
|
2594
|
+
console.error(`${YELLOW}Warning:${RESET} overwriting existing env entry "${name}" (${existing.from_key ? `was alias → ${existing.from_key}` : "was a regular entry"})`);
|
|
2595
|
+
}
|
|
2596
|
+
const block = buildEnvAliasBlock(name, options);
|
|
2597
|
+
if (options.dryRun) {
|
|
2598
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
2599
|
+
if (existing) console.log(`${DIM}# (would replace existing [env.${name}] block)${RESET}\n`);
|
|
2600
|
+
console.log(block);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
2604
|
+
writeFileSync(configPath, appendSection(existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw, block), "utf-8");
|
|
2605
|
+
console.log(`${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
2606
|
+
});
|
|
2607
|
+
});
|
|
2608
|
+
};
|
|
2306
2609
|
const runEnvRename = (oldName, newName, options) => {
|
|
2307
2610
|
withConfig$1(Option(options.config), (configPath, raw) => {
|
|
2308
2611
|
renameSection(raw, `[env.${oldName}]`, `[env.${newName}]`).fold((err) => {
|
|
@@ -2342,6 +2645,9 @@ const registerEnvCommands = (program) => {
|
|
|
2342
2645
|
env.command("rename").description("Rename an env entry, preserving all fields").argument("<old>", "Current env variable name").argument("<new>", "New env variable name").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((oldName, newName, options) => {
|
|
2343
2646
|
runEnvRename(oldName, newName, options);
|
|
2344
2647
|
});
|
|
2648
|
+
env.command("alias").description("Create an alias entry that reuses another env entry's resolved value").argument("<name>", "Alias name (becomes the env var key)").requiredOption("--from <ref>", "Target reference — must be \"env.<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) => {
|
|
2649
|
+
runEnvAlias(name, options);
|
|
2650
|
+
});
|
|
2345
2651
|
};
|
|
2346
2652
|
//#endregion
|
|
2347
2653
|
//#region src/cli/commands/exec.ts
|
|
@@ -2969,6 +3275,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2969
3275
|
status: s.status,
|
|
2970
3276
|
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
2971
3277
|
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
3278
|
+
alias_of: s.alias_of.fold(() => null, (a) => a),
|
|
2972
3279
|
issues: s.issues.toArray()
|
|
2973
3280
|
}));
|
|
2974
3281
|
return textResult(JSON.stringify({
|
|
@@ -2980,6 +3287,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2980
3287
|
expired: audit.expired,
|
|
2981
3288
|
stale: audit.stale,
|
|
2982
3289
|
missing: audit.missing,
|
|
3290
|
+
aliases: audit.aliases,
|
|
2983
3291
|
secrets: secretDetails
|
|
2984
3292
|
}, null, 2));
|
|
2985
3293
|
};
|
|
@@ -3009,11 +3317,30 @@ const handleGetSecretMeta = (args) => {
|
|
|
3009
3317
|
const loaded = loadConfigForTool(configPathArg(args));
|
|
3010
3318
|
if (!loaded.ok) return loaded.result;
|
|
3011
3319
|
const { config } = loaded;
|
|
3012
|
-
|
|
3013
|
-
|
|
3320
|
+
const secretEntries = config.secret ?? {};
|
|
3321
|
+
return Option(secretEntries[key]).fold(() => errorResult(`Secret not found: ${key}`), (meta) => {
|
|
3322
|
+
const { encrypted_value: _, from_key: fromKey, ...rest } = meta;
|
|
3323
|
+
if (fromKey !== void 0) {
|
|
3324
|
+
const targetKey = /^secret\.(.+)$/.exec(fromKey)?.[1];
|
|
3325
|
+
const target = targetKey !== void 0 ? secretEntries[targetKey] : void 0;
|
|
3326
|
+
if (target) {
|
|
3327
|
+
const { encrypted_value: __, from_key: ___, ...targetRest } = target;
|
|
3328
|
+
return textResult(JSON.stringify({
|
|
3329
|
+
key,
|
|
3330
|
+
...targetRest,
|
|
3331
|
+
...rest,
|
|
3332
|
+
alias_of: fromKey
|
|
3333
|
+
}, null, 2));
|
|
3334
|
+
}
|
|
3335
|
+
return textResult(JSON.stringify({
|
|
3336
|
+
key,
|
|
3337
|
+
...rest,
|
|
3338
|
+
alias_of: fromKey
|
|
3339
|
+
}, null, 2));
|
|
3340
|
+
}
|
|
3014
3341
|
return textResult(JSON.stringify({
|
|
3015
3342
|
key,
|
|
3016
|
-
...
|
|
3343
|
+
...rest
|
|
3017
3344
|
}, null, 2));
|
|
3018
3345
|
});
|
|
3019
3346
|
};
|
|
@@ -3546,6 +3873,77 @@ const runSecretRename = (oldName, newName, options) => {
|
|
|
3546
3873
|
});
|
|
3547
3874
|
});
|
|
3548
3875
|
};
|
|
3876
|
+
const ALIAS_REF_RE = /^(secret|env)\.(.+)$/;
|
|
3877
|
+
const buildSecretAliasBlock = (name, options) => {
|
|
3878
|
+
const lines = [`[secret.${name}]`, `from_key = "${options.from}"`];
|
|
3879
|
+
if (options.purpose) lines.push(`purpose = "${options.purpose}"`);
|
|
3880
|
+
if (options.comment) lines.push(`comment = "${options.comment}"`);
|
|
3881
|
+
if (options.tags) {
|
|
3882
|
+
const pairs = options.tags.split(",").map((pair) => {
|
|
3883
|
+
const [k, v] = pair.split("=").map((s) => s.trim());
|
|
3884
|
+
return `${k} = "${v}"`;
|
|
3885
|
+
});
|
|
3886
|
+
lines.push(`tags = { ${pairs.join(", ")} }`);
|
|
3887
|
+
}
|
|
3888
|
+
return `${lines.join("\n")}\n`;
|
|
3889
|
+
};
|
|
3890
|
+
const runSecretAlias = (name, options) => {
|
|
3891
|
+
const match = ALIAS_REF_RE.exec(options.from);
|
|
3892
|
+
if (!match) {
|
|
3893
|
+
console.error(`${RED}Error:${RESET} --from "${options.from}" must be formatted as "secret.<KEY>" or "env.<KEY>"`);
|
|
3894
|
+
process.exit(1);
|
|
3895
|
+
}
|
|
3896
|
+
const [, targetKind, targetKey] = match;
|
|
3897
|
+
if (targetKind !== "secret") {
|
|
3898
|
+
console.error(`${RED}Error:${RESET} secret alias must point at another secret — got "${options.from}". Use \`envpkt env alias\` for env→env aliases.`);
|
|
3899
|
+
process.exit(1);
|
|
3900
|
+
}
|
|
3901
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
3902
|
+
console.error(formatError(err));
|
|
3903
|
+
process.exit(2);
|
|
3904
|
+
}, ({ path: configPath, source }) => {
|
|
3905
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
3906
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
3907
|
+
loadConfig(configPath).fold((err) => {
|
|
3908
|
+
console.error(formatError(err));
|
|
3909
|
+
process.exit(2);
|
|
3910
|
+
}, (config) => {
|
|
3911
|
+
const secrets = config.secret ?? {};
|
|
3912
|
+
if (name === targetKey) {
|
|
3913
|
+
console.error(`${RED}Error:${RESET} alias "${name}" cannot reference itself`);
|
|
3914
|
+
process.exit(1);
|
|
3915
|
+
}
|
|
3916
|
+
const target = secrets[targetKey];
|
|
3917
|
+
if (!target) {
|
|
3918
|
+
console.error(`${RED}Error:${RESET} alias target "${options.from}" not found in ${configPath}. Add the target secret first.`);
|
|
3919
|
+
process.exit(1);
|
|
3920
|
+
}
|
|
3921
|
+
if (target.from_key !== void 0) {
|
|
3922
|
+
console.error(`${RED}Error:${RESET} alias target "${options.from}" is itself an alias. Chained aliases are not supported — point at the canonical entry instead.`);
|
|
3923
|
+
process.exit(1);
|
|
3924
|
+
}
|
|
3925
|
+
const existing = secrets[name];
|
|
3926
|
+
if (existing) {
|
|
3927
|
+
if (!options.force) {
|
|
3928
|
+
console.error(`${YELLOW}Warning:${RESET} secret "${name}" already exists in ${configPath} (${existing.from_key ? `currently alias → ${existing.from_key}` : "currently a regular entry"}).`);
|
|
3929
|
+
console.error(` Pass ${BOLD}--force${RESET} to overwrite, or use a different name.`);
|
|
3930
|
+
process.exit(1);
|
|
3931
|
+
}
|
|
3932
|
+
console.error(`${YELLOW}Warning:${RESET} overwriting existing entry "${name}" (${existing.from_key ? `was alias → ${existing.from_key}` : "was a regular entry"})`);
|
|
3933
|
+
}
|
|
3934
|
+
const block = buildSecretAliasBlock(name, options);
|
|
3935
|
+
if (options.dryRun) {
|
|
3936
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
3937
|
+
if (existing) console.log(`${DIM}# (would replace existing [secret.${name}] block)${RESET}\n`);
|
|
3938
|
+
console.log(block);
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
3942
|
+
writeFileSync(configPath, appendSection(existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw, block), "utf-8");
|
|
3943
|
+
console.log(`${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
3944
|
+
});
|
|
3945
|
+
});
|
|
3946
|
+
};
|
|
3549
3947
|
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)");
|
|
3550
3948
|
const registerSecretCommands = (program) => {
|
|
3551
3949
|
const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
|
|
@@ -3561,6 +3959,9 @@ const registerSecretCommands = (program) => {
|
|
|
3561
3959
|
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) => {
|
|
3562
3960
|
runSecretRename(oldName, newName, options);
|
|
3563
3961
|
});
|
|
3962
|
+
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) => {
|
|
3963
|
+
runSecretAlias(name, options);
|
|
3964
|
+
});
|
|
3564
3965
|
};
|
|
3565
3966
|
//#endregion
|
|
3566
3967
|
//#region src/cli/commands/shell-hook.ts
|