envpkt 0.11.9 → 0.12.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.
Files changed (3) hide show
  1. package/dist/cli.js +144 -43
  2. package/dist/index.js +60 -16
  3. package/package.json +5 -5
package/dist/cli.js CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
- import { $, Cond, Do, Either, Left, List, Map as Map$1, Option, Right, Set as Set$1, Try } from "functype";
6
+ import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try } from "functype";
7
7
  import { TypeCompiler } from "@sinclair/typebox/compiler";
8
8
  import { Env, Fs, Path, Platform } from "functype-os";
9
9
  import { TomlDate, parse, stringify } from "smol-toml";
10
10
  import { FormatRegistry, Type } from "@sinclair/typebox";
11
+ import { randomBytes } from "node:crypto";
12
+ import { homedir, tmpdir } from "node:os";
11
13
  import { directSilentLogger } from "functype-log/direct";
12
14
  import { execFileSync } from "node:child_process";
13
- import { homedir } from "node:os";
14
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
17
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -1062,6 +1063,11 @@ const formatAliasError = (error) => {
1062
1063
  //#region src/core/keygen.ts
1063
1064
  /** Resolve the age identity file path: ENVPKT_AGE_KEY_FILE env var > ~/.envpkt/age-key.txt */
1064
1065
  const resolveKeyPath = () => process.env["ENVPKT_AGE_KEY_FILE"] ?? join(homedir(), ".envpkt", "age-key.txt");
1066
+ /** Resolve an inline age key from ENVPKT_AGE_KEY env var (for CI) */
1067
+ const resolveInlineKey = () => {
1068
+ const key = process.env["ENVPKT_AGE_KEY"];
1069
+ return key ? Some(key) : None();
1070
+ };
1065
1071
  /** Generate an age keypair and write to disk. Refuses to overwrite if the file already exists. */
1066
1072
  const generateKeypair = (options) => {
1067
1073
  if (!ageAvailable()) return Left({
@@ -1297,6 +1303,47 @@ const resolveIdentityFilePath = (config, configDir, useDefaultFallback) => {
1297
1303
  const defaultPath = resolveKeyPath();
1298
1304
  return existsSync(defaultPath) ? Option(defaultPath) : Option(void 0);
1299
1305
  };
1306
+ const noop = () => {};
1307
+ /** Write an inline age key to a private temp file so the `age` CLI (file-based) can use it. */
1308
+ const materializeInlineKey = (key) => {
1309
+ const dir = mkdtempSync(join(tmpdir(), "envpkt-age-"));
1310
+ const keyPath = join(dir, "age-key.txt");
1311
+ writeFileSync(keyPath, key.endsWith("\n") ? key : `${key}\n`);
1312
+ chmodSync(keyPath, 384);
1313
+ return {
1314
+ path: keyPath,
1315
+ dispose: () => rmSync(dir, {
1316
+ recursive: true,
1317
+ force: true
1318
+ })
1319
+ };
1320
+ };
1321
+ /**
1322
+ * Resolve an age identity for unsealing sealed packets. Precedence:
1323
+ * config.identity.key_file > ENVPKT_AGE_KEY_FILE > ENVPKT_AGE_KEY (inline) > ~/.envpkt/age-key.txt
1324
+ * The inline key (CI secret) ranks above the homedir default so an explicit
1325
+ * env var beats a stray local key. Inline keys are written to a 0600 temp file
1326
+ * the caller must dispose().
1327
+ */
1328
+ const resolveSealIdentity = (config, configDir) => {
1329
+ if (config.identity?.key_file) return Option({
1330
+ path: resolve(configDir, expandPath(config.identity.key_file)),
1331
+ dispose: noop
1332
+ });
1333
+ const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
1334
+ if (envFile && existsSync(envFile)) return Option({
1335
+ path: envFile,
1336
+ dispose: noop
1337
+ });
1338
+ const inlineKey = resolveInlineKey().orUndefined();
1339
+ if (inlineKey) return Option(materializeInlineKey(inlineKey));
1340
+ const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1341
+ if (existsSync(defaultPath)) return Option({
1342
+ path: defaultPath,
1343
+ dispose: noop
1344
+ });
1345
+ return Option(void 0);
1346
+ };
1300
1347
  const resolveIdentityKey = (config, configDir) => {
1301
1348
  return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
1302
1349
  };
@@ -1379,23 +1426,26 @@ const bootSafe = (options) => {
1379
1426
  process.env[envEnv(key)] = value;
1380
1427
  });
1381
1428
  const sealedKeys = /* @__PURE__ */ new Set();
1382
- const identityFilePath = resolveIdentityFilePath(config, configDir, true);
1383
- if (hasSealedValues) identityFilePath.fold(() => {
1429
+ if (hasSealedValues) resolveSealIdentity(config, configDir).fold(() => {
1384
1430
  log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
1385
1431
  warnings.push("Sealed values found but no identity file available for decryption");
1386
- }, (idPath) => {
1387
- unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
1388
- log.warn("phase.sealed.decrypt_failed", { message: err.message });
1389
- warnings.push(`Sealed value decryption failed: ${err.message}`);
1390
- }, (unsealed) => {
1391
- const unsealedEntries = Object.entries(unsealed);
1392
- Object.assign(secrets, unsealed);
1393
- injected.push(...unsealedEntries.map(([key]) => key));
1394
- unsealedEntries.forEach(([key]) => {
1395
- sealedKeys.add(key);
1396
- log.debug("phase.sealed.resolved", { key });
1432
+ }, ({ path: idPath, dispose }) => {
1433
+ try {
1434
+ unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
1435
+ log.warn("phase.sealed.decrypt_failed", { message: err.message });
1436
+ warnings.push(`Sealed value decryption failed: ${err.message}`);
1437
+ }, (unsealed) => {
1438
+ const unsealedEntries = Object.entries(unsealed);
1439
+ Object.assign(secrets, unsealed);
1440
+ injected.push(...unsealedEntries.map(([key]) => key));
1441
+ unsealedEntries.forEach(([key]) => {
1442
+ sealedKeys.add(key);
1443
+ log.debug("phase.sealed.resolved", { key });
1444
+ });
1397
1445
  });
1398
- });
1446
+ } finally {
1447
+ dispose();
1448
+ }
1399
1449
  });
1400
1450
  const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
1401
1451
  if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
@@ -2693,38 +2743,86 @@ const runEnvCheck = (options) => {
2693
2743
  });
2694
2744
  };
2695
2745
  const shellEscape = (value) => value.replace(/'/g, "'\\''");
2746
+ /** Shared resolution for the emit commands (`export`, `github`): resolve without injecting. */
2747
+ const resolveForEmit = (options) => bootSafe({
2748
+ inject: false,
2749
+ configPath: options.config,
2750
+ profile: options.profile,
2751
+ warnOnly: true
2752
+ });
2753
+ /**
2754
+ * Flatten a resolved BootResult into the ordered (wire-name, value) pairs to emit:
2755
+ * env defaults, then overridden env entries (value from config), then secrets (which
2756
+ * override). `secret` marks values that consumers must redact (e.g. GitHub masking).
2757
+ */
2758
+ const collectEmitEntries = (boot) => {
2759
+ const wireName = (key) => boot.envNames[key] ?? key;
2760
+ const defaults = Object.entries(boot.envDefaults).map(([key, value]) => ({
2761
+ name: wireName(key),
2762
+ value,
2763
+ secret: false
2764
+ }));
2765
+ const overridden = boot.overridden.length === 0 ? [] : loadConfig(boot.configPath).fold(() => [], (config) => {
2766
+ const envEntries = config.env ?? {};
2767
+ return boot.overridden.flatMap((key) => {
2768
+ const entry = envEntries[key];
2769
+ if (!entry) return [];
2770
+ const name = wireName(key);
2771
+ return [{
2772
+ name,
2773
+ value: entry.value ?? process.env[name] ?? "",
2774
+ secret: false
2775
+ }];
2776
+ });
2777
+ });
2778
+ const secrets = Object.entries(boot.secrets).map(([key, value]) => ({
2779
+ name: wireName(key),
2780
+ value,
2781
+ secret: true
2782
+ }));
2783
+ return [
2784
+ ...defaults,
2785
+ ...overridden,
2786
+ ...secrets
2787
+ ];
2788
+ };
2789
+ const emitWarnings = (boot) => {
2790
+ const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
2791
+ if (sourceMsg) console.error(sourceMsg);
2792
+ boot.warnings.forEach((warning) => {
2793
+ console.error(`${YELLOW}Warning:${RESET} ${warning}`);
2794
+ });
2795
+ };
2696
2796
  const runEnvExport = (options) => {
2697
- bootSafe({
2698
- inject: false,
2699
- configPath: options.config,
2700
- profile: options.profile,
2701
- warnOnly: true
2702
- }).fold((err) => {
2797
+ resolveForEmit(options).fold((err) => {
2703
2798
  console.error(formatError(err));
2704
2799
  process.exit(2);
2705
2800
  }, (boot) => {
2706
- const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
2707
- if (sourceMsg) console.error(sourceMsg);
2708
- boot.warnings.forEach((warning) => {
2709
- console.error(`${YELLOW}Warning:${RESET} ${warning}`);
2710
- });
2711
- const wireName = (key) => boot.envNames[key] ?? key;
2712
- Object.entries(boot.envDefaults).forEach(([key, value]) => {
2713
- console.log(`export ${wireName(key)}='${shellEscape(value)}'`);
2714
- });
2715
- if (boot.overridden.length > 0) loadConfig(boot.configPath).fold(() => {}, (config) => {
2716
- const envEntries = config.env ?? {};
2717
- boot.overridden.forEach((key) => {
2718
- if (!(key in envEntries)) return;
2719
- const entry = envEntries[key];
2720
- const name = wireName(key);
2721
- const value = entry.value ?? process.env[name] ?? "";
2722
- console.log(`export ${name}='${shellEscape(value)}'`);
2723
- });
2801
+ emitWarnings(boot);
2802
+ collectEmitEntries(boot).forEach(({ name, value }) => {
2803
+ console.log(`export ${name}='${shellEscape(value)}'`);
2724
2804
  });
2725
- Object.entries(boot.secrets).forEach(([key, value]) => {
2726
- console.log(`export ${wireName(key)}='${shellEscape(value)}'`);
2805
+ });
2806
+ };
2807
+ const runEnvGithub = (options) => {
2808
+ resolveForEmit(options).fold((err) => {
2809
+ console.error(formatError(err));
2810
+ process.exit(2);
2811
+ }, (boot) => {
2812
+ emitWarnings(boot);
2813
+ const entries = collectEmitEntries(boot);
2814
+ entries.filter((e) => e.secret).forEach((e) => console.log(`::add-mask::${e.value}`));
2815
+ const lines = entries.map(({ name, value }) => {
2816
+ const delim = `__ENVPKT_${randomBytes(9).toString("hex")}__`;
2817
+ return `${name}<<${delim}\n${value}\n${delim}`;
2727
2818
  });
2819
+ const githubEnv = process.env["GITHUB_ENV"];
2820
+ if (githubEnv) appendFileSync(githubEnv, lines.length > 0 ? `${lines.join("\n")}\n` : "");
2821
+ else {
2822
+ console.error(`${YELLOW}Warning:${RESET} GITHUB_ENV not set — printing assignments to stdout (not a GitHub Actions runner)`);
2823
+ lines.forEach((l) => console.log(l));
2824
+ }
2825
+ if (options.strict) process.exit(exitCodeForAudit(boot.audit));
2728
2826
  });
2729
2827
  };
2730
2828
  const buildEnvBlock = (name, value, options) => {
@@ -2922,6 +3020,9 @@ const registerEnvCommands = (program) => {
2922
3020
  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").action((options) => {
2923
3021
  runEnvExport(options);
2924
3022
  });
3023
+ 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) => {
3024
+ runEnvGithub(options);
3025
+ });
2925
3026
  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) => {
2926
3027
  runEnvAdd(name, value, options);
2927
3028
  });
package/dist/index.js CHANGED
@@ -4,10 +4,10 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
4
4
  import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try } from "functype";
5
5
  import { Env, Fs, Path, Platform } from "functype-os";
6
6
  import { TomlDate, parse } from "smol-toml";
7
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
8
9
  import { createDirectConsoleLogger, createDirectTestLogger, directSilentLogger, directSilentLogger as directSilentLogger$1 } from "functype-log/direct";
9
10
  import { execFileSync } from "node:child_process";
10
- import { homedir } from "node:os";
11
11
  import { createInterface } from "node:readline";
12
12
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
13
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1926,6 +1926,47 @@ const resolveIdentityFilePath = (config, configDir, useDefaultFallback) => {
1926
1926
  const defaultPath = resolveKeyPath();
1927
1927
  return existsSync(defaultPath) ? Option(defaultPath) : Option(void 0);
1928
1928
  };
1929
+ const noop = () => {};
1930
+ /** Write an inline age key to a private temp file so the `age` CLI (file-based) can use it. */
1931
+ const materializeInlineKey = (key) => {
1932
+ const dir = mkdtempSync(join(tmpdir(), "envpkt-age-"));
1933
+ const keyPath = join(dir, "age-key.txt");
1934
+ writeFileSync(keyPath, key.endsWith("\n") ? key : `${key}\n`);
1935
+ chmodSync(keyPath, 384);
1936
+ return {
1937
+ path: keyPath,
1938
+ dispose: () => rmSync(dir, {
1939
+ recursive: true,
1940
+ force: true
1941
+ })
1942
+ };
1943
+ };
1944
+ /**
1945
+ * Resolve an age identity for unsealing sealed packets. Precedence:
1946
+ * config.identity.key_file > ENVPKT_AGE_KEY_FILE > ENVPKT_AGE_KEY (inline) > ~/.envpkt/age-key.txt
1947
+ * The inline key (CI secret) ranks above the homedir default so an explicit
1948
+ * env var beats a stray local key. Inline keys are written to a 0600 temp file
1949
+ * the caller must dispose().
1950
+ */
1951
+ const resolveSealIdentity = (config, configDir) => {
1952
+ if (config.identity?.key_file) return Option({
1953
+ path: resolve(configDir, expandPath(config.identity.key_file)),
1954
+ dispose: noop
1955
+ });
1956
+ const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
1957
+ if (envFile && existsSync(envFile)) return Option({
1958
+ path: envFile,
1959
+ dispose: noop
1960
+ });
1961
+ const inlineKey = resolveInlineKey().orUndefined();
1962
+ if (inlineKey) return Option(materializeInlineKey(inlineKey));
1963
+ const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
1964
+ if (existsSync(defaultPath)) return Option({
1965
+ path: defaultPath,
1966
+ dispose: noop
1967
+ });
1968
+ return Option(void 0);
1969
+ };
1929
1970
  const resolveIdentityKey = (config, configDir) => {
1930
1971
  return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
1931
1972
  };
@@ -2008,23 +2049,26 @@ const bootSafe = (options) => {
2008
2049
  process.env[envEnv(key)] = value;
2009
2050
  });
2010
2051
  const sealedKeys = /* @__PURE__ */ new Set();
2011
- const identityFilePath = resolveIdentityFilePath(config, configDir, true);
2012
- if (hasSealedValues) identityFilePath.fold(() => {
2052
+ if (hasSealedValues) resolveSealIdentity(config, configDir).fold(() => {
2013
2053
  log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
2014
2054
  warnings.push("Sealed values found but no identity file available for decryption");
2015
- }, (idPath) => {
2016
- unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
2017
- log.warn("phase.sealed.decrypt_failed", { message: err.message });
2018
- warnings.push(`Sealed value decryption failed: ${err.message}`);
2019
- }, (unsealed) => {
2020
- const unsealedEntries = Object.entries(unsealed);
2021
- Object.assign(secrets, unsealed);
2022
- injected.push(...unsealedEntries.map(([key]) => key));
2023
- unsealedEntries.forEach(([key]) => {
2024
- sealedKeys.add(key);
2025
- log.debug("phase.sealed.resolved", { key });
2055
+ }, ({ path: idPath, dispose }) => {
2056
+ try {
2057
+ unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
2058
+ log.warn("phase.sealed.decrypt_failed", { message: err.message });
2059
+ warnings.push(`Sealed value decryption failed: ${err.message}`);
2060
+ }, (unsealed) => {
2061
+ const unsealedEntries = Object.entries(unsealed);
2062
+ Object.assign(secrets, unsealed);
2063
+ injected.push(...unsealedEntries.map(([key]) => key));
2064
+ unsealedEntries.forEach(([key]) => {
2065
+ sealedKeys.add(key);
2066
+ log.debug("phase.sealed.resolved", { key });
2067
+ });
2026
2068
  });
2027
- });
2069
+ } finally {
2070
+ dispose();
2071
+ }
2028
2072
  });
2029
2073
  const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
2030
2074
  if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.11.9",
3
+ "version": "0.12.0",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -42,14 +42,14 @@
42
42
  "@modelcontextprotocol/sdk": "^1.29.0",
43
43
  "@sinclair/typebox": "^0.34.49",
44
44
  "commander": "^15.0.0",
45
- "functype": "^1.3.0",
46
- "functype-log": "^1.3.0",
47
- "functype-os": "^1.3.0",
45
+ "functype": "^1.3.1",
46
+ "functype-log": "^1.3.1",
47
+ "functype-os": "^1.3.1",
48
48
  "smol-toml": "^1.6.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.13.1",
52
- "ts-builds": "^3.0.0",
52
+ "ts-builds": "^3.0.1",
53
53
  "tsdown": "^0.22.2",
54
54
  "tsx": "^4.22.4"
55
55
  },