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/README.md +296 -107
- package/dist/cli.js +803 -198
- package/dist/index.d.ts +85 -7
- package/dist/index.js +369 -75
- package/package.json +22 -20
- package/schemas/envpkt.schema.json +46 -3
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
|
|
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
|
|
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
|
|
64
|
-
const
|
|
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
|
-
|
|
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
|
-
/**
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
206
|
-
if (!("
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
return findConfigPath(dir).fold(() => Left({
|
|
323
|
+
return discoverConfig(cwd).fold(() => Left({
|
|
252
324
|
_tag: "FileNotFound",
|
|
253
|
-
path: join(
|
|
254
|
-
}), (path) => Right(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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:
|
|
664
|
+
secrets: audit.secrets.filter((s) => !!secretEntries[s.key]?.encrypted_value)
|
|
562
665
|
};
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 [
|
|
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(`[
|
|
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(`[
|
|
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
|
|
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()
|
|
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 ${
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
}).fold(() =>
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
|
1466
|
-
const
|
|
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
|
-
}, (
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1481
|
-
console.error(
|
|
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
|
-
|
|
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 (
|
|
1505
|
-
|
|
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
|
-
|
|
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 `[
|
|
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 [
|
|
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(`# [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
...
|
|
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,
|
|
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,
|
|
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,
|
|
2111
|
-
server.setRequestHandler(ReadResourceRequestSchema,
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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();
|