envpkt 0.17.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/dist/cli.js CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join, resolve } from "node:path";
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 { execFileSync } from "node:child_process";
12
- import { homedir, tmpdir } from "node:os";
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/fnox/identity.ts
590
- /** Check if the age CLI is available on PATH */
591
- const ageAvailable = () => Try(() => {
592
- execFileSync("age", ["--version"], { stdio: "pipe" });
593
- return true;
594
- }).fold(() => false, (v) => v);
595
- /** The age CLI version string (e.g. "v1.2.0"), or None if age isn't on PATH. */
596
- const ageVersion = () => Try(() => execFileSync("age", ["--version"], {
597
- stdio: [
598
- "pipe",
599
- "pipe",
600
- "pipe"
601
- ],
602
- encoding: "utf-8"
603
- }).trim()).toOption();
604
- /** Platform-aware instructions for installing the age CLI. */
605
- const ageInstallHint = () => {
606
- return `Install age:\n ${{
607
- darwin: "brew install age",
608
- linux: "apt install age (or: apk add age · dnf install age · nix-env -iA nixpkgs.age)",
609
- win32: "scoop install age (or: winget install FiloSottile.age)"
610
- }[process.platform] ?? "see the install guide"}\n https://github.com/FiloSottile/age#installation`;
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
- * Extract the secret key from an age identity file (plain or encrypted).
614
- * - Plain identity files (from `age-keygen`) contain `AGE-SECRET-KEY-*` lines directly
615
- * - Encrypted identity files need `age --decrypt` to unwrap
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: "DecryptFailed",
624
- message: `Failed to read identity file: ${err}`
625
- }), (content) => {
626
- const secretKeyLine = content.split("\n").find((l) => l.startsWith("AGE-SECRET-KEY-"));
627
- if (secretKeyLine) return Right(secretKeyLine.trim());
628
- if (!ageAvailable()) return Left({
629
- _tag: "AgeNotFound",
630
- message: "age CLI not found on PATH"
631
- });
632
- return Try(() => execFileSync("age", ["--decrypt", identityPath], {
633
- stdio: [
634
- "pipe",
635
- "pipe",
636
- "pipe"
637
- ],
638
- encoding: "utf-8"
639
- })).fold((err) => Left({
640
- _tag: "DecryptFailed",
641
- message: `age decrypt failed: ${err}`
642
- }), (output) => Right(output.trim()));
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 "AgeNotFound": return `${RED}Error:${RESET} age is required for this operation but was not found on PATH.\n${DIM}${ageInstallHint()}${RESET}`;
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
- /** Generate an age keypair and write to disk. Refuses to overwrite if the file already exists. */
1164
- const generateKeypair = (options) => {
1165
- if (!ageAvailable()) return Left({
1166
- _tag: "AgeNotFound",
1167
- message: "age-keygen CLI not found on PATH. Install age: https://github.com/FiloSottile/age"
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 Try(() => execFileSync("age-keygen", [], {
1175
- stdio: [
1176
- "pipe",
1177
- "pipe",
1178
- "pipe"
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 using age with the given recipient public key (armored output) */
1292
- const ageEncrypt = (plaintext, recipient) => {
1293
- if (!ageAvailable()) return Left({
1294
- _tag: "AgeNotFound",
1295
- message: "age CLI not found on PATH"
1296
- });
1297
- return Try(() => execFileSync("age", [
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. The plaintext is returned as-is, untrimmed. */
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));
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
- if (!ageAvailable()) return Left({
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
- ...result,
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
- ...result,
1358
- [key]: {
1359
- ...secretMeta,
1360
- encrypted_value: ciphertext
1361
- }
1362
- }));
1363
- }), Right({}));
1364
- };
1365
- /** Unseal secrets: decrypt encrypted_value for each meta entry that has one */
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 noop = () => {};
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 an age identity for unsealing sealed packets. Precedence:
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. Inline keys are written to a 0600 temp file
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 Option({
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 Option({
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 Option(materializeInlineKey(inlineKey));
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 Option({
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), (key) => Right(Option(key))));
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 — returns Either<BootError, BootResult> */
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
- return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => validateAliases(config).fold((err) => {
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
- }, (aliasTable) => {
1505
- log.debug("alias.validate.success", { aliases: aliasTable.entries.size });
1506
- const secretEntries = config.secret ?? {};
1507
- const envEntries = config.env ?? {};
1508
- const namer = makeEnvNamer(config);
1509
- const secretEnv = (key) => namer(key, secretEntries[key]?.namespace);
1510
- const envEnv = (key) => namer(key, envEntries[key]?.namespace);
1511
- const envNames = {
1512
- ...Object.fromEntries(Object.keys(envEntries).map((k) => [k, envEnv(k)])),
1513
- ...Object.fromEntries(Object.keys(secretEntries).map((k) => [k, secretEnv(k)]))
1514
- };
1515
- const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
1516
- const aliasSecretKeys = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1517
- const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
1518
- const aliasEnvKeys = Object.entries(envEntries).filter(([, meta]) => meta.from_key !== void 0).map(([k]) => k);
1519
- const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
1520
- const hasSealedValues = Object.values(nonAliasSecretEntries).some((meta) => !!meta.encrypted_value);
1521
- const identityKeyResult = resolveIdentityKey(config, configDir);
1522
- const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
1523
- if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
1524
- _tag: "ReadError",
1525
- message: "unexpected"
1526
- }));
1527
- const fnoxKeys = detectFnoxKeys(configDir);
1528
- const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
1529
- const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
1530
- if (hasSealedValues && sealIdentity.isEmpty) return Left({
1531
- _tag: "SealKeyUnavailable",
1532
- sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
1533
- searched: describeSealKeySearch(config, configDir),
1534
- configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
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
- return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
1537
- const secrets = {};
1538
- const injected = [];
1539
- const skipped = [];
1540
- warnings.push(...checkEnvMisclassification(config));
1541
- const nsSeparator = config.namespace?.separator;
1542
- 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 '__'.`);
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
- const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
1570
- if (remainingKeys.length > 0) {
1571
- if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
1572
- log.warn("phase.fnox.export_failed", {
1573
- message: err.message,
1574
- skipped: remainingKeys.length
1575
- });
1576
- warnings.push(`fnox export failed: ${err.message}`);
1577
- skipped.push(...remainingKeys);
1578
- }, (exported) => {
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
- else {
1598
- log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
1599
- if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
1600
- else warnings.push("fnox not available — unsealed secrets could not be resolved");
1601
- skipped.push(...remainingKeys);
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
- aliasEnvKeys.forEach((aliasKey) => {
1623
- const entry = aliasTable.entries.get(`env.${aliasKey}`);
1624
- if (!entry) return;
1625
- if (process.env[envEnv(aliasKey)] !== void 0) {
1626
- overridden.push(aliasKey);
1627
- return;
1628
- }
1629
- const targetEntry = envEntries[entry.targetKey];
1630
- if (targetEntry?.value === void 0) return;
1631
- const resolvedTarget = process.env[envEnv(entry.targetKey)] ?? targetEntry.value;
1632
- envDefaults[aliasKey] = resolvedTarget;
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 identity = resolveSealIdentity(srcConfig, dirname(srcPath)).fold(() => {
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
- }, (id) => id);
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 unsealed = unsealSecrets({ [key]: meta }, identity.path);
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("config", `${err._tag}`);
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 age installed, is a config resolvable, and do its sealed
2410
- * secrets decrypt with an available key? Read-only; exits non-zero if any check fails.
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
- const ageOk = ageVersion().fold(() => {
2415
- bad("age ", "not found on PATH");
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 (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
2409
+ if (resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
2431
2410
  else {
2432
- console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
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
- const runInspect = (options) => {
4038
- resolveConfigPath(options.config).fold((err) => {
4039
- console.error(formatError(err));
4040
- process.exit(2);
4041
- }, ({ path, source }) => {
4042
- const sourceMsg = formatConfigSource(path, source);
4043
- if (sourceMsg) console.error(sourceMsg);
4044
- loadConfig(path).fold((err) => {
4045
- console.error(formatError(err));
4046
- process.exit(2);
4047
- }, (config) => {
4048
- const configDir = dirname(path);
4049
- resolveConfig(config, configDir).fold((err) => {
4050
- console.error(formatError(err));
4051
- process.exit(2);
4052
- }, (resolveResult) => {
4053
- const showResolved = options.resolved ?? !!resolveResult.catalogPath;
4054
- const showConfig = showResolved ? resolveResult.config : config;
4055
- if (options.format === "json") {
4056
- console.log(JSON.stringify(showConfig, null, 2));
4057
- return;
4058
- }
4059
- const showSecrets = showConfig.secret ?? {};
4060
- const printOpts = options.secrets ? (() => {
4061
- const sealedEntries = Object.fromEntries(Object.entries(showSecrets).filter(([, meta]) => meta.encrypted_value));
4062
- return {
4063
- secrets: Object.keys(sealedEntries).length > 0 ? (() => {
4064
- const identityPath = showConfig.identity?.key_file ? resolve(configDir, expandPath(showConfig.identity.key_file)) : (() => {
4065
- const defaultPath = resolveKeyPath();
4066
- return existsSync(defaultPath) ? defaultPath : void 0;
4067
- })();
4068
- if (!identityPath) return {};
4069
- return unsealSecrets(sealedEntries, identityPath).fold((err) => {
4070
- console.error(`${YELLOW}Warning:${RESET} Could not decrypt sealed secrets: ${err.message}`);
4071
- return {};
4072
- }, (d) => d);
4073
- })() : {},
4074
- secretDisplay: options.plaintext ? "plaintext" : "encrypted"
4075
- };
4076
- })() : void 0;
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 = deriveIdentityName(configPath);
4125
- const keyFile = tildeShorten(identityPath);
4110
+ const name = identityName(configPath);
4111
+ const keyFile = keyFileFor(configPath, identityPath);
4126
4112
  updateConfigIdentity(configPath, {
4127
4113
  recipient,
4128
4114
  name,
@@ -4760,10 +4746,9 @@ const runSeal = async (options) => {
4760
4746
  const identityKey = Option(config.identity.key_file).flatMap((keyFile) => {
4761
4747
  const identityPath = resolve(configDir, expandPath(keyFile));
4762
4748
  return unwrapAgentKey(identityPath).fold((err) => {
4763
- const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
4764
- console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
4749
+ console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${describeIdentityError(err)}`);
4765
4750
  return Option.none();
4766
- }, (k) => Option(k));
4751
+ }, (keys) => Option(keys[0]));
4767
4752
  });
4768
4753
  const editKeys = options.edit ? options.edit.split(",").map((k) => k.trim()).filter((k) => k.length > 0) : [];
4769
4754
  if (options.stdin && editKeys.length === 0) {
@@ -4806,7 +4791,7 @@ const runSeal = async (options) => {
4806
4791
  console.error(`${RED}Error:${RESET} No values provided`);
4807
4792
  process.exit(2);
4808
4793
  }
4809
- sealSecrets(secretEntries, values, recipient).fold((err) => {
4794
+ (await sealSecrets(secretEntries, values, recipient)).fold((err) => {
4810
4795
  console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
4811
4796
  process.exit(2);
4812
4797
  }, (sealedMeta) => {
@@ -4853,8 +4838,13 @@ const runSeal = async (options) => {
4853
4838
  console.error(`${DIM} 3. ~/.envpkt/age-key.txt${RESET}`);
4854
4839
  process.exit(2);
4855
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);
4856
4846
  const sealedEntries = Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]]));
4857
- const decrypted = unsealSecrets(sealedEntries, identityPath).fold((err) => {
4847
+ const decrypted = (await unsealSecrets(sealedEntries, keys)).fold((err) => {
4858
4848
  console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
4859
4849
  process.exit(2);
4860
4850
  return {};
@@ -4877,7 +4867,7 @@ const runSeal = async (options) => {
4877
4867
  const skippedKeys = metaKeys.filter((k) => !(k in values));
4878
4868
  console.log(`${YELLOW}Skipped${RESET} ${skipped} key(s) with no value: ${skippedKeys.join(", ")}`);
4879
4869
  }
4880
- sealSecrets(secretEntries, values, recipient).fold((err) => {
4870
+ (await sealSecrets(secretEntries, values, recipient)).fold((err) => {
4881
4871
  console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
4882
4872
  process.exit(2);
4883
4873
  }, (sealedMeta) => {
@@ -5213,7 +5203,7 @@ const runSecretRotate = async (name, options) => {
5213
5203
  console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
5214
5204
  process.exit(1);
5215
5205
  }
5216
- const ciphertext = ageEncrypt(value, recipient).fold((err) => {
5206
+ const ciphertext = (await ageEncrypt(value, recipient)).fold((err) => {
5217
5207
  console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
5218
5208
  process.exit(2);
5219
5209
  return "";
@@ -5677,8 +5667,8 @@ program.name("envpkt").description("Credential lifecycle and fleet management fo
5677
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) => {
5678
5668
  runInit(process.cwd(), options);
5679
5669
  });
5680
- 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) => {
5681
- 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);
5682
5672
  });
5683
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) => {
5684
5674
  runAudit(options);
@@ -5689,14 +5679,14 @@ program.command("fleet").description("Scan directory tree for envpkt.toml files
5689
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) => {
5690
5680
  runValidate(options);
5691
5681
  });
5692
- 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) => {
5693
- 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);
5694
5684
  });
5695
- 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) => {
5696
- 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);
5697
5687
  });
5698
- 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) => {
5699
- 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);
5700
5690
  });
5701
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) => {
5702
5692
  runResolve(options);
@@ -5715,14 +5705,14 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
5715
5705
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
5716
5706
  runUpgrade();
5717
5707
  });
5718
- 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) => {
5719
- 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);
5720
5710
  });
5721
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) => {
5722
5712
  runDiff(a, b, options);
5723
5713
  });
5724
- program.command("doctor").description("Check that age is installed and that the resolved config's sealed secrets can be decrypted").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
5725
- 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);
5726
5716
  });
5727
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) => {
5728
5718
  runConfigPath(options);
@@ -5730,6 +5720,6 @@ program.command("config-path").description("Print the envpkt.toml path resolved
5730
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) => {
5731
5721
  runShellHook(shell, options);
5732
5722
  });
5733
- program.parse();
5723
+ await program.parseAsync();
5734
5724
  //#endregion
5735
5725
  export {};