envpkt 0.1.0 → 0.4.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
@@ -3,6 +3,7 @@ import { Command } from "commander";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Cond, Left, List, Option, Right, Try } from "functype";
5
5
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
6
7
  import { TypeCompiler } from "@sinclair/typebox/compiler";
7
8
  import { TomlDate, parse, stringify } from "smol-toml";
8
9
  import { FormatRegistry, Type } from "@sinclair/typebox";
@@ -10,13 +11,14 @@ import { execFileSync } from "node:child_process";
10
11
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
13
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
14
+ import { createInterface } from "node:readline";
13
15
 
14
16
  //#region src/core/audit.ts
15
17
  const MS_PER_DAY = 864e5;
16
18
  const WARN_BEFORE_DAYS = 30;
17
19
  const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / MS_PER_DAY);
18
20
  const parseDate = (dateStr) => {
19
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00Z");
21
+ const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00Z`);
20
22
  return Number.isNaN(d.getTime()) ? Option(void 0) : Option(d);
21
23
  };
22
24
  const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration, requireService, today) => {
@@ -31,7 +33,8 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
31
33
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
32
34
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
33
35
  const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
34
- const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key);
36
+ const hasSealed = !!meta?.encrypted_value;
37
+ const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
35
38
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
36
39
  if (isExpired) issues.push("Secret has expired");
37
40
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
@@ -60,8 +63,9 @@ const computeAudit = (config, fnoxKeys, today) => {
60
63
  const requireExpiration = lifecycle.require_expiration ?? false;
61
64
  const requireService = lifecycle.require_service ?? false;
62
65
  const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
63
- const metaKeys = new Set(Object.keys(config.meta));
64
- const secrets = List(Object.entries(config.meta).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
66
+ const secretEntries = config.secret ?? {};
67
+ const metaKeys = new Set(Object.keys(secretEntries));
68
+ const secrets = List(Object.entries(secretEntries).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
65
69
  const orphaned = keys.size > 0 ? [...metaKeys].filter((k) => !keys.has(k)).length : 0;
66
70
  const total = secrets.size;
67
71
  const expired = secrets.count((s) => s.status === "expired");
@@ -84,6 +88,28 @@ const computeAudit = (config, fnoxKeys, today) => {
84
88
  agent: config.agent
85
89
  };
86
90
  };
91
+ const computeEnvAudit = (config, env = process.env) => {
92
+ const envEntries = config.env ?? {};
93
+ const entries = [];
94
+ for (const [key, entry] of Object.entries(envEntries)) {
95
+ const currentValue = env[key];
96
+ const status = Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== entry.value, "overridden").else("default");
97
+ entries.push({
98
+ key,
99
+ defaultValue: entry.value,
100
+ currentValue,
101
+ status,
102
+ purpose: entry.purpose
103
+ });
104
+ }
105
+ return {
106
+ entries,
107
+ total: entries.length,
108
+ defaults_applied: entries.filter((e) => e.status === "default").length,
109
+ overridden: entries.filter((e) => e.status === "overridden").length,
110
+ missing: entries.filter((e) => e.status === "missing").length
111
+ };
112
+ };
87
113
 
88
114
  //#endregion
89
115
  //#region src/core/schema.ts
@@ -122,6 +148,7 @@ const SecretMetaSchema = Type.Object({
122
148
  description: "URL or reference for secret rotation procedure"
123
149
  })),
124
150
  purpose: Type.Optional(Type.String({ description: "Why this secret exists and what it enables" })),
151
+ comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
125
152
  capabilities: Type.Optional(Type.Array(Type.String(), { description: "What operations this secret grants (e.g. read, write, admin)" })),
126
153
  created: Type.Optional(Type.String({
127
154
  format: "date",
@@ -131,6 +158,7 @@ const SecretMetaSchema = Type.Object({
131
158
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
132
159
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
133
160
  source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
161
+ encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
134
162
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
135
163
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
136
164
  }, { description: "Metadata about a single secret" });
@@ -154,6 +182,12 @@ const CallbackConfigSchema = Type.Object({
154
182
  on_audit_fail: Type.Optional(Type.String({ description: "Command or webhook on audit failure" }))
155
183
  }, { description: "Automation callbacks for lifecycle events" });
156
184
  const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
185
+ const EnvMetaSchema = Type.Object({
186
+ value: Type.String({ description: "Default value for this environment variable" }),
187
+ purpose: Type.Optional(Type.String({ description: "Why this env var exists" })),
188
+ comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
189
+ tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
190
+ }, { description: "Metadata for a plaintext environment default (non-secret)" });
157
191
  const EnvpktConfigSchema = Type.Object({
158
192
  version: Type.Number({
159
193
  description: "Schema version number",
@@ -161,7 +195,8 @@ const EnvpktConfigSchema = Type.Object({
161
195
  }),
162
196
  catalog: Type.Optional(Type.String({ description: "Path to shared secret catalog (relative to this config file)" })),
163
197
  agent: Type.Optional(AgentIdentitySchema),
164
- meta: Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" }),
198
+ secret: Type.Optional(Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" })),
199
+ env: Type.Optional(Type.Record(Type.String(), EnvMetaSchema, { description: "Plaintext environment defaults keyed by variable name" })),
165
200
  lifecycle: Type.Optional(LifecycleConfigSchema),
166
201
  callbacks: Type.Optional(CallbackConfigSchema),
167
202
  tools: Type.Optional(ToolsConfigSchema)
@@ -183,10 +218,41 @@ const normalizeDates = (obj) => {
183
218
  if (obj !== null && typeof obj === "object") return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, normalizeDates(v)]));
184
219
  return obj;
185
220
  };
186
- /** Find envpkt.toml in the given directory */
187
- const findConfigPath = (dir) => {
188
- const candidate = join(dir, CONFIG_FILENAME$2);
189
- return existsSync(candidate) ? Option(candidate) : Option(void 0);
221
+ /** Expand ~ and $ENV_VAR / ${ENV_VAR} in a path string */
222
+ const expandPath = (p) => {
223
+ return (p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p).replace(/\$\{(\w+)\}|\$(\w+)/g, (_, braced, bare) => {
224
+ const name = braced ?? bare ?? "";
225
+ return process.env[name] ?? "";
226
+ });
227
+ };
228
+ /** Ordered candidate paths for config discovery beyond CWD */
229
+ const CONFIG_SEARCH_PATHS = [
230
+ "~/.envpkt/envpkt.toml",
231
+ "$WINHOME/OneDrive/.envpkt/envpkt.toml",
232
+ "$USERPROFILE/OneDrive/.envpkt/envpkt.toml",
233
+ "~/Library/Mobile Documents/com~apple~CloudDocs/.envpkt/envpkt.toml",
234
+ "~/Dropbox/.envpkt/envpkt.toml",
235
+ "$DROPBOX_PATH/.envpkt/envpkt.toml",
236
+ "$GOOGLE_DRIVE/.envpkt/envpkt.toml",
237
+ "$WINHOME/.envpkt/envpkt.toml",
238
+ "$USERPROFILE/.envpkt/envpkt.toml"
239
+ ];
240
+ /** Discover config by checking CWD, then ENVPKT_SEARCH_PATH, then built-in candidate paths */
241
+ const discoverConfig = (cwd) => {
242
+ const cwdCandidate = join(cwd ?? process.cwd(), CONFIG_FILENAME$2);
243
+ if (existsSync(cwdCandidate)) return Option({
244
+ path: cwdCandidate,
245
+ source: "cwd"
246
+ });
247
+ const customPaths = process.env.ENVPKT_SEARCH_PATH?.split(":").filter(Boolean) ?? [];
248
+ for (const template of [...customPaths, ...CONFIG_SEARCH_PATHS]) {
249
+ const expanded = expandPath(template);
250
+ if (expanded && !expanded.startsWith("/.envpkt") && existsSync(expanded)) return Option({
251
+ path: expanded,
252
+ source: "search"
253
+ });
254
+ }
255
+ return Option(void 0);
190
256
  };
191
257
  /** Read a config file, returning Either<ConfigError, string> */
192
258
  const readConfigFile = (path) => {
@@ -199,14 +265,13 @@ const readConfigFile = (path) => {
199
265
  message: String(err)
200
266
  }), (content) => Right(content));
201
267
  };
202
- /** Ensure required fields have defaults for valid configs (e.g. agent configs with catalog may omit meta) */
268
+ /** Ensure required fields have defaults for valid configs (e.g. agent configs with catalog may omit secret) */
203
269
  const applyDefaults = (data) => {
204
270
  if (data !== null && typeof data === "object" && !Array.isArray(data)) {
205
- const obj = data;
206
- if (!("meta" in obj)) return {
207
- ...obj,
208
- meta: {}
209
- };
271
+ const result = { ...data };
272
+ if (!("secret" in result)) result.secret = {};
273
+ if (!("env" in result)) result.env = {};
274
+ return result;
210
275
  }
211
276
  return data;
212
277
  };
@@ -229,12 +294,16 @@ const loadConfig = (path) => readConfigFile(path).flatMap(parseToml).flatMap(val
229
294
  * Resolve config path via priority chain:
230
295
  * 1. Explicit flag path
231
296
  * 2. ENVPKT_CONFIG env var
232
- * 3. CWD discovery
297
+ * 3. CWD + discovery chain (home dir, cloud storage, custom search paths)
233
298
  */
234
299
  const resolveConfigPath = (flagPath, envVar, cwd) => {
235
300
  if (flagPath) {
236
301
  const resolved = resolve(flagPath);
237
- return existsSync(resolved) ? Right(resolved) : Left({
302
+ const result = {
303
+ path: resolved,
304
+ source: "flag"
305
+ };
306
+ return existsSync(resolved) ? Right(result) : Left({
238
307
  _tag: "FileNotFound",
239
308
  path: resolved
240
309
  });
@@ -242,16 +311,22 @@ const resolveConfigPath = (flagPath, envVar, cwd) => {
242
311
  const envPath = envVar ?? process.env[ENV_VAR_CONFIG];
243
312
  if (envPath) {
244
313
  const resolved = resolve(envPath);
245
- return existsSync(resolved) ? Right(resolved) : Left({
314
+ const result = {
315
+ path: resolved,
316
+ source: "env"
317
+ };
318
+ return existsSync(resolved) ? Right(result) : Left({
246
319
  _tag: "FileNotFound",
247
320
  path: resolved
248
321
  });
249
322
  }
250
- const dir = cwd ?? process.cwd();
251
- return findConfigPath(dir).fold(() => Left({
323
+ return discoverConfig(cwd).fold(() => Left({
252
324
  _tag: "FileNotFound",
253
- path: join(dir, CONFIG_FILENAME$2)
254
- }), (path) => Right(path));
325
+ path: join(cwd ?? process.cwd(), CONFIG_FILENAME$2)
326
+ }), ({ path, source }) => Right({
327
+ path,
328
+ source
329
+ }));
255
330
  };
256
331
 
257
332
  //#endregion
@@ -300,13 +375,14 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
300
375
  });
301
376
  const catalogPath = resolve(agentConfigDir, agentConfig.catalog);
302
377
  const agentSecrets = agentConfig.agent.secrets;
303
- return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentConfig.meta, catalogConfig.meta, agentSecrets, catalogPath).map((resolvedMeta) => {
378
+ const agentSecretEntries = agentConfig.secret ?? {};
379
+ return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentSecretEntries, catalogConfig.secret ?? {}, agentSecrets, catalogPath).map((resolvedMeta) => {
304
380
  const merged = [];
305
381
  const overridden = [];
306
382
  const warnings = [];
307
383
  for (const key of agentSecrets) {
308
384
  merged.push(key);
309
- if (agentConfig.meta[key]) overridden.push(key);
385
+ if (agentSecretEntries[key]) overridden.push(key);
310
386
  }
311
387
  const { catalog: _catalog, ...agentWithoutCatalog } = agentConfig;
312
388
  const agentIdentity = agentConfig.agent ? (() => {
@@ -320,7 +396,7 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
320
396
  ...agentIdentity,
321
397
  name: agentIdentity.name
322
398
  } : void 0,
323
- meta: resolvedMeta
399
+ secret: resolvedMeta
324
400
  },
325
401
  catalogPath,
326
402
  merged,
@@ -528,6 +604,10 @@ const formatAuditMinimal = (audit) => {
528
604
  if (audit.missing > 0) parts.push(`${audit.missing} missing`);
529
605
  return `${audit.status === "critical" ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`} ${parts.join(", ")}`;
530
606
  };
607
+ const formatConfigSource = (path, source) => {
608
+ if (source === "cwd") return "";
609
+ return `${DIM}envpkt: loaded ${path}${RESET}`;
610
+ };
531
611
 
532
612
  //#endregion
533
613
  //#region src/cli/commands/audit.ts
@@ -535,7 +615,9 @@ const runAudit = (options) => {
535
615
  resolveConfigPath(options.config).fold((err) => {
536
616
  console.error(formatError(err));
537
617
  process.exit(2);
538
- }, (path) => {
618
+ }, ({ path, source }) => {
619
+ const sourceMsg = formatConfigSource(path, source);
620
+ if (sourceMsg) console.error(sourceMsg);
539
621
  loadConfig(path).fold((err) => {
540
622
  console.error(formatError(err));
541
623
  process.exit(2);
@@ -550,32 +632,373 @@ const runAudit = (options) => {
550
632
  });
551
633
  });
552
634
  };
635
+ const formatEnvAuditTable = (config) => {
636
+ const envAudit = computeEnvAudit(config);
637
+ if (envAudit.total === 0) {
638
+ console.log(`${DIM}No [env.*] entries configured.${RESET}`);
639
+ return;
640
+ }
641
+ console.log(`\n${BOLD}Environment Defaults${RESET} (${envAudit.total} entries)`);
642
+ for (const entry of envAudit.entries) {
643
+ const statusIcon = entry.status === "default" ? `${GREEN}=${RESET}` : entry.status === "overridden" ? `${YELLOW}~${RESET}` : `${RED}!${RESET}`;
644
+ const statusLabel = entry.status === "default" ? `${DIM}using default${RESET}` : entry.status === "overridden" ? `${YELLOW}overridden${RESET} (${entry.currentValue})` : `${RED}not set${RESET}`;
645
+ console.log(` ${statusIcon} ${BOLD}${entry.key}${RESET} = "${entry.defaultValue}" ${statusLabel}`);
646
+ }
647
+ };
648
+ const formatEnvAuditJson = (config) => {
649
+ const envAudit = computeEnvAudit(config);
650
+ return JSON.stringify(envAudit, null, 2);
651
+ };
553
652
  const runAuditOnConfig = (config, options) => {
653
+ if (options.envOnly) {
654
+ if (options.format === "json") console.log(formatEnvAuditJson(config));
655
+ else formatEnvAuditTable(config);
656
+ process.exit(0);
657
+ return;
658
+ }
554
659
  const audit = computeAudit(config);
555
- let filtered = audit;
556
- if (options.status) {
557
- const statusFilter = options.status;
558
- const filteredSecrets = audit.secrets.filter((s) => s.status === statusFilter);
559
- filtered = {
660
+ const afterSealed = options.sealed ? (() => {
661
+ const secretEntries = config.secret ?? {};
662
+ return {
560
663
  ...audit,
561
- secrets: filteredSecrets
664
+ secrets: audit.secrets.filter((s) => !!secretEntries[s.key]?.encrypted_value)
562
665
  };
563
- }
564
- if (options.expiring !== void 0) {
565
- const days = options.expiring;
566
- const filteredSecrets = filtered.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= days));
567
- filtered = {
568
- ...filtered,
569
- secrets: filteredSecrets
666
+ })() : audit;
667
+ const afterExternal = options.external ? (() => {
668
+ const secretEntries = config.secret ?? {};
669
+ return {
670
+ ...afterSealed,
671
+ secrets: afterSealed.secrets.filter((s) => !secretEntries[s.key]?.encrypted_value)
570
672
  };
571
- }
673
+ })() : afterSealed;
674
+ const afterStatus = options.status ? {
675
+ ...afterExternal,
676
+ secrets: afterExternal.secrets.filter((s) => s.status === options.status)
677
+ } : afterExternal;
678
+ const filtered = options.expiring !== void 0 ? {
679
+ ...afterStatus,
680
+ secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= options.expiring))
681
+ } : afterStatus;
572
682
  if (options.format === "json") console.log(formatAuditJson(filtered));
573
683
  else if (options.format === "minimal") console.log(formatAuditMinimal(filtered));
574
684
  else console.log(formatAudit(filtered));
685
+ if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
686
+ else formatEnvAuditTable(config);
575
687
  const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
576
688
  process.exit(code);
577
689
  };
578
690
 
691
+ //#endregion
692
+ //#region src/fnox/cli.ts
693
+ /** Export all secrets from fnox as key=value pairs for a given profile */
694
+ const fnoxExport = (profile, agentKey) => {
695
+ const args = profile ? [
696
+ "export",
697
+ "--profile",
698
+ profile
699
+ ] : ["export"];
700
+ const env = agentKey ? {
701
+ ...process.env,
702
+ FNOX_AGE_KEY: agentKey
703
+ } : void 0;
704
+ return Try(() => execFileSync("fnox", args, {
705
+ stdio: "pipe",
706
+ encoding: "utf-8",
707
+ env
708
+ })).fold((err) => Left({
709
+ _tag: "FnoxCliError",
710
+ message: `fnox export failed: ${err}`
711
+ }), (output) => {
712
+ const entries = {};
713
+ for (const line of output.split("\n")) {
714
+ const eq = line.indexOf("=");
715
+ if (eq > 0) {
716
+ const key = line.slice(0, eq).trim();
717
+ entries[key] = line.slice(eq + 1).trim();
718
+ }
719
+ }
720
+ return Right(entries);
721
+ });
722
+ };
723
+
724
+ //#endregion
725
+ //#region src/fnox/detect.ts
726
+ const FNOX_CONFIG = "fnox.toml";
727
+ /** Detect fnox.toml in the given directory */
728
+ const detectFnox = (dir) => {
729
+ const candidate = join(dir, FNOX_CONFIG);
730
+ return existsSync(candidate) ? Option(candidate) : Option(void 0);
731
+ };
732
+ /** Check if fnox CLI is available on PATH */
733
+ const fnoxAvailable = () => Try(() => {
734
+ execFileSync("fnox", ["--version"], { stdio: "pipe" });
735
+ return true;
736
+ }).fold(() => false, (v) => v);
737
+
738
+ //#endregion
739
+ //#region src/fnox/identity.ts
740
+ /** Check if the age CLI is available on PATH */
741
+ const ageAvailable = () => Try(() => {
742
+ execFileSync("age", ["--version"], { stdio: "pipe" });
743
+ return true;
744
+ }).fold(() => false, (v) => v);
745
+ /** Unwrap an encrypted agent key using age --decrypt */
746
+ const unwrapAgentKey = (identityPath) => {
747
+ if (!existsSync(identityPath)) return Left({
748
+ _tag: "IdentityNotFound",
749
+ path: identityPath
750
+ });
751
+ if (!ageAvailable()) return Left({
752
+ _tag: "AgeNotFound",
753
+ message: "age CLI not found on PATH"
754
+ });
755
+ return Try(() => execFileSync("age", ["--decrypt", identityPath], {
756
+ stdio: [
757
+ "pipe",
758
+ "pipe",
759
+ "pipe"
760
+ ],
761
+ encoding: "utf-8"
762
+ })).fold((err) => Left({
763
+ _tag: "DecryptFailed",
764
+ message: `age decrypt failed: ${err}`
765
+ }), (output) => Right(output.trim()));
766
+ };
767
+
768
+ //#endregion
769
+ //#region src/fnox/parse.ts
770
+ /** Read and parse fnox.toml, extracting secret keys and profiles */
771
+ const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((err) => Left({
772
+ _tag: "FnoxParseError",
773
+ message: `Failed to read ${path}: ${err}`
774
+ }), (content) => Try(() => parse(content)).fold((err) => Left({
775
+ _tag: "FnoxParseError",
776
+ message: `Failed to parse fnox.toml: ${err}`
777
+ }), (data) => {
778
+ const profiles = data["profiles"] && typeof data["profiles"] === "object" ? Option(data["profiles"]) : Option(void 0);
779
+ const secrets = { ...data };
780
+ delete secrets["profiles"];
781
+ return Right({
782
+ secrets,
783
+ profiles
784
+ });
785
+ }));
786
+ /** Extract the set of secret key names from a parsed fnox config */
787
+ const extractFnoxKeys = (config) => new Set(Object.keys(config.secrets));
788
+
789
+ //#endregion
790
+ //#region src/core/seal.ts
791
+ /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
792
+ const ageEncrypt = (plaintext, recipient) => {
793
+ if (!ageAvailable()) return Left({
794
+ _tag: "AgeNotFound",
795
+ message: "age CLI not found on PATH"
796
+ });
797
+ return Try(() => execFileSync("age", [
798
+ "--encrypt",
799
+ "--recipient",
800
+ recipient,
801
+ "--armor"
802
+ ], {
803
+ input: plaintext,
804
+ stdio: [
805
+ "pipe",
806
+ "pipe",
807
+ "pipe"
808
+ ],
809
+ encoding: "utf-8"
810
+ })).fold((err) => Left({
811
+ _tag: "EncryptFailed",
812
+ key: "",
813
+ message: `age encrypt failed: ${err}`
814
+ }), (output) => Right(output.trim()));
815
+ };
816
+ /** Decrypt an age-armored ciphertext using the given identity file */
817
+ const ageDecrypt = (ciphertext, identityPath) => {
818
+ if (!ageAvailable()) return Left({
819
+ _tag: "AgeNotFound",
820
+ message: "age CLI not found on PATH"
821
+ });
822
+ return Try(() => execFileSync("age", [
823
+ "--decrypt",
824
+ "--identity",
825
+ identityPath
826
+ ], {
827
+ input: ciphertext,
828
+ stdio: [
829
+ "pipe",
830
+ "pipe",
831
+ "pipe"
832
+ ],
833
+ encoding: "utf-8"
834
+ })).fold((err) => Left({
835
+ _tag: "DecryptFailed",
836
+ key: "",
837
+ message: `age decrypt failed: ${err}`
838
+ }), (output) => Right(output.trim()));
839
+ };
840
+ /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
841
+ const sealSecrets = (meta, values, recipient) => {
842
+ if (!ageAvailable()) return Left({
843
+ _tag: "AgeNotFound",
844
+ message: "age CLI not found on PATH"
845
+ });
846
+ const result = {};
847
+ for (const [key, secretMeta] of Object.entries(meta)) {
848
+ const plaintext = values[key];
849
+ if (plaintext === void 0) {
850
+ result[key] = secretMeta;
851
+ continue;
852
+ }
853
+ const outcome = ageEncrypt(plaintext, recipient).fold((err) => Left({
854
+ _tag: "EncryptFailed",
855
+ key,
856
+ message: err.message
857
+ }), (ciphertext) => Right(ciphertext));
858
+ const failed = outcome.fold((err) => err, () => void 0);
859
+ if (failed) return Left(failed);
860
+ const ciphertext = outcome.fold(() => "", (v) => v);
861
+ result[key] = {
862
+ ...secretMeta,
863
+ encrypted_value: ciphertext
864
+ };
865
+ }
866
+ return Right(result);
867
+ };
868
+ /** Unseal secrets: decrypt encrypted_value for each meta entry that has one */
869
+ const unsealSecrets = (meta, identityPath) => {
870
+ if (!ageAvailable()) return Left({
871
+ _tag: "AgeNotFound",
872
+ message: "age CLI not found on PATH"
873
+ });
874
+ const result = {};
875
+ for (const [key, secretMeta] of Object.entries(meta)) {
876
+ if (!secretMeta.encrypted_value) continue;
877
+ const outcome = ageDecrypt(secretMeta.encrypted_value, identityPath).fold((err) => Left({
878
+ _tag: "DecryptFailed",
879
+ key,
880
+ message: err.message
881
+ }), (plaintext) => Right(plaintext));
882
+ const failed = outcome.fold((err) => err, () => void 0);
883
+ if (failed) return Left(failed);
884
+ result[key] = outcome.fold(() => "", (v) => v);
885
+ }
886
+ return Right(result);
887
+ };
888
+
889
+ //#endregion
890
+ //#region src/core/boot.ts
891
+ const resolveAndLoad = (opts) => resolveConfigPath(opts.configPath).fold((err) => Left(err), ({ path: configPath, source: configSource }) => loadConfig(configPath).fold((err) => Left(err), (config) => {
892
+ const configDir = dirname(configPath);
893
+ return resolveConfig(config, configDir).fold((err) => Left(err), (result) => Right({
894
+ config: result.config,
895
+ configPath,
896
+ configDir,
897
+ configSource
898
+ }));
899
+ }));
900
+ const resolveAgentKey = (config, configDir) => {
901
+ if (!config.agent?.identity) return Right(void 0);
902
+ return unwrapAgentKey(resolve(configDir, expandPath(config.agent.identity))).fold((err) => Left(err), (key) => Right(key));
903
+ };
904
+ const detectFnoxKeys = (configDir) => detectFnox(configDir).fold(() => /* @__PURE__ */ new Set(), (fnoxPath) => readFnoxConfig(fnoxPath).fold(() => /* @__PURE__ */ new Set(), (fnoxConfig) => extractFnoxKeys(fnoxConfig)));
905
+ const checkExpiration = (audit, failOnExpired, warnOnly) => {
906
+ const warnings = [];
907
+ if (audit.expired > 0 && failOnExpired && !warnOnly) return Left({
908
+ _tag: "AuditFailed",
909
+ audit,
910
+ message: `${audit.expired} secret(s) have expired`
911
+ });
912
+ if (audit.expired > 0 && warnOnly) warnings.push(`${audit.expired} secret(s) have expired (warn-only mode)`);
913
+ return Right(warnings);
914
+ };
915
+ const SECRET_PATTERNS = [
916
+ /^sk-/,
917
+ /^ghp_/,
918
+ /^ghu_/,
919
+ /^AKIA[0-9A-Z]{16}/,
920
+ /^xox[bpras]-/,
921
+ /:\/\/[^:]+:[^@]+@/,
922
+ /^ey[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/
923
+ ];
924
+ const looksLikeSecret = (value) => {
925
+ if (SECRET_PATTERNS.some((p) => p.test(value))) return true;
926
+ if (value.length > 40 && /^[A-Za-z0-9+/=]+$/.test(value)) return true;
927
+ return false;
928
+ };
929
+ const checkEnvMisclassification = (config) => {
930
+ const warnings = [];
931
+ const envEntries = config.env ?? {};
932
+ for (const [key, entry] of Object.entries(envEntries)) if (looksLikeSecret(entry.value)) warnings.push(`[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
933
+ return warnings;
934
+ };
935
+ /** Programmatic boot — returns Either<BootError, BootResult> */
936
+ const bootSafe = (options) => {
937
+ const opts = options ?? {};
938
+ const inject = opts.inject !== false;
939
+ const failOnExpired = opts.failOnExpired !== false;
940
+ const warnOnly = opts.warnOnly ?? false;
941
+ return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => {
942
+ const secretEntries = config.secret ?? {};
943
+ const metaKeys = Object.keys(secretEntries);
944
+ const hasSealedValues = metaKeys.some((k) => !!secretEntries[k]?.encrypted_value);
945
+ const agentKeyResult = resolveAgentKey(config, configDir);
946
+ const agentKey = agentKeyResult.fold(() => void 0, (k) => k);
947
+ const agentKeyError = agentKeyResult.fold((err) => err, () => void 0);
948
+ if (agentKeyError && !hasSealedValues) return Left(agentKeyError);
949
+ const audit = computeAudit(config, detectFnoxKeys(configDir));
950
+ return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
951
+ const secrets = {};
952
+ const injected = [];
953
+ const skipped = [];
954
+ warnings.push(...checkEnvMisclassification(config));
955
+ const envEntries = config.env ?? {};
956
+ const envDefaults = {};
957
+ const overridden = [];
958
+ for (const [key, entry] of Object.entries(envEntries)) if (process.env[key] === void 0) {
959
+ envDefaults[key] = entry.value;
960
+ if (inject) process.env[key] = entry.value;
961
+ } else overridden.push(key);
962
+ const sealedKeys = /* @__PURE__ */ new Set();
963
+ if (hasSealedValues && config.agent?.identity) unsealSecrets(secretEntries, resolve(configDir, expandPath(config.agent.identity))).fold((err) => {
964
+ warnings.push(`Sealed value decryption failed: ${err.message}`);
965
+ }, (unsealed) => {
966
+ for (const [key, value] of Object.entries(unsealed)) {
967
+ secrets[key] = value;
968
+ injected.push(key);
969
+ sealedKeys.add(key);
970
+ }
971
+ });
972
+ const remainingKeys = metaKeys.filter((k) => !sealedKeys.has(k));
973
+ if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, agentKey).fold((err) => {
974
+ warnings.push(`fnox export failed: ${err.message}`);
975
+ for (const key of remainingKeys) skipped.push(key);
976
+ }, (exported) => {
977
+ for (const key of remainingKeys) if (key in exported) {
978
+ secrets[key] = exported[key];
979
+ injected.push(key);
980
+ } else skipped.push(key);
981
+ });
982
+ else {
983
+ if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
984
+ for (const key of remainingKeys) skipped.push(key);
985
+ }
986
+ if (inject) for (const [key, value] of Object.entries(secrets)) process.env[key] = value;
987
+ return {
988
+ audit,
989
+ injected,
990
+ skipped,
991
+ secrets,
992
+ warnings,
993
+ envDefaults,
994
+ overridden,
995
+ configPath,
996
+ configSource
997
+ };
998
+ });
999
+ });
1000
+ };
1001
+
579
1002
  //#endregion
580
1003
  //#region src/core/patterns.ts
581
1004
  const EXCLUDED_VARS = new Set([
@@ -1207,7 +1630,7 @@ const matchValueShape = (value) => {
1207
1630
  };
1208
1631
  /** Strip common suffixes and derive a service name from an env var name */
1209
1632
  const deriveServiceFromName = (name) => {
1210
- const suffixes = [
1633
+ const matchedSuffix = [
1211
1634
  "_API_KEY",
1212
1635
  "_SECRET_KEY",
1213
1636
  "_ACCESS_KEY",
@@ -1225,13 +1648,8 @@ const deriveServiceFromName = (name) => {
1225
1648
  "_DSN",
1226
1649
  "_URL",
1227
1650
  "_URI"
1228
- ];
1229
- let stripped = name;
1230
- for (const suffix of suffixes) if (stripped.endsWith(suffix)) {
1231
- stripped = stripped.slice(0, -suffix.length);
1232
- break;
1233
- }
1234
- return stripped.toLowerCase().replace(/_/g, "-");
1651
+ ].find((s) => name.endsWith(s));
1652
+ return (matchedSuffix ? name.slice(0, -matchedSuffix.length) : name).toLowerCase().replace(/_/g, "-");
1235
1653
  };
1236
1654
  /** Match a single env var against all patterns */
1237
1655
  const matchEnvVar = (name, value) => {
@@ -1301,10 +1719,11 @@ const envScan = (env, options) => {
1301
1719
  /** Bidirectional drift detection between config and live environment */
1302
1720
  const envCheck = (config, env) => {
1303
1721
  const entries = [];
1304
- const metaKeys = Object.keys(config.meta);
1722
+ const secretEntries = config.secret ?? {};
1723
+ const metaKeys = Object.keys(secretEntries);
1305
1724
  const trackedSet = new Set(metaKeys);
1306
1725
  for (const key of metaKeys) {
1307
- const meta = config.meta[key];
1726
+ const meta = secretEntries[key];
1308
1727
  const present = env[key] !== void 0 && env[key] !== "";
1309
1728
  entries.push({
1310
1729
  envVar: key,
@@ -1313,6 +1732,17 @@ const envCheck = (config, env) => {
1313
1732
  confidence: Option(void 0)
1314
1733
  });
1315
1734
  }
1735
+ const envDefaults = config.env ?? {};
1736
+ for (const key of Object.keys(envDefaults)) if (!trackedSet.has(key)) {
1737
+ trackedSet.add(key);
1738
+ const present = env[key] !== void 0 && env[key] !== "";
1739
+ entries.push({
1740
+ envVar: key,
1741
+ service: Option(void 0),
1742
+ status: present ? "tracked" : "missing_from_env",
1743
+ confidence: Option(void 0)
1744
+ });
1745
+ }
1316
1746
  const envMatches = scanEnv(env);
1317
1747
  for (const match of envMatches) if (!trackedSet.has(match.envVar)) entries.push({
1318
1748
  envVar: match.envVar,
@@ -1332,12 +1762,12 @@ const envCheck = (config, env) => {
1332
1762
  };
1333
1763
  };
1334
1764
  const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1335
- /** Generate TOML [meta.*] blocks from scan results, mirroring init.ts pattern */
1765
+ /** Generate TOML [secret.*] blocks from scan results, mirroring init.ts pattern */
1336
1766
  const generateTomlFromScan = (matches) => {
1337
1767
  const blocks = [];
1338
1768
  for (const match of matches) {
1339
1769
  const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
1340
- blocks.push(`[meta.${match.envVar}]
1770
+ blocks.push(`[secret.${match.envVar}]
1341
1771
  service = "${svc}"
1342
1772
  # purpose = "" # Why: what this secret enables
1343
1773
  # capabilities = [] # What operations this grants
@@ -1368,16 +1798,16 @@ const runEnvScan = (options) => {
1368
1798
  console.log(toml);
1369
1799
  return;
1370
1800
  }
1371
- const configPath = join(process.cwd(), "envpkt.toml");
1801
+ const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
1372
1802
  if (existsSync(configPath)) {
1373
1803
  const existing = Try(() => readFileSync(configPath, "utf-8")).fold(() => "", (c) => c);
1374
- const newEntries = scan.discovered.toArray().filter((m) => !existing.includes(`[meta.${m.envVar}]`));
1804
+ const newEntries = scan.discovered.toArray().filter((m) => !existing.includes(`[secret.${m.envVar}]`));
1375
1805
  if (newEntries.length === 0) {
1376
- console.log(`\n${GREEN}✓${RESET} All discovered credentials already tracked in envpkt.toml`);
1806
+ console.log(`\n${GREEN}✓${RESET} All discovered credentials already tracked in ${CYAN}${configPath}${RESET}`);
1377
1807
  return;
1378
1808
  }
1379
1809
  const newToml = generateTomlFromScan(newEntries);
1380
- Try(() => writeFileSync(configPath, existing.trimEnd() + "\n\n" + newToml, "utf-8")).fold((err) => {
1810
+ Try(() => writeFileSync(configPath, `${existing.trimEnd()}\n\n${newToml}`, "utf-8")).fold((err) => {
1381
1811
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
1382
1812
  process.exit(1);
1383
1813
  }, () => {
@@ -1389,7 +1819,7 @@ const runEnvScan = (options) => {
1389
1819
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
1390
1820
  process.exit(1);
1391
1821
  }, () => {
1392
- console.log(`\n${GREEN}✓${RESET} Created ${BOLD}envpkt.toml${RESET} with ${CYAN}${scan.discovered.size}${RESET} credential(s)`);
1822
+ console.log(`\n${GREEN}✓${RESET} Created ${CYAN}${configPath}${RESET} with ${BOLD}${scan.discovered.size}${RESET} credential(s)`);
1393
1823
  });
1394
1824
  }
1395
1825
  }
@@ -1398,7 +1828,9 @@ const runEnvCheck = (options) => {
1398
1828
  resolveConfigPath(options.config).fold((err) => {
1399
1829
  console.error(formatError(err));
1400
1830
  process.exit(2);
1401
- }, (path) => {
1831
+ }, ({ path, source }) => {
1832
+ const sourceMsg = formatConfigSource(path, source);
1833
+ if (sourceMsg) console.error(sourceMsg);
1402
1834
  loadConfig(path).fold((err) => {
1403
1835
  console.error(formatError(err));
1404
1836
  process.exit(2);
@@ -1415,43 +1847,23 @@ const runEnvCheck = (options) => {
1415
1847
  });
1416
1848
  });
1417
1849
  };
1418
-
1419
- //#endregion
1420
- //#region src/fnox/detect.ts
1421
- /** Check if fnox CLI is available on PATH */
1422
- const fnoxAvailable = () => Try(() => {
1423
- execFileSync("fnox", ["--version"], { stdio: "pipe" });
1424
- return true;
1425
- }).fold(() => false, (v) => v);
1426
-
1427
- //#endregion
1428
- //#region src/fnox/identity.ts
1429
- /** Check if the age CLI is available on PATH */
1430
- const ageAvailable = () => Try(() => {
1431
- execFileSync("age", ["--version"], { stdio: "pipe" });
1432
- return true;
1433
- }).fold(() => false, (v) => v);
1434
- /** Unwrap an encrypted agent key using age --decrypt */
1435
- const unwrapAgentKey = (identityPath) => {
1436
- if (!existsSync(identityPath)) return Left({
1437
- _tag: "IdentityNotFound",
1438
- path: identityPath
1439
- });
1440
- if (!ageAvailable()) return Left({
1441
- _tag: "AgeNotFound",
1442
- message: "age CLI not found on PATH"
1850
+ const shellEscape = (value) => value.replace(/'/g, "'\\''");
1851
+ const runEnvExport = (options) => {
1852
+ bootSafe({
1853
+ inject: false,
1854
+ configPath: options.config,
1855
+ profile: options.profile,
1856
+ warnOnly: true
1857
+ }).fold((err) => {
1858
+ console.error(formatError(err));
1859
+ process.exit(2);
1860
+ }, (boot) => {
1861
+ const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
1862
+ if (sourceMsg) console.error(sourceMsg);
1863
+ for (const warning of boot.warnings) console.error(`${YELLOW}Warning:${RESET} ${warning}`);
1864
+ for (const [key, value] of Object.entries(boot.envDefaults)) console.log(`export ${key}='${shellEscape(value)}'`);
1865
+ for (const [key, value] of Object.entries(boot.secrets)) console.log(`export ${key}='${shellEscape(value)}'`);
1443
1866
  });
1444
- return Try(() => execFileSync("age", ["--decrypt", identityPath], {
1445
- stdio: [
1446
- "pipe",
1447
- "pipe",
1448
- "pipe"
1449
- ],
1450
- encoding: "utf-8"
1451
- })).fold((err) => Left({
1452
- _tag: "DecryptFailed",
1453
- message: `age decrypt failed: ${err}`
1454
- }), (output) => Right(output.trim()));
1455
1867
  };
1456
1868
 
1457
1869
  //#endregion
@@ -1462,71 +1874,40 @@ const runExec = (args, options) => {
1462
1874
  process.exit(2);
1463
1875
  return;
1464
1876
  }
1465
- const skipAudit = options.skipAudit || options.check === false;
1466
- const configData = resolveConfigPath(options.config).fold((err) => {
1877
+ const skipAudit = options.skipAudit ?? options.check === false;
1878
+ const boot = bootSafe({
1879
+ inject: false,
1880
+ configPath: options.config,
1881
+ profile: options.profile,
1882
+ failOnExpired: false,
1883
+ warnOnly: true
1884
+ }).fold((err) => {
1467
1885
  console.error(formatError(err));
1468
1886
  process.exit(2);
1469
- }, (path) => loadConfig(path).fold((err) => {
1470
- console.error(formatError(err));
1471
- process.exit(2);
1472
- }, (config) => ({
1473
- config,
1474
- path
1475
- })));
1476
- if (!configData) return;
1477
- const { config, path } = configData;
1478
- const configDir = dirname(path);
1887
+ }, (b) => b);
1888
+ if (!boot) return;
1889
+ const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
1890
+ if (sourceMsg) console.error(sourceMsg);
1479
1891
  if (!skipAudit) {
1480
- const audit = computeAudit(config);
1481
- console.error(`${BOLD}envpkt${RESET} pre-flight audit ${path}`);
1482
- console.error(formatAudit(audit));
1892
+ console.error(`${BOLD}envpkt${RESET} pre-flight audit`);
1893
+ console.error(formatAudit(boot.audit));
1483
1894
  console.error("");
1484
- if (options.strict && audit.status !== "healthy") {
1485
- console.error(`${RED}Aborting:${RESET} --strict mode and audit status is ${audit.status}`);
1486
- process.exit(exitCodeForAudit(audit));
1895
+ if (options.strict && boot.audit.status !== "healthy") {
1896
+ console.error(`${RED}Aborting:${RESET} --strict mode and audit status is ${boot.audit.status}`);
1897
+ process.exit(exitCodeForAudit(boot.audit));
1487
1898
  return;
1488
1899
  }
1489
- if (audit.status === "critical" && !options.warnOnly) {
1900
+ if (boot.audit.status === "critical" && !options.warnOnly) {
1490
1901
  console.error(`${RED}Aborting:${RESET} audit status is critical (use --warn-only to proceed)`);
1491
- process.exit(exitCodeForAudit(audit));
1902
+ process.exit(exitCodeForAudit(boot.audit));
1492
1903
  return;
1493
1904
  }
1494
- if (audit.status === "critical" && options.warnOnly) console.error(`${YELLOW}Warning:${RESET} Proceeding despite critical audit status (--warn-only)`);
1905
+ if (boot.audit.status === "critical" && options.warnOnly) console.error(`${YELLOW}Warning:${RESET} Proceeding despite critical audit status (--warn-only)`);
1495
1906
  }
1496
- let agentKey;
1497
- if (config.agent?.identity) unwrapAgentKey(resolve(configDir, config.agent.identity)).fold((err) => {
1498
- console.error(`${YELLOW}Warning:${RESET} Agent key unwrap failed: ${err._tag}`);
1499
- }, (key) => {
1500
- agentKey = key;
1501
- });
1502
- if (!fnoxAvailable()) console.error(`${YELLOW}Warning:${RESET} fnox not available — running command without secret injection`);
1907
+ for (const warning of boot.warnings) console.error(`${YELLOW}Warning:${RESET} ${warning}`);
1503
1908
  const env = { ...process.env };
1504
- if (fnoxAvailable()) {
1505
- const fnoxArgs = options.profile ? [
1506
- "export",
1507
- "--profile",
1508
- options.profile
1509
- ] : ["export"];
1510
- const fnoxEnv = agentKey ? {
1511
- ...process.env,
1512
- FNOX_AGE_KEY: agentKey
1513
- } : void 0;
1514
- Try(() => execFileSync("fnox", fnoxArgs, {
1515
- stdio: "pipe",
1516
- encoding: "utf-8",
1517
- env: fnoxEnv
1518
- })).fold((err) => {
1519
- console.error(`${YELLOW}Warning:${RESET} fnox export failed: ${err}`);
1520
- }, (output) => {
1521
- for (const line of output.split("\n")) {
1522
- const eq = line.indexOf("=");
1523
- if (eq > 0) {
1524
- const key = line.slice(0, eq).trim();
1525
- env[key] = line.slice(eq + 1).trim();
1526
- }
1527
- }
1528
- });
1529
- }
1909
+ for (const [key, value] of Object.entries(boot.envDefaults)) if (!(key in env)) env[key] = value;
1910
+ for (const [key, value] of Object.entries(boot.secrets)) env[key] = value;
1530
1911
  const [cmd, ...cmdArgs] = args;
1531
1912
  try {
1532
1913
  execFileSync(cmd, cmdArgs, {
@@ -1572,10 +1953,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
1572
1953
  const configPath = join(dir, CONFIG_FILENAME$1);
1573
1954
  if (Try(() => statSync(configPath).isFile()).fold(() => false, (v) => v)) yield configPath;
1574
1955
  if (currentDepth >= maxDepth) return;
1575
- let entries = [];
1576
- Try(() => readdirSync(dir, { withFileTypes: true })).fold(() => {}, (e) => {
1577
- entries = e;
1578
- });
1956
+ const entries = Try(() => readdirSync(dir, { withFileTypes: true })).fold(() => [], (e) => e);
1579
1957
  for (const entry of entries) if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) yield* findEnvpktFiles(join(dir, entry.name), maxDepth, currentDepth + 1);
1580
1958
  }
1581
1959
  const scanFleet = (rootDir, options) => {
@@ -1642,7 +2020,7 @@ const runFleet = (options) => {
1642
2020
  const CONFIG_FILENAME = "envpkt.toml";
1643
2021
  const todayIso = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1644
2022
  const generateSecretBlock = (key, service) => {
1645
- return `[meta.${key}]
2023
+ return `[secret.${key}]
1646
2024
  service = "${service ?? key}"
1647
2025
  # purpose = "" # Why: what this secret enables
1648
2026
  # capabilities = [] # What operations this grants
@@ -1681,18 +2059,26 @@ const generateTemplate = (options, fnoxKeys) => {
1681
2059
  lines.push(`# require_expiration = false`);
1682
2060
  lines.push(`# require_service = false`);
1683
2061
  lines.push(``);
2062
+ lines.push(`# Plaintext environment defaults (non-secret, safe to commit)`);
2063
+ lines.push(`# [env.PORT]`);
2064
+ lines.push(`# value = "3000"`);
2065
+ lines.push(`# purpose = "Application port"`);
2066
+ lines.push(`# [env.NODE_ENV]`);
2067
+ lines.push(`# value = "production"`);
2068
+ lines.push(`# purpose = "Runtime environment"`);
2069
+ lines.push(``);
1684
2070
  if (fnoxKeys && fnoxKeys.length > 0) {
1685
2071
  lines.push(`# Secrets detected from fnox.toml`);
1686
2072
  for (const key of fnoxKeys) lines.push(generateSecretBlock(key));
1687
2073
  } else {
1688
2074
  lines.push(`# Add your secret metadata below.`);
1689
- lines.push(`# Each [meta.<key>] describes a secret your agent needs.`);
2075
+ lines.push(`# Each [secret.<key>] describes a secret your agent needs.`);
1690
2076
  lines.push(``);
1691
2077
  lines.push(generateSecretBlock("EXAMPLE_API_KEY", "example-service"));
1692
2078
  }
1693
2079
  } else {
1694
2080
  lines.push(`# Optional: override catalog metadata for specific secrets`);
1695
- lines.push(`# [meta.KEY_NAME]`);
2081
+ lines.push(`# [secret.KEY_NAME]`);
1696
2082
  lines.push(`# capabilities = ["read"] # narrows catalog's broader definition`);
1697
2083
  }
1698
2084
  return lines.join("\n");
@@ -1718,20 +2104,17 @@ const runInit = (dir, options) => {
1718
2104
  console.error(`${RED}Error:${RESET} ${CONFIG_FILENAME} already exists. Use --force to overwrite.`);
1719
2105
  process.exit(1);
1720
2106
  }
1721
- let fnoxKeys;
1722
- if (options.fromFnox) {
2107
+ const fnoxKeys = options.fromFnox ? (() => {
1723
2108
  const fnoxPath = options.fromFnox === "true" || options.fromFnox === "" ? join(dir, "fnox.toml") : options.fromFnox;
1724
2109
  if (!existsSync(fnoxPath)) {
1725
2110
  console.error(`${RED}Error:${RESET} fnox.toml not found at ${fnoxPath}`);
1726
2111
  process.exit(1);
1727
2112
  }
1728
- readFnoxKeys(fnoxPath).fold((err) => {
2113
+ return readFnoxKeys(fnoxPath).fold((err) => {
1729
2114
  console.error(`${RED}Error:${RESET} Failed to read fnox.toml: ${formatConfigError(err)}`);
1730
2115
  process.exit(1);
1731
- }, (keys) => {
1732
- fnoxKeys = keys;
1733
- });
1734
- }
2116
+ }, (keys) => keys);
2117
+ })() : void 0;
1735
2118
  const content = generateTemplate(options, fnoxKeys);
1736
2119
  Try(() => writeFileSync(outPath, content, "utf-8")).fold((err) => {
1737
2120
  console.error(`${RED}Error:${RESET} Failed to write ${CONFIG_FILENAME}: ${err}`);
@@ -1754,6 +2137,7 @@ const maskValue = (value) => {
1754
2137
  //#region src/cli/commands/inspect.ts
1755
2138
  const printSecretMeta = (meta, indent) => {
1756
2139
  if (meta.purpose) console.log(`${indent}purpose: ${meta.purpose}`);
2140
+ if (meta.comment) console.log(`${indent}comment: ${DIM}${meta.comment}${RESET}`);
1757
2141
  if (meta.capabilities) console.log(`${indent}capabilities: ${DIM}${meta.capabilities.join(", ")}${RESET}`);
1758
2142
  const dateParts = [];
1759
2143
  if (meta.created) dateParts.push(`created: ${meta.created}`);
@@ -1787,13 +2171,29 @@ const printConfig = (config, path, resolveResult, opts) => {
1787
2171
  if (config.agent.secrets) console.log(` secrets: ${config.agent.secrets.join(", ")}`);
1788
2172
  console.log("");
1789
2173
  }
1790
- console.log(`${BOLD}Secrets:${RESET} ${Object.keys(config.meta).length}`);
1791
- for (const [key, meta] of Object.entries(config.meta)) {
2174
+ const secretEntries = config.secret ?? {};
2175
+ console.log(`${BOLD}Secrets:${RESET} ${Object.keys(secretEntries).length}`);
2176
+ for (const [key, meta] of Object.entries(secretEntries)) {
1792
2177
  const secretValue = opts?.secrets?.[key];
1793
2178
  const valueSuffix = secretValue !== void 0 ? ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}` : "";
1794
- console.log(` ${BOLD}${key}${RESET} ${meta.service ?? key}${valueSuffix}`);
2179
+ const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
2180
+ console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
1795
2181
  printSecretMeta(meta, " ");
1796
2182
  }
2183
+ const envEntries = config.env ?? {};
2184
+ const envKeys = Object.keys(envEntries);
2185
+ if (envKeys.length > 0) {
2186
+ console.log("");
2187
+ console.log(`${BOLD}Environment Defaults:${RESET} ${envKeys.length}`);
2188
+ for (const [key, entry] of Object.entries(envEntries)) {
2189
+ const currentValue = process.env[key];
2190
+ const statusIcon = currentValue === void 0 ? `${RED}!${RESET}` : currentValue === entry.value ? `${GREEN}=${RESET}` : `${YELLOW}~${RESET}`;
2191
+ const statusLabel = currentValue === void 0 ? `${DIM}not set${RESET}` : currentValue === entry.value ? `${DIM}using default${RESET}` : `${YELLOW}overridden${RESET}`;
2192
+ console.log(` ${statusIcon} ${BOLD}${key}${RESET} = "${entry.value}" ${statusLabel}`);
2193
+ if (entry.purpose) console.log(` purpose: ${entry.purpose}`);
2194
+ if (entry.comment) console.log(` comment: ${DIM}${entry.comment}${RESET}`);
2195
+ }
2196
+ }
1797
2197
  if (config.lifecycle) {
1798
2198
  console.log("");
1799
2199
  console.log(`${BOLD}Lifecycle:${RESET}`);
@@ -1814,7 +2214,9 @@ const runInspect = (options) => {
1814
2214
  resolveConfigPath(options.config).fold((err) => {
1815
2215
  console.error(formatError(err));
1816
2216
  process.exit(2);
1817
- }, (path) => {
2217
+ }, ({ path, source }) => {
2218
+ const sourceMsg = formatConfigSource(path, source);
2219
+ if (sourceMsg) console.error(sourceMsg);
1818
2220
  loadConfig(path).fold((err) => {
1819
2221
  console.error(formatError(err));
1820
2222
  process.exit(2);
@@ -1823,14 +2225,15 @@ const runInspect = (options) => {
1823
2225
  console.error(formatError(err));
1824
2226
  process.exit(2);
1825
2227
  }, (resolveResult) => {
1826
- const showResolved = options.resolved || !!resolveResult.catalogPath;
2228
+ const showResolved = options.resolved ?? !!resolveResult.catalogPath;
1827
2229
  const showConfig = showResolved ? resolveResult.config : config;
1828
2230
  if (options.format === "json") {
1829
2231
  console.log(JSON.stringify(showConfig, null, 2));
1830
2232
  return;
1831
2233
  }
2234
+ const showSecrets = showConfig.secret ?? {};
1832
2235
  const printOpts = options.secrets ? {
1833
- secrets: Object.fromEntries(Object.keys(showConfig.meta).filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])),
2236
+ secrets: Object.fromEntries(Object.keys(showSecrets).filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])),
1834
2237
  secretDisplay: options.plaintext ? "plaintext" : "encrypted"
1835
2238
  } : void 0;
1836
2239
  printConfig(showConfig, path, showResolved ? resolveResult : void 0, printOpts);
@@ -1842,7 +2245,7 @@ const runInspect = (options) => {
1842
2245
  //#endregion
1843
2246
  //#region src/mcp/resources.ts
1844
2247
  const loadConfigSafe = () => {
1845
- return resolveConfigPath().fold(() => void 0, (path) => loadConfig(path).fold(() => void 0, (config) => ({
2248
+ return resolveConfigPath().fold(() => void 0, ({ path }) => loadConfig(path).fold(() => void 0, (config) => ({
1846
2249
  config,
1847
2250
  path
1848
2251
  })));
@@ -1892,7 +2295,8 @@ const readCapabilities = () => {
1892
2295
  const { config } = loaded;
1893
2296
  const agentCapabilities = config.agent?.capabilities ?? [];
1894
2297
  const secretCapabilities = {};
1895
- for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2298
+ const secretEntries = config.secret ?? {};
2299
+ for (const [key, meta] of Object.entries(secretEntries)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
1896
2300
  return { contents: [{
1897
2301
  uri: "envpkt://capabilities",
1898
2302
  mimeType: "application/json",
@@ -1933,7 +2337,7 @@ const loadConfigForTool = (configPath) => {
1933
2337
  return resolveConfigPath(configPath).fold((err) => ({
1934
2338
  ok: false,
1935
2339
  result: errorResult(`Config error: ${err._tag} — ${err._tag === "FileNotFound" ? err.path : ""}`)
1936
- }), (path) => loadConfig(path).fold((err) => ({
2340
+ }), ({ path }) => loadConfig(path).fold((err) => ({
1937
2341
  ok: false,
1938
2342
  result: errorResult(`Config error: ${err._tag} — ${err._tag === "ValidationError" ? err.errors.toArray().join(", ") : ""}`)
1939
2343
  }), (config) => ({
@@ -2000,6 +2404,17 @@ const toolDefinitions = [
2000
2404
  },
2001
2405
  required: ["key"]
2002
2406
  }
2407
+ },
2408
+ {
2409
+ name: "getEnvMeta",
2410
+ description: "Get metadata for environment defaults — returns configured default values, purposes, and current drift status",
2411
+ inputSchema: {
2412
+ type: "object",
2413
+ properties: { configPath: {
2414
+ type: "string",
2415
+ description: "Optional path to envpkt.toml"
2416
+ } }
2417
+ }
2003
2418
  }
2004
2419
  ];
2005
2420
  const handleGetPacketHealth = (args) => {
@@ -2033,7 +2448,8 @@ const handleListCapabilities = (args) => {
2033
2448
  const { config } = loaded;
2034
2449
  const agentCapabilities = config.agent?.capabilities ?? [];
2035
2450
  const secretCapabilities = {};
2036
- for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2451
+ const secretEntries = config.secret ?? {};
2452
+ for (const [key, meta] of Object.entries(secretEntries)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2037
2453
  return textResult(JSON.stringify({
2038
2454
  agent: config.agent ? {
2039
2455
  name: config.agent.name,
@@ -2041,7 +2457,8 @@ const handleListCapabilities = (args) => {
2041
2457
  description: config.agent.description,
2042
2458
  capabilities: agentCapabilities
2043
2459
  } : null,
2044
- secrets: secretCapabilities
2460
+ secrets: secretCapabilities,
2461
+ env_defaults: Object.keys(config.env ?? {}).length
2045
2462
  }, null, 2));
2046
2463
  };
2047
2464
  const handleGetSecretMeta = (args) => {
@@ -2050,11 +2467,12 @@ const handleGetSecretMeta = (args) => {
2050
2467
  const loaded = loadConfigForTool(args.configPath);
2051
2468
  if (!loaded.ok) return loaded.result;
2052
2469
  const { config } = loaded;
2053
- const meta = config.meta[key];
2470
+ const meta = (config.secret ?? {})[key];
2054
2471
  if (!meta) return errorResult(`Secret not found: ${key}`);
2472
+ const { encrypted_value: _, ...safeMeta } = meta;
2055
2473
  return textResult(JSON.stringify({
2056
2474
  key,
2057
- ...meta
2475
+ ...safeMeta
2058
2476
  }, null, 2));
2059
2477
  };
2060
2478
  const handleCheckExpiration = (args) => {
@@ -2073,11 +2491,19 @@ const handleCheckExpiration = (args) => {
2073
2491
  issues: s.issues.toArray()
2074
2492
  }, null, 2)));
2075
2493
  };
2494
+ const handleGetEnvMeta = (args) => {
2495
+ const loaded = loadConfigForTool(args.configPath);
2496
+ if (!loaded.ok) return loaded.result;
2497
+ const { config } = loaded;
2498
+ const envAudit = computeEnvAudit(config);
2499
+ return textResult(JSON.stringify(envAudit, null, 2));
2500
+ };
2076
2501
  const handlers = {
2077
2502
  getPacketHealth: handleGetPacketHealth,
2078
2503
  listCapabilities: handleListCapabilities,
2079
2504
  getSecretMeta: handleGetSecretMeta,
2080
- checkExpiration: handleCheckExpiration
2505
+ checkExpiration: handleCheckExpiration,
2506
+ getEnvMeta: handleGetEnvMeta
2081
2507
  };
2082
2508
  const callTool = (name, args) => {
2083
2509
  const handler = handlers[name];
@@ -2098,17 +2524,17 @@ const createServer = () => {
2098
2524
  },
2099
2525
  instructions: "envpkt provides credential lifecycle awareness for AI agents. Use tools to check health, capabilities, and secret metadata. No secret values are ever exposed."
2100
2526
  });
2101
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions.map((t) => ({
2527
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: toolDefinitions.map((t) => ({
2102
2528
  name: t.name,
2103
2529
  description: t.description,
2104
2530
  inputSchema: t.inputSchema
2105
2531
  })) }));
2106
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2532
+ server.setRequestHandler(CallToolRequestSchema, (request) => {
2107
2533
  const { name, arguments: args } = request.params;
2108
2534
  return callTool(name, args ?? {});
2109
2535
  });
2110
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [...resourceDefinitions] }));
2111
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2536
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [...resourceDefinitions] }));
2537
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
2112
2538
  const { uri } = request.params;
2113
2539
  const result = readResource(uri);
2114
2540
  if (!result) return { contents: [{
@@ -2141,7 +2567,9 @@ const runResolve = (options) => {
2141
2567
  resolveConfigPath(options.config).fold((err) => {
2142
2568
  console.error(formatError(err));
2143
2569
  process.exit(2);
2144
- }, (configPath) => {
2570
+ }, ({ path: configPath, source }) => {
2571
+ const sourceMsg = formatConfigSource(configPath, source);
2572
+ if (sourceMsg) console.error(sourceMsg);
2145
2573
  loadConfig(configPath).fold((err) => {
2146
2574
  console.error(formatError(err));
2147
2575
  process.exit(2);
@@ -2150,10 +2578,7 @@ const runResolve = (options) => {
2150
2578
  console.error(formatError(err));
2151
2579
  process.exit(2);
2152
2580
  }, (result) => {
2153
- const outputFormat = options.format ?? "toml";
2154
- let content;
2155
- if (outputFormat === "json") content = JSON.stringify(result.config, null, 2) + "\n";
2156
- else content = `# Generated by envpkt resolve — do not edit\n${stringify(result.config)}\n`;
2581
+ const content = (options.format ?? "toml") === "json" ? `${JSON.stringify(result.config, null, 2)}\n` : `# Generated by envpkt resolve — do not edit\n${stringify(result.config)}\n`;
2157
2582
  if (options.dryRun) {
2158
2583
  console.log(`${DIM}# Dry run — would write:${RESET}`);
2159
2584
  console.log(content);
@@ -2163,7 +2588,7 @@ const runResolve = (options) => {
2163
2588
  } else process.stdout.write(content);
2164
2589
  if (result.catalogPath) {
2165
2590
  const summaryTarget = options.output ? process.stdout : process.stderr;
2166
- summaryTarget.write(`\n${CYAN}Catalog:${RESET} ${result.catalogPath}\n${GREEN}Merged:${RESET} ${result.merged.length} key(s)` + (result.overridden.length > 0 ? ` ${YELLOW}(${result.overridden.length} overridden: ${result.overridden.join(", ")})${RESET}` : "") + "\n");
2591
+ summaryTarget.write(`\n${CYAN}Catalog:${RESET} ${result.catalogPath}\n${GREEN}Merged:${RESET} ${result.merged.length} key(s)${result.overridden.length > 0 ? ` ${YELLOW}(${result.overridden.length} overridden: ${result.overridden.join(", ")})${RESET}` : ""}\n`);
2167
2592
  for (const w of result.warnings) summaryTarget.write(`${RED}Warning:${RESET} ${w}\n`);
2168
2593
  }
2169
2594
  });
@@ -2171,13 +2596,189 @@ const runResolve = (options) => {
2171
2596
  });
2172
2597
  };
2173
2598
 
2599
+ //#endregion
2600
+ //#region src/core/resolve-values.ts
2601
+ /** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
2602
+ const resolveValues = async (keys, profile, agentKey) => {
2603
+ const result = {};
2604
+ const remaining = new Set(keys);
2605
+ if (fnoxAvailable()) fnoxExport(profile, agentKey).fold(() => {}, (exported) => {
2606
+ for (const key of [...remaining]) if (key in exported) {
2607
+ result[key] = exported[key];
2608
+ remaining.delete(key);
2609
+ }
2610
+ });
2611
+ for (const key of [...remaining]) {
2612
+ const envValue = process.env[key];
2613
+ if (envValue !== void 0 && envValue !== "") {
2614
+ result[key] = envValue;
2615
+ remaining.delete(key);
2616
+ }
2617
+ }
2618
+ if (remaining.size > 0 && process.stdin.isTTY) {
2619
+ const rl = createInterface({
2620
+ input: process.stdin,
2621
+ output: process.stderr
2622
+ });
2623
+ const prompt = (question) => new Promise((resolve) => {
2624
+ rl.question(question, (answer) => resolve(answer));
2625
+ });
2626
+ for (const key of remaining) {
2627
+ const value = await prompt(`Enter value for ${key}: `);
2628
+ if (value !== "") result[key] = value;
2629
+ }
2630
+ rl.close();
2631
+ }
2632
+ return result;
2633
+ };
2634
+
2635
+ //#endregion
2636
+ //#region src/cli/commands/seal.ts
2637
+ /** Write sealed values back into the TOML file, preserving structure */
2638
+ const writeSealedToml = (configPath, sealedMeta) => {
2639
+ const lines = readFileSync(configPath, "utf-8").split("\n");
2640
+ const output = [];
2641
+ let currentMetaKey;
2642
+ let insideMetaBlock = false;
2643
+ let hasEncryptedValue = false;
2644
+ const pendingSeals = /* @__PURE__ */ new Map();
2645
+ for (const [key, meta] of Object.entries(sealedMeta)) if (meta.encrypted_value) pendingSeals.set(key, meta.encrypted_value);
2646
+ const metaSectionRe = /^\[secret\.(.+)\]\s*$/;
2647
+ const encryptedValueRe = /^encrypted_value\s*=/;
2648
+ const newSectionRe = /^\[/;
2649
+ for (let i = 0; i < lines.length; i++) {
2650
+ const line = lines[i];
2651
+ const metaMatch = metaSectionRe.exec(line);
2652
+ if (metaMatch) {
2653
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2654
+ output.push(`encrypted_value = """`);
2655
+ output.push(pendingSeals.get(currentMetaKey));
2656
+ output.push(`"""`);
2657
+ pendingSeals.delete(currentMetaKey);
2658
+ }
2659
+ currentMetaKey = metaMatch[1];
2660
+ insideMetaBlock = true;
2661
+ hasEncryptedValue = false;
2662
+ output.push(line);
2663
+ continue;
2664
+ }
2665
+ if (insideMetaBlock && newSectionRe.test(line) && !metaSectionRe.test(line)) {
2666
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2667
+ output.push(`encrypted_value = """`);
2668
+ output.push(pendingSeals.get(currentMetaKey));
2669
+ output.push(`"""`);
2670
+ pendingSeals.delete(currentMetaKey);
2671
+ }
2672
+ insideMetaBlock = false;
2673
+ currentMetaKey = void 0;
2674
+ output.push(line);
2675
+ continue;
2676
+ }
2677
+ if (insideMetaBlock && encryptedValueRe.test(line)) {
2678
+ hasEncryptedValue = true;
2679
+ if (currentMetaKey && pendingSeals.has(currentMetaKey)) {
2680
+ output.push(`encrypted_value = """`);
2681
+ output.push(pendingSeals.get(currentMetaKey));
2682
+ output.push(`"""`);
2683
+ pendingSeals.delete(currentMetaKey);
2684
+ if (line.includes("\"\"\"") && !line.endsWith("\"\"\"")) {
2685
+ const afterEquals = line.slice(line.indexOf("=") + 1).trim();
2686
+ if (afterEquals.startsWith("\"\"\"") && !afterEquals.slice(3).includes("\"\"\"")) {
2687
+ while (i + 1 < lines.length && !lines[i + 1].includes("\"\"\"")) i++;
2688
+ if (i + 1 < lines.length) i++;
2689
+ }
2690
+ }
2691
+ } else output.push(line);
2692
+ continue;
2693
+ }
2694
+ output.push(line);
2695
+ }
2696
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2697
+ output.push(`encrypted_value = """`);
2698
+ output.push(pendingSeals.get(currentMetaKey));
2699
+ output.push(`"""`);
2700
+ pendingSeals.delete(currentMetaKey);
2701
+ }
2702
+ writeFileSync(configPath, output.join("\n"));
2703
+ };
2704
+ const runSeal = async (options) => {
2705
+ const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
2706
+ console.error(formatError(err));
2707
+ process.exit(2);
2708
+ return {
2709
+ path: "",
2710
+ source: "flag"
2711
+ };
2712
+ }, (r) => r);
2713
+ const sourceMsg = formatConfigSource(configPath, configSource);
2714
+ if (sourceMsg) console.error(sourceMsg);
2715
+ const config = loadConfig(configPath).fold((err) => {
2716
+ console.error(formatError(err));
2717
+ process.exit(2);
2718
+ }, (c) => c);
2719
+ if (!config.agent?.recipient) {
2720
+ console.error(`${RED}Error:${RESET} agent.recipient is required for sealing (age public key)`);
2721
+ console.error(`${DIM}Add [agent] section with recipient = "age1..." to your envpkt.toml${RESET}`);
2722
+ process.exit(2);
2723
+ }
2724
+ const { recipient } = config.agent;
2725
+ const configDir = dirname(configPath);
2726
+ const envEntries = config.env ?? {};
2727
+ const secretEntries0 = config.secret ?? {};
2728
+ const envConflicts = Object.keys(secretEntries0).filter((k) => k in envEntries);
2729
+ if (envConflicts.length > 0) {
2730
+ console.error(`${RED}Error:${RESET} Cannot seal keys that are also defined in [env.*]: ${envConflicts.join(", ")}`);
2731
+ console.error(`${DIM}Move these to [secret.*] only, or remove from [env.*] before sealing.${RESET}`);
2732
+ process.exit(2);
2733
+ }
2734
+ const agentKey = config.agent.identity ? unwrapAgentKey(resolve(configDir, expandPath(config.agent.identity))).fold((err) => {
2735
+ const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
2736
+ console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
2737
+ }, (k) => k) : void 0;
2738
+ const allSecretEntries = config.secret ?? {};
2739
+ const allKeys = Object.keys(allSecretEntries);
2740
+ const alreadySealed = allKeys.filter((k) => allSecretEntries[k]?.encrypted_value);
2741
+ const unsealed = allKeys.filter((k) => !allSecretEntries[k]?.encrypted_value);
2742
+ if (!options.reseal && alreadySealed.length > 0) {
2743
+ if (unsealed.length === 0) {
2744
+ console.log(`${GREEN}✓${RESET} All ${BOLD}${alreadySealed.length}${RESET} secret(s) already sealed. Use ${CYAN}--reseal${RESET} to re-encrypt.`);
2745
+ process.exit(0);
2746
+ }
2747
+ console.log(`${DIM}Skipping ${alreadySealed.length} already-sealed secret(s). Use --reseal to re-encrypt all.${RESET}`);
2748
+ }
2749
+ const targetKeys = options.reseal ? allKeys : unsealed;
2750
+ const secretEntries = Object.fromEntries(targetKeys.map((k) => [k, allSecretEntries[k]]));
2751
+ const metaKeys = targetKeys;
2752
+ console.log(`${BOLD}Sealing ${metaKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
2753
+ console.log("");
2754
+ const values = await resolveValues(metaKeys, options.profile, agentKey);
2755
+ const resolved = Object.keys(values).length;
2756
+ const skipped = metaKeys.length - resolved;
2757
+ if (resolved === 0) {
2758
+ console.error(`${RED}Error:${RESET} No values resolved for any secret key`);
2759
+ process.exit(2);
2760
+ }
2761
+ if (skipped > 0) {
2762
+ const skippedKeys = metaKeys.filter((k) => !(k in values));
2763
+ console.log(`${YELLOW}Skipped${RESET} ${skipped} key(s) with no value: ${skippedKeys.join(", ")}`);
2764
+ }
2765
+ sealSecrets(secretEntries, values, recipient).fold((err) => {
2766
+ console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
2767
+ process.exit(2);
2768
+ }, (sealedMeta) => {
2769
+ writeSealedToml(configPath, sealedMeta);
2770
+ const sealedCount = resolved;
2771
+ const prevSealed = options.reseal ? 0 : alreadySealed.length;
2772
+ const summary = prevSealed > 0 ? ` (${prevSealed} previously sealed kept)` : "";
2773
+ console.log(`${GREEN}Sealed${RESET} ${sealedCount} secret(s) into ${DIM}${configPath}${RESET}${summary}`);
2774
+ });
2775
+ };
2776
+
2174
2777
  //#endregion
2175
2778
  //#region src/cli/commands/shell-hook.ts
2176
2779
  const ZSH_HOOK = `# envpkt shell hook — add to your .zshrc
2177
2780
  _envpkt_chpwd() {
2178
- if [[ -f envpkt.toml ]]; then
2179
- envpkt audit --format minimal 2>/dev/null
2180
- fi
2781
+ envpkt audit --format minimal 2>/dev/null
2181
2782
  }
2182
2783
 
2183
2784
  if (( $+functions[add-zsh-hook] )); then
@@ -2190,9 +2791,7 @@ fi
2190
2791
  `;
2191
2792
  const BASH_HOOK = `# envpkt shell hook — add to your .bashrc
2192
2793
  _envpkt_prompt() {
2193
- if [[ -f envpkt.toml ]]; then
2194
- envpkt audit --format minimal 2>/dev/null
2195
- fi
2794
+ envpkt audit --format minimal 2>/dev/null
2196
2795
  }
2197
2796
 
2198
2797
  if [[ ! "$PROMPT_COMMAND" == *"_envpkt_prompt"* ]]; then
@@ -2216,36 +2815,42 @@ const runShellHook = (shell) => {
2216
2815
  //#endregion
2217
2816
  //#region src/cli/index.ts
2218
2817
  const program = new Command();
2219
- program.name("envpkt").description("Credential lifecycle and fleet management for AI agents").version("0.1.0");
2818
+ program.name("envpkt").description("Credential lifecycle and fleet management for AI agents\n\n Developer workflow: env scan → catalog → cloud-synced folder → eval $(envpkt env export)\n Agent / CI workflow: catalog → audit --strict → seal → exec --strict → fleet").version("0.1.0");
2220
2819
  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("--agent", "Include [agent] section").option("--name <name>", "Agent name (requires --agent)").option("--capabilities <caps>", "Comma-separated capabilities (requires --agent)").option("--expires <date>", "Agent credential expiration YYYY-MM-DD (requires --agent)").option("--force", "Overwrite existing envpkt.toml").action((options) => {
2221
2820
  runInit(process.cwd(), options);
2222
2821
  });
2223
- program.command("audit").description("Audit credential health from envpkt.toml").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").option("--strict", "Exit non-zero on any non-healthy secret").action((options) => {
2822
+ 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").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("--external", "Show only secrets without encrypted_value").action((options) => {
2224
2823
  runAudit(options);
2225
2824
  });
2226
- program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
2825
+ program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
2227
2826
  runFleet(options);
2228
2827
  });
2229
2828
  program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").action((options) => {
2230
2829
  runInspect(options);
2231
2830
  });
2232
- program.command("exec").description("Run pre-flight audit then execute a command with fnox-injected env").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) => {
2831
+ 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) => {
2233
2832
  runExec(args, options);
2234
2833
  });
2235
2834
  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) => {
2236
2835
  runResolve(options);
2237
2836
  });
2837
+ program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").action(async (options) => {
2838
+ await runSeal(options);
2839
+ });
2238
2840
  program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
2239
2841
  runMcp(options);
2240
2842
  });
2241
2843
  const env = program.command("env").description("Discover and check credentials in your shell environment");
2242
- env.command("scan").description("Auto-discover credentials from process.env and scaffold TOML entries").option("--format <format>", "Output format: table | json", "table").option("--write", "Write discovered credentials to envpkt.toml").option("--dry-run", "Preview TOML that would be written (implies --write)").option("--include-unknown", "Include vars where service could not be inferred").action((options) => {
2844
+ env.command("scan").description("Auto-discover credentials from process.env and scaffold TOML entries — first step in the developer workflow").option("-c, --config <path>", "Path to envpkt.toml (write target for --write)").option("--format <format>", "Output format: table | json", "table").option("--write", "Write discovered credentials to envpkt.toml").option("--dry-run", "Preview TOML that would be written (implies --write)").option("--include-unknown", "Include vars where service could not be inferred").action((options) => {
2243
2845
  runEnvScan(options);
2244
2846
  });
2245
2847
  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) => {
2246
2848
  runEnvCheck(options);
2247
2849
  });
2248
- program.command("shell-hook").description("Output shell function for ambient credential warnings on cd").argument("<shell>", "Shell type: zsh | bash").action((shell) => {
2850
+ 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) => {
2851
+ runEnvExport(options);
2852
+ });
2853
+ program.command("shell-hook").description("Output shell function for ambient credential warnings on cd — combine with env export for full setup").argument("<shell>", "Shell type: zsh | bash").action((shell) => {
2249
2854
  runShellHook(shell);
2250
2855
  });
2251
2856
  program.parse();