envpkt 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Command } from "commander";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { Cond, Left, List, Option, Right, Try } from "functype";
5
6
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
6
8
  import { TypeCompiler } from "@sinclair/typebox/compiler";
7
9
  import { TomlDate, parse, stringify } from "smol-toml";
8
10
  import { FormatRegistry, Type } from "@sinclair/typebox";
@@ -17,7 +19,7 @@ const MS_PER_DAY = 864e5;
17
19
  const WARN_BEFORE_DAYS = 30;
18
20
  const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / MS_PER_DAY);
19
21
  const parseDate = (dateStr) => {
20
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00Z");
22
+ const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00Z`);
21
23
  return Number.isNaN(d.getTime()) ? Option(void 0) : Option(d);
22
24
  };
23
25
  const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration, requireService, today) => {
@@ -62,8 +64,9 @@ const computeAudit = (config, fnoxKeys, today) => {
62
64
  const requireExpiration = lifecycle.require_expiration ?? false;
63
65
  const requireService = lifecycle.require_service ?? false;
64
66
  const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
65
- const metaKeys = new Set(Object.keys(config.meta));
66
- const secrets = List(Object.entries(config.meta).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
67
+ const secretEntries = config.secret ?? {};
68
+ const metaKeys = new Set(Object.keys(secretEntries));
69
+ const secrets = List(Object.entries(secretEntries).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
67
70
  const orphaned = keys.size > 0 ? [...metaKeys].filter((k) => !keys.has(k)).length : 0;
68
71
  const total = secrets.size;
69
72
  const expired = secrets.count((s) => s.status === "expired");
@@ -86,6 +89,28 @@ const computeAudit = (config, fnoxKeys, today) => {
86
89
  agent: config.agent
87
90
  };
88
91
  };
92
+ const computeEnvAudit = (config, env = process.env) => {
93
+ const envEntries = config.env ?? {};
94
+ const entries = [];
95
+ for (const [key, entry] of Object.entries(envEntries)) {
96
+ const currentValue = env[key];
97
+ const status = Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== entry.value, "overridden").else("default");
98
+ entries.push({
99
+ key,
100
+ defaultValue: entry.value,
101
+ currentValue,
102
+ status,
103
+ purpose: entry.purpose
104
+ });
105
+ }
106
+ return {
107
+ entries,
108
+ total: entries.length,
109
+ defaults_applied: entries.filter((e) => e.status === "default").length,
110
+ overridden: entries.filter((e) => e.status === "overridden").length,
111
+ missing: entries.filter((e) => e.status === "missing").length
112
+ };
113
+ };
89
114
 
90
115
  //#endregion
91
116
  //#region src/core/schema.ts
@@ -124,6 +149,7 @@ const SecretMetaSchema = Type.Object({
124
149
  description: "URL or reference for secret rotation procedure"
125
150
  })),
126
151
  purpose: Type.Optional(Type.String({ description: "Why this secret exists and what it enables" })),
152
+ comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
127
153
  capabilities: Type.Optional(Type.Array(Type.String(), { description: "What operations this secret grants (e.g. read, write, admin)" })),
128
154
  created: Type.Optional(Type.String({
129
155
  format: "date",
@@ -157,6 +183,12 @@ const CallbackConfigSchema = Type.Object({
157
183
  on_audit_fail: Type.Optional(Type.String({ description: "Command or webhook on audit failure" }))
158
184
  }, { description: "Automation callbacks for lifecycle events" });
159
185
  const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
186
+ const EnvMetaSchema = Type.Object({
187
+ value: Type.String({ description: "Default value for this environment variable" }),
188
+ purpose: Type.Optional(Type.String({ description: "Why this env var exists" })),
189
+ comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
190
+ tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
191
+ }, { description: "Metadata for a plaintext environment default (non-secret)" });
160
192
  const EnvpktConfigSchema = Type.Object({
161
193
  version: Type.Number({
162
194
  description: "Schema version number",
@@ -164,7 +196,8 @@ const EnvpktConfigSchema = Type.Object({
164
196
  }),
165
197
  catalog: Type.Optional(Type.String({ description: "Path to shared secret catalog (relative to this config file)" })),
166
198
  agent: Type.Optional(AgentIdentitySchema),
167
- meta: Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" }),
199
+ secret: Type.Optional(Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" })),
200
+ env: Type.Optional(Type.Record(Type.String(), EnvMetaSchema, { description: "Plaintext environment defaults keyed by variable name" })),
168
201
  lifecycle: Type.Optional(LifecycleConfigSchema),
169
202
  callbacks: Type.Optional(CallbackConfigSchema),
170
203
  tools: Type.Optional(ToolsConfigSchema)
@@ -186,10 +219,69 @@ const normalizeDates = (obj) => {
186
219
  if (obj !== null && typeof obj === "object") return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, normalizeDates(v)]));
187
220
  return obj;
188
221
  };
189
- /** Find envpkt.toml in the given directory */
190
- const findConfigPath = (dir) => {
191
- const candidate = join(dir, CONFIG_FILENAME$2);
192
- return existsSync(candidate) ? Option(candidate) : Option(void 0);
222
+ /** Expand ~ and $ENV_VAR / ${ENV_VAR} in a path string */
223
+ const expandPath = (p) => {
224
+ return (p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p).replace(/\$\{(\w+)\}|\$(\w+)/g, (_, braced, bare) => {
225
+ const name = braced ?? bare ?? "";
226
+ return process.env[name] ?? "";
227
+ });
228
+ };
229
+ /**
230
+ * Expand a path template that may contain a single `*` glob segment.
231
+ * Returns all matching paths (or empty array if parent doesn't exist).
232
+ * Non-glob paths return a single-element array if they exist.
233
+ */
234
+ const expandGlobPath = (expanded) => {
235
+ if (!expanded.includes("*")) return existsSync(expanded) ? [expanded] : [];
236
+ const segments = expanded.split("/");
237
+ const globIdx = segments.findIndex((s) => s.includes("*"));
238
+ if (globIdx < 0) return [];
239
+ const parentDir = segments.slice(0, globIdx).join("/");
240
+ const globSegment = segments[globIdx];
241
+ const suffix = segments.slice(globIdx + 1).join("/");
242
+ if (!existsSync(parentDir)) return [];
243
+ const prefix = globSegment.replace(/\*.*$/, "");
244
+ return readdirSync(parentDir).filter((entry) => entry.startsWith(prefix)).map((entry) => join(parentDir, entry, suffix)).filter((p) => existsSync(p));
245
+ };
246
+ /** Ordered candidate paths for config discovery beyond CWD */
247
+ const CONFIG_SEARCH_PATHS = [
248
+ "~/.envpkt/envpkt.toml",
249
+ "~/OneDrive/.envpkt/envpkt.toml",
250
+ "~/Library/CloudStorage/OneDrive-Personal/.envpkt/envpkt.toml",
251
+ "~/Library/CloudStorage/OneDrive-SharedLibraries-*/.envpkt/envpkt.toml",
252
+ "$WINHOME/OneDrive/.envpkt/envpkt.toml",
253
+ "$USERPROFILE/OneDrive/.envpkt/envpkt.toml",
254
+ "$OneDrive/.envpkt/envpkt.toml",
255
+ "$OneDriveConsumer/.envpkt/envpkt.toml",
256
+ "$OneDriveCommercial/.envpkt/envpkt.toml",
257
+ "/mnt/c/Users/$USER/OneDrive/.envpkt/envpkt.toml",
258
+ "~/Library/Mobile Documents/com~apple~CloudDocs/.envpkt/envpkt.toml",
259
+ "~/Dropbox/.envpkt/envpkt.toml",
260
+ "$DROPBOX_PATH/.envpkt/envpkt.toml",
261
+ "~/Google Drive/My Drive/.envpkt/envpkt.toml",
262
+ "~/Library/CloudStorage/GoogleDrive-*/.envpkt/envpkt.toml",
263
+ "$GOOGLE_DRIVE/.envpkt/envpkt.toml",
264
+ "$WINHOME/.envpkt/envpkt.toml",
265
+ "$USERPROFILE/.envpkt/envpkt.toml"
266
+ ];
267
+ /** Discover config by checking CWD, then ENVPKT_SEARCH_PATH, then built-in candidate paths */
268
+ const discoverConfig = (cwd) => {
269
+ const cwdCandidate = join(cwd ?? process.cwd(), CONFIG_FILENAME$2);
270
+ if (existsSync(cwdCandidate)) return Option({
271
+ path: cwdCandidate,
272
+ source: "cwd"
273
+ });
274
+ const customPaths = process.env.ENVPKT_SEARCH_PATH?.split(":").filter(Boolean) ?? [];
275
+ for (const template of [...customPaths, ...CONFIG_SEARCH_PATHS]) {
276
+ const expanded = expandPath(template);
277
+ if (!expanded || expanded.startsWith("/.envpkt")) continue;
278
+ const matches = expandGlobPath(expanded);
279
+ if (matches.length > 0) return Option({
280
+ path: matches[0],
281
+ source: "search"
282
+ });
283
+ }
284
+ return Option(void 0);
193
285
  };
194
286
  /** Read a config file, returning Either<ConfigError, string> */
195
287
  const readConfigFile = (path) => {
@@ -202,14 +294,13 @@ const readConfigFile = (path) => {
202
294
  message: String(err)
203
295
  }), (content) => Right(content));
204
296
  };
205
- /** Ensure required fields have defaults for valid configs (e.g. agent configs with catalog may omit meta) */
297
+ /** Ensure required fields have defaults for valid configs (e.g. agent configs with catalog may omit secret) */
206
298
  const applyDefaults = (data) => {
207
299
  if (data !== null && typeof data === "object" && !Array.isArray(data)) {
208
- const obj = data;
209
- if (!("meta" in obj)) return {
210
- ...obj,
211
- meta: {}
212
- };
300
+ const result = { ...data };
301
+ if (!("secret" in result)) result.secret = {};
302
+ if (!("env" in result)) result.env = {};
303
+ return result;
213
304
  }
214
305
  return data;
215
306
  };
@@ -232,12 +323,16 @@ const loadConfig = (path) => readConfigFile(path).flatMap(parseToml).flatMap(val
232
323
  * Resolve config path via priority chain:
233
324
  * 1. Explicit flag path
234
325
  * 2. ENVPKT_CONFIG env var
235
- * 3. CWD discovery
326
+ * 3. CWD + discovery chain (home dir, cloud storage, custom search paths)
236
327
  */
237
328
  const resolveConfigPath = (flagPath, envVar, cwd) => {
238
329
  if (flagPath) {
239
330
  const resolved = resolve(flagPath);
240
- return existsSync(resolved) ? Right(resolved) : Left({
331
+ const result = {
332
+ path: resolved,
333
+ source: "flag"
334
+ };
335
+ return existsSync(resolved) ? Right(result) : Left({
241
336
  _tag: "FileNotFound",
242
337
  path: resolved
243
338
  });
@@ -245,16 +340,22 @@ const resolveConfigPath = (flagPath, envVar, cwd) => {
245
340
  const envPath = envVar ?? process.env[ENV_VAR_CONFIG];
246
341
  if (envPath) {
247
342
  const resolved = resolve(envPath);
248
- return existsSync(resolved) ? Right(resolved) : Left({
343
+ const result = {
344
+ path: resolved,
345
+ source: "env"
346
+ };
347
+ return existsSync(resolved) ? Right(result) : Left({
249
348
  _tag: "FileNotFound",
250
349
  path: resolved
251
350
  });
252
351
  }
253
- const dir = cwd ?? process.cwd();
254
- return findConfigPath(dir).fold(() => Left({
352
+ return discoverConfig(cwd).fold(() => Left({
255
353
  _tag: "FileNotFound",
256
- path: join(dir, CONFIG_FILENAME$2)
257
- }), (path) => Right(path));
354
+ path: join(cwd ?? process.cwd(), CONFIG_FILENAME$2)
355
+ }), ({ path, source }) => Right({
356
+ path,
357
+ source
358
+ }));
258
359
  };
259
360
 
260
361
  //#endregion
@@ -303,13 +404,14 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
303
404
  });
304
405
  const catalogPath = resolve(agentConfigDir, agentConfig.catalog);
305
406
  const agentSecrets = agentConfig.agent.secrets;
306
- return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentConfig.meta, catalogConfig.meta, agentSecrets, catalogPath).map((resolvedMeta) => {
407
+ const agentSecretEntries = agentConfig.secret ?? {};
408
+ return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentSecretEntries, catalogConfig.secret ?? {}, agentSecrets, catalogPath).map((resolvedMeta) => {
307
409
  const merged = [];
308
410
  const overridden = [];
309
411
  const warnings = [];
310
412
  for (const key of agentSecrets) {
311
413
  merged.push(key);
312
- if (agentConfig.meta[key]) overridden.push(key);
414
+ if (agentSecretEntries[key]) overridden.push(key);
313
415
  }
314
416
  const { catalog: _catalog, ...agentWithoutCatalog } = agentConfig;
315
417
  const agentIdentity = agentConfig.agent ? (() => {
@@ -323,7 +425,7 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
323
425
  ...agentIdentity,
324
426
  name: agentIdentity.name
325
427
  } : void 0,
326
- meta: resolvedMeta
428
+ secret: resolvedMeta
327
429
  },
328
430
  catalogPath,
329
431
  merged,
@@ -531,6 +633,10 @@ const formatAuditMinimal = (audit) => {
531
633
  if (audit.missing > 0) parts.push(`${audit.missing} missing`);
532
634
  return `${audit.status === "critical" ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`} ${parts.join(", ")}`;
533
635
  };
636
+ const formatConfigSource = (path, source) => {
637
+ if (source === "cwd") return "";
638
+ return `${DIM}envpkt: loaded ${path}${RESET}`;
639
+ };
534
640
 
535
641
  //#endregion
536
642
  //#region src/cli/commands/audit.ts
@@ -538,7 +644,9 @@ const runAudit = (options) => {
538
644
  resolveConfigPath(options.config).fold((err) => {
539
645
  console.error(formatError(err));
540
646
  process.exit(2);
541
- }, (path) => {
647
+ }, ({ path, source }) => {
648
+ const sourceMsg = formatConfigSource(path, source);
649
+ if (sourceMsg) console.error(sourceMsg);
542
650
  loadConfig(path).fold((err) => {
543
651
  console.error(formatError(err));
544
652
  process.exit(2);
@@ -553,32 +661,373 @@ const runAudit = (options) => {
553
661
  });
554
662
  });
555
663
  };
664
+ const formatEnvAuditTable = (config) => {
665
+ const envAudit = computeEnvAudit(config);
666
+ if (envAudit.total === 0) {
667
+ console.log(`${DIM}No [env.*] entries configured.${RESET}`);
668
+ return;
669
+ }
670
+ console.log(`\n${BOLD}Environment Defaults${RESET} (${envAudit.total} entries)`);
671
+ for (const entry of envAudit.entries) {
672
+ const statusIcon = entry.status === "default" ? `${GREEN}=${RESET}` : entry.status === "overridden" ? `${YELLOW}~${RESET}` : `${RED}!${RESET}`;
673
+ const statusLabel = entry.status === "default" ? `${DIM}using default${RESET}` : entry.status === "overridden" ? `${YELLOW}overridden${RESET} (${entry.currentValue})` : `${RED}not set${RESET}`;
674
+ console.log(` ${statusIcon} ${BOLD}${entry.key}${RESET} = "${entry.defaultValue}" ${statusLabel}`);
675
+ }
676
+ };
677
+ const formatEnvAuditJson = (config) => {
678
+ const envAudit = computeEnvAudit(config);
679
+ return JSON.stringify(envAudit, null, 2);
680
+ };
556
681
  const runAuditOnConfig = (config, options) => {
682
+ if (options.envOnly) {
683
+ if (options.format === "json") console.log(formatEnvAuditJson(config));
684
+ else formatEnvAuditTable(config);
685
+ process.exit(0);
686
+ return;
687
+ }
557
688
  const audit = computeAudit(config);
558
- let filtered = audit;
559
- if (options.status) {
560
- const statusFilter = options.status;
561
- const filteredSecrets = audit.secrets.filter((s) => s.status === statusFilter);
562
- filtered = {
689
+ const afterSealed = options.sealed ? (() => {
690
+ const secretEntries = config.secret ?? {};
691
+ return {
563
692
  ...audit,
564
- secrets: filteredSecrets
693
+ secrets: audit.secrets.filter((s) => !!secretEntries[s.key]?.encrypted_value)
565
694
  };
566
- }
567
- if (options.expiring !== void 0) {
568
- const days = options.expiring;
569
- const filteredSecrets = filtered.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= days));
570
- filtered = {
571
- ...filtered,
572
- secrets: filteredSecrets
695
+ })() : audit;
696
+ const afterExternal = options.external ? (() => {
697
+ const secretEntries = config.secret ?? {};
698
+ return {
699
+ ...afterSealed,
700
+ secrets: afterSealed.secrets.filter((s) => !secretEntries[s.key]?.encrypted_value)
573
701
  };
574
- }
702
+ })() : afterSealed;
703
+ const afterStatus = options.status ? {
704
+ ...afterExternal,
705
+ secrets: afterExternal.secrets.filter((s) => s.status === options.status)
706
+ } : afterExternal;
707
+ const filtered = options.expiring !== void 0 ? {
708
+ ...afterStatus,
709
+ secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= options.expiring))
710
+ } : afterStatus;
575
711
  if (options.format === "json") console.log(formatAuditJson(filtered));
576
712
  else if (options.format === "minimal") console.log(formatAuditMinimal(filtered));
577
713
  else console.log(formatAudit(filtered));
714
+ if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
715
+ else formatEnvAuditTable(config);
578
716
  const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
579
717
  process.exit(code);
580
718
  };
581
719
 
720
+ //#endregion
721
+ //#region src/fnox/cli.ts
722
+ /** Export all secrets from fnox as key=value pairs for a given profile */
723
+ const fnoxExport = (profile, agentKey) => {
724
+ const args = profile ? [
725
+ "export",
726
+ "--profile",
727
+ profile
728
+ ] : ["export"];
729
+ const env = agentKey ? {
730
+ ...process.env,
731
+ FNOX_AGE_KEY: agentKey
732
+ } : void 0;
733
+ return Try(() => execFileSync("fnox", args, {
734
+ stdio: "pipe",
735
+ encoding: "utf-8",
736
+ env
737
+ })).fold((err) => Left({
738
+ _tag: "FnoxCliError",
739
+ message: `fnox export failed: ${err}`
740
+ }), (output) => {
741
+ const entries = {};
742
+ for (const line of output.split("\n")) {
743
+ const eq = line.indexOf("=");
744
+ if (eq > 0) {
745
+ const key = line.slice(0, eq).trim();
746
+ entries[key] = line.slice(eq + 1).trim();
747
+ }
748
+ }
749
+ return Right(entries);
750
+ });
751
+ };
752
+
753
+ //#endregion
754
+ //#region src/fnox/detect.ts
755
+ const FNOX_CONFIG = "fnox.toml";
756
+ /** Detect fnox.toml in the given directory */
757
+ const detectFnox = (dir) => {
758
+ const candidate = join(dir, FNOX_CONFIG);
759
+ return existsSync(candidate) ? Option(candidate) : Option(void 0);
760
+ };
761
+ /** Check if fnox CLI is available on PATH */
762
+ const fnoxAvailable = () => Try(() => {
763
+ execFileSync("fnox", ["--version"], { stdio: "pipe" });
764
+ return true;
765
+ }).fold(() => false, (v) => v);
766
+
767
+ //#endregion
768
+ //#region src/fnox/identity.ts
769
+ /** Check if the age CLI is available on PATH */
770
+ const ageAvailable = () => Try(() => {
771
+ execFileSync("age", ["--version"], { stdio: "pipe" });
772
+ return true;
773
+ }).fold(() => false, (v) => v);
774
+ /** Unwrap an encrypted agent key using age --decrypt */
775
+ const unwrapAgentKey = (identityPath) => {
776
+ if (!existsSync(identityPath)) return Left({
777
+ _tag: "IdentityNotFound",
778
+ path: identityPath
779
+ });
780
+ if (!ageAvailable()) return Left({
781
+ _tag: "AgeNotFound",
782
+ message: "age CLI not found on PATH"
783
+ });
784
+ return Try(() => execFileSync("age", ["--decrypt", identityPath], {
785
+ stdio: [
786
+ "pipe",
787
+ "pipe",
788
+ "pipe"
789
+ ],
790
+ encoding: "utf-8"
791
+ })).fold((err) => Left({
792
+ _tag: "DecryptFailed",
793
+ message: `age decrypt failed: ${err}`
794
+ }), (output) => Right(output.trim()));
795
+ };
796
+
797
+ //#endregion
798
+ //#region src/fnox/parse.ts
799
+ /** Read and parse fnox.toml, extracting secret keys and profiles */
800
+ const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((err) => Left({
801
+ _tag: "FnoxParseError",
802
+ message: `Failed to read ${path}: ${err}`
803
+ }), (content) => Try(() => parse(content)).fold((err) => Left({
804
+ _tag: "FnoxParseError",
805
+ message: `Failed to parse fnox.toml: ${err}`
806
+ }), (data) => {
807
+ const profiles = data["profiles"] && typeof data["profiles"] === "object" ? Option(data["profiles"]) : Option(void 0);
808
+ const secrets = { ...data };
809
+ delete secrets["profiles"];
810
+ return Right({
811
+ secrets,
812
+ profiles
813
+ });
814
+ }));
815
+ /** Extract the set of secret key names from a parsed fnox config */
816
+ const extractFnoxKeys = (config) => new Set(Object.keys(config.secrets));
817
+
818
+ //#endregion
819
+ //#region src/core/seal.ts
820
+ /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
821
+ const ageEncrypt = (plaintext, recipient) => {
822
+ if (!ageAvailable()) return Left({
823
+ _tag: "AgeNotFound",
824
+ message: "age CLI not found on PATH"
825
+ });
826
+ return Try(() => execFileSync("age", [
827
+ "--encrypt",
828
+ "--recipient",
829
+ recipient,
830
+ "--armor"
831
+ ], {
832
+ input: plaintext,
833
+ stdio: [
834
+ "pipe",
835
+ "pipe",
836
+ "pipe"
837
+ ],
838
+ encoding: "utf-8"
839
+ })).fold((err) => Left({
840
+ _tag: "EncryptFailed",
841
+ key: "",
842
+ message: `age encrypt failed: ${err}`
843
+ }), (output) => Right(output.trim()));
844
+ };
845
+ /** Decrypt an age-armored ciphertext using the given identity file */
846
+ const ageDecrypt = (ciphertext, identityPath) => {
847
+ if (!ageAvailable()) return Left({
848
+ _tag: "AgeNotFound",
849
+ message: "age CLI not found on PATH"
850
+ });
851
+ return Try(() => execFileSync("age", [
852
+ "--decrypt",
853
+ "--identity",
854
+ identityPath
855
+ ], {
856
+ input: ciphertext,
857
+ stdio: [
858
+ "pipe",
859
+ "pipe",
860
+ "pipe"
861
+ ],
862
+ encoding: "utf-8"
863
+ })).fold((err) => Left({
864
+ _tag: "DecryptFailed",
865
+ key: "",
866
+ message: `age decrypt failed: ${err}`
867
+ }), (output) => Right(output.trim()));
868
+ };
869
+ /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
870
+ const sealSecrets = (meta, values, recipient) => {
871
+ if (!ageAvailable()) return Left({
872
+ _tag: "AgeNotFound",
873
+ message: "age CLI not found on PATH"
874
+ });
875
+ const result = {};
876
+ for (const [key, secretMeta] of Object.entries(meta)) {
877
+ const plaintext = values[key];
878
+ if (plaintext === void 0) {
879
+ result[key] = secretMeta;
880
+ continue;
881
+ }
882
+ const outcome = ageEncrypt(plaintext, recipient).fold((err) => Left({
883
+ _tag: "EncryptFailed",
884
+ key,
885
+ message: err.message
886
+ }), (ciphertext) => Right(ciphertext));
887
+ const failed = outcome.fold((err) => err, () => void 0);
888
+ if (failed) return Left(failed);
889
+ const ciphertext = outcome.fold(() => "", (v) => v);
890
+ result[key] = {
891
+ ...secretMeta,
892
+ encrypted_value: ciphertext
893
+ };
894
+ }
895
+ return Right(result);
896
+ };
897
+ /** Unseal secrets: decrypt encrypted_value for each meta entry that has one */
898
+ const unsealSecrets = (meta, identityPath) => {
899
+ if (!ageAvailable()) return Left({
900
+ _tag: "AgeNotFound",
901
+ message: "age CLI not found on PATH"
902
+ });
903
+ const result = {};
904
+ for (const [key, secretMeta] of Object.entries(meta)) {
905
+ if (!secretMeta.encrypted_value) continue;
906
+ const outcome = ageDecrypt(secretMeta.encrypted_value, identityPath).fold((err) => Left({
907
+ _tag: "DecryptFailed",
908
+ key,
909
+ message: err.message
910
+ }), (plaintext) => Right(plaintext));
911
+ const failed = outcome.fold((err) => err, () => void 0);
912
+ if (failed) return Left(failed);
913
+ result[key] = outcome.fold(() => "", (v) => v);
914
+ }
915
+ return Right(result);
916
+ };
917
+
918
+ //#endregion
919
+ //#region src/core/boot.ts
920
+ const resolveAndLoad = (opts) => resolveConfigPath(opts.configPath).fold((err) => Left(err), ({ path: configPath, source: configSource }) => loadConfig(configPath).fold((err) => Left(err), (config) => {
921
+ const configDir = dirname(configPath);
922
+ return resolveConfig(config, configDir).fold((err) => Left(err), (result) => Right({
923
+ config: result.config,
924
+ configPath,
925
+ configDir,
926
+ configSource
927
+ }));
928
+ }));
929
+ const resolveAgentKey = (config, configDir) => {
930
+ if (!config.agent?.identity) return Right(void 0);
931
+ return unwrapAgentKey(resolve(configDir, expandPath(config.agent.identity))).fold((err) => Left(err), (key) => Right(key));
932
+ };
933
+ const detectFnoxKeys = (configDir) => detectFnox(configDir).fold(() => /* @__PURE__ */ new Set(), (fnoxPath) => readFnoxConfig(fnoxPath).fold(() => /* @__PURE__ */ new Set(), (fnoxConfig) => extractFnoxKeys(fnoxConfig)));
934
+ const checkExpiration = (audit, failOnExpired, warnOnly) => {
935
+ const warnings = [];
936
+ if (audit.expired > 0 && failOnExpired && !warnOnly) return Left({
937
+ _tag: "AuditFailed",
938
+ audit,
939
+ message: `${audit.expired} secret(s) have expired`
940
+ });
941
+ if (audit.expired > 0 && warnOnly) warnings.push(`${audit.expired} secret(s) have expired (warn-only mode)`);
942
+ return Right(warnings);
943
+ };
944
+ const SECRET_PATTERNS = [
945
+ /^sk-/,
946
+ /^ghp_/,
947
+ /^ghu_/,
948
+ /^AKIA[0-9A-Z]{16}/,
949
+ /^xox[bpras]-/,
950
+ /:\/\/[^:]+:[^@]+@/,
951
+ /^ey[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/
952
+ ];
953
+ const looksLikeSecret = (value) => {
954
+ if (SECRET_PATTERNS.some((p) => p.test(value))) return true;
955
+ if (value.length > 40 && /^[A-Za-z0-9+/=]+$/.test(value)) return true;
956
+ return false;
957
+ };
958
+ const checkEnvMisclassification = (config) => {
959
+ const warnings = [];
960
+ const envEntries = config.env ?? {};
961
+ 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}]`);
962
+ return warnings;
963
+ };
964
+ /** Programmatic boot — returns Either<BootError, BootResult> */
965
+ const bootSafe = (options) => {
966
+ const opts = options ?? {};
967
+ const inject = opts.inject !== false;
968
+ const failOnExpired = opts.failOnExpired !== false;
969
+ const warnOnly = opts.warnOnly ?? false;
970
+ return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => {
971
+ const secretEntries = config.secret ?? {};
972
+ const metaKeys = Object.keys(secretEntries);
973
+ const hasSealedValues = metaKeys.some((k) => !!secretEntries[k]?.encrypted_value);
974
+ const agentKeyResult = resolveAgentKey(config, configDir);
975
+ const agentKey = agentKeyResult.fold(() => void 0, (k) => k);
976
+ const agentKeyError = agentKeyResult.fold((err) => err, () => void 0);
977
+ if (agentKeyError && !hasSealedValues) return Left(agentKeyError);
978
+ const audit = computeAudit(config, detectFnoxKeys(configDir));
979
+ return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
980
+ const secrets = {};
981
+ const injected = [];
982
+ const skipped = [];
983
+ warnings.push(...checkEnvMisclassification(config));
984
+ const envEntries = config.env ?? {};
985
+ const envDefaults = {};
986
+ const overridden = [];
987
+ for (const [key, entry] of Object.entries(envEntries)) if (process.env[key] === void 0) {
988
+ envDefaults[key] = entry.value;
989
+ if (inject) process.env[key] = entry.value;
990
+ } else overridden.push(key);
991
+ const sealedKeys = /* @__PURE__ */ new Set();
992
+ if (hasSealedValues && config.agent?.identity) unsealSecrets(secretEntries, resolve(configDir, expandPath(config.agent.identity))).fold((err) => {
993
+ warnings.push(`Sealed value decryption failed: ${err.message}`);
994
+ }, (unsealed) => {
995
+ for (const [key, value] of Object.entries(unsealed)) {
996
+ secrets[key] = value;
997
+ injected.push(key);
998
+ sealedKeys.add(key);
999
+ }
1000
+ });
1001
+ const remainingKeys = metaKeys.filter((k) => !sealedKeys.has(k));
1002
+ if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, agentKey).fold((err) => {
1003
+ warnings.push(`fnox export failed: ${err.message}`);
1004
+ for (const key of remainingKeys) skipped.push(key);
1005
+ }, (exported) => {
1006
+ for (const key of remainingKeys) if (key in exported) {
1007
+ secrets[key] = exported[key];
1008
+ injected.push(key);
1009
+ } else skipped.push(key);
1010
+ });
1011
+ else {
1012
+ if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
1013
+ for (const key of remainingKeys) skipped.push(key);
1014
+ }
1015
+ if (inject) for (const [key, value] of Object.entries(secrets)) process.env[key] = value;
1016
+ return {
1017
+ audit,
1018
+ injected,
1019
+ skipped,
1020
+ secrets,
1021
+ warnings,
1022
+ envDefaults,
1023
+ overridden,
1024
+ configPath,
1025
+ configSource
1026
+ };
1027
+ });
1028
+ });
1029
+ };
1030
+
582
1031
  //#endregion
583
1032
  //#region src/core/patterns.ts
584
1033
  const EXCLUDED_VARS = new Set([
@@ -1210,7 +1659,7 @@ const matchValueShape = (value) => {
1210
1659
  };
1211
1660
  /** Strip common suffixes and derive a service name from an env var name */
1212
1661
  const deriveServiceFromName = (name) => {
1213
- const suffixes = [
1662
+ const matchedSuffix = [
1214
1663
  "_API_KEY",
1215
1664
  "_SECRET_KEY",
1216
1665
  "_ACCESS_KEY",
@@ -1228,13 +1677,8 @@ const deriveServiceFromName = (name) => {
1228
1677
  "_DSN",
1229
1678
  "_URL",
1230
1679
  "_URI"
1231
- ];
1232
- let stripped = name;
1233
- for (const suffix of suffixes) if (stripped.endsWith(suffix)) {
1234
- stripped = stripped.slice(0, -suffix.length);
1235
- break;
1236
- }
1237
- return stripped.toLowerCase().replace(/_/g, "-");
1680
+ ].find((s) => name.endsWith(s));
1681
+ return (matchedSuffix ? name.slice(0, -matchedSuffix.length) : name).toLowerCase().replace(/_/g, "-");
1238
1682
  };
1239
1683
  /** Match a single env var against all patterns */
1240
1684
  const matchEnvVar = (name, value) => {
@@ -1304,10 +1748,11 @@ const envScan = (env, options) => {
1304
1748
  /** Bidirectional drift detection between config and live environment */
1305
1749
  const envCheck = (config, env) => {
1306
1750
  const entries = [];
1307
- const metaKeys = Object.keys(config.meta);
1751
+ const secretEntries = config.secret ?? {};
1752
+ const metaKeys = Object.keys(secretEntries);
1308
1753
  const trackedSet = new Set(metaKeys);
1309
1754
  for (const key of metaKeys) {
1310
- const meta = config.meta[key];
1755
+ const meta = secretEntries[key];
1311
1756
  const present = env[key] !== void 0 && env[key] !== "";
1312
1757
  entries.push({
1313
1758
  envVar: key,
@@ -1316,6 +1761,17 @@ const envCheck = (config, env) => {
1316
1761
  confidence: Option(void 0)
1317
1762
  });
1318
1763
  }
1764
+ const envDefaults = config.env ?? {};
1765
+ for (const key of Object.keys(envDefaults)) if (!trackedSet.has(key)) {
1766
+ trackedSet.add(key);
1767
+ const present = env[key] !== void 0 && env[key] !== "";
1768
+ entries.push({
1769
+ envVar: key,
1770
+ service: Option(void 0),
1771
+ status: present ? "tracked" : "missing_from_env",
1772
+ confidence: Option(void 0)
1773
+ });
1774
+ }
1319
1775
  const envMatches = scanEnv(env);
1320
1776
  for (const match of envMatches) if (!trackedSet.has(match.envVar)) entries.push({
1321
1777
  envVar: match.envVar,
@@ -1335,12 +1791,12 @@ const envCheck = (config, env) => {
1335
1791
  };
1336
1792
  };
1337
1793
  const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1338
- /** Generate TOML [meta.*] blocks from scan results, mirroring init.ts pattern */
1794
+ /** Generate TOML [secret.*] blocks from scan results, mirroring init.ts pattern */
1339
1795
  const generateTomlFromScan = (matches) => {
1340
1796
  const blocks = [];
1341
1797
  for (const match of matches) {
1342
1798
  const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
1343
- blocks.push(`[meta.${match.envVar}]
1799
+ blocks.push(`[secret.${match.envVar}]
1344
1800
  service = "${svc}"
1345
1801
  # purpose = "" # Why: what this secret enables
1346
1802
  # capabilities = [] # What operations this grants
@@ -1371,16 +1827,16 @@ const runEnvScan = (options) => {
1371
1827
  console.log(toml);
1372
1828
  return;
1373
1829
  }
1374
- const configPath = join(process.cwd(), "envpkt.toml");
1830
+ const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
1375
1831
  if (existsSync(configPath)) {
1376
1832
  const existing = Try(() => readFileSync(configPath, "utf-8")).fold(() => "", (c) => c);
1377
- const newEntries = scan.discovered.toArray().filter((m) => !existing.includes(`[meta.${m.envVar}]`));
1833
+ const newEntries = scan.discovered.toArray().filter((m) => !existing.includes(`[secret.${m.envVar}]`));
1378
1834
  if (newEntries.length === 0) {
1379
- console.log(`\n${GREEN}✓${RESET} All discovered credentials already tracked in envpkt.toml`);
1835
+ console.log(`\n${GREEN}✓${RESET} All discovered credentials already tracked in ${CYAN}${configPath}${RESET}`);
1380
1836
  return;
1381
1837
  }
1382
1838
  const newToml = generateTomlFromScan(newEntries);
1383
- Try(() => writeFileSync(configPath, existing.trimEnd() + "\n\n" + newToml, "utf-8")).fold((err) => {
1839
+ Try(() => writeFileSync(configPath, `${existing.trimEnd()}\n\n${newToml}`, "utf-8")).fold((err) => {
1384
1840
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
1385
1841
  process.exit(1);
1386
1842
  }, () => {
@@ -1392,7 +1848,7 @@ const runEnvScan = (options) => {
1392
1848
  console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
1393
1849
  process.exit(1);
1394
1850
  }, () => {
1395
- console.log(`\n${GREEN}✓${RESET} Created ${BOLD}envpkt.toml${RESET} with ${CYAN}${scan.discovered.size}${RESET} credential(s)`);
1851
+ console.log(`\n${GREEN}✓${RESET} Created ${CYAN}${configPath}${RESET} with ${BOLD}${scan.discovered.size}${RESET} credential(s)`);
1396
1852
  });
1397
1853
  }
1398
1854
  }
@@ -1401,7 +1857,9 @@ const runEnvCheck = (options) => {
1401
1857
  resolveConfigPath(options.config).fold((err) => {
1402
1858
  console.error(formatError(err));
1403
1859
  process.exit(2);
1404
- }, (path) => {
1860
+ }, ({ path, source }) => {
1861
+ const sourceMsg = formatConfigSource(path, source);
1862
+ if (sourceMsg) console.error(sourceMsg);
1405
1863
  loadConfig(path).fold((err) => {
1406
1864
  console.error(formatError(err));
1407
1865
  process.exit(2);
@@ -1418,43 +1876,23 @@ const runEnvCheck = (options) => {
1418
1876
  });
1419
1877
  });
1420
1878
  };
1421
-
1422
- //#endregion
1423
- //#region src/fnox/detect.ts
1424
- /** Check if fnox CLI is available on PATH */
1425
- const fnoxAvailable = () => Try(() => {
1426
- execFileSync("fnox", ["--version"], { stdio: "pipe" });
1427
- return true;
1428
- }).fold(() => false, (v) => v);
1429
-
1430
- //#endregion
1431
- //#region src/fnox/identity.ts
1432
- /** Check if the age CLI is available on PATH */
1433
- const ageAvailable = () => Try(() => {
1434
- execFileSync("age", ["--version"], { stdio: "pipe" });
1435
- return true;
1436
- }).fold(() => false, (v) => v);
1437
- /** Unwrap an encrypted agent key using age --decrypt */
1438
- const unwrapAgentKey = (identityPath) => {
1439
- if (!existsSync(identityPath)) return Left({
1440
- _tag: "IdentityNotFound",
1441
- path: identityPath
1442
- });
1443
- if (!ageAvailable()) return Left({
1444
- _tag: "AgeNotFound",
1445
- message: "age CLI not found on PATH"
1879
+ const shellEscape = (value) => value.replace(/'/g, "'\\''");
1880
+ const runEnvExport = (options) => {
1881
+ bootSafe({
1882
+ inject: false,
1883
+ configPath: options.config,
1884
+ profile: options.profile,
1885
+ warnOnly: true
1886
+ }).fold((err) => {
1887
+ console.error(formatError(err));
1888
+ process.exit(2);
1889
+ }, (boot) => {
1890
+ const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
1891
+ if (sourceMsg) console.error(sourceMsg);
1892
+ for (const warning of boot.warnings) console.error(`${YELLOW}Warning:${RESET} ${warning}`);
1893
+ for (const [key, value] of Object.entries(boot.envDefaults)) console.log(`export ${key}='${shellEscape(value)}'`);
1894
+ for (const [key, value] of Object.entries(boot.secrets)) console.log(`export ${key}='${shellEscape(value)}'`);
1446
1895
  });
1447
- return Try(() => execFileSync("age", ["--decrypt", identityPath], {
1448
- stdio: [
1449
- "pipe",
1450
- "pipe",
1451
- "pipe"
1452
- ],
1453
- encoding: "utf-8"
1454
- })).fold((err) => Left({
1455
- _tag: "DecryptFailed",
1456
- message: `age decrypt failed: ${err}`
1457
- }), (output) => Right(output.trim()));
1458
1896
  };
1459
1897
 
1460
1898
  //#endregion
@@ -1465,71 +1903,40 @@ const runExec = (args, options) => {
1465
1903
  process.exit(2);
1466
1904
  return;
1467
1905
  }
1468
- const skipAudit = options.skipAudit || options.check === false;
1469
- const configData = resolveConfigPath(options.config).fold((err) => {
1906
+ const skipAudit = options.skipAudit ?? options.check === false;
1907
+ const boot = bootSafe({
1908
+ inject: false,
1909
+ configPath: options.config,
1910
+ profile: options.profile,
1911
+ failOnExpired: false,
1912
+ warnOnly: true
1913
+ }).fold((err) => {
1470
1914
  console.error(formatError(err));
1471
1915
  process.exit(2);
1472
- }, (path) => loadConfig(path).fold((err) => {
1473
- console.error(formatError(err));
1474
- process.exit(2);
1475
- }, (config) => ({
1476
- config,
1477
- path
1478
- })));
1479
- if (!configData) return;
1480
- const { config, path } = configData;
1481
- const configDir = dirname(path);
1916
+ }, (b) => b);
1917
+ if (!boot) return;
1918
+ const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
1919
+ if (sourceMsg) console.error(sourceMsg);
1482
1920
  if (!skipAudit) {
1483
- const audit = computeAudit(config);
1484
- console.error(`${BOLD}envpkt${RESET} pre-flight audit ${path}`);
1485
- console.error(formatAudit(audit));
1921
+ console.error(`${BOLD}envpkt${RESET} pre-flight audit`);
1922
+ console.error(formatAudit(boot.audit));
1486
1923
  console.error("");
1487
- if (options.strict && audit.status !== "healthy") {
1488
- console.error(`${RED}Aborting:${RESET} --strict mode and audit status is ${audit.status}`);
1489
- process.exit(exitCodeForAudit(audit));
1924
+ if (options.strict && boot.audit.status !== "healthy") {
1925
+ console.error(`${RED}Aborting:${RESET} --strict mode and audit status is ${boot.audit.status}`);
1926
+ process.exit(exitCodeForAudit(boot.audit));
1490
1927
  return;
1491
1928
  }
1492
- if (audit.status === "critical" && !options.warnOnly) {
1929
+ if (boot.audit.status === "critical" && !options.warnOnly) {
1493
1930
  console.error(`${RED}Aborting:${RESET} audit status is critical (use --warn-only to proceed)`);
1494
- process.exit(exitCodeForAudit(audit));
1931
+ process.exit(exitCodeForAudit(boot.audit));
1495
1932
  return;
1496
1933
  }
1497
- if (audit.status === "critical" && options.warnOnly) console.error(`${YELLOW}Warning:${RESET} Proceeding despite critical audit status (--warn-only)`);
1934
+ if (boot.audit.status === "critical" && options.warnOnly) console.error(`${YELLOW}Warning:${RESET} Proceeding despite critical audit status (--warn-only)`);
1498
1935
  }
1499
- let agentKey;
1500
- if (config.agent?.identity) unwrapAgentKey(resolve(configDir, config.agent.identity)).fold((err) => {
1501
- console.error(`${YELLOW}Warning:${RESET} Agent key unwrap failed: ${err._tag}`);
1502
- }, (key) => {
1503
- agentKey = key;
1504
- });
1505
- if (!fnoxAvailable()) console.error(`${YELLOW}Warning:${RESET} fnox not available — running command without secret injection`);
1936
+ for (const warning of boot.warnings) console.error(`${YELLOW}Warning:${RESET} ${warning}`);
1506
1937
  const env = { ...process.env };
1507
- if (fnoxAvailable()) {
1508
- const fnoxArgs = options.profile ? [
1509
- "export",
1510
- "--profile",
1511
- options.profile
1512
- ] : ["export"];
1513
- const fnoxEnv = agentKey ? {
1514
- ...process.env,
1515
- FNOX_AGE_KEY: agentKey
1516
- } : void 0;
1517
- Try(() => execFileSync("fnox", fnoxArgs, {
1518
- stdio: "pipe",
1519
- encoding: "utf-8",
1520
- env: fnoxEnv
1521
- })).fold((err) => {
1522
- console.error(`${YELLOW}Warning:${RESET} fnox export failed: ${err}`);
1523
- }, (output) => {
1524
- for (const line of output.split("\n")) {
1525
- const eq = line.indexOf("=");
1526
- if (eq > 0) {
1527
- const key = line.slice(0, eq).trim();
1528
- env[key] = line.slice(eq + 1).trim();
1529
- }
1530
- }
1531
- });
1532
- }
1938
+ for (const [key, value] of Object.entries(boot.envDefaults)) if (!(key in env)) env[key] = value;
1939
+ for (const [key, value] of Object.entries(boot.secrets)) env[key] = value;
1533
1940
  const [cmd, ...cmdArgs] = args;
1534
1941
  try {
1535
1942
  execFileSync(cmd, cmdArgs, {
@@ -1575,10 +1982,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
1575
1982
  const configPath = join(dir, CONFIG_FILENAME$1);
1576
1983
  if (Try(() => statSync(configPath).isFile()).fold(() => false, (v) => v)) yield configPath;
1577
1984
  if (currentDepth >= maxDepth) return;
1578
- let entries = [];
1579
- Try(() => readdirSync(dir, { withFileTypes: true })).fold(() => {}, (e) => {
1580
- entries = e;
1581
- });
1985
+ const entries = Try(() => readdirSync(dir, { withFileTypes: true })).fold(() => [], (e) => e);
1582
1986
  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);
1583
1987
  }
1584
1988
  const scanFleet = (rootDir, options) => {
@@ -1645,7 +2049,7 @@ const runFleet = (options) => {
1645
2049
  const CONFIG_FILENAME = "envpkt.toml";
1646
2050
  const todayIso = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1647
2051
  const generateSecretBlock = (key, service) => {
1648
- return `[meta.${key}]
2052
+ return `[secret.${key}]
1649
2053
  service = "${service ?? key}"
1650
2054
  # purpose = "" # Why: what this secret enables
1651
2055
  # capabilities = [] # What operations this grants
@@ -1684,18 +2088,26 @@ const generateTemplate = (options, fnoxKeys) => {
1684
2088
  lines.push(`# require_expiration = false`);
1685
2089
  lines.push(`# require_service = false`);
1686
2090
  lines.push(``);
2091
+ lines.push(`# Plaintext environment defaults (non-secret, safe to commit)`);
2092
+ lines.push(`# [env.PORT]`);
2093
+ lines.push(`# value = "3000"`);
2094
+ lines.push(`# purpose = "Application port"`);
2095
+ lines.push(`# [env.NODE_ENV]`);
2096
+ lines.push(`# value = "production"`);
2097
+ lines.push(`# purpose = "Runtime environment"`);
2098
+ lines.push(``);
1687
2099
  if (fnoxKeys && fnoxKeys.length > 0) {
1688
2100
  lines.push(`# Secrets detected from fnox.toml`);
1689
2101
  for (const key of fnoxKeys) lines.push(generateSecretBlock(key));
1690
2102
  } else {
1691
2103
  lines.push(`# Add your secret metadata below.`);
1692
- lines.push(`# Each [meta.<key>] describes a secret your agent needs.`);
2104
+ lines.push(`# Each [secret.<key>] describes a secret your agent needs.`);
1693
2105
  lines.push(``);
1694
2106
  lines.push(generateSecretBlock("EXAMPLE_API_KEY", "example-service"));
1695
2107
  }
1696
2108
  } else {
1697
2109
  lines.push(`# Optional: override catalog metadata for specific secrets`);
1698
- lines.push(`# [meta.KEY_NAME]`);
2110
+ lines.push(`# [secret.KEY_NAME]`);
1699
2111
  lines.push(`# capabilities = ["read"] # narrows catalog's broader definition`);
1700
2112
  }
1701
2113
  return lines.join("\n");
@@ -1721,20 +2133,17 @@ const runInit = (dir, options) => {
1721
2133
  console.error(`${RED}Error:${RESET} ${CONFIG_FILENAME} already exists. Use --force to overwrite.`);
1722
2134
  process.exit(1);
1723
2135
  }
1724
- let fnoxKeys;
1725
- if (options.fromFnox) {
2136
+ const fnoxKeys = options.fromFnox ? (() => {
1726
2137
  const fnoxPath = options.fromFnox === "true" || options.fromFnox === "" ? join(dir, "fnox.toml") : options.fromFnox;
1727
2138
  if (!existsSync(fnoxPath)) {
1728
2139
  console.error(`${RED}Error:${RESET} fnox.toml not found at ${fnoxPath}`);
1729
2140
  process.exit(1);
1730
2141
  }
1731
- readFnoxKeys(fnoxPath).fold((err) => {
2142
+ return readFnoxKeys(fnoxPath).fold((err) => {
1732
2143
  console.error(`${RED}Error:${RESET} Failed to read fnox.toml: ${formatConfigError(err)}`);
1733
2144
  process.exit(1);
1734
- }, (keys) => {
1735
- fnoxKeys = keys;
1736
- });
1737
- }
2145
+ }, (keys) => keys);
2146
+ })() : void 0;
1738
2147
  const content = generateTemplate(options, fnoxKeys);
1739
2148
  Try(() => writeFileSync(outPath, content, "utf-8")).fold((err) => {
1740
2149
  console.error(`${RED}Error:${RESET} Failed to write ${CONFIG_FILENAME}: ${err}`);
@@ -1757,6 +2166,7 @@ const maskValue = (value) => {
1757
2166
  //#region src/cli/commands/inspect.ts
1758
2167
  const printSecretMeta = (meta, indent) => {
1759
2168
  if (meta.purpose) console.log(`${indent}purpose: ${meta.purpose}`);
2169
+ if (meta.comment) console.log(`${indent}comment: ${DIM}${meta.comment}${RESET}`);
1760
2170
  if (meta.capabilities) console.log(`${indent}capabilities: ${DIM}${meta.capabilities.join(", ")}${RESET}`);
1761
2171
  const dateParts = [];
1762
2172
  if (meta.created) dateParts.push(`created: ${meta.created}`);
@@ -1790,14 +2200,29 @@ const printConfig = (config, path, resolveResult, opts) => {
1790
2200
  if (config.agent.secrets) console.log(` secrets: ${config.agent.secrets.join(", ")}`);
1791
2201
  console.log("");
1792
2202
  }
1793
- console.log(`${BOLD}Secrets:${RESET} ${Object.keys(config.meta).length}`);
1794
- for (const [key, meta] of Object.entries(config.meta)) {
2203
+ const secretEntries = config.secret ?? {};
2204
+ console.log(`${BOLD}Secrets:${RESET} ${Object.keys(secretEntries).length}`);
2205
+ for (const [key, meta] of Object.entries(secretEntries)) {
1795
2206
  const secretValue = opts?.secrets?.[key];
1796
2207
  const valueSuffix = secretValue !== void 0 ? ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}` : "";
1797
2208
  const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
1798
2209
  console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
1799
2210
  printSecretMeta(meta, " ");
1800
2211
  }
2212
+ const envEntries = config.env ?? {};
2213
+ const envKeys = Object.keys(envEntries);
2214
+ if (envKeys.length > 0) {
2215
+ console.log("");
2216
+ console.log(`${BOLD}Environment Defaults:${RESET} ${envKeys.length}`);
2217
+ for (const [key, entry] of Object.entries(envEntries)) {
2218
+ const currentValue = process.env[key];
2219
+ const statusIcon = currentValue === void 0 ? `${RED}!${RESET}` : currentValue === entry.value ? `${GREEN}=${RESET}` : `${YELLOW}~${RESET}`;
2220
+ const statusLabel = currentValue === void 0 ? `${DIM}not set${RESET}` : currentValue === entry.value ? `${DIM}using default${RESET}` : `${YELLOW}overridden${RESET}`;
2221
+ console.log(` ${statusIcon} ${BOLD}${key}${RESET} = "${entry.value}" ${statusLabel}`);
2222
+ if (entry.purpose) console.log(` purpose: ${entry.purpose}`);
2223
+ if (entry.comment) console.log(` comment: ${DIM}${entry.comment}${RESET}`);
2224
+ }
2225
+ }
1801
2226
  if (config.lifecycle) {
1802
2227
  console.log("");
1803
2228
  console.log(`${BOLD}Lifecycle:${RESET}`);
@@ -1818,7 +2243,9 @@ const runInspect = (options) => {
1818
2243
  resolveConfigPath(options.config).fold((err) => {
1819
2244
  console.error(formatError(err));
1820
2245
  process.exit(2);
1821
- }, (path) => {
2246
+ }, ({ path, source }) => {
2247
+ const sourceMsg = formatConfigSource(path, source);
2248
+ if (sourceMsg) console.error(sourceMsg);
1822
2249
  loadConfig(path).fold((err) => {
1823
2250
  console.error(formatError(err));
1824
2251
  process.exit(2);
@@ -1827,14 +2254,15 @@ const runInspect = (options) => {
1827
2254
  console.error(formatError(err));
1828
2255
  process.exit(2);
1829
2256
  }, (resolveResult) => {
1830
- const showResolved = options.resolved || !!resolveResult.catalogPath;
2257
+ const showResolved = options.resolved ?? !!resolveResult.catalogPath;
1831
2258
  const showConfig = showResolved ? resolveResult.config : config;
1832
2259
  if (options.format === "json") {
1833
2260
  console.log(JSON.stringify(showConfig, null, 2));
1834
2261
  return;
1835
2262
  }
2263
+ const showSecrets = showConfig.secret ?? {};
1836
2264
  const printOpts = options.secrets ? {
1837
- secrets: Object.fromEntries(Object.keys(showConfig.meta).filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])),
2265
+ secrets: Object.fromEntries(Object.keys(showSecrets).filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])),
1838
2266
  secretDisplay: options.plaintext ? "plaintext" : "encrypted"
1839
2267
  } : void 0;
1840
2268
  printConfig(showConfig, path, showResolved ? resolveResult : void 0, printOpts);
@@ -1846,7 +2274,7 @@ const runInspect = (options) => {
1846
2274
  //#endregion
1847
2275
  //#region src/mcp/resources.ts
1848
2276
  const loadConfigSafe = () => {
1849
- return resolveConfigPath().fold(() => void 0, (path) => loadConfig(path).fold(() => void 0, (config) => ({
2277
+ return resolveConfigPath().fold(() => void 0, ({ path }) => loadConfig(path).fold(() => void 0, (config) => ({
1850
2278
  config,
1851
2279
  path
1852
2280
  })));
@@ -1896,7 +2324,8 @@ const readCapabilities = () => {
1896
2324
  const { config } = loaded;
1897
2325
  const agentCapabilities = config.agent?.capabilities ?? [];
1898
2326
  const secretCapabilities = {};
1899
- for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2327
+ const secretEntries = config.secret ?? {};
2328
+ for (const [key, meta] of Object.entries(secretEntries)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
1900
2329
  return { contents: [{
1901
2330
  uri: "envpkt://capabilities",
1902
2331
  mimeType: "application/json",
@@ -1937,7 +2366,7 @@ const loadConfigForTool = (configPath) => {
1937
2366
  return resolveConfigPath(configPath).fold((err) => ({
1938
2367
  ok: false,
1939
2368
  result: errorResult(`Config error: ${err._tag} — ${err._tag === "FileNotFound" ? err.path : ""}`)
1940
- }), (path) => loadConfig(path).fold((err) => ({
2369
+ }), ({ path }) => loadConfig(path).fold((err) => ({
1941
2370
  ok: false,
1942
2371
  result: errorResult(`Config error: ${err._tag} — ${err._tag === "ValidationError" ? err.errors.toArray().join(", ") : ""}`)
1943
2372
  }), (config) => ({
@@ -2004,6 +2433,17 @@ const toolDefinitions = [
2004
2433
  },
2005
2434
  required: ["key"]
2006
2435
  }
2436
+ },
2437
+ {
2438
+ name: "getEnvMeta",
2439
+ description: "Get metadata for environment defaults — returns configured default values, purposes, and current drift status",
2440
+ inputSchema: {
2441
+ type: "object",
2442
+ properties: { configPath: {
2443
+ type: "string",
2444
+ description: "Optional path to envpkt.toml"
2445
+ } }
2446
+ }
2007
2447
  }
2008
2448
  ];
2009
2449
  const handleGetPacketHealth = (args) => {
@@ -2037,7 +2477,8 @@ const handleListCapabilities = (args) => {
2037
2477
  const { config } = loaded;
2038
2478
  const agentCapabilities = config.agent?.capabilities ?? [];
2039
2479
  const secretCapabilities = {};
2040
- for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2480
+ const secretEntries = config.secret ?? {};
2481
+ for (const [key, meta] of Object.entries(secretEntries)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
2041
2482
  return textResult(JSON.stringify({
2042
2483
  agent: config.agent ? {
2043
2484
  name: config.agent.name,
@@ -2045,7 +2486,8 @@ const handleListCapabilities = (args) => {
2045
2486
  description: config.agent.description,
2046
2487
  capabilities: agentCapabilities
2047
2488
  } : null,
2048
- secrets: secretCapabilities
2489
+ secrets: secretCapabilities,
2490
+ env_defaults: Object.keys(config.env ?? {}).length
2049
2491
  }, null, 2));
2050
2492
  };
2051
2493
  const handleGetSecretMeta = (args) => {
@@ -2054,11 +2496,12 @@ const handleGetSecretMeta = (args) => {
2054
2496
  const loaded = loadConfigForTool(args.configPath);
2055
2497
  if (!loaded.ok) return loaded.result;
2056
2498
  const { config } = loaded;
2057
- const meta = config.meta[key];
2499
+ const meta = (config.secret ?? {})[key];
2058
2500
  if (!meta) return errorResult(`Secret not found: ${key}`);
2501
+ const { encrypted_value: _, ...safeMeta } = meta;
2059
2502
  return textResult(JSON.stringify({
2060
2503
  key,
2061
- ...meta
2504
+ ...safeMeta
2062
2505
  }, null, 2));
2063
2506
  };
2064
2507
  const handleCheckExpiration = (args) => {
@@ -2077,11 +2520,19 @@ const handleCheckExpiration = (args) => {
2077
2520
  issues: s.issues.toArray()
2078
2521
  }, null, 2)));
2079
2522
  };
2523
+ const handleGetEnvMeta = (args) => {
2524
+ const loaded = loadConfigForTool(args.configPath);
2525
+ if (!loaded.ok) return loaded.result;
2526
+ const { config } = loaded;
2527
+ const envAudit = computeEnvAudit(config);
2528
+ return textResult(JSON.stringify(envAudit, null, 2));
2529
+ };
2080
2530
  const handlers = {
2081
2531
  getPacketHealth: handleGetPacketHealth,
2082
2532
  listCapabilities: handleListCapabilities,
2083
2533
  getSecretMeta: handleGetSecretMeta,
2084
- checkExpiration: handleCheckExpiration
2534
+ checkExpiration: handleCheckExpiration,
2535
+ getEnvMeta: handleGetEnvMeta
2085
2536
  };
2086
2537
  const callTool = (name, args) => {
2087
2538
  const handler = handlers[name];
@@ -2102,17 +2553,17 @@ const createServer = () => {
2102
2553
  },
2103
2554
  instructions: "envpkt provides credential lifecycle awareness for AI agents. Use tools to check health, capabilities, and secret metadata. No secret values are ever exposed."
2104
2555
  });
2105
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions.map((t) => ({
2556
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: toolDefinitions.map((t) => ({
2106
2557
  name: t.name,
2107
2558
  description: t.description,
2108
2559
  inputSchema: t.inputSchema
2109
2560
  })) }));
2110
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2561
+ server.setRequestHandler(CallToolRequestSchema, (request) => {
2111
2562
  const { name, arguments: args } = request.params;
2112
2563
  return callTool(name, args ?? {});
2113
2564
  });
2114
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [...resourceDefinitions] }));
2115
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2565
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [...resourceDefinitions] }));
2566
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
2116
2567
  const { uri } = request.params;
2117
2568
  const result = readResource(uri);
2118
2569
  if (!result) return { contents: [{
@@ -2145,7 +2596,9 @@ const runResolve = (options) => {
2145
2596
  resolveConfigPath(options.config).fold((err) => {
2146
2597
  console.error(formatError(err));
2147
2598
  process.exit(2);
2148
- }, (configPath) => {
2599
+ }, ({ path: configPath, source }) => {
2600
+ const sourceMsg = formatConfigSource(configPath, source);
2601
+ if (sourceMsg) console.error(sourceMsg);
2149
2602
  loadConfig(configPath).fold((err) => {
2150
2603
  console.error(formatError(err));
2151
2604
  process.exit(2);
@@ -2154,10 +2607,7 @@ const runResolve = (options) => {
2154
2607
  console.error(formatError(err));
2155
2608
  process.exit(2);
2156
2609
  }, (result) => {
2157
- const outputFormat = options.format ?? "toml";
2158
- let content;
2159
- if (outputFormat === "json") content = JSON.stringify(result.config, null, 2) + "\n";
2160
- else content = `# Generated by envpkt resolve — do not edit\n${stringify(result.config)}\n`;
2610
+ 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`;
2161
2611
  if (options.dryRun) {
2162
2612
  console.log(`${DIM}# Dry run — would write:${RESET}`);
2163
2613
  console.log(content);
@@ -2167,7 +2617,7 @@ const runResolve = (options) => {
2167
2617
  } else process.stdout.write(content);
2168
2618
  if (result.catalogPath) {
2169
2619
  const summaryTarget = options.output ? process.stdout : process.stderr;
2170
- 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");
2620
+ 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`);
2171
2621
  for (const w of result.warnings) summaryTarget.write(`${RED}Warning:${RESET} ${w}\n`);
2172
2622
  }
2173
2623
  });
@@ -2175,39 +2625,6 @@ const runResolve = (options) => {
2175
2625
  });
2176
2626
  };
2177
2627
 
2178
- //#endregion
2179
- //#region src/fnox/cli.ts
2180
- /** Export all secrets from fnox as key=value pairs for a given profile */
2181
- const fnoxExport = (profile, agentKey) => {
2182
- const args = profile ? [
2183
- "export",
2184
- "--profile",
2185
- profile
2186
- ] : ["export"];
2187
- const env = agentKey ? {
2188
- ...process.env,
2189
- FNOX_AGE_KEY: agentKey
2190
- } : void 0;
2191
- return Try(() => execFileSync("fnox", args, {
2192
- stdio: "pipe",
2193
- encoding: "utf-8",
2194
- env
2195
- })).fold((err) => Left({
2196
- _tag: "FnoxCliError",
2197
- message: `fnox export failed: ${err}`
2198
- }), (output) => {
2199
- const entries = {};
2200
- for (const line of output.split("\n")) {
2201
- const eq = line.indexOf("=");
2202
- if (eq > 0) {
2203
- const key = line.slice(0, eq).trim();
2204
- entries[key] = line.slice(eq + 1).trim();
2205
- }
2206
- }
2207
- return Right(entries);
2208
- });
2209
- };
2210
-
2211
2628
  //#endregion
2212
2629
  //#region src/core/resolve-values.ts
2213
2630
  /** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
@@ -2244,62 +2661,6 @@ const resolveValues = async (keys, profile, agentKey) => {
2244
2661
  return result;
2245
2662
  };
2246
2663
 
2247
- //#endregion
2248
- //#region src/core/seal.ts
2249
- /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
2250
- const ageEncrypt = (plaintext, recipient) => {
2251
- if (!ageAvailable()) return Left({
2252
- _tag: "AgeNotFound",
2253
- message: "age CLI not found on PATH"
2254
- });
2255
- return Try(() => execFileSync("age", [
2256
- "--encrypt",
2257
- "--recipient",
2258
- recipient,
2259
- "--armor"
2260
- ], {
2261
- input: plaintext,
2262
- stdio: [
2263
- "pipe",
2264
- "pipe",
2265
- "pipe"
2266
- ],
2267
- encoding: "utf-8"
2268
- })).fold((err) => Left({
2269
- _tag: "EncryptFailed",
2270
- key: "",
2271
- message: `age encrypt failed: ${err}`
2272
- }), (output) => Right(output.trim()));
2273
- };
2274
- /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
2275
- const sealSecrets = (meta, values, recipient) => {
2276
- if (!ageAvailable()) return Left({
2277
- _tag: "AgeNotFound",
2278
- message: "age CLI not found on PATH"
2279
- });
2280
- const result = {};
2281
- for (const [key, secretMeta] of Object.entries(meta)) {
2282
- const plaintext = values[key];
2283
- if (plaintext === void 0) {
2284
- result[key] = secretMeta;
2285
- continue;
2286
- }
2287
- const outcome = ageEncrypt(plaintext, recipient).fold((err) => Left({
2288
- _tag: "EncryptFailed",
2289
- key,
2290
- message: err.message
2291
- }), (ciphertext) => Right(ciphertext));
2292
- const failed = outcome.fold((err) => err, () => void 0);
2293
- if (failed) return Left(failed);
2294
- const ciphertext = outcome.fold(() => "", (v) => v);
2295
- result[key] = {
2296
- ...secretMeta,
2297
- encrypted_value: ciphertext
2298
- };
2299
- }
2300
- return Right(result);
2301
- };
2302
-
2303
2664
  //#endregion
2304
2665
  //#region src/cli/commands/seal.ts
2305
2666
  /** Write sealed values back into the TOML file, preserving structure */
@@ -2311,7 +2672,7 @@ const writeSealedToml = (configPath, sealedMeta) => {
2311
2672
  let hasEncryptedValue = false;
2312
2673
  const pendingSeals = /* @__PURE__ */ new Map();
2313
2674
  for (const [key, meta] of Object.entries(sealedMeta)) if (meta.encrypted_value) pendingSeals.set(key, meta.encrypted_value);
2314
- const metaSectionRe = /^\[meta\.(.+)\]\s*$/;
2675
+ const metaSectionRe = /^\[secret\.(.+)\]\s*$/;
2315
2676
  const encryptedValueRe = /^encrypted_value\s*=/;
2316
2677
  const newSectionRe = /^\[/;
2317
2678
  for (let i = 0; i < lines.length; i++) {
@@ -2370,11 +2731,16 @@ const writeSealedToml = (configPath, sealedMeta) => {
2370
2731
  writeFileSync(configPath, output.join("\n"));
2371
2732
  };
2372
2733
  const runSeal = async (options) => {
2373
- const configPath = resolveConfigPath(options.config).fold((err) => {
2734
+ const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
2374
2735
  console.error(formatError(err));
2375
2736
  process.exit(2);
2376
- return "";
2377
- }, (p) => p);
2737
+ return {
2738
+ path: "",
2739
+ source: "flag"
2740
+ };
2741
+ }, (r) => r);
2742
+ const sourceMsg = formatConfigSource(configPath, configSource);
2743
+ if (sourceMsg) console.error(sourceMsg);
2378
2744
  const config = loadConfig(configPath).fold((err) => {
2379
2745
  console.error(formatError(err));
2380
2746
  process.exit(2);
@@ -2384,14 +2750,34 @@ const runSeal = async (options) => {
2384
2750
  console.error(`${DIM}Add [agent] section with recipient = "age1..." to your envpkt.toml${RESET}`);
2385
2751
  process.exit(2);
2386
2752
  }
2387
- const recipient = config.agent.recipient;
2753
+ const { recipient } = config.agent;
2388
2754
  const configDir = dirname(configPath);
2389
- let agentKey;
2390
- if (config.agent.identity) agentKey = unwrapAgentKey(resolve(configDir, config.agent.identity)).fold((err) => {
2755
+ const envEntries = config.env ?? {};
2756
+ const secretEntries0 = config.secret ?? {};
2757
+ const envConflicts = Object.keys(secretEntries0).filter((k) => k in envEntries);
2758
+ if (envConflicts.length > 0) {
2759
+ console.error(`${RED}Error:${RESET} Cannot seal keys that are also defined in [env.*]: ${envConflicts.join(", ")}`);
2760
+ console.error(`${DIM}Move these to [secret.*] only, or remove from [env.*] before sealing.${RESET}`);
2761
+ process.exit(2);
2762
+ }
2763
+ const agentKey = config.agent.identity ? unwrapAgentKey(resolve(configDir, expandPath(config.agent.identity))).fold((err) => {
2391
2764
  const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
2392
2765
  console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
2393
- }, (k) => k);
2394
- const metaKeys = Object.keys(config.meta);
2766
+ }, (k) => k) : void 0;
2767
+ const allSecretEntries = config.secret ?? {};
2768
+ const allKeys = Object.keys(allSecretEntries);
2769
+ const alreadySealed = allKeys.filter((k) => allSecretEntries[k]?.encrypted_value);
2770
+ const unsealed = allKeys.filter((k) => !allSecretEntries[k]?.encrypted_value);
2771
+ if (!options.reseal && alreadySealed.length > 0) {
2772
+ if (unsealed.length === 0) {
2773
+ console.log(`${GREEN}✓${RESET} All ${BOLD}${alreadySealed.length}${RESET} secret(s) already sealed. Use ${CYAN}--reseal${RESET} to re-encrypt.`);
2774
+ process.exit(0);
2775
+ }
2776
+ console.log(`${DIM}Skipping ${alreadySealed.length} already-sealed secret(s). Use --reseal to re-encrypt all.${RESET}`);
2777
+ }
2778
+ const targetKeys = options.reseal ? allKeys : unsealed;
2779
+ const secretEntries = Object.fromEntries(targetKeys.map((k) => [k, allSecretEntries[k]]));
2780
+ const metaKeys = targetKeys;
2395
2781
  console.log(`${BOLD}Sealing ${metaKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
2396
2782
  console.log("");
2397
2783
  const values = await resolveValues(metaKeys, options.profile, agentKey);
@@ -2405,12 +2791,15 @@ const runSeal = async (options) => {
2405
2791
  const skippedKeys = metaKeys.filter((k) => !(k in values));
2406
2792
  console.log(`${YELLOW}Skipped${RESET} ${skipped} key(s) with no value: ${skippedKeys.join(", ")}`);
2407
2793
  }
2408
- sealSecrets(config.meta, values, recipient).fold((err) => {
2794
+ sealSecrets(secretEntries, values, recipient).fold((err) => {
2409
2795
  console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
2410
2796
  process.exit(2);
2411
2797
  }, (sealedMeta) => {
2412
2798
  writeSealedToml(configPath, sealedMeta);
2413
- console.log(`${GREEN}Sealed${RESET} ${resolved} secret(s) into ${DIM}${configPath}${RESET}`);
2799
+ const sealedCount = resolved;
2800
+ const prevSealed = options.reseal ? 0 : alreadySealed.length;
2801
+ const summary = prevSealed > 0 ? ` (${prevSealed} previously sealed kept)` : "";
2802
+ console.log(`${GREEN}Sealed${RESET} ${sealedCount} secret(s) into ${DIM}${configPath}${RESET}${summary}`);
2414
2803
  });
2415
2804
  };
2416
2805
 
@@ -2418,9 +2807,7 @@ const runSeal = async (options) => {
2418
2807
  //#region src/cli/commands/shell-hook.ts
2419
2808
  const ZSH_HOOK = `# envpkt shell hook — add to your .zshrc
2420
2809
  _envpkt_chpwd() {
2421
- if [[ -f envpkt.toml ]]; then
2422
- envpkt audit --format minimal 2>/dev/null
2423
- fi
2810
+ envpkt audit --format minimal 2>/dev/null
2424
2811
  }
2425
2812
 
2426
2813
  if (( $+functions[add-zsh-hook] )); then
@@ -2433,9 +2820,7 @@ fi
2433
2820
  `;
2434
2821
  const BASH_HOOK = `# envpkt shell hook — add to your .bashrc
2435
2822
  _envpkt_prompt() {
2436
- if [[ -f envpkt.toml ]]; then
2437
- envpkt audit --format minimal 2>/dev/null
2438
- fi
2823
+ envpkt audit --format minimal 2>/dev/null
2439
2824
  }
2440
2825
 
2441
2826
  if [[ ! "$PROMPT_COMMAND" == *"_envpkt_prompt"* ]]; then
@@ -2459,39 +2844,42 @@ const runShellHook = (shell) => {
2459
2844
  //#endregion
2460
2845
  //#region src/cli/index.ts
2461
2846
  const program = new Command();
2462
- program.name("envpkt").description("Credential lifecycle and fleet management for AI agents").version("0.1.0");
2847
+ 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(createRequire(import.meta.url)("../../package.json").version);
2463
2848
  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) => {
2464
2849
  runInit(process.cwd(), options);
2465
2850
  });
2466
- 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) => {
2851
+ 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) => {
2467
2852
  runAudit(options);
2468
2853
  });
2469
- 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) => {
2854
+ 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) => {
2470
2855
  runFleet(options);
2471
2856
  });
2472
2857
  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) => {
2473
2858
  runInspect(options);
2474
2859
  });
2475
- 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) => {
2860
+ 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) => {
2476
2861
  runExec(args, options);
2477
2862
  });
2478
2863
  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) => {
2479
2864
  runResolve(options);
2480
2865
  });
2481
- program.command("seal").description("Encrypt secret values into envpkt.toml using age (sealed packets)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").action(async (options) => {
2866
+ 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) => {
2482
2867
  await runSeal(options);
2483
2868
  });
2484
2869
  program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
2485
2870
  runMcp(options);
2486
2871
  });
2487
2872
  const env = program.command("env").description("Discover and check credentials in your shell environment");
2488
- 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) => {
2873
+ 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) => {
2489
2874
  runEnvScan(options);
2490
2875
  });
2491
2876
  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) => {
2492
2877
  runEnvCheck(options);
2493
2878
  });
2494
- program.command("shell-hook").description("Output shell function for ambient credential warnings on cd").argument("<shell>", "Shell type: zsh | bash").action((shell) => {
2879
+ 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) => {
2880
+ runEnvExport(options);
2881
+ });
2882
+ 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) => {
2495
2883
  runShellHook(shell);
2496
2884
  });
2497
2885
  program.parse();