envpkt 0.16.0 → 0.18.0
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 +32 -24
- package/dist/cli.js +468 -476
- package/dist/index.d.ts +37 -30
- package/dist/index.js +342 -344
- package/package.json +6 -2
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { Command } from "commander";
|
|
6
|
-
import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try } from "functype";
|
|
7
|
+
import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try, tryCatch, tryCatchAsync } from "functype";
|
|
7
8
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
8
9
|
import { Env, Fs, Path, Platform } from "functype-os";
|
|
9
10
|
import { TomlDate, parse, stringify } from "smol-toml";
|
|
10
11
|
import { FormatRegistry, Type } from "@sinclair/typebox";
|
|
11
|
-
import {
|
|
12
|
-
import { homedir
|
|
12
|
+
import { Decrypter, Encrypter, armor, generateX25519Identity, identityToRecipient } from "age-encryption";
|
|
13
|
+
import { homedir } from "node:os";
|
|
13
14
|
import { directSilentLogger } from "functype-log/direct";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
14
16
|
import { randomBytes } from "node:crypto";
|
|
15
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -586,33 +588,109 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
|
586
588
|
}));
|
|
587
589
|
};
|
|
588
590
|
//#endregion
|
|
589
|
-
//#region src/
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
591
|
+
//#region src/core/age.ts
|
|
592
|
+
/**
|
|
593
|
+
* In-process age encryption via typage (`age-encryption`). This is the only module that imports
|
|
594
|
+
* it. Ciphertext is age's ASCII armor, interoperable with the `age` CLI in both directions.
|
|
595
|
+
*/
|
|
596
|
+
/** Encrypt `plaintext` to an age recipient (`age1…`), returning armored text without a trailing newline. */
|
|
597
|
+
const encryptArmored = (plaintext, recipient) => tryCatchAsync(async () => {
|
|
598
|
+
const encrypter = new Encrypter();
|
|
599
|
+
encrypter.addRecipient(recipient);
|
|
600
|
+
return armor.encode(await encrypter.encrypt(plaintext)).trim();
|
|
601
|
+
}, (err) => ({
|
|
602
|
+
_tag: "EncryptFailed",
|
|
603
|
+
key: "",
|
|
604
|
+
message: `age encrypt failed: ${String(err)}`
|
|
605
|
+
}));
|
|
606
|
+
const decryptFailed = (message) => ({
|
|
607
|
+
_tag: "DecryptFailed",
|
|
608
|
+
key: "",
|
|
609
|
+
message: `age decrypt failed: ${message}`
|
|
610
|
+
});
|
|
611
|
+
/** Mask any age secret key in an error message. typage's bech32 errors quote the key they rejected. */
|
|
612
|
+
const redactKeys = (message) => message.replace(/AGE-SECRET-KEY-[0-9A-Z-]+/gi, "AGE-SECRET-KEY-…");
|
|
613
|
+
/** Add each identity to a fresh Decrypter. A malformed key fails with its position, never its text. */
|
|
614
|
+
const toDecrypter = (identities) => identities.reduce((acc, identity, i) => acc.flatMap((decrypter) => tryCatch(() => {
|
|
615
|
+
decrypter.addIdentity(identity);
|
|
616
|
+
return decrypter;
|
|
617
|
+
}, () => decryptFailed(`identity ${i + 1} is not a valid age secret key`))), Right(new Decrypter()));
|
|
618
|
+
const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
619
|
+
/**
|
|
620
|
+
* Decrypt armored age ciphertext with any of `identities` (`AGE-SECRET-KEY-…`). The plaintext is
|
|
621
|
+
* returned as-is: byte for byte when it is valid UTF-8.
|
|
622
|
+
*/
|
|
623
|
+
const decryptArmored = (armored, identities) => {
|
|
624
|
+
if (identities.length === 0) return Promise.resolve(Left(decryptFailed("no identity provided")));
|
|
625
|
+
return toDecrypter(identities).fold((err) => Promise.resolve(Left(err)), (decrypter) => tryCatchAsync(async () => utf8.decode(await decrypter.decrypt(armor.decode(armored))), (err) => decryptFailed(redactKeys(String(err)))));
|
|
611
626
|
};
|
|
627
|
+
/** Generate an X25519 age keypair: the secret identity and its public recipient. */
|
|
628
|
+
const generateAgeKeypair = () => tryCatchAsync(async () => {
|
|
629
|
+
const identity = await generateX25519Identity();
|
|
630
|
+
return {
|
|
631
|
+
identity,
|
|
632
|
+
recipient: await identityToRecipient(identity)
|
|
633
|
+
};
|
|
634
|
+
}, (err) => ({
|
|
635
|
+
_tag: "KeygenFailed",
|
|
636
|
+
message: `age keygen failed: ${String(err)}`
|
|
637
|
+
}));
|
|
638
|
+
/** The installed `age-encryption` version, for `envpkt doctor`. Its `exports` hide package.json, so read it beside the entry point. */
|
|
639
|
+
const ageEncryptionVersion = () => Try(() => {
|
|
640
|
+
const entry = createRequire(import.meta.url).resolve("age-encryption");
|
|
641
|
+
return JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf-8")).version;
|
|
642
|
+
}).toOption();
|
|
643
|
+
const SECRET_KEY_PREFIX = "AGE-SECRET-KEY-";
|
|
644
|
+
/** Classify a non-key line, so the error can name what it is without echoing its content. */
|
|
645
|
+
const unsupportedLine = (line, lineNumber) => {
|
|
646
|
+
if (line.startsWith("-----BEGIN AGE ENCRYPTED FILE-----") || line.startsWith("age-encryption.org/")) return { _tag: "PassphraseProtectedIdentity" };
|
|
647
|
+
if (line.startsWith("AGE-PLUGIN-")) return {
|
|
648
|
+
_tag: "UnsupportedIdentity",
|
|
649
|
+
kind: "plugin",
|
|
650
|
+
line: lineNumber
|
|
651
|
+
};
|
|
652
|
+
if (line.startsWith("-----BEGIN") || line.startsWith("ssh-")) return {
|
|
653
|
+
_tag: "UnsupportedIdentity",
|
|
654
|
+
kind: "ssh",
|
|
655
|
+
line: lineNumber
|
|
656
|
+
};
|
|
657
|
+
return {
|
|
658
|
+
_tag: "UnsupportedIdentity",
|
|
659
|
+
kind: "unknown",
|
|
660
|
+
line: lineNumber
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* Read every `AGE-SECRET-KEY-…` line from an age identity file, as the `age` CLI does. Blank lines
|
|
665
|
+
* and `#` comments are skipped. Any other line — a plugin or SSH key, or a passphrase-protected
|
|
666
|
+
* (age-encrypted) key file — is an error, since in-process decryption can't use it.
|
|
667
|
+
*/
|
|
668
|
+
const parseIdentityFile = (content) => {
|
|
669
|
+
const significant = content.split("\n").map((line, i) => ({
|
|
670
|
+
text: line.trim(),
|
|
671
|
+
number: i + 1
|
|
672
|
+
})).filter(({ text }) => text !== "" && !text.startsWith("#"));
|
|
673
|
+
const firstUnsupported = significant.find(({ text }) => !text.startsWith(SECRET_KEY_PREFIX));
|
|
674
|
+
if (firstUnsupported) return Left(unsupportedLine(firstUnsupported.text, firstUnsupported.number));
|
|
675
|
+
if (significant.length === 0) return Left({ _tag: "NoIdentityKey" });
|
|
676
|
+
return Right(significant.map(({ text }) => text));
|
|
677
|
+
};
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/fnox/identity.ts
|
|
680
|
+
/** The `source` name for the inline key in identity errors. */
|
|
681
|
+
const INLINE_KEY_SOURCE = "ENVPKT_AGE_KEY";
|
|
682
|
+
/**
|
|
683
|
+
* Read the age secret keys (`AGE-SECRET-KEY-…`) from identity text, such as a key file's content
|
|
684
|
+
* or the inline `ENVPKT_AGE_KEY`. `source` names it in errors.
|
|
685
|
+
*/
|
|
686
|
+
const parseIdentityKeys = (content, source) => parseIdentityFile(content).mapLeft((err) => ({
|
|
687
|
+
...err,
|
|
688
|
+
source
|
|
689
|
+
}));
|
|
612
690
|
/**
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
691
|
+
* Read every secret key from a plain age identity file (as written by `envpkt keygen` or
|
|
692
|
+
* `age-keygen`). A missing or unreadable file, and passphrase-protected, plugin and SSH key files,
|
|
693
|
+
* are rejected with a typed error.
|
|
616
694
|
*/
|
|
617
695
|
const unwrapAgentKey = (identityPath) => {
|
|
618
696
|
if (!existsSync(identityPath)) return Left({
|
|
@@ -620,27 +698,34 @@ const unwrapAgentKey = (identityPath) => {
|
|
|
620
698
|
path: identityPath
|
|
621
699
|
});
|
|
622
700
|
return Try(() => readFileSync(identityPath, "utf-8")).fold((err) => Left({
|
|
623
|
-
_tag: "
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
701
|
+
_tag: "IdentityUnreadable",
|
|
702
|
+
path: identityPath,
|
|
703
|
+
message: err.message
|
|
704
|
+
}), (content) => parseIdentityKeys(content, identityPath));
|
|
705
|
+
};
|
|
706
|
+
const IDENTITY_ERROR_TAGS = new Set(Object.keys({
|
|
707
|
+
IdentityNotFound: true,
|
|
708
|
+
IdentityUnreadable: true,
|
|
709
|
+
NoIdentityKey: true,
|
|
710
|
+
PassphraseProtectedIdentity: true,
|
|
711
|
+
UnsupportedIdentity: true
|
|
712
|
+
}));
|
|
713
|
+
/** True when `err` is about the age key itself rather than the config. */
|
|
714
|
+
const isIdentityError = (err) => IDENTITY_ERROR_TAGS.has(err._tag);
|
|
715
|
+
const UNSUPPORTED_KIND = {
|
|
716
|
+
plugin: "an age plugin key",
|
|
717
|
+
ssh: "an SSH key",
|
|
718
|
+
unknown: "not an age secret key"
|
|
719
|
+
};
|
|
720
|
+
/** Explain an identity error. Never includes key material. */
|
|
721
|
+
const describeIdentityError = (err) => {
|
|
722
|
+
switch (err._tag) {
|
|
723
|
+
case "IdentityNotFound": return `Identity file not found: ${err.path}`;
|
|
724
|
+
case "IdentityUnreadable": return `Can't read key file ${err.path}: ${err.message}`;
|
|
725
|
+
case "NoIdentityKey": return `${err.source} has no age secret key (AGE-SECRET-KEY-…)`;
|
|
726
|
+
case "PassphraseProtectedIdentity": return err.source === "ENVPKT_AGE_KEY" ? `${INLINE_KEY_SOURCE} holds a passphrase-protected key, which envpkt can't read. Set it to the plain key file's content (its AGE-SECRET-KEY-… line).` : `${err.source} is a passphrase-protected key file, which envpkt can't read. Unwrap it once with \`age -d -o <plain-key-file> ${err.source}\` and point envpkt at the plain file.`;
|
|
727
|
+
case "UnsupportedIdentity": return `${err.source} line ${err.line} is ${UNSUPPORTED_KIND[err.kind]}; envpkt reads plain age secret keys (AGE-SECRET-KEY-…) only`;
|
|
728
|
+
}
|
|
644
729
|
};
|
|
645
730
|
//#endregion
|
|
646
731
|
//#region src/cli/output.ts
|
|
@@ -747,7 +832,10 @@ const formatError = (error) => {
|
|
|
747
832
|
case "ParseError": return `${RED}Error:${RESET} Failed to parse TOML: ${error.message}`;
|
|
748
833
|
case "ValidationError": return `${RED}Error:${RESET} Config validation failed:\n${String(error.errors)}`;
|
|
749
834
|
case "ReadError": return `${RED}Error:${RESET} Could not read file: ${error.message}`;
|
|
750
|
-
case "
|
|
835
|
+
case "IdentityUnreadable":
|
|
836
|
+
case "NoIdentityKey":
|
|
837
|
+
case "PassphraseProtectedIdentity":
|
|
838
|
+
case "UnsupportedIdentity": return `${RED}Error:${RESET} ${describeIdentityError(error)}`;
|
|
751
839
|
case "DecryptFailed": return `${RED}Error:${RESET} Decrypt failed: ${error.message}`;
|
|
752
840
|
case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
|
|
753
841
|
case "SealKeyUnavailable": {
|
|
@@ -1160,54 +1248,32 @@ const resolveInlineKey = () => {
|
|
|
1160
1248
|
const key = process.env["ENVPKT_AGE_KEY"];
|
|
1161
1249
|
return key ? Some(key) : None();
|
|
1162
1250
|
};
|
|
1163
|
-
/**
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1251
|
+
/** The identity file `age-keygen` writes: creation time, public key, secret key. */
|
|
1252
|
+
const identityFileContent = (identity, recipient) => `# created: ${(/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z")}\n# public key: ${recipient}\n${identity}\n`;
|
|
1253
|
+
/** Write the identity file (0600), creating its directory if needed. Never overwrites a file. */
|
|
1254
|
+
const writeIdentityFile = (outputPath, content) => Try(() => {
|
|
1255
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1256
|
+
writeFileSync(outputPath, content, {
|
|
1257
|
+
mode: 384,
|
|
1258
|
+
flag: "wx"
|
|
1168
1259
|
});
|
|
1260
|
+
chmodSync(outputPath, 384);
|
|
1261
|
+
}).fold((err) => Left({
|
|
1262
|
+
_tag: "WriteError",
|
|
1263
|
+
message: `Failed to write identity file: ${err}`
|
|
1264
|
+
}), () => Right(void 0));
|
|
1265
|
+
/** Generate an age keypair and write to disk. Refuses to overwrite if the file already exists. */
|
|
1266
|
+
const generateKeypair = async (options) => {
|
|
1169
1267
|
const outputPath = options?.outputPath ?? resolveKeyPath();
|
|
1170
1268
|
if (existsSync(outputPath)) return Left({
|
|
1171
1269
|
_tag: "KeyExists",
|
|
1172
1270
|
path: outputPath
|
|
1173
1271
|
});
|
|
1174
|
-
return
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
],
|
|
1180
|
-
encoding: "utf-8"
|
|
1181
|
-
})).fold((err) => Left({
|
|
1182
|
-
_tag: "KeygenFailed",
|
|
1183
|
-
message: `age-keygen failed: ${err}`
|
|
1184
|
-
}), (output) => {
|
|
1185
|
-
const recipientLine = output.split("\n").find((l) => l.startsWith("# public key:"));
|
|
1186
|
-
if (!recipientLine) return Left({
|
|
1187
|
-
_tag: "KeygenFailed",
|
|
1188
|
-
message: "Could not parse public key from age-keygen output"
|
|
1189
|
-
});
|
|
1190
|
-
const recipient = recipientLine.replace("# public key: ", "").trim();
|
|
1191
|
-
const dir = dirname(outputPath);
|
|
1192
|
-
const mkdirFailed = Try(() => {
|
|
1193
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1194
|
-
}).fold((err) => ({
|
|
1195
|
-
_tag: "WriteError",
|
|
1196
|
-
message: `Failed to create directory ${dir}: ${err}`
|
|
1197
|
-
}), () => void 0);
|
|
1198
|
-
if (mkdirFailed) return Left(mkdirFailed);
|
|
1199
|
-
return Try(() => {
|
|
1200
|
-
writeFileSync(outputPath, output, { mode: 384 });
|
|
1201
|
-
chmodSync(outputPath, 384);
|
|
1202
|
-
}).fold((err) => Left({
|
|
1203
|
-
_tag: "WriteError",
|
|
1204
|
-
message: `Failed to write identity file: ${err}`
|
|
1205
|
-
}), () => Right({
|
|
1206
|
-
recipient,
|
|
1207
|
-
identityPath: outputPath,
|
|
1208
|
-
configUpdated: false
|
|
1209
|
-
}));
|
|
1210
|
-
});
|
|
1272
|
+
return (await generateAgeKeypair()).flatMap(({ identity, recipient }) => writeIdentityFile(outputPath, identityFileContent(identity, recipient)).map(() => ({
|
|
1273
|
+
recipient,
|
|
1274
|
+
identityPath: outputPath,
|
|
1275
|
+
configUpdated: false
|
|
1276
|
+
})));
|
|
1211
1277
|
};
|
|
1212
1278
|
/** Update identity fields (recipient, key_file, name) in an envpkt.toml file, preserving structure */
|
|
1213
1279
|
const updateConfigIdentity = (configPath, options) => {
|
|
@@ -1288,94 +1354,36 @@ const updateConfigIdentity = (configPath, options) => {
|
|
|
1288
1354
|
};
|
|
1289
1355
|
//#endregion
|
|
1290
1356
|
//#region src/core/seal.ts
|
|
1291
|
-
/** Encrypt a plaintext string
|
|
1292
|
-
const ageEncrypt = (plaintext, recipient) =>
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
"--encrypt",
|
|
1299
|
-
"--recipient",
|
|
1300
|
-
recipient,
|
|
1301
|
-
"--armor"
|
|
1302
|
-
], {
|
|
1303
|
-
input: plaintext,
|
|
1304
|
-
stdio: [
|
|
1305
|
-
"pipe",
|
|
1306
|
-
"pipe",
|
|
1307
|
-
"pipe"
|
|
1308
|
-
],
|
|
1309
|
-
encoding: "utf-8"
|
|
1310
|
-
})).fold((err) => Left({
|
|
1311
|
-
_tag: "EncryptFailed",
|
|
1312
|
-
key: "",
|
|
1313
|
-
message: `age encrypt failed: ${err}`
|
|
1314
|
-
}), (output) => Right(output.trim()));
|
|
1315
|
-
};
|
|
1316
|
-
/** Decrypt an age-armored ciphertext using the given identity file */
|
|
1317
|
-
const ageDecrypt = (ciphertext, identityPath) => {
|
|
1318
|
-
if (!ageAvailable()) return Left({
|
|
1319
|
-
_tag: "AgeNotFound",
|
|
1320
|
-
message: "age CLI not found on PATH"
|
|
1321
|
-
});
|
|
1322
|
-
return Try(() => execFileSync("age", [
|
|
1323
|
-
"--decrypt",
|
|
1324
|
-
"--identity",
|
|
1325
|
-
identityPath
|
|
1326
|
-
], {
|
|
1327
|
-
input: ciphertext,
|
|
1328
|
-
stdio: [
|
|
1329
|
-
"pipe",
|
|
1330
|
-
"pipe",
|
|
1331
|
-
"pipe"
|
|
1332
|
-
],
|
|
1333
|
-
encoding: "utf-8"
|
|
1334
|
-
})).fold((err) => Left({
|
|
1335
|
-
_tag: "DecryptFailed",
|
|
1336
|
-
key: "",
|
|
1337
|
-
message: `age decrypt failed: ${err}`
|
|
1338
|
-
}), (output) => Right(output.trim()));
|
|
1339
|
-
};
|
|
1357
|
+
/** Encrypt a plaintext string to an age recipient public key (armored output) */
|
|
1358
|
+
const ageEncrypt = (plaintext, recipient) => encryptArmored(plaintext, recipient);
|
|
1359
|
+
/**
|
|
1360
|
+
* Decrypt an age-armored ciphertext with any of the given secret keys (`AGE-SECRET-KEY-…`, as
|
|
1361
|
+
* returned by `unwrapAgentKey`). The plaintext is returned as-is, untrimmed.
|
|
1362
|
+
*/
|
|
1363
|
+
const ageDecrypt = (ciphertext, identities) => decryptArmored(ciphertext, identities);
|
|
1340
1364
|
/** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
|
|
1341
|
-
const sealSecrets = (meta, values, recipient) => {
|
|
1342
|
-
|
|
1343
|
-
_tag: "AgeNotFound",
|
|
1344
|
-
message: "age CLI not found on PATH"
|
|
1345
|
-
});
|
|
1346
|
-
return Object.entries(meta).reduce((acc, [key, secretMeta]) => acc.flatMap((result) => {
|
|
1365
|
+
const sealSecrets = async (meta, values, recipient) => {
|
|
1366
|
+
return (await Either.traverseAsync(Object.entries(meta), async ([key, secretMeta]) => {
|
|
1347
1367
|
const value = values[key];
|
|
1348
|
-
if (value === void 0) return Right(
|
|
1349
|
-
|
|
1350
|
-
[key]: secretMeta
|
|
1351
|
-
});
|
|
1352
|
-
return ageEncrypt(value, recipient).mapLeft((err) => ({
|
|
1368
|
+
if (value === void 0) return Right([key, secretMeta]);
|
|
1369
|
+
return (await ageEncrypt(value, recipient)).mapLeft((err) => ({
|
|
1353
1370
|
_tag: "EncryptFailed",
|
|
1354
1371
|
key,
|
|
1355
1372
|
message: err.message
|
|
1356
|
-
})).map((ciphertext) =>
|
|
1357
|
-
...
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
const unsealSecrets = (meta, identityPath) => {
|
|
1367
|
-
if (!ageAvailable()) return Left({
|
|
1368
|
-
_tag: "AgeNotFound",
|
|
1369
|
-
message: "age CLI not found on PATH"
|
|
1370
|
-
});
|
|
1371
|
-
return Object.entries(meta).filter(([, secretMeta]) => secretMeta.encrypted_value !== void 0 && secretMeta.encrypted_value !== "").reduce((acc, [key, secretMeta]) => acc.flatMap((result) => ageDecrypt(secretMeta.encrypted_value, identityPath).mapLeft((err) => ({
|
|
1373
|
+
})).map((ciphertext) => [key, {
|
|
1374
|
+
...secretMeta,
|
|
1375
|
+
encrypted_value: ciphertext
|
|
1376
|
+
}]);
|
|
1377
|
+
})).map((entries) => Object.fromEntries(entries));
|
|
1378
|
+
};
|
|
1379
|
+
/** Unseal secrets: decrypt encrypted_value for each meta entry that has one, with any of `identities` */
|
|
1380
|
+
const unsealSecrets = async (meta, identities) => {
|
|
1381
|
+
const sealedEntries = Object.entries(meta).filter(([, secretMeta]) => secretMeta.encrypted_value !== void 0 && secretMeta.encrypted_value !== "");
|
|
1382
|
+
return (await Either.traverseAsync(sealedEntries, async ([key, secretMeta]) => (await ageDecrypt(secretMeta.encrypted_value, identities)).mapLeft((err) => ({
|
|
1372
1383
|
_tag: "DecryptFailed",
|
|
1373
1384
|
key,
|
|
1374
1385
|
message: err.message
|
|
1375
|
-
})).map((plaintext) => (
|
|
1376
|
-
...result,
|
|
1377
|
-
[key]: plaintext
|
|
1378
|
-
}))), Right({}));
|
|
1386
|
+
})).map((plaintext) => [key, plaintext]))).map((entries) => Object.fromEntries(entries));
|
|
1379
1387
|
};
|
|
1380
1388
|
//#endregion
|
|
1381
1389
|
//#region src/core/boot.ts
|
|
@@ -1395,51 +1403,27 @@ const resolveIdentityFilePath = (config, configDir, useDefaultFallback) => {
|
|
|
1395
1403
|
const defaultPath = resolveKeyPath();
|
|
1396
1404
|
return existsSync(defaultPath) ? Option(defaultPath) : Option(void 0);
|
|
1397
1405
|
};
|
|
1398
|
-
const
|
|
1399
|
-
/** Write an inline age key to a private temp file so the `age` CLI (file-based) can use it. */
|
|
1400
|
-
const materializeInlineKey = (key) => {
|
|
1401
|
-
const dir = mkdtempSync(join(tmpdir(), "envpkt-age-"));
|
|
1402
|
-
const keyPath = join(dir, "age-key.txt");
|
|
1403
|
-
writeFileSync(keyPath, key.endsWith("\n") ? key : `${key}\n`);
|
|
1404
|
-
chmodSync(keyPath, 384);
|
|
1405
|
-
return {
|
|
1406
|
-
path: keyPath,
|
|
1407
|
-
dispose: () => rmSync(dir, {
|
|
1408
|
-
recursive: true,
|
|
1409
|
-
force: true
|
|
1410
|
-
})
|
|
1411
|
-
};
|
|
1412
|
-
};
|
|
1406
|
+
const keysFrom = (keys) => keys.map((k) => Option(k));
|
|
1413
1407
|
/**
|
|
1414
|
-
* Resolve
|
|
1408
|
+
* Resolve the age secret keys for unsealing sealed packets. Precedence:
|
|
1415
1409
|
* config.identity.key_file > ENVPKT_AGE_KEY_FILE > ENVPKT_AGE_KEY (inline) > ~/.envpkt/age-key.txt
|
|
1416
1410
|
* The inline key (CI secret) ranks above the homedir default so an explicit
|
|
1417
|
-
* env var beats a stray local key.
|
|
1418
|
-
* the caller must dispose().
|
|
1411
|
+
* env var beats a stray local key. It is parsed in memory and never written to disk.
|
|
1419
1412
|
*/
|
|
1420
1413
|
const resolveSealIdentity = (config, configDir) => {
|
|
1421
1414
|
if (config.identity?.key_file) {
|
|
1422
1415
|
const keyFilePath = resolve(configDir, expandPath(config.identity.key_file));
|
|
1423
|
-
if (existsSync(keyFilePath)) return
|
|
1424
|
-
path: keyFilePath,
|
|
1425
|
-
dispose: noop
|
|
1426
|
-
});
|
|
1416
|
+
if (existsSync(keyFilePath)) return keysFrom(unwrapAgentKey(keyFilePath));
|
|
1427
1417
|
}
|
|
1428
1418
|
const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
|
|
1429
|
-
if (envFile && existsSync(envFile)) return
|
|
1430
|
-
path: envFile,
|
|
1431
|
-
dispose: noop
|
|
1432
|
-
});
|
|
1419
|
+
if (envFile && existsSync(envFile)) return keysFrom(unwrapAgentKey(envFile));
|
|
1433
1420
|
const inlineKey = resolveInlineKey().orUndefined();
|
|
1434
|
-
if (inlineKey) return
|
|
1421
|
+
if (inlineKey) return keysFrom(parseIdentityKeys(inlineKey, INLINE_KEY_SOURCE));
|
|
1435
1422
|
if (!config.identity?.key_file) {
|
|
1436
1423
|
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1437
|
-
if (existsSync(defaultPath)) return
|
|
1438
|
-
path: defaultPath,
|
|
1439
|
-
dispose: noop
|
|
1440
|
-
});
|
|
1424
|
+
if (existsSync(defaultPath)) return keysFrom(unwrapAgentKey(defaultPath));
|
|
1441
1425
|
}
|
|
1442
|
-
return Option(void 0);
|
|
1426
|
+
return Right(Option(void 0));
|
|
1443
1427
|
};
|
|
1444
1428
|
/** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
|
|
1445
1429
|
const describeSealKeySearch = (config, configDir) => {
|
|
@@ -1457,7 +1441,7 @@ const describeSealKeySearch = (config, configDir) => {
|
|
|
1457
1441
|
];
|
|
1458
1442
|
};
|
|
1459
1443
|
const resolveIdentityKey = (config, configDir) => {
|
|
1460
|
-
return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (
|
|
1444
|
+
return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (keys) => Right(Option(keys[0]))));
|
|
1461
1445
|
};
|
|
1462
1446
|
const detectFnoxKeys = (configDir) => detectFnox(configDir).fold(() => /* @__PURE__ */ new Set(), (fnoxPath) => readFnoxConfig(fnoxPath).fold(() => /* @__PURE__ */ new Set(), (fnoxConfig) => extractFnoxKeys(fnoxConfig)));
|
|
1463
1447
|
const checkExpiration = (audit, failOnExpired, warnOnly) => {
|
|
@@ -1470,6 +1454,19 @@ const checkExpiration = (audit, failOnExpired, warnOnly) => {
|
|
|
1470
1454
|
if (audit.expired > 0 && warnOnly) warnings.push(`${audit.expired} secret(s) have expired (warn-only mode)`);
|
|
1471
1455
|
return Right(warnings);
|
|
1472
1456
|
};
|
|
1457
|
+
/**
|
|
1458
|
+
* Sealed values are declared, so a usable key is required. No key anywhere → SealKeyUnavailable;
|
|
1459
|
+
* a key that can't be used (an unreadable file, or a passphrase-protected, plugin or SSH key) → its
|
|
1460
|
+
* IdentityError. Either way
|
|
1461
|
+
* boot refuses rather than inject empty values — even under warnOnly, which governs soft issues
|
|
1462
|
+
* like expiry, not an absent key.
|
|
1463
|
+
*/
|
|
1464
|
+
const requireSealKeys = (config, configDir, entries) => resolveSealIdentity(config, configDir).flatMap((keys) => keys.fold(() => Left({
|
|
1465
|
+
_tag: "SealKeyUnavailable",
|
|
1466
|
+
sealedKeys: Object.keys(entries).filter((k) => !!entries[k]?.encrypted_value),
|
|
1467
|
+
searched: describeSealKeySearch(config, configDir),
|
|
1468
|
+
configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
|
|
1469
|
+
}), (k) => Right(k)));
|
|
1473
1470
|
const SECRET_PATTERNS = [
|
|
1474
1471
|
/^sk-/,
|
|
1475
1472
|
/^ghp_/,
|
|
@@ -1488,171 +1485,162 @@ const checkEnvMisclassification = (config) => {
|
|
|
1488
1485
|
const envEntries = config.env ?? {};
|
|
1489
1486
|
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}]`);
|
|
1490
1487
|
};
|
|
1491
|
-
/** Programmatic boot —
|
|
1492
|
-
const bootSafe = (options) => {
|
|
1488
|
+
/** Programmatic boot — resolves to Either<BootError, BootResult> */
|
|
1489
|
+
const bootSafe = async (options) => {
|
|
1493
1490
|
const opts = options ?? {};
|
|
1494
1491
|
const inject = opts.inject !== false;
|
|
1495
1492
|
const failOnExpired = opts.failOnExpired !== false;
|
|
1496
1493
|
const warnOnly = opts.warnOnly ?? false;
|
|
1497
1494
|
const log = (opts.logger ?? directSilentLogger).withContext({ component: "envpkt.boot" });
|
|
1498
|
-
|
|
1495
|
+
const loaded = resolveAndLoad(opts);
|
|
1496
|
+
if (loaded.isLeft()) return Left(loaded.value);
|
|
1497
|
+
const { config, configPath, configDir, configSource } = loaded.value;
|
|
1498
|
+
const aliases = validateAliases(config);
|
|
1499
|
+
if (aliases.isLeft()) {
|
|
1500
|
+
const err = aliases.value;
|
|
1499
1501
|
log.warn("alias.validate.failed", {
|
|
1500
1502
|
tag: err._tag,
|
|
1501
1503
|
key: "key" in err ? err.key : void 0
|
|
1502
1504
|
});
|
|
1503
1505
|
return Left(err);
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1506
|
+
}
|
|
1507
|
+
const aliasTable = aliases.value;
|
|
1508
|
+
log.debug("alias.validate.success", { aliases: aliasTable.entries.size });
|
|
1509
|
+
const secretEntries = config.secret ?? {};
|
|
1510
|
+
const envEntries = config.env ?? {};
|
|
1511
|
+
const namer = makeEnvNamer(config);
|
|
1512
|
+
const secretEnv = (key) => namer(key, secretEntries[key]?.namespace);
|
|
1513
|
+
const envEnv = (key) => namer(key, envEntries[key]?.namespace);
|
|
1514
|
+
const envNames = {
|
|
1515
|
+
...Object.fromEntries(Object.keys(envEntries).map((k) => [k, envEnv(k)])),
|
|
1516
|
+
...Object.fromEntries(Object.keys(secretEntries).map((k) => [k, secretEnv(k)]))
|
|
1517
|
+
};
|
|
1518
|
+
const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
|
|
1519
|
+
const aliasSecretKeys = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
|
|
1520
|
+
const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
1521
|
+
const aliasEnvKeys = Object.entries(envEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
|
|
1522
|
+
const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
|
|
1523
|
+
const hasSealedValues = Object.values(nonAliasSecretEntries).some((meta) => !!meta.encrypted_value);
|
|
1524
|
+
const identityKeyResult = resolveIdentityKey(config, configDir);
|
|
1525
|
+
const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
|
|
1526
|
+
if (identityKeyResult.isLeft() && !hasSealedValues) return Left(identityKeyResult.value);
|
|
1527
|
+
const fnoxKeys = detectFnoxKeys(configDir);
|
|
1528
|
+
const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
|
|
1529
|
+
const sealKeys = hasSealedValues ? requireSealKeys(config, configDir, nonAliasSecretEntries) : Right([]);
|
|
1530
|
+
if (sealKeys.isLeft()) return Left(sealKeys.value);
|
|
1531
|
+
const unsealKeys = sealKeys.value;
|
|
1532
|
+
const expiration = checkExpiration(audit, failOnExpired, warnOnly);
|
|
1533
|
+
if (expiration.isLeft()) return Left(expiration.value);
|
|
1534
|
+
const warnings = expiration.value;
|
|
1535
|
+
const secrets = {};
|
|
1536
|
+
const injected = [];
|
|
1537
|
+
const skipped = [];
|
|
1538
|
+
warnings.push(...checkEnvMisclassification(config));
|
|
1539
|
+
const nsSeparator = config.namespace?.separator;
|
|
1540
|
+
if (nsSeparator !== void 0 && !isShellSafeSeparator(nsSeparator)) warnings.push(`[namespace] separator "${nsSeparator}" is not shell-safe — injected names won't be usable via shell $VAR/export. Use '_' or '__'.`);
|
|
1541
|
+
const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[envEnv(key)]).fold(() => Option(entry.value).fold(() => [], (v) => [[key, v]]), () => [])));
|
|
1542
|
+
const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[envEnv(key)]).fold(() => [], () => [key]));
|
|
1543
|
+
if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1544
|
+
process.env[envEnv(key)] = value;
|
|
1545
|
+
});
|
|
1546
|
+
const sealedKeys = /* @__PURE__ */ new Set();
|
|
1547
|
+
if (hasSealedValues) (await unsealSecrets(nonAliasSecretEntries, unsealKeys)).fold((err) => {
|
|
1548
|
+
log.warn("phase.sealed.decrypt_failed", { message: err.message });
|
|
1549
|
+
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1550
|
+
}, (unsealed) => {
|
|
1551
|
+
const unsealedEntries = Object.entries(unsealed);
|
|
1552
|
+
Object.assign(secrets, unsealed);
|
|
1553
|
+
injected.push(...unsealedEntries.map(([key]) => key));
|
|
1554
|
+
unsealedEntries.forEach(([key]) => {
|
|
1555
|
+
sealedKeys.add(key);
|
|
1556
|
+
log.debug("phase.sealed.resolved", { key });
|
|
1535
1557
|
});
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[envEnv(key)]).fold(() => Option(entry.value).fold(() => [], (v) => [[key, v]]), () => [])));
|
|
1544
|
-
const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[envEnv(key)]).fold(() => [], () => [key]));
|
|
1545
|
-
if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1546
|
-
process.env[envEnv(key)] = value;
|
|
1547
|
-
});
|
|
1548
|
-
const sealedKeys = /* @__PURE__ */ new Set();
|
|
1549
|
-
if (hasSealedValues) sealIdentity.fold(() => {
|
|
1550
|
-
log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
|
|
1551
|
-
}, ({ path: idPath, dispose }) => {
|
|
1552
|
-
try {
|
|
1553
|
-
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
1554
|
-
log.warn("phase.sealed.decrypt_failed", { message: err.message });
|
|
1555
|
-
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1556
|
-
}, (unsealed) => {
|
|
1557
|
-
const unsealedEntries = Object.entries(unsealed);
|
|
1558
|
-
Object.assign(secrets, unsealed);
|
|
1559
|
-
injected.push(...unsealedEntries.map(([key]) => key));
|
|
1560
|
-
unsealedEntries.forEach(([key]) => {
|
|
1561
|
-
sealedKeys.add(key);
|
|
1562
|
-
log.debug("phase.sealed.resolved", { key });
|
|
1563
|
-
});
|
|
1564
|
-
});
|
|
1565
|
-
} finally {
|
|
1566
|
-
dispose();
|
|
1567
|
-
}
|
|
1558
|
+
});
|
|
1559
|
+
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1560
|
+
if (remainingKeys.length > 0) {
|
|
1561
|
+
if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1562
|
+
log.warn("phase.fnox.export_failed", {
|
|
1563
|
+
message: err.message,
|
|
1564
|
+
skipped: remainingKeys.length
|
|
1568
1565
|
});
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
const found = remainingKeys.filter((key) => key in exported);
|
|
1580
|
-
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1581
|
-
found.forEach((key) => {
|
|
1582
|
-
secrets[key] = exported[key];
|
|
1583
|
-
log.debug("phase.fnox.resolved", {
|
|
1584
|
-
key,
|
|
1585
|
-
profile: opts.profile
|
|
1586
|
-
});
|
|
1587
|
-
});
|
|
1588
|
-
notFound.forEach((key) => {
|
|
1589
|
-
log.debug("phase.fnox.not_in_export", {
|
|
1590
|
-
key,
|
|
1591
|
-
profile: opts.profile
|
|
1592
|
-
});
|
|
1593
|
-
});
|
|
1594
|
-
injected.push(...found);
|
|
1595
|
-
skipped.push(...notFound);
|
|
1566
|
+
warnings.push(`fnox export failed: ${err.message}`);
|
|
1567
|
+
skipped.push(...remainingKeys);
|
|
1568
|
+
}, (exported) => {
|
|
1569
|
+
const found = remainingKeys.filter((key) => key in exported);
|
|
1570
|
+
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1571
|
+
found.forEach((key) => {
|
|
1572
|
+
secrets[key] = exported[key];
|
|
1573
|
+
log.debug("phase.fnox.resolved", {
|
|
1574
|
+
key,
|
|
1575
|
+
profile: opts.profile
|
|
1596
1576
|
});
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
aliasSecretKeys.forEach((aliasKey) => {
|
|
1605
|
-
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
1606
|
-
if (!entry) return;
|
|
1607
|
-
Option(secrets[entry.targetKey]).fold(() => {
|
|
1608
|
-
skipped.push(aliasKey);
|
|
1609
|
-
log.debug("phase.alias.target_unresolved", {
|
|
1610
|
-
alias: aliasKey,
|
|
1611
|
-
target: entry.targetKey
|
|
1612
|
-
});
|
|
1613
|
-
}, (targetValue) => {
|
|
1614
|
-
secrets[aliasKey] = targetValue;
|
|
1615
|
-
injected.push(aliasKey);
|
|
1616
|
-
log.debug("phase.alias.copied", {
|
|
1617
|
-
alias: aliasKey,
|
|
1618
|
-
target: entry.targetKey
|
|
1619
|
-
});
|
|
1577
|
+
});
|
|
1578
|
+
notFound.forEach((key) => {
|
|
1579
|
+
log.debug("phase.fnox.not_in_export", {
|
|
1580
|
+
key,
|
|
1581
|
+
profile: opts.profile
|
|
1620
1582
|
});
|
|
1621
1583
|
});
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1584
|
+
injected.push(...found);
|
|
1585
|
+
skipped.push(...notFound);
|
|
1586
|
+
});
|
|
1587
|
+
else {
|
|
1588
|
+
log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
|
|
1589
|
+
if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
|
|
1590
|
+
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1591
|
+
skipped.push(...remainingKeys);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
aliasSecretKeys.forEach((aliasKey) => {
|
|
1595
|
+
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
1596
|
+
if (!entry) return;
|
|
1597
|
+
Option(secrets[entry.targetKey]).fold(() => {
|
|
1598
|
+
skipped.push(aliasKey);
|
|
1599
|
+
log.debug("phase.alias.target_unresolved", {
|
|
1600
|
+
alias: aliasKey,
|
|
1601
|
+
target: entry.targetKey
|
|
1602
|
+
});
|
|
1603
|
+
}, (targetValue) => {
|
|
1604
|
+
secrets[aliasKey] = targetValue;
|
|
1605
|
+
injected.push(aliasKey);
|
|
1606
|
+
log.debug("phase.alias.copied", {
|
|
1607
|
+
alias: aliasKey,
|
|
1608
|
+
target: entry.targetKey
|
|
1633
1609
|
});
|
|
1634
|
-
if (inject) {
|
|
1635
|
-
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1636
|
-
process.env[envEnv(key)] ??= value;
|
|
1637
|
-
});
|
|
1638
|
-
Object.entries(secrets).forEach(([key, value]) => {
|
|
1639
|
-
process.env[secretEnv(key)] = value;
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
return {
|
|
1643
|
-
audit,
|
|
1644
|
-
injected,
|
|
1645
|
-
skipped,
|
|
1646
|
-
secrets,
|
|
1647
|
-
warnings,
|
|
1648
|
-
envDefaults,
|
|
1649
|
-
overridden,
|
|
1650
|
-
envNames,
|
|
1651
|
-
configPath,
|
|
1652
|
-
configSource
|
|
1653
|
-
};
|
|
1654
1610
|
});
|
|
1655
|
-
})
|
|
1611
|
+
});
|
|
1612
|
+
aliasEnvKeys.forEach((aliasKey) => {
|
|
1613
|
+
const entry = aliasTable.entries.get(`env.${aliasKey}`);
|
|
1614
|
+
if (!entry) return;
|
|
1615
|
+
if (process.env[envEnv(aliasKey)] !== void 0) {
|
|
1616
|
+
overridden.push(aliasKey);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const targetEntry = envEntries[entry.targetKey];
|
|
1620
|
+
if (targetEntry?.value === void 0) return;
|
|
1621
|
+
const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
|
|
1622
|
+
envDefaults[aliasKey] = resolvedTarget;
|
|
1623
|
+
});
|
|
1624
|
+
if (inject) {
|
|
1625
|
+
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1626
|
+
process.env[envEnv(key)] ??= value;
|
|
1627
|
+
});
|
|
1628
|
+
Object.entries(secrets).forEach(([key, value]) => {
|
|
1629
|
+
process.env[secretEnv(key)] = value;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
return Right({
|
|
1633
|
+
audit,
|
|
1634
|
+
injected,
|
|
1635
|
+
skipped,
|
|
1636
|
+
secrets,
|
|
1637
|
+
warnings,
|
|
1638
|
+
envDefaults,
|
|
1639
|
+
overridden,
|
|
1640
|
+
envNames,
|
|
1641
|
+
configPath,
|
|
1642
|
+
configSource
|
|
1643
|
+
});
|
|
1656
1644
|
};
|
|
1657
1645
|
//#endregion
|
|
1658
1646
|
//#region src/core/copy.ts
|
|
@@ -2216,7 +2204,7 @@ const todayIso$2 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0
|
|
|
2216
2204
|
* value are copied as metadata only (with a warning) — there's nothing to reseal.
|
|
2217
2205
|
* Overwriting an existing destination entry keeps its `created`; a new entry gets today.
|
|
2218
2206
|
*/
|
|
2219
|
-
const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
2207
|
+
const sealedSecretBlock = async (key, destName, srcConfig, srcPath, destConfig) => {
|
|
2220
2208
|
const meta = srcConfig.secret[key];
|
|
2221
2209
|
const created = destConfig.secret?.[destName]?.created ?? todayIso$2();
|
|
2222
2210
|
if (meta.encrypted_value === void 0 || meta.encrypted_value === "") {
|
|
@@ -2226,26 +2214,23 @@ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
|
2226
2214
|
encryptedValue: Option.none()
|
|
2227
2215
|
}));
|
|
2228
2216
|
}
|
|
2229
|
-
const
|
|
2217
|
+
const keys = orExit(resolveSealIdentity(srcConfig, dirname(srcPath)), (err) => `${RED}Error:${RESET} cannot unseal "${key}" from source — ${describeIdentityError(err)}`, 2).fold(() => {
|
|
2230
2218
|
console.error(`${RED}Error:${RESET} cannot unseal "${key}" from source — no age key found.`);
|
|
2231
2219
|
describeSealKeySearch(srcConfig, dirname(srcPath)).forEach((line) => console.error(`${DIM} ${line}${RESET}`));
|
|
2232
2220
|
return process.exit(2);
|
|
2233
|
-
}, (
|
|
2221
|
+
}, (k) => k);
|
|
2234
2222
|
const recipient = destConfig.identity?.recipient;
|
|
2235
2223
|
if (recipient === void 0) {
|
|
2236
|
-
identity.dispose();
|
|
2237
2224
|
console.error(`${RED}Error:${RESET} destination needs identity.recipient to reseal "${destName}".`);
|
|
2238
2225
|
console.error(`${DIM} Run ${CYAN}envpkt keygen${DIM} in the destination, or add an [identity] recipient.${RESET}`);
|
|
2239
2226
|
return process.exit(1);
|
|
2240
2227
|
}
|
|
2241
|
-
const
|
|
2242
|
-
identity.dispose();
|
|
2243
|
-
const plaintext = orExit(unsealed, (err) => `${RED}Error:${RESET} decrypt failed for "${key}": ${err.message}`, 2)[key];
|
|
2228
|
+
const plaintext = orExit(await unsealSecrets({ [key]: meta }, keys), (err) => `${RED}Error:${RESET} decrypt failed for "${key}": ${err.message}`, 2)[key];
|
|
2244
2229
|
if (plaintext === void 0) {
|
|
2245
2230
|
console.error(`${RED}Error:${RESET} "${key}" produced no plaintext on unseal.`);
|
|
2246
2231
|
return process.exit(2);
|
|
2247
2232
|
}
|
|
2248
|
-
const cipher = orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
|
|
2233
|
+
const cipher = orExit(await ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
|
|
2249
2234
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2250
2235
|
created,
|
|
2251
2236
|
encryptedValue: Option(cipher)
|
|
@@ -2266,7 +2251,7 @@ const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
|
|
|
2266
2251
|
* (secret vs env) is detected from where the key lives in the source. `--from`/`--to`
|
|
2267
2252
|
* default to the resolved config for the current directory.
|
|
2268
2253
|
*/
|
|
2269
|
-
const runCopy = (key, options) => {
|
|
2254
|
+
const runCopy = async (key, options) => {
|
|
2270
2255
|
const src = orExit(resolveConfigPath(options.from), formatError, 2);
|
|
2271
2256
|
const dest = orExit(resolveConfigPath(options.to), formatError, 2);
|
|
2272
2257
|
const srcConfig = orExit(loadConfig(src.path), formatError, 2);
|
|
@@ -2293,7 +2278,7 @@ const runCopy = (key, options) => {
|
|
|
2293
2278
|
console.error(`${RED}Error:${RESET} ${kindWord} "${destName}" already exists in ${dest.path}. Use --force to overwrite.`);
|
|
2294
2279
|
process.exit(1);
|
|
2295
2280
|
}
|
|
2296
|
-
const block = inSecret ? sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
|
|
2281
|
+
const block = inSecret ? await sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
|
|
2297
2282
|
const renamed = destName !== key ? ` → ${BOLD}${destName}${RESET}` : "";
|
|
2298
2283
|
const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${renamed} to ${CYAN}${dest.path}${RESET}`;
|
|
2299
2284
|
writeBlock(dest.path, `[${kindWord}.${destName}]`, block, existsInDest, options.dryRun === true, successMsg);
|
|
@@ -2388,15 +2373,16 @@ const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM
|
|
|
2388
2373
|
const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
|
|
2389
2374
|
const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
|
|
2390
2375
|
/** Print the resolution/key check, returning whether it passed. */
|
|
2391
|
-
const reportResolution = (configPath) => bootSafe({
|
|
2376
|
+
const reportResolution = async (configPath) => (await bootSafe({
|
|
2392
2377
|
configPath,
|
|
2393
2378
|
inject: false,
|
|
2394
2379
|
warnOnly: true
|
|
2395
|
-
}).fold((err) => {
|
|
2380
|
+
})).fold((err) => {
|
|
2396
2381
|
if (err._tag === "SealKeyUnavailable") {
|
|
2397
2382
|
bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
|
|
2398
2383
|
err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
|
|
2399
|
-
} else bad("
|
|
2384
|
+
} else if (isIdentityError(err)) bad("key ", describeIdentityError(err));
|
|
2385
|
+
else bad("config", `${err._tag}`);
|
|
2400
2386
|
return false;
|
|
2401
2387
|
}, (boot) => {
|
|
2402
2388
|
const resolved = Object.keys(boot.secrets).length;
|
|
@@ -2406,30 +2392,23 @@ const reportResolution = (configPath) => bootSafe({
|
|
|
2406
2392
|
return true;
|
|
2407
2393
|
});
|
|
2408
2394
|
/**
|
|
2409
|
-
* One-shot environment check: is
|
|
2410
|
-
*
|
|
2395
|
+
* One-shot environment check: is a config resolvable, and do its sealed secrets decrypt with an
|
|
2396
|
+
* available key? Also reports the built-in age version. Read-only; exits non-zero if a check fails.
|
|
2411
2397
|
*/
|
|
2412
|
-
const runDoctor = (options) => {
|
|
2398
|
+
const runDoctor = async (options) => {
|
|
2413
2399
|
console.log(`${BOLD}envpkt doctor${RESET}\n`);
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
|
|
2417
|
-
return false;
|
|
2418
|
-
}, (version) => {
|
|
2419
|
-
ok("age ", version);
|
|
2420
|
-
return true;
|
|
2421
|
-
});
|
|
2422
|
-
const resolveOk = resolveConfigPath(options.config).fold(() => {
|
|
2400
|
+
ok("age ", `built in (age-encryption ${ageEncryptionVersion().orElse("version unknown")})`);
|
|
2401
|
+
const resolveOk = await resolveConfigPath(options.config).fold(() => {
|
|
2423
2402
|
warn("config", "no envpkt.toml found for this directory");
|
|
2424
|
-
return true;
|
|
2403
|
+
return Promise.resolve(true);
|
|
2425
2404
|
}, ({ path }) => {
|
|
2426
2405
|
ok("config", path);
|
|
2427
2406
|
return reportResolution(path);
|
|
2428
2407
|
});
|
|
2429
2408
|
console.log("");
|
|
2430
|
-
if (
|
|
2409
|
+
if (resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
|
|
2431
2410
|
else {
|
|
2432
|
-
console.log(`${RED}✗
|
|
2411
|
+
console.log(`${RED}✗ issue found${RESET} ${CYAN}(see above)${RESET}`);
|
|
2433
2412
|
process.exit(1);
|
|
2434
2413
|
}
|
|
2435
2414
|
};
|
|
@@ -3388,8 +3367,8 @@ const emitWarnings = (boot) => {
|
|
|
3388
3367
|
console.error(`${YELLOW}Warning:${RESET} ${warning}`);
|
|
3389
3368
|
});
|
|
3390
3369
|
};
|
|
3391
|
-
const runEnvExport = (options) => {
|
|
3392
|
-
resolveForEmit(options).fold((err) => {
|
|
3370
|
+
const runEnvExport = async (options) => {
|
|
3371
|
+
(await resolveForEmit(options)).fold((err) => {
|
|
3393
3372
|
console.error(formatError(err));
|
|
3394
3373
|
process.exit(2);
|
|
3395
3374
|
}, (boot) => {
|
|
@@ -3404,8 +3383,8 @@ const runEnvExport = (options) => {
|
|
|
3404
3383
|
if (options.track) console.log(`_ENVPKT_INJECTED='${emit.map((e) => e.name).join(" ")}'`);
|
|
3405
3384
|
});
|
|
3406
3385
|
};
|
|
3407
|
-
const runEnvGithub = (options) => {
|
|
3408
|
-
resolveForEmit(options).fold((err) => {
|
|
3386
|
+
const runEnvGithub = async (options) => {
|
|
3387
|
+
(await resolveForEmit(options)).fold((err) => {
|
|
3409
3388
|
console.error(formatError(err));
|
|
3410
3389
|
process.exit(2);
|
|
3411
3390
|
}, (boot) => {
|
|
@@ -3426,8 +3405,8 @@ const runEnvGithub = (options) => {
|
|
|
3426
3405
|
});
|
|
3427
3406
|
};
|
|
3428
3407
|
const buildDotenvHeader = (configPath) => `# Generated by envpkt — regenerate with: envpkt env dotenv\n# Source: ${configPath}. Do not edit by hand.`;
|
|
3429
|
-
const runEnvDotenv = (options) => {
|
|
3430
|
-
resolveForEmit(options).fold((err) => {
|
|
3408
|
+
const runEnvDotenv = async (options) => {
|
|
3409
|
+
(await resolveForEmit(options)).fold((err) => {
|
|
3431
3410
|
console.error(formatError(err));
|
|
3432
3411
|
process.exit(2);
|
|
3433
3412
|
}, (boot) => {
|
|
@@ -3648,14 +3627,14 @@ const registerEnvCommands = (program) => {
|
|
|
3648
3627
|
env.command("check").description("Bidirectional drift detection between envpkt.toml and live environment").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--strict", "Exit non-zero on any drift").action((options) => {
|
|
3649
3628
|
runEnvCheck(options);
|
|
3650
3629
|
});
|
|
3651
|
-
env.command("export").description("Output export statements for eval-ing secrets into the current shell. Usage: eval \"$(envpkt env export)\"").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit").option("--track", "Emit prior-value snapshots + an _ENVPKT_INJECTED list (for the shell hook to restore on cd)").action((options) => {
|
|
3652
|
-
runEnvExport(options);
|
|
3630
|
+
env.command("export").description("Output export statements for eval-ing secrets into the current shell. Usage: eval \"$(envpkt env export)\"").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit").option("--track", "Emit prior-value snapshots + an _ENVPKT_INJECTED list (for the shell hook to restore on cd)").action(async (options) => {
|
|
3631
|
+
await runEnvExport(options);
|
|
3653
3632
|
});
|
|
3654
|
-
env.command("github").description("Inject resolved secrets into $GITHUB_ENV for GitHub Actions, masking secret values in the log (::add-mask::)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--strict", "Exit non-zero if the pre-flight audit is not healthy").action((options) => {
|
|
3655
|
-
runEnvGithub(options);
|
|
3633
|
+
env.command("github").description("Inject resolved secrets into $GITHUB_ENV for GitHub Actions, masking secret values in the log (::add-mask::)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--strict", "Exit non-zero if the pre-flight audit is not healthy").action(async (options) => {
|
|
3634
|
+
await runEnvGithub(options);
|
|
3656
3635
|
});
|
|
3657
|
-
env.command("dotenv").description("Output resolved credentials in .env format (for Wrangler, Docker --env-file, Vite, etc.)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("-o, --output <file>", "Write to a file instead of stdout").option("--no-secrets", "Omit secret values (emit KEY= with a note) — values are included by default").action((options) => {
|
|
3658
|
-
runEnvDotenv(options);
|
|
3636
|
+
env.command("dotenv").description("Output resolved credentials in .env format (for Wrangler, Docker --env-file, Vite, etc.)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("-o, --output <file>", "Write to a file instead of stdout").option("--no-secrets", "Omit secret values (emit KEY= with a note) — values are included by default").action(async (options) => {
|
|
3637
|
+
await runEnvDotenv(options);
|
|
3659
3638
|
});
|
|
3660
3639
|
env.command("add").description("Add a new environment default entry to envpkt.toml").argument("<name>", "Environment variable name").argument("<value>", "Default value").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this env var exists").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--dry-run", "Preview the TOML block without writing").action((name, value, options) => {
|
|
3661
3640
|
runEnvAdd(name, value, options);
|
|
@@ -3675,20 +3654,20 @@ const registerEnvCommands = (program) => {
|
|
|
3675
3654
|
};
|
|
3676
3655
|
//#endregion
|
|
3677
3656
|
//#region src/cli/commands/exec.ts
|
|
3678
|
-
const runExec = (args, options) => {
|
|
3657
|
+
const runExec = async (args, options) => {
|
|
3679
3658
|
if (args.length === 0) {
|
|
3680
3659
|
console.error(`${RED}Error:${RESET} No command specified`);
|
|
3681
3660
|
process.exit(2);
|
|
3682
3661
|
return;
|
|
3683
3662
|
}
|
|
3684
3663
|
const skipAudit = options.skipAudit ?? options.check === false;
|
|
3685
|
-
const boot = bootSafe({
|
|
3664
|
+
const boot = (await bootSafe({
|
|
3686
3665
|
inject: false,
|
|
3687
3666
|
configPath: options.config,
|
|
3688
3667
|
profile: options.profile,
|
|
3689
3668
|
failOnExpired: false,
|
|
3690
3669
|
warnOnly: true
|
|
3691
|
-
}).fold((err) => {
|
|
3670
|
+
})).fold((err) => {
|
|
3692
3671
|
console.error(formatError(err));
|
|
3693
3672
|
process.exit(2);
|
|
3694
3673
|
}, (b) => b);
|
|
@@ -3808,13 +3787,13 @@ const SERVER_ENV = "CLAUDE_CODE_MCP_SERVER_NAME";
|
|
|
3808
3787
|
* JSON object. stdout carries the JSON and nothing else; every diagnostic goes to stderr,
|
|
3809
3788
|
* because Claude parses stdout verbatim.
|
|
3810
3789
|
*/
|
|
3811
|
-
const runHeaders = (serverArg, options) => {
|
|
3790
|
+
const runHeaders = async (serverArg, options) => {
|
|
3812
3791
|
const server = serverArg ?? process.env[SERVER_ENV];
|
|
3813
3792
|
if (!server) {
|
|
3814
3793
|
console.error(`${RED}Error:${RESET} No MCP server named. Pass [server] or set ${SERVER_ENV}.`);
|
|
3815
3794
|
process.exit(2);
|
|
3816
3795
|
}
|
|
3817
|
-
resolveForEmit(options).fold((err) => {
|
|
3796
|
+
(await resolveForEmit(options)).fold((err) => {
|
|
3818
3797
|
console.error(`MCP server "${server}":\n${formatError(err)}`);
|
|
3819
3798
|
process.exit(2);
|
|
3820
3799
|
}, (boot) => {
|
|
@@ -4034,52 +4013,46 @@ const printConfig = (config, path, resolveResult, opts) => {
|
|
|
4034
4013
|
});
|
|
4035
4014
|
}
|
|
4036
4015
|
};
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
printConfig(showConfig, path, showResolved ? resolveResult : void 0, {
|
|
4078
|
-
...printOpts ?? {},
|
|
4079
|
-
sort: options.sort
|
|
4080
|
-
});
|
|
4081
|
-
});
|
|
4082
|
-
});
|
|
4016
|
+
/** Print a formatted error and exit 2. */
|
|
4017
|
+
const exitWithError = (err) => {
|
|
4018
|
+
console.error(formatError(err));
|
|
4019
|
+
return process.exit(2);
|
|
4020
|
+
};
|
|
4021
|
+
/** Decrypt sealed values for `--secrets`. A failure is a warning: inspect still prints the rest. */
|
|
4022
|
+
const decryptForDisplay = async (config, configDir) => {
|
|
4023
|
+
const sealedEntries = Object.fromEntries(Object.entries(config.secret ?? {}).filter(([, meta]) => meta.encrypted_value));
|
|
4024
|
+
if (Object.keys(sealedEntries).length === 0) return {};
|
|
4025
|
+
const identityPath = config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : (() => {
|
|
4026
|
+
const defaultPath = resolveKeyPath();
|
|
4027
|
+
return existsSync(defaultPath) ? defaultPath : void 0;
|
|
4028
|
+
})();
|
|
4029
|
+
if (!identityPath) return {};
|
|
4030
|
+
const warnUndecrypted = (message) => {
|
|
4031
|
+
console.error(`${YELLOW}Warning:${RESET} Could not decrypt sealed secrets: ${message}`);
|
|
4032
|
+
return {};
|
|
4033
|
+
};
|
|
4034
|
+
return unwrapAgentKey(identityPath).fold((err) => Promise.resolve(warnUndecrypted(describeIdentityError(err))), async (keys) => (await unsealSecrets(sealedEntries, keys)).fold((err) => warnUndecrypted(err.message), (d) => d));
|
|
4035
|
+
};
|
|
4036
|
+
const runInspect = async (options) => {
|
|
4037
|
+
const { path, source } = resolveConfigPath(options.config).fold(exitWithError, (r) => r);
|
|
4038
|
+
const sourceMsg = formatConfigSource(path, source);
|
|
4039
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
4040
|
+
const config = loadConfig(path).fold(exitWithError, (c) => c);
|
|
4041
|
+
const configDir = dirname(path);
|
|
4042
|
+
const resolveResult = resolveConfig(config, configDir).fold(exitWithError, (r) => r);
|
|
4043
|
+
const showResolved = options.resolved ?? !!resolveResult.catalogPath;
|
|
4044
|
+
const showConfig = showResolved ? resolveResult.config : config;
|
|
4045
|
+
if (options.format === "json") {
|
|
4046
|
+
console.log(JSON.stringify(showConfig, null, 2));
|
|
4047
|
+
return;
|
|
4048
|
+
}
|
|
4049
|
+
const secretOpts = options.secrets ? {
|
|
4050
|
+
secrets: await decryptForDisplay(showConfig, configDir),
|
|
4051
|
+
secretDisplay: options.plaintext ? "plaintext" : "encrypted"
|
|
4052
|
+
} : {};
|
|
4053
|
+
printConfig(showConfig, path, showResolved ? resolveResult : void 0, {
|
|
4054
|
+
...secretOpts,
|
|
4055
|
+
sort: options.sort
|
|
4083
4056
|
});
|
|
4084
4057
|
};
|
|
4085
4058
|
//#endregion
|
|
@@ -4091,6 +4064,19 @@ const tildeShorten = (p) => {
|
|
|
4091
4064
|
};
|
|
4092
4065
|
/** Derive a default identity name from the config path's parent directory */
|
|
4093
4066
|
const deriveIdentityName = (configPath) => basename(dirname(resolve(configPath)));
|
|
4067
|
+
/** Keep the config's identity.name if it has one; otherwise derive one */
|
|
4068
|
+
const identityName = (configPath) => loadConfig(configPath).toOption().flatMap((config) => Option(config.identity?.name)).orElse(deriveIdentityName(configPath));
|
|
4069
|
+
/**
|
|
4070
|
+
* The key_file value to write. boot resolves key_file against the config's directory, so a key
|
|
4071
|
+
* inside that directory is written relative to it; any other key is written as an absolute path,
|
|
4072
|
+
* shortened to ~ under $HOME.
|
|
4073
|
+
*/
|
|
4074
|
+
const keyFileFor = (configPath, identityPath) => {
|
|
4075
|
+
const realPath = (p) => Try(() => realpathSync(p)).orElse(resolve(p));
|
|
4076
|
+
const abs = realPath(identityPath);
|
|
4077
|
+
const rel = relative(realPath(dirname(configPath)), abs);
|
|
4078
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? tildeShorten(abs) : rel;
|
|
4079
|
+
};
|
|
4094
4080
|
/**
|
|
4095
4081
|
* Derive a project-specific key path from the config path.
|
|
4096
4082
|
* - `envpkt.toml` → `~/.envpkt/<dir>-key.txt`
|
|
@@ -4104,10 +4090,10 @@ const deriveKeyPath = (configPath) => {
|
|
|
4104
4090
|
const name = stem === "envpkt" || stem === "" ? dir : `${dir}-${stem}`;
|
|
4105
4091
|
return join(homedir(), ".envpkt", `${name}-key.txt`);
|
|
4106
4092
|
};
|
|
4107
|
-
const runKeygen = (options) => {
|
|
4093
|
+
const runKeygen = async (options) => {
|
|
4108
4094
|
const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
|
|
4109
4095
|
const outputPath = options.output ?? (options.global ? resolveKeyPath() : deriveKeyPath(configPath));
|
|
4110
|
-
generateKeypair({ outputPath }).fold((err) => {
|
|
4096
|
+
(await generateKeypair({ outputPath })).fold((err) => {
|
|
4111
4097
|
if (err._tag === "KeyExists") {
|
|
4112
4098
|
console.error(`${YELLOW}Warning:${RESET} Identity file already exists: ${CYAN}${err.path}${RESET}`);
|
|
4113
4099
|
console.error(`${DIM}To replace it: remove the file first, then re-run keygen.${RESET}`);
|
|
@@ -4121,8 +4107,8 @@ const runKeygen = (options) => {
|
|
|
4121
4107
|
console.log(`${BOLD}Recipient:${RESET} ${recipient}`);
|
|
4122
4108
|
console.log("");
|
|
4123
4109
|
if (existsSync(configPath)) {
|
|
4124
|
-
const name =
|
|
4125
|
-
const keyFile =
|
|
4110
|
+
const name = identityName(configPath);
|
|
4111
|
+
const keyFile = keyFileFor(configPath, identityPath);
|
|
4126
4112
|
updateConfigIdentity(configPath, {
|
|
4127
4113
|
recipient,
|
|
4128
4114
|
name,
|
|
@@ -4503,6 +4489,8 @@ const runResolve = (options) => {
|
|
|
4503
4489
|
};
|
|
4504
4490
|
//#endregion
|
|
4505
4491
|
//#region src/core/resolve-values.ts
|
|
4492
|
+
/** Strip one trailing newline (`\n` or `\r\n`): a shell-set env var often carries one by mistake. */
|
|
4493
|
+
const stripTrailingNewline = (value) => value.replace(/\r?\n$/, "");
|
|
4506
4494
|
/** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
|
|
4507
4495
|
const resolveValues = async (keys, profile, agentKey) => {
|
|
4508
4496
|
const result = {};
|
|
@@ -4516,8 +4504,8 @@ const resolveValues = async (keys, profile, agentKey) => {
|
|
|
4516
4504
|
});
|
|
4517
4505
|
});
|
|
4518
4506
|
[...remaining].forEach((key) => {
|
|
4519
|
-
const envValue = process.env[key];
|
|
4520
|
-
if (envValue !==
|
|
4507
|
+
const envValue = stripTrailingNewline(process.env[key] ?? "");
|
|
4508
|
+
if (envValue.trim() !== "") {
|
|
4521
4509
|
result[key] = envValue;
|
|
4522
4510
|
remaining.delete(key);
|
|
4523
4511
|
}
|
|
@@ -4532,7 +4520,7 @@ const resolveValues = async (keys, profile, agentKey) => {
|
|
|
4532
4520
|
});
|
|
4533
4521
|
for (const key of remaining) {
|
|
4534
4522
|
const value = await prompt(`Enter value for ${key}: `);
|
|
4535
|
-
if (value !== "") result[key] = value;
|
|
4523
|
+
if (value.trim() !== "") result[key] = value;
|
|
4536
4524
|
}
|
|
4537
4525
|
rl.close();
|
|
4538
4526
|
}
|
|
@@ -4635,8 +4623,8 @@ const applySealedToml = (raw, sealedMeta) => {
|
|
|
4635
4623
|
return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
|
|
4636
4624
|
};
|
|
4637
4625
|
/**
|
|
4638
|
-
* A value with no non-whitespace content.
|
|
4639
|
-
*
|
|
4626
|
+
* A value with no non-whitespace content. No credential is all whitespace, so such a value is a
|
|
4627
|
+
* mistake — it is treated as empty on both the prompt and stdin paths.
|
|
4640
4628
|
*/
|
|
4641
4629
|
const isBlank = (value) => value.trim() === "";
|
|
4642
4630
|
/** Affirmative answer to a `[y/N]` confirm prompt. */
|
|
@@ -4758,10 +4746,9 @@ const runSeal = async (options) => {
|
|
|
4758
4746
|
const identityKey = Option(config.identity.key_file).flatMap((keyFile) => {
|
|
4759
4747
|
const identityPath = resolve(configDir, expandPath(keyFile));
|
|
4760
4748
|
return unwrapAgentKey(identityPath).fold((err) => {
|
|
4761
|
-
|
|
4762
|
-
console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
|
|
4749
|
+
console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${describeIdentityError(err)}`);
|
|
4763
4750
|
return Option.none();
|
|
4764
|
-
}, (
|
|
4751
|
+
}, (keys) => Option(keys[0]));
|
|
4765
4752
|
});
|
|
4766
4753
|
const editKeys = options.edit ? options.edit.split(",").map((k) => k.trim()).filter((k) => k.length > 0) : [];
|
|
4767
4754
|
if (options.stdin && editKeys.length === 0) {
|
|
@@ -4804,7 +4791,7 @@ const runSeal = async (options) => {
|
|
|
4804
4791
|
console.error(`${RED}Error:${RESET} No values provided`);
|
|
4805
4792
|
process.exit(2);
|
|
4806
4793
|
}
|
|
4807
|
-
sealSecrets(secretEntries, values, recipient).fold((err) => {
|
|
4794
|
+
(await sealSecrets(secretEntries, values, recipient)).fold((err) => {
|
|
4808
4795
|
console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
|
|
4809
4796
|
process.exit(2);
|
|
4810
4797
|
}, (sealedMeta) => {
|
|
@@ -4851,8 +4838,13 @@ const runSeal = async (options) => {
|
|
|
4851
4838
|
console.error(`${DIM} 3. ~/.envpkt/age-key.txt${RESET}`);
|
|
4852
4839
|
process.exit(2);
|
|
4853
4840
|
}
|
|
4841
|
+
const keys = unwrapAgentKey(identityPath).fold((err) => {
|
|
4842
|
+
console.error(`${RED}Error:${RESET} ${describeIdentityError(err)}`);
|
|
4843
|
+
process.exit(2);
|
|
4844
|
+
return [];
|
|
4845
|
+
}, (k) => k);
|
|
4854
4846
|
const sealedEntries = Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]]));
|
|
4855
|
-
const decrypted = unsealSecrets(sealedEntries,
|
|
4847
|
+
const decrypted = (await unsealSecrets(sealedEntries, keys)).fold((err) => {
|
|
4856
4848
|
console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
|
|
4857
4849
|
process.exit(2);
|
|
4858
4850
|
return {};
|
|
@@ -4875,7 +4867,7 @@ const runSeal = async (options) => {
|
|
|
4875
4867
|
const skippedKeys = metaKeys.filter((k) => !(k in values));
|
|
4876
4868
|
console.log(`${YELLOW}Skipped${RESET} ${skipped} key(s) with no value: ${skippedKeys.join(", ")}`);
|
|
4877
4869
|
}
|
|
4878
|
-
sealSecrets(secretEntries, values, recipient).fold((err) => {
|
|
4870
|
+
(await sealSecrets(secretEntries, values, recipient)).fold((err) => {
|
|
4879
4871
|
console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
|
|
4880
4872
|
process.exit(2);
|
|
4881
4873
|
}, (sealedMeta) => {
|
|
@@ -5207,11 +5199,11 @@ const runSecretRotate = async (name, options) => {
|
|
|
5207
5199
|
}
|
|
5208
5200
|
const { recipient } = config.identity;
|
|
5209
5201
|
const value = await readNewValue(name);
|
|
5210
|
-
if (value === "") {
|
|
5202
|
+
if (value.trim() === "") {
|
|
5211
5203
|
console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
|
|
5212
5204
|
process.exit(1);
|
|
5213
5205
|
}
|
|
5214
|
-
const ciphertext = ageEncrypt(value, recipient).fold((err) => {
|
|
5206
|
+
const ciphertext = (await ageEncrypt(value, recipient)).fold((err) => {
|
|
5215
5207
|
console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
|
|
5216
5208
|
process.exit(2);
|
|
5217
5209
|
return "";
|
|
@@ -5675,8 +5667,8 @@ program.name("envpkt").description("Credential lifecycle and fleet management fo
|
|
|
5675
5667
|
program.command("init").description("Initialize a new envpkt.toml in the current directory").option("--from-fnox [path]", "Scaffold from fnox.toml (optionally specify path)").option("--catalog <path>", "Path to shared secret catalog").option("--identity", "Include [identity] section").option("--name <name>", "Identity name (requires --identity)").option("--capabilities <caps>", "Comma-separated capabilities (requires --identity)").option("--expires <date>", "Credential expiration YYYY-MM-DD (requires --identity)").option("--scope <scope>", "Top-level scope: \"shell\" (secrets load ambiently) or \"exec\" (only via envpkt exec)").option("--global", "Scaffold a global/ambient package (implies scope = \"shell\")").option("--force", "Overwrite existing envpkt.toml").action((options) => {
|
|
5676
5668
|
runInit(process.cwd(), options);
|
|
5677
5669
|
});
|
|
5678
|
-
program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
|
|
5679
|
-
runKeygen(options);
|
|
5670
|
+
program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action(async (options) => {
|
|
5671
|
+
await runKeygen(options);
|
|
5680
5672
|
});
|
|
5681
5673
|
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing | missing_metadata | external").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--unsealed", "Show only secrets without encrypted_value (resolved at runtime or external)").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
|
|
5682
5674
|
runAudit(options);
|
|
@@ -5687,14 +5679,14 @@ program.command("fleet").description("Scan directory tree for envpkt.toml files
|
|
|
5687
5679
|
program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, MCP header, 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) => {
|
|
5688
5680
|
runValidate(options);
|
|
5689
5681
|
});
|
|
5690
|
-
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)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action((options) => {
|
|
5691
|
-
runInspect(options);
|
|
5682
|
+
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)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action(async (options) => {
|
|
5683
|
+
await runInspect(options);
|
|
5692
5684
|
});
|
|
5693
|
-
program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action((args, options) => {
|
|
5694
|
-
runExec(args, options);
|
|
5685
|
+
program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action(async (args, options) => {
|
|
5686
|
+
await runExec(args, options);
|
|
5695
5687
|
});
|
|
5696
|
-
program.command("headers").description("Print HTTP headers for an MCP server as JSON — for Claude Code's headersHelper in .mcp.json").argument("[server]", "MCP server name (default: $CLAUDE_CODE_MCP_SERVER_NAME)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").action((server, options) => {
|
|
5697
|
-
runHeaders(server, options);
|
|
5688
|
+
program.command("headers").description("Print HTTP headers for an MCP server as JSON — for Claude Code's headersHelper in .mcp.json").argument("[server]", "MCP server name (default: $CLAUDE_CODE_MCP_SERVER_NAME)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").action(async (server, options) => {
|
|
5689
|
+
await runHeaders(server, options);
|
|
5698
5690
|
});
|
|
5699
5691
|
program.command("resolve").description("Resolve catalog references and output a flat, self-contained config").option("-c, --config <path>", "Path to envpkt.toml").option("-o, --output <path>", "Write resolved config to file (default: stdout)").option("--format <format>", "Output format: toml | json", "toml").option("--dry-run", "Show what would be resolved without writing").action((options) => {
|
|
5700
5692
|
runResolve(options);
|
|
@@ -5713,14 +5705,14 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
|
|
|
5713
5705
|
program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
|
|
5714
5706
|
runUpgrade();
|
|
5715
5707
|
});
|
|
5716
|
-
program.command("copy").description("Copy a secret or env entry from one config to another (auto unseal → reseal for secrets)").argument("<key>", "The secret or env key to copy").option("--from <path>", "Source config path (default: resolved config for this directory)").option("--to <path>", "Destination config path (default: resolved config for this directory)").option("--as <newKey>", "Copy under a different key name in the destination").option("--force", "Overwrite the entry if it already exists in the destination").option("--dry-run", "Preview the change without writing").action((key, options) => {
|
|
5717
|
-
runCopy(key, options);
|
|
5708
|
+
program.command("copy").description("Copy a secret or env entry from one config to another (auto unseal → reseal for secrets)").argument("<key>", "The secret or env key to copy").option("--from <path>", "Source config path (default: resolved config for this directory)").option("--to <path>", "Destination config path (default: resolved config for this directory)").option("--as <newKey>", "Copy under a different key name in the destination").option("--force", "Overwrite the entry if it already exists in the destination").option("--dry-run", "Preview the change without writing").action(async (key, options) => {
|
|
5709
|
+
await runCopy(key, options);
|
|
5718
5710
|
});
|
|
5719
5711
|
program.command("diff").description("Compare two envpkt.toml configs by their secret/env entries (keys + metadata)").argument("<a>", "First config path").argument("<b>", "Second config path").option("--format <format>", "Output format: text | json", "text").option("--exit-code", "Exit non-zero when the configs differ (for CI drift gates)").action((a, b, options) => {
|
|
5720
5712
|
runDiff(a, b, options);
|
|
5721
5713
|
});
|
|
5722
|
-
program.command("doctor").description("Check that age is
|
|
5723
|
-
runDoctor(options);
|
|
5714
|
+
program.command("doctor").description("Check that the config resolves, a usable age key is found, and its sealed secrets decrypt").option("-c, --config <path>", "Path to envpkt.toml").action(async (options) => {
|
|
5715
|
+
await runDoctor(options);
|
|
5724
5716
|
});
|
|
5725
5717
|
program.command("config-path").description("Print the envpkt.toml path resolved for the current directory (empty if none). Resolve-only — no decryption.").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
|
5726
5718
|
runConfigPath(options);
|
|
@@ -5728,6 +5720,6 @@ program.command("config-path").description("Print the envpkt.toml path resolved
|
|
|
5728
5720
|
program.command("shell-hook").description("Output a shell hook that loads a project's credentials on cd and restores them on leave").argument("<shell>", "Shell type: zsh | bash").option("--no-audit", "Omit the credential-health audit line from the emitted hook").action((shell, options) => {
|
|
5729
5721
|
runShellHook(shell, options);
|
|
5730
5722
|
});
|
|
5731
|
-
program.
|
|
5723
|
+
await program.parseAsync();
|
|
5732
5724
|
//#endregion
|
|
5733
5725
|
export {};
|