envpkt 0.11.9 → 0.13.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 +50 -1
- package/dist/cli.js +409 -87
- package/dist/index.d.ts +36 -2
- package/dist/index.js +140 -22
- package/package.json +5 -5
- package/schemas/envpkt.schema.json +13 -0
package/README.md
CHANGED
|
@@ -210,6 +210,32 @@ const result = boot() // decrypts sealed values, injects into process.env
|
|
|
210
210
|
|
|
211
211
|
Mixed mode is supported — sealed values take priority, with fnox as fallback for keys without `encrypted_value`.
|
|
212
212
|
|
|
213
|
+
## GitHub Actions
|
|
214
|
+
|
|
215
|
+
A composite action resolves the credentials in `envpkt.toml` into the CI job — with secret values masked in the log — so later steps just see them as environment variables:
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
- uses: actions/checkout@v5
|
|
219
|
+
|
|
220
|
+
# Sealed packets are decrypted with the `age` CLI (not preinstalled on runners).
|
|
221
|
+
- run: sudo apt-get update && sudo apt-get install -y age
|
|
222
|
+
|
|
223
|
+
- uses: jordanburke/envpkt@v0.12.0
|
|
224
|
+
with:
|
|
225
|
+
config: ./envpkt.toml
|
|
226
|
+
strict: "true" # fail the build if a credential is expired/unhealthy
|
|
227
|
+
env:
|
|
228
|
+
ENVPKT_AGE_KEY: ${{ secrets.ENVPKT_AGE_KEY }}
|
|
229
|
+
|
|
230
|
+
- run: ./deploy.sh # sees the resolved vars; secret values redacted in the log
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**How it works.** Commit sealed (`encrypted_value`) packets to the repo and supply the age private key as the `ENVPKT_AGE_KEY` secret. `boot()` materializes the inline key to a `0600` temp file to decrypt, then [`env github`](#envpkt-env-github) masks each secret (`::add-mask::`) and writes it to `$GITHUB_ENV`. Identity precedence: `identity.key_file` → `ENVPKT_AGE_KEY_FILE` → `ENVPKT_AGE_KEY` (inline) → `~/.envpkt/age-key.txt`.
|
|
234
|
+
|
|
235
|
+
**Inputs:** `config`, `version` (npm version to run, default `latest`), `strict`, `profile`.
|
|
236
|
+
|
|
237
|
+
> Decrypting sealed packets requires the [`age`](https://github.com/FiloSottile/age) CLI on the runner (install it first, as above) — not needed if you only inject plaintext `[env.*]` defaults or resolve via fnox. Pin to a released tag (e.g. `@v0.12.0`); no moving major tag (`@v1`) is published yet. Node is assumed present; add `actions/setup-node` first to pin a version.
|
|
238
|
+
|
|
213
239
|
## Fleet Management
|
|
214
240
|
|
|
215
241
|
When you're running multiple agents, `envpkt fleet` scans a directory tree for `envpkt.toml` files and aggregates credential health across your entire fleet.
|
|
@@ -431,12 +457,35 @@ eval "$(envpkt env export --profile staging)"
|
|
|
431
457
|
eval "$(envpkt env export -c path/to/envpkt.toml)"
|
|
432
458
|
```
|
|
433
459
|
|
|
434
|
-
Add to your shell startup (e.g. `~/.zshrc`
|
|
460
|
+
Add to your shell startup (e.g. `~/.zshrc`) to load a global package once at login:
|
|
435
461
|
|
|
436
462
|
```bash
|
|
437
463
|
eval "$(envpkt env export 2>/dev/null)"
|
|
438
464
|
```
|
|
439
465
|
|
|
466
|
+
Secret values are emitted **only when the package sets top-level `scope = "shell"`** — the default `scope = "exec"` withholds them (use `envpkt exec`). For **per-project** credentials that load on `cd`, use [`envpkt shell-hook`](#envpkt-shell-hook) rather than a one-time startup eval.
|
|
467
|
+
|
|
468
|
+
### `envpkt shell-hook`
|
|
469
|
+
|
|
470
|
+
Generate a `cd` hook (zsh/bash) that loads a project's credentials when you enter its directory tree and restores your environment when you leave:
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc (or: shell-hook bash)
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
On each directory change it resolves the **nearest `envpkt.toml`, walking up from the current directory** (like `git`/`direnv` — so it works from any subdirectory, not just the project root), injects that package via `env export --track`, and restores the previous package on leave (prior values, not a blind unset). Env defaults always load; secret values load only for `scope = "shell"` packages. Backed by `envpkt config-path` — a resolve-only command that prints the active config path (no decryption).
|
|
477
|
+
|
|
478
|
+
> **Upward-walk discovery**: config resolution now walks up the directory tree to the nearest `envpkt.toml` before falling back to the global package. This also applies to `exec`, `env export`, and `audit` — running any of them from a subdirectory finds the enclosing project.
|
|
479
|
+
|
|
480
|
+
### `envpkt env github`
|
|
481
|
+
|
|
482
|
+
Inject resolved secrets into a GitHub Actions job. Emits `::add-mask::` for each secret value (redacting it from the log) and appends assignments to `$GITHUB_ENV` — under their namespaced wire names — so later steps in the job inherit them. Env defaults are written but not masked. `--strict` exits non-zero if the pre-flight audit is unhealthy. This is the engine behind the [GitHub Action](#github-actions).
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
# Run as a step; later steps in the job see the resolved vars
|
|
486
|
+
npx envpkt env github --strict
|
|
487
|
+
```
|
|
488
|
+
|
|
440
489
|
### `envpkt shell-hook`
|
|
441
490
|
|
|
442
491
|
Output a shell function that runs `envpkt audit --format minimal` whenever you `cd` into a directory. envpkt's config discovery chain automatically finds config files beyond CWD (see [Config Resolution](#config-resolution)), so the hook works even in directories without a local `envpkt.toml`.
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { $, Cond, Do, Either, Left, List, Map as Map$1, Option, Right, Set as Set$1, Try } from "functype";
|
|
6
|
+
import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try } from "functype";
|
|
7
7
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
8
8
|
import { Env, Fs, Path, Platform } from "functype-os";
|
|
9
9
|
import { TomlDate, parse, stringify } from "smol-toml";
|
|
10
10
|
import { FormatRegistry, Type } from "@sinclair/typebox";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
13
|
import { directSilentLogger } from "functype-log/direct";
|
|
12
14
|
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
17
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -302,6 +303,7 @@ const EnvpktConfigSchema = Type.Object({
|
|
|
302
303
|
default: 1
|
|
303
304
|
}),
|
|
304
305
|
catalog: Type.Optional(Type.String({ description: "Path to shared secret catalog (relative to this config file)" })),
|
|
306
|
+
scope: Type.Optional(Type.Union([Type.Literal("shell"), Type.Literal("exec")], { description: "Whether `env export` emits this package's secrets for ambient/shell loading (`shell`) or withholds them so they are only available via `envpkt exec` (`exec`). Default `exec`. Never affects `envpkt exec`, which always injects everything." })),
|
|
305
307
|
namespace: Type.Optional(NamespaceSchema),
|
|
306
308
|
identity: Type.Optional(IdentitySchema),
|
|
307
309
|
secret: Type.Optional(Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" })),
|
|
@@ -368,11 +370,24 @@ const buildSearchPaths = () => {
|
|
|
368
370
|
const cloudPaths = Platform.cloudStorageDirs().toArray().map((cloud) => join(cloud.path, ".envpkt", CONFIG_FILENAME$2));
|
|
369
371
|
return [...homePaths, ...cloudPaths];
|
|
370
372
|
};
|
|
371
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* Walk up from `dir` to the filesystem root, returning the nearest `envpkt.toml`.
|
|
375
|
+
* Makes a project's config apply throughout its subtree (like git/.env/direnv finding
|
|
376
|
+
* their root file), not only in the directory that literally contains the file.
|
|
377
|
+
* The global package lives in `~/.envpkt/` (a subdir), so it is not matched by this
|
|
378
|
+
* walk — it is resolved by the search-path/Platform fallback below.
|
|
379
|
+
*/
|
|
380
|
+
const walkUpForConfig = (dir) => {
|
|
381
|
+
const candidate = join(dir, CONFIG_FILENAME$2);
|
|
382
|
+
if (Fs.existsSync(candidate)) return Option(candidate);
|
|
383
|
+
const parent = dirname(dir);
|
|
384
|
+
return parent === dir ? Option(void 0) : walkUpForConfig(parent);
|
|
385
|
+
};
|
|
386
|
+
/** Discover config by walking up from CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
|
|
372
387
|
const discoverConfig = (cwd) => {
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
path:
|
|
388
|
+
const walked = walkUpForConfig(cwd ?? process.cwd()).orUndefined();
|
|
389
|
+
if (walked) return Option({
|
|
390
|
+
path: walked,
|
|
376
391
|
source: "cwd"
|
|
377
392
|
});
|
|
378
393
|
const customMatch = Env.get("ENVPKT_SEARCH_PATH").fold(() => [], (v) => v.split(":").filter(Boolean)).map((template) => ({
|
|
@@ -834,6 +849,19 @@ const runAuditOnConfig = (config, options) => {
|
|
|
834
849
|
process.exit(code);
|
|
835
850
|
};
|
|
836
851
|
//#endregion
|
|
852
|
+
//#region src/cli/commands/config-path.ts
|
|
853
|
+
/**
|
|
854
|
+
* Print the `envpkt.toml` path resolved for the current directory, or nothing if none
|
|
855
|
+
* is found. Resolve-only: no config load, no boot, no decryption — cheap enough for a
|
|
856
|
+
* per-`cd` shell hook to gate on. Always exits 0; a missing config means "no package
|
|
857
|
+
* here", not an error.
|
|
858
|
+
*/
|
|
859
|
+
const runConfigPath = (options) => {
|
|
860
|
+
resolveConfigPath(options.config).fold(() => {}, ({ path }) => {
|
|
861
|
+
console.log(path);
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
//#endregion
|
|
837
865
|
//#region src/fnox/cli.ts
|
|
838
866
|
/** Export all secrets from fnox as key=value pairs for a given profile */
|
|
839
867
|
const fnoxExport = (profile, agentKey) => {
|
|
@@ -1062,6 +1090,11 @@ const formatAliasError = (error) => {
|
|
|
1062
1090
|
//#region src/core/keygen.ts
|
|
1063
1091
|
/** Resolve the age identity file path: ENVPKT_AGE_KEY_FILE env var > ~/.envpkt/age-key.txt */
|
|
1064
1092
|
const resolveKeyPath = () => process.env["ENVPKT_AGE_KEY_FILE"] ?? join(homedir(), ".envpkt", "age-key.txt");
|
|
1093
|
+
/** Resolve an inline age key from ENVPKT_AGE_KEY env var (for CI) */
|
|
1094
|
+
const resolveInlineKey = () => {
|
|
1095
|
+
const key = process.env["ENVPKT_AGE_KEY"];
|
|
1096
|
+
return key ? Some(key) : None();
|
|
1097
|
+
};
|
|
1065
1098
|
/** Generate an age keypair and write to disk. Refuses to overwrite if the file already exists. */
|
|
1066
1099
|
const generateKeypair = (options) => {
|
|
1067
1100
|
if (!ageAvailable()) return Left({
|
|
@@ -1297,6 +1330,65 @@ const resolveIdentityFilePath = (config, configDir, useDefaultFallback) => {
|
|
|
1297
1330
|
const defaultPath = resolveKeyPath();
|
|
1298
1331
|
return existsSync(defaultPath) ? Option(defaultPath) : Option(void 0);
|
|
1299
1332
|
};
|
|
1333
|
+
const noop = () => {};
|
|
1334
|
+
/** Write an inline age key to a private temp file so the `age` CLI (file-based) can use it. */
|
|
1335
|
+
const materializeInlineKey = (key) => {
|
|
1336
|
+
const dir = mkdtempSync(join(tmpdir(), "envpkt-age-"));
|
|
1337
|
+
const keyPath = join(dir, "age-key.txt");
|
|
1338
|
+
writeFileSync(keyPath, key.endsWith("\n") ? key : `${key}\n`);
|
|
1339
|
+
chmodSync(keyPath, 384);
|
|
1340
|
+
return {
|
|
1341
|
+
path: keyPath,
|
|
1342
|
+
dispose: () => rmSync(dir, {
|
|
1343
|
+
recursive: true,
|
|
1344
|
+
force: true
|
|
1345
|
+
})
|
|
1346
|
+
};
|
|
1347
|
+
};
|
|
1348
|
+
/**
|
|
1349
|
+
* Resolve an age identity for unsealing sealed packets. Precedence:
|
|
1350
|
+
* config.identity.key_file > ENVPKT_AGE_KEY_FILE > ENVPKT_AGE_KEY (inline) > ~/.envpkt/age-key.txt
|
|
1351
|
+
* The inline key (CI secret) ranks above the homedir default so an explicit
|
|
1352
|
+
* env var beats a stray local key. Inline keys are written to a 0600 temp file
|
|
1353
|
+
* the caller must dispose().
|
|
1354
|
+
*/
|
|
1355
|
+
const resolveSealIdentity = (config, configDir) => {
|
|
1356
|
+
if (config.identity?.key_file) {
|
|
1357
|
+
const keyFilePath = resolve(configDir, expandPath(config.identity.key_file));
|
|
1358
|
+
if (existsSync(keyFilePath)) return Option({
|
|
1359
|
+
path: keyFilePath,
|
|
1360
|
+
dispose: noop
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
|
|
1364
|
+
if (envFile && existsSync(envFile)) return Option({
|
|
1365
|
+
path: envFile,
|
|
1366
|
+
dispose: noop
|
|
1367
|
+
});
|
|
1368
|
+
const inlineKey = resolveInlineKey().orUndefined();
|
|
1369
|
+
if (inlineKey) return Option(materializeInlineKey(inlineKey));
|
|
1370
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1371
|
+
if (existsSync(defaultPath)) return Option({
|
|
1372
|
+
path: defaultPath,
|
|
1373
|
+
dispose: noop
|
|
1374
|
+
});
|
|
1375
|
+
return Option(void 0);
|
|
1376
|
+
};
|
|
1377
|
+
/** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
|
|
1378
|
+
const describeSealKeySearch = (config, configDir) => {
|
|
1379
|
+
const keyFile = config.identity?.key_file;
|
|
1380
|
+
const keyFileLine = keyFile ? `identity.key_file → ${resolve(configDir, expandPath(keyFile))} (${existsSync(resolve(configDir, expandPath(keyFile))) ? "found" : "missing"})` : `identity.key_file (not set)`;
|
|
1381
|
+
const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
|
|
1382
|
+
const envFileLine = envFile ? `ENVPKT_AGE_KEY_FILE → ${envFile} (${existsSync(envFile) ? "found" : "missing"})` : `ENVPKT_AGE_KEY_FILE (unset)`;
|
|
1383
|
+
const inlineLine = resolveInlineKey().isEmpty ? `ENVPKT_AGE_KEY (unset)` : `ENVPKT_AGE_KEY (set, inline)`;
|
|
1384
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1385
|
+
return [
|
|
1386
|
+
keyFileLine,
|
|
1387
|
+
envFileLine,
|
|
1388
|
+
inlineLine,
|
|
1389
|
+
`${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
1390
|
+
];
|
|
1391
|
+
};
|
|
1300
1392
|
const resolveIdentityKey = (config, configDir) => {
|
|
1301
1393
|
return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
|
|
1302
1394
|
};
|
|
@@ -1366,6 +1458,12 @@ const bootSafe = (options) => {
|
|
|
1366
1458
|
message: "unexpected"
|
|
1367
1459
|
}));
|
|
1368
1460
|
const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
|
|
1461
|
+
const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
|
|
1462
|
+
if (hasSealedValues && sealIdentity.isEmpty) return Left({
|
|
1463
|
+
_tag: "SealKeyUnavailable",
|
|
1464
|
+
sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
|
|
1465
|
+
searched: describeSealKeySearch(config, configDir)
|
|
1466
|
+
});
|
|
1369
1467
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1370
1468
|
const secrets = {};
|
|
1371
1469
|
const injected = [];
|
|
@@ -1379,23 +1477,25 @@ const bootSafe = (options) => {
|
|
|
1379
1477
|
process.env[envEnv(key)] = value;
|
|
1380
1478
|
});
|
|
1381
1479
|
const sealedKeys = /* @__PURE__ */ new Set();
|
|
1382
|
-
|
|
1383
|
-
if (hasSealedValues) identityFilePath.fold(() => {
|
|
1480
|
+
if (hasSealedValues) sealIdentity.fold(() => {
|
|
1384
1481
|
log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1482
|
+
}, ({ path: idPath, dispose }) => {
|
|
1483
|
+
try {
|
|
1484
|
+
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
1485
|
+
log.warn("phase.sealed.decrypt_failed", { message: err.message });
|
|
1486
|
+
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1487
|
+
}, (unsealed) => {
|
|
1488
|
+
const unsealedEntries = Object.entries(unsealed);
|
|
1489
|
+
Object.assign(secrets, unsealed);
|
|
1490
|
+
injected.push(...unsealedEntries.map(([key]) => key));
|
|
1491
|
+
unsealedEntries.forEach(([key]) => {
|
|
1492
|
+
sealedKeys.add(key);
|
|
1493
|
+
log.debug("phase.sealed.resolved", { key });
|
|
1494
|
+
});
|
|
1397
1495
|
});
|
|
1398
|
-
}
|
|
1496
|
+
} finally {
|
|
1497
|
+
dispose();
|
|
1498
|
+
}
|
|
1399
1499
|
});
|
|
1400
1500
|
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1401
1501
|
if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
@@ -1483,6 +1583,30 @@ const bootSafe = (options) => {
|
|
|
1483
1583
|
}));
|
|
1484
1584
|
};
|
|
1485
1585
|
//#endregion
|
|
1586
|
+
//#region src/core/dotenv.ts
|
|
1587
|
+
const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
1588
|
+
/**
|
|
1589
|
+
* Quote a single value for dotenv output. Returns the value bare when safe,
|
|
1590
|
+
* otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
|
|
1591
|
+
* whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
|
|
1592
|
+
*/
|
|
1593
|
+
const quoteDotenvValue = (value) => {
|
|
1594
|
+
if (value === "") return "";
|
|
1595
|
+
if (BARE_SAFE.test(value)) return value;
|
|
1596
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
1597
|
+
};
|
|
1598
|
+
const formatEntry = (entry, includeSecrets) => {
|
|
1599
|
+
if (entry.secret && !includeSecrets) return `# (secret value omitted — re-run without --no-secrets to include)\n${entry.name}=`;
|
|
1600
|
+
return `${entry.name}=${quoteDotenvValue(entry.value)}`;
|
|
1601
|
+
};
|
|
1602
|
+
/** Serialize entries to dotenv text (no trailing newline). */
|
|
1603
|
+
const formatDotenv = (entries, options) => {
|
|
1604
|
+
const includeSecrets = options?.includeSecrets ?? true;
|
|
1605
|
+
const body = entries.map((e) => formatEntry(e, includeSecrets)).join("\n");
|
|
1606
|
+
const header = options?.header;
|
|
1607
|
+
return header ? `${header}\n\n${body}` : body;
|
|
1608
|
+
};
|
|
1609
|
+
//#endregion
|
|
1486
1610
|
//#region src/core/patterns.ts
|
|
1487
1611
|
const EXCLUDED_VARS = Set$1([
|
|
1488
1612
|
"PATH",
|
|
@@ -2615,6 +2739,20 @@ const writeIfValid = (configPath, updated, successMsg) => {
|
|
|
2615
2739
|
writeFileSync(configPath, updated, "utf-8");
|
|
2616
2740
|
console.log(successMsg);
|
|
2617
2741
|
};
|
|
2742
|
+
/**
|
|
2743
|
+
* Validate then preview (no write) — the `--dry-run` counterpart of `writeIfValid`.
|
|
2744
|
+
* Runs the same structural validation the real write would, so a dry-run can never
|
|
2745
|
+
* show a result that the actual write would reject. On invalid output it prints the
|
|
2746
|
+
* same error and exits 1, exactly as the write path does.
|
|
2747
|
+
*
|
|
2748
|
+
* `display` lets callers preview a focused slice (e.g. just the new block for `add`)
|
|
2749
|
+
* while still validating the full resulting config.
|
|
2750
|
+
*/
|
|
2751
|
+
const previewIfValid = (updated, display) => {
|
|
2752
|
+
validateOrExit(updated);
|
|
2753
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
2754
|
+
console.log(display ?? updated);
|
|
2755
|
+
};
|
|
2618
2756
|
//#endregion
|
|
2619
2757
|
//#region src/cli/commands/env.ts
|
|
2620
2758
|
const printPostWriteGuidance = () => {
|
|
@@ -2693,37 +2831,118 @@ const runEnvCheck = (options) => {
|
|
|
2693
2831
|
});
|
|
2694
2832
|
};
|
|
2695
2833
|
const shellEscape = (value) => value.replace(/'/g, "'\\''");
|
|
2834
|
+
/** Shared resolution for the emit commands (`export`, `github`): resolve without injecting. */
|
|
2835
|
+
const resolveForEmit = (options) => bootSafe({
|
|
2836
|
+
inject: false,
|
|
2837
|
+
configPath: options.config,
|
|
2838
|
+
profile: options.profile,
|
|
2839
|
+
warnOnly: true
|
|
2840
|
+
});
|
|
2841
|
+
/**
|
|
2842
|
+
* Flatten a resolved BootResult into the ordered (wire-name, value) pairs to emit:
|
|
2843
|
+
* env defaults, then overridden env entries (value from config), then secrets (which
|
|
2844
|
+
* override). `secret` marks values that consumers must redact (e.g. GitHub masking).
|
|
2845
|
+
*/
|
|
2846
|
+
const collectEmitEntries = (boot) => {
|
|
2847
|
+
const wireName = (key) => boot.envNames[key] ?? key;
|
|
2848
|
+
const defaults = Object.entries(boot.envDefaults).map(([key, value]) => ({
|
|
2849
|
+
name: wireName(key),
|
|
2850
|
+
value,
|
|
2851
|
+
secret: false
|
|
2852
|
+
}));
|
|
2853
|
+
const overridden = boot.overridden.length === 0 ? [] : loadConfig(boot.configPath).fold(() => [], (config) => {
|
|
2854
|
+
const envEntries = config.env ?? {};
|
|
2855
|
+
return boot.overridden.flatMap((key) => {
|
|
2856
|
+
const entry = envEntries[key];
|
|
2857
|
+
if (!entry) return [];
|
|
2858
|
+
const name = wireName(key);
|
|
2859
|
+
return [{
|
|
2860
|
+
name,
|
|
2861
|
+
value: entry.value ?? process.env[name] ?? "",
|
|
2862
|
+
secret: false
|
|
2863
|
+
}];
|
|
2864
|
+
});
|
|
2865
|
+
});
|
|
2866
|
+
const secrets = Object.entries(boot.secrets).map(([key, value]) => ({
|
|
2867
|
+
name: wireName(key),
|
|
2868
|
+
value,
|
|
2869
|
+
secret: true
|
|
2870
|
+
}));
|
|
2871
|
+
return [
|
|
2872
|
+
...defaults,
|
|
2873
|
+
...overridden,
|
|
2874
|
+
...secrets
|
|
2875
|
+
];
|
|
2876
|
+
};
|
|
2877
|
+
const emitWarnings = (boot) => {
|
|
2878
|
+
const sourceMsg = formatConfigSource(boot.configPath, boot.configSource);
|
|
2879
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
2880
|
+
boot.warnings.forEach((warning) => {
|
|
2881
|
+
console.error(`${YELLOW}Warning:${RESET} ${warning}`);
|
|
2882
|
+
});
|
|
2883
|
+
};
|
|
2696
2884
|
const runEnvExport = (options) => {
|
|
2697
|
-
|
|
2698
|
-
inject: false,
|
|
2699
|
-
configPath: options.config,
|
|
2700
|
-
profile: options.profile,
|
|
2701
|
-
warnOnly: true
|
|
2702
|
-
}).fold((err) => {
|
|
2885
|
+
resolveForEmit(options).fold((err) => {
|
|
2703
2886
|
console.error(formatError(err));
|
|
2704
2887
|
process.exit(2);
|
|
2705
2888
|
}, (boot) => {
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
boot
|
|
2709
|
-
|
|
2889
|
+
emitWarnings(boot);
|
|
2890
|
+
const scope = loadConfig(boot.configPath).fold(() => "exec", (config) => config.scope ?? "exec");
|
|
2891
|
+
const entries = collectEmitEntries(boot);
|
|
2892
|
+
const emit = scope === "shell" ? entries : entries.filter((e) => !e.secret);
|
|
2893
|
+
const withheld = entries.length - emit.length;
|
|
2894
|
+
if (withheld > 0) console.error(`${DIM}${withheld} secret(s) withheld (scope="${scope}") — use \`envpkt exec\` or set top-level scope="shell".${RESET}`);
|
|
2895
|
+
emit.forEach(({ name, value }) => {
|
|
2896
|
+
console.log(options.track ? `_ENVPKT_HAD_${name}=\${${name}+1}; _ENVPKT_PREV_${name}="\${${name}-}"; export ${name}='${shellEscape(value)}'` : `export ${name}='${shellEscape(value)}'`);
|
|
2710
2897
|
});
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2898
|
+
if (options.track) console.log(`_ENVPKT_INJECTED='${emit.map((e) => e.name).join(" ")}'`);
|
|
2899
|
+
});
|
|
2900
|
+
};
|
|
2901
|
+
const runEnvGithub = (options) => {
|
|
2902
|
+
resolveForEmit(options).fold((err) => {
|
|
2903
|
+
console.error(formatError(err));
|
|
2904
|
+
process.exit(2);
|
|
2905
|
+
}, (boot) => {
|
|
2906
|
+
emitWarnings(boot);
|
|
2907
|
+
const entries = collectEmitEntries(boot);
|
|
2908
|
+
entries.filter((e) => e.secret).forEach((e) => console.log(`::add-mask::${e.value}`));
|
|
2909
|
+
const lines = entries.map(({ name, value }) => {
|
|
2910
|
+
const delim = `__ENVPKT_${randomBytes(9).toString("hex")}__`;
|
|
2911
|
+
return `${name}<<${delim}\n${value}\n${delim}`;
|
|
2714
2912
|
});
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2913
|
+
const githubEnv = process.env["GITHUB_ENV"];
|
|
2914
|
+
if (githubEnv) appendFileSync(githubEnv, lines.length > 0 ? `${lines.join("\n")}\n` : "");
|
|
2915
|
+
else {
|
|
2916
|
+
console.error(`${YELLOW}Warning:${RESET} GITHUB_ENV not set — printing assignments to stdout (not a GitHub Actions runner)`);
|
|
2917
|
+
lines.forEach((l) => console.log(l));
|
|
2918
|
+
}
|
|
2919
|
+
if (options.strict) process.exit(exitCodeForAudit(boot.audit));
|
|
2920
|
+
});
|
|
2921
|
+
};
|
|
2922
|
+
const buildDotenvHeader = (configPath) => `# Generated by envpkt — regenerate with: envpkt env dotenv\n# Source: ${configPath}. Do not edit by hand.`;
|
|
2923
|
+
const runEnvDotenv = (options) => {
|
|
2924
|
+
resolveForEmit(options).fold((err) => {
|
|
2925
|
+
console.error(formatError(err));
|
|
2926
|
+
process.exit(2);
|
|
2927
|
+
}, (boot) => {
|
|
2928
|
+
emitWarnings(boot);
|
|
2929
|
+
const entries = collectEmitEntries(boot);
|
|
2930
|
+
const includeSecrets = options.secrets !== false;
|
|
2931
|
+
const text = formatDotenv(entries, {
|
|
2932
|
+
includeSecrets,
|
|
2933
|
+
header: buildDotenvHeader(boot.configPath)
|
|
2724
2934
|
});
|
|
2725
|
-
|
|
2726
|
-
console.log(
|
|
2935
|
+
if (!options.output) {
|
|
2936
|
+
console.log(text);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
const outPath = resolve(options.output);
|
|
2940
|
+
Try(() => writeFileSync(outPath, `${text}\n`, "utf-8")).fold((writeErr) => {
|
|
2941
|
+
console.error(`${RED}Error:${RESET} Failed to write ${outPath}: ${writeErr}`);
|
|
2942
|
+
process.exit(1);
|
|
2943
|
+
}, () => {
|
|
2944
|
+
console.error(`${GREEN}✓${RESET} Wrote ${CYAN}${outPath}${RESET}`);
|
|
2945
|
+
if (includeSecrets && entries.some((e) => e.secret)) console.error(`${YELLOW}Note:${RESET} ${outPath} contains secret values — ensure it is .gitignored.`);
|
|
2727
2946
|
});
|
|
2728
2947
|
});
|
|
2729
2948
|
};
|
|
@@ -2919,9 +3138,15 @@ const registerEnvCommands = (program) => {
|
|
|
2919
3138
|
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) => {
|
|
2920
3139
|
runEnvCheck(options);
|
|
2921
3140
|
});
|
|
2922
|
-
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) => {
|
|
3141
|
+
env.command("export").description("Output export statements for eval-ing secrets into the current shell. Usage: eval \"$(envpkt env export)\"").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit").option("--track", "Emit prior-value snapshots + an _ENVPKT_INJECTED list (for the shell hook to restore on cd)").action((options) => {
|
|
2923
3142
|
runEnvExport(options);
|
|
2924
3143
|
});
|
|
3144
|
+
env.command("github").description("Inject resolved secrets into $GITHUB_ENV for GitHub Actions, masking secret values in the log (::add-mask::)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--strict", "Exit non-zero if the pre-flight audit is not healthy").action((options) => {
|
|
3145
|
+
runEnvGithub(options);
|
|
3146
|
+
});
|
|
3147
|
+
env.command("dotenv").description("Output resolved credentials in .env format (for Wrangler, Docker --env-file, Vite, etc.)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("-o, --output <file>", "Write to a file instead of stdout").option("--no-secrets", "Omit secret values (emit KEY= with a note) — values are included by default").action((options) => {
|
|
3148
|
+
runEnvDotenv(options);
|
|
3149
|
+
});
|
|
2925
3150
|
env.command("add").description("Add a new environment default entry to envpkt.toml").argument("<name>", "Environment variable name").argument("<value>", "Default value").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this env var exists").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--dry-run", "Preview the TOML block without writing").action((name, value, options) => {
|
|
2926
3151
|
runEnvAdd(name, value, options);
|
|
2927
3152
|
});
|
|
@@ -3857,6 +4082,34 @@ const applySealedToml = (raw, sealedMeta) => {
|
|
|
3857
4082
|
};
|
|
3858
4083
|
return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
|
|
3859
4084
|
};
|
|
4085
|
+
/** Affirmative answer to a `[y/N]` confirm prompt. */
|
|
4086
|
+
const isAffirmative = (answer) => /^y(es)?$/i.test(answer.trim());
|
|
4087
|
+
/**
|
|
4088
|
+
* Collect new plaintext values for the `--edit`ed keys via the injected `prompt`. Before
|
|
4089
|
+
* overwriting an entry that is **already sealed** (`encrypted_value` present), require an
|
|
4090
|
+
* explicit confirmation — replacing it discards the only ciphertext, and the prior value
|
|
4091
|
+
* can't be recovered without the original key. Declined or empty entries are skipped (reported
|
|
4092
|
+
* via `onSkip`). I/O flows only through `prompt`/`onSkip`, so the confirm/skip logic is
|
|
4093
|
+
* unit-testable without a TTY.
|
|
4094
|
+
*/
|
|
4095
|
+
const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
4096
|
+
const values = {};
|
|
4097
|
+
for (const key of editKeys) {
|
|
4098
|
+
if (entries[key]?.encrypted_value) {
|
|
4099
|
+
if (!isAffirmative(await prompt(`Replace the sealed value for ${key}? The previous value can't be recovered without the original key. [y/N] `))) {
|
|
4100
|
+
onSkip?.(key, "declined");
|
|
4101
|
+
continue;
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
const value = await prompt(`Enter new value for ${key}: `);
|
|
4105
|
+
if (value === "") {
|
|
4106
|
+
onSkip?.(key, "empty");
|
|
4107
|
+
continue;
|
|
4108
|
+
}
|
|
4109
|
+
values[key] = value;
|
|
4110
|
+
}
|
|
4111
|
+
return values;
|
|
4112
|
+
};
|
|
3860
4113
|
/** Write sealed values back into the TOML file, preserving structure. */
|
|
3861
4114
|
const writeSealedToml = (configPath, sealedMeta) => {
|
|
3862
4115
|
const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
|
|
@@ -3933,15 +4186,9 @@ const runSeal = async (options) => {
|
|
|
3933
4186
|
const prompt = (question) => new Promise((resolve) => {
|
|
3934
4187
|
rl.question(question, (answer) => resolve(answer));
|
|
3935
4188
|
});
|
|
3936
|
-
const values = {
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
if (value === "") {
|
|
3940
|
-
console.error(`${YELLOW}Skipped${RESET} ${key} (empty value)`);
|
|
3941
|
-
continue;
|
|
3942
|
-
}
|
|
3943
|
-
values[key] = value;
|
|
3944
|
-
}
|
|
4189
|
+
const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
|
|
4190
|
+
console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
|
|
4191
|
+
});
|
|
3945
4192
|
rl.close();
|
|
3946
4193
|
if (Object.keys(values).length === 0) {
|
|
3947
4194
|
console.error(`${RED}Error:${RESET} No values provided`);
|
|
@@ -4028,6 +4275,27 @@ const runSeal = async (options) => {
|
|
|
4028
4275
|
};
|
|
4029
4276
|
//#endregion
|
|
4030
4277
|
//#region src/cli/commands/secret.ts
|
|
4278
|
+
/**
|
|
4279
|
+
* Fields removable via `--unset` — exactly the metadata fields settable via a flag.
|
|
4280
|
+
* Mental model: you can unset any field you can set. Structural/managed fields
|
|
4281
|
+
* (`created`, `last_rotated_at`, `encrypted_value`, `from_key`, `namespace`) are
|
|
4282
|
+
* intentionally excluded — they're owned by `seal`/`rotate`/`alias`, not `edit`.
|
|
4283
|
+
* Field names are the canonical TOML keys (e.g. `rate_limit`, not `rate-limit`).
|
|
4284
|
+
*/
|
|
4285
|
+
const UNSETTABLE_FIELDS = [
|
|
4286
|
+
"service",
|
|
4287
|
+
"purpose",
|
|
4288
|
+
"comment",
|
|
4289
|
+
"expires",
|
|
4290
|
+
"rotates",
|
|
4291
|
+
"rate_limit",
|
|
4292
|
+
"model_hint",
|
|
4293
|
+
"source",
|
|
4294
|
+
"rotation_url",
|
|
4295
|
+
"required",
|
|
4296
|
+
"capabilities",
|
|
4297
|
+
"tags"
|
|
4298
|
+
];
|
|
4031
4299
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4032
4300
|
const buildSecretBlock = (name, options) => {
|
|
4033
4301
|
const lines = [`[secret.${name}]`];
|
|
@@ -4073,6 +4341,9 @@ const buildFieldUpdates = (options) => {
|
|
|
4073
4341
|
const [k, v] = pair.split("=").map((s) => s.trim());
|
|
4074
4342
|
return `${k} = "${v}"`;
|
|
4075
4343
|
}).join(", ")} }`;
|
|
4344
|
+
options.unset?.forEach((field) => {
|
|
4345
|
+
updates[field] = null;
|
|
4346
|
+
});
|
|
4076
4347
|
return updates;
|
|
4077
4348
|
};
|
|
4078
4349
|
const withConfig = (configFlag, fn) => {
|
|
@@ -4105,12 +4376,12 @@ const runSecretAdd = (name, options) => {
|
|
|
4105
4376
|
process.exit(1);
|
|
4106
4377
|
}
|
|
4107
4378
|
const block = buildSecretBlock(name, options);
|
|
4379
|
+
const updated = appendSection(readFileSync(configPath, "utf-8"), block);
|
|
4108
4380
|
if (options.dryRun) {
|
|
4109
|
-
|
|
4110
|
-
console.log(block);
|
|
4381
|
+
previewIfValid(updated, block);
|
|
4111
4382
|
return;
|
|
4112
4383
|
}
|
|
4113
|
-
writeIfValid(configPath,
|
|
4384
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
|
|
4114
4385
|
});
|
|
4115
4386
|
});
|
|
4116
4387
|
};
|
|
@@ -4119,6 +4390,12 @@ const runSecretEdit = (name, options) => {
|
|
|
4119
4390
|
console.error(`${RED}Error:${RESET} Invalid date format for --expires: "${options.expires}" (expected YYYY-MM-DD)`);
|
|
4120
4391
|
process.exit(1);
|
|
4121
4392
|
}
|
|
4393
|
+
const unknownUnset = (options.unset ?? []).filter((f) => !UNSETTABLE_FIELDS.includes(f));
|
|
4394
|
+
if (unknownUnset.length > 0) {
|
|
4395
|
+
console.error(`${RED}Error:${RESET} Cannot --unset unknown field(s): ${unknownUnset.join(", ")}`);
|
|
4396
|
+
console.error(`${DIM}Unsettable fields: ${UNSETTABLE_FIELDS.join(", ")}${RESET}`);
|
|
4397
|
+
process.exit(1);
|
|
4398
|
+
}
|
|
4122
4399
|
withConfig(Option(options.config), (configPath, raw) => {
|
|
4123
4400
|
loadConfig(configPath).fold((err) => {
|
|
4124
4401
|
console.error(formatError(err));
|
|
@@ -4138,8 +4415,7 @@ const runSecretEdit = (name, options) => {
|
|
|
4138
4415
|
process.exit(2);
|
|
4139
4416
|
}, (updated) => {
|
|
4140
4417
|
if (options.dryRun) {
|
|
4141
|
-
|
|
4142
|
-
console.log(updated);
|
|
4418
|
+
previewIfValid(updated);
|
|
4143
4419
|
return;
|
|
4144
4420
|
}
|
|
4145
4421
|
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
@@ -4154,8 +4430,7 @@ const runSecretRm = (name, options) => {
|
|
|
4154
4430
|
process.exit(1);
|
|
4155
4431
|
}, (updated) => {
|
|
4156
4432
|
if (options.dryRun) {
|
|
4157
|
-
|
|
4158
|
-
console.log(updated);
|
|
4433
|
+
previewIfValid(updated);
|
|
4159
4434
|
return;
|
|
4160
4435
|
}
|
|
4161
4436
|
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
|
|
@@ -4169,8 +4444,7 @@ const runSecretRename = (oldName, newName, options) => {
|
|
|
4169
4444
|
process.exit(1);
|
|
4170
4445
|
}, (updated) => {
|
|
4171
4446
|
if (options.dryRun) {
|
|
4172
|
-
|
|
4173
|
-
console.log(updated);
|
|
4447
|
+
previewIfValid(updated);
|
|
4174
4448
|
return;
|
|
4175
4449
|
}
|
|
4176
4450
|
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
@@ -4303,8 +4577,7 @@ const runSecretRotate = async (name, options) => {
|
|
|
4303
4577
|
process.exit(2);
|
|
4304
4578
|
}, (result) => {
|
|
4305
4579
|
if (options.dryRun) {
|
|
4306
|
-
|
|
4307
|
-
console.log(result);
|
|
4580
|
+
previewIfValid(result);
|
|
4308
4581
|
return;
|
|
4309
4582
|
}
|
|
4310
4583
|
writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
|
|
@@ -4332,8 +4605,7 @@ const runSecretRotate = async (name, options) => {
|
|
|
4332
4605
|
process.exit(2);
|
|
4333
4606
|
}, (result) => {
|
|
4334
4607
|
if (options.dryRun) {
|
|
4335
|
-
|
|
4336
|
-
console.log(result);
|
|
4608
|
+
previewIfValid(result);
|
|
4337
4609
|
return;
|
|
4338
4610
|
}
|
|
4339
4611
|
writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
|
|
@@ -4345,7 +4617,8 @@ const registerSecretCommands = (program) => {
|
|
|
4345
4617
|
addSecretFlags(secret.command("add").description("Add a new secret entry to envpkt.toml").argument("<name>", "Secret name (becomes the env var key)").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--dry-run", "Preview the TOML block without writing")).action((name, options) => {
|
|
4346
4618
|
runSecretAdd(name, options);
|
|
4347
4619
|
});
|
|
4348
|
-
|
|
4620
|
+
const collect = (value, previous) => [...previous, value];
|
|
4621
|
+
addSecretFlags(secret.command("edit").description("Update metadata fields on an existing secret").argument("<name>", "Secret name to edit").option("-c, --config <path>", "Path to envpkt.toml").option("--required", "Mark this secret as required").option("--no-required", "Mark this secret as not required").option("--unset <field>", "Remove an optional field (repeatable). Field names match TOML keys, e.g. expires, rate_limit", collect, []).option("--dry-run", "Preview the changes without writing")).action((name, options) => {
|
|
4349
4622
|
runSecretEdit(name, options);
|
|
4350
4623
|
});
|
|
4351
4624
|
secret.command("rm").description("Remove a secret entry from envpkt.toml").argument("<name>", "Secret name to remove").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((name, options) => {
|
|
@@ -4363,31 +4636,77 @@ const registerSecretCommands = (program) => {
|
|
|
4363
4636
|
};
|
|
4364
4637
|
//#endregion
|
|
4365
4638
|
//#region src/cli/commands/shell-hook.ts
|
|
4366
|
-
const ZSH_HOOK = `# envpkt shell hook — add to
|
|
4639
|
+
const ZSH_HOOK = `# envpkt shell hook — add to ~/.zshrc: eval "$(envpkt shell-hook zsh)"
|
|
4640
|
+
# Loads a project envpkt.toml on cd (secrets only for scope="shell" packages), restores the
|
|
4641
|
+
# prior environment on leave, and warns on credential health. Use \`envpkt exec\` for scope="exec".
|
|
4642
|
+
_envpkt_restore() {
|
|
4643
|
+
[[ -n "$_ENVPKT_INJECTED" ]] || return
|
|
4644
|
+
local k had prev
|
|
4645
|
+
for k in \${(s: :)_ENVPKT_INJECTED}; do
|
|
4646
|
+
had="_ENVPKT_HAD_$k"
|
|
4647
|
+
prev="_ENVPKT_PREV_$k"
|
|
4648
|
+
if [[ -n "\${(P)had}" ]]; then
|
|
4649
|
+
export "$k=\${(P)prev}"
|
|
4650
|
+
else
|
|
4651
|
+
unset "$k"
|
|
4652
|
+
fi
|
|
4653
|
+
unset "$had" "$prev"
|
|
4654
|
+
done
|
|
4655
|
+
unset _ENVPKT_INJECTED
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4367
4658
|
_envpkt_chpwd() {
|
|
4659
|
+
local cfg
|
|
4660
|
+
cfg="$(envpkt config-path 2>/dev/null)"
|
|
4661
|
+
[[ "$cfg" == "$_ENVPKT_DIR" ]] && return
|
|
4662
|
+
_envpkt_restore
|
|
4663
|
+
_ENVPKT_DIR="$cfg"
|
|
4664
|
+
[[ -z "$cfg" ]] && return
|
|
4665
|
+
eval "$(envpkt env export --track 2>/dev/null)"
|
|
4368
4666
|
envpkt audit --format minimal 2>/dev/null
|
|
4369
4667
|
}
|
|
4370
4668
|
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
else
|
|
4375
|
-
autoload -Uz add-zsh-hook
|
|
4376
|
-
add-zsh-hook chpwd _envpkt_chpwd
|
|
4377
|
-
fi
|
|
4669
|
+
autoload -Uz add-zsh-hook
|
|
4670
|
+
add-zsh-hook chpwd _envpkt_chpwd
|
|
4671
|
+
_envpkt_chpwd
|
|
4378
4672
|
`;
|
|
4379
|
-
const BASH_HOOK = `# envpkt shell hook — add to
|
|
4380
|
-
|
|
4673
|
+
const BASH_HOOK = `# envpkt shell hook — add to ~/.bashrc: eval "$(envpkt shell-hook bash)"
|
|
4674
|
+
# Loads a project envpkt.toml on cd (secrets only for scope="shell" packages), restores the
|
|
4675
|
+
# prior environment on leave, and warns on credential health. Use \`envpkt exec\` for scope="exec".
|
|
4676
|
+
_envpkt_restore() {
|
|
4677
|
+
[ -n "$_ENVPKT_INJECTED" ] || return
|
|
4678
|
+
local k had prev
|
|
4679
|
+
for k in $_ENVPKT_INJECTED; do
|
|
4680
|
+
had="_ENVPKT_HAD_$k"
|
|
4681
|
+
prev="_ENVPKT_PREV_$k"
|
|
4682
|
+
if [ -n "\${!had}" ]; then
|
|
4683
|
+
export "$k=\${!prev}"
|
|
4684
|
+
else
|
|
4685
|
+
unset "$k"
|
|
4686
|
+
fi
|
|
4687
|
+
unset "$had" "$prev"
|
|
4688
|
+
done
|
|
4689
|
+
unset _ENVPKT_INJECTED
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4381
4692
|
_envpkt_prompt() {
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4693
|
+
[ "$PWD" = "$_ENVPKT_PWD" ] && return
|
|
4694
|
+
_ENVPKT_PWD="$PWD"
|
|
4695
|
+
local cfg
|
|
4696
|
+
cfg="$(envpkt config-path 2>/dev/null)"
|
|
4697
|
+
[ "$cfg" = "$_ENVPKT_DIR" ] && return
|
|
4698
|
+
_envpkt_restore
|
|
4699
|
+
_ENVPKT_DIR="$cfg"
|
|
4700
|
+
[ -z "$cfg" ] && return
|
|
4701
|
+
eval "$(envpkt env export --track 2>/dev/null)"
|
|
4702
|
+
envpkt audit --format minimal 2>/dev/null
|
|
4386
4703
|
}
|
|
4387
4704
|
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4705
|
+
case "$PROMPT_COMMAND" in
|
|
4706
|
+
*_envpkt_prompt*) ;;
|
|
4707
|
+
*) PROMPT_COMMAND="_envpkt_prompt\${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
|
|
4708
|
+
esac
|
|
4709
|
+
_envpkt_prompt
|
|
4391
4710
|
`;
|
|
4392
4711
|
const runShellHook = (shell) => {
|
|
4393
4712
|
switch (shell) {
|
|
@@ -4730,6 +5049,9 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
|
|
|
4730
5049
|
program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
|
|
4731
5050
|
runUpgrade();
|
|
4732
5051
|
});
|
|
5052
|
+
program.command("config-path").description("Print the envpkt.toml path resolved for the current directory (empty if none). Resolve-only — no decryption.").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
|
5053
|
+
runConfigPath(options);
|
|
5054
|
+
});
|
|
4733
5055
|
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) => {
|
|
4734
5056
|
runShellHook(shell);
|
|
4735
5057
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ type EnvMeta = Static<typeof EnvMetaSchema>;
|
|
|
78
78
|
declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
|
|
79
79
|
version: import("@sinclair/typebox").TNumber;
|
|
80
80
|
catalog: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
81
|
+
scope: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"shell">, import("@sinclair/typebox").TLiteral<"exec">]>>;
|
|
81
82
|
namespace: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
82
83
|
prefix: import("@sinclair/typebox").TString;
|
|
83
84
|
separator: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -327,6 +328,10 @@ type BootError = ConfigError | FnoxError | CatalogError | AliasError | {
|
|
|
327
328
|
readonly _tag: "AuditFailed";
|
|
328
329
|
readonly audit: AuditResult;
|
|
329
330
|
readonly message: string;
|
|
331
|
+
} | {
|
|
332
|
+
readonly _tag: "SealKeyUnavailable";
|
|
333
|
+
readonly sealedKeys: ReadonlyArray<string>;
|
|
334
|
+
readonly searched: ReadonlyArray<string>;
|
|
330
335
|
} | IdentityError;
|
|
331
336
|
type IdentityError = {
|
|
332
337
|
readonly _tag: "AgeNotFound";
|
|
@@ -389,7 +394,7 @@ type DiscoveredConfig = {
|
|
|
389
394
|
readonly path: string;
|
|
390
395
|
readonly source: "cwd" | "search";
|
|
391
396
|
};
|
|
392
|
-
/** Discover config by
|
|
397
|
+
/** Discover config by walking up from CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
|
|
393
398
|
declare const discoverConfig: (cwd?: string) => Option<DiscoveredConfig>;
|
|
394
399
|
/** Read a config file, returning Either<ConfigError, string> */
|
|
395
400
|
declare const readConfigFile: (path: string) => Either<ConfigError, string>;
|
|
@@ -549,6 +554,35 @@ declare const updateConfigIdentity: (configPath: string, options: UpdateIdentity
|
|
|
549
554
|
/** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
|
|
550
555
|
declare const resolveValues: (keys: ReadonlyArray<string>, profile?: string, agentKey?: string) => Promise<Record<string, string>>;
|
|
551
556
|
//#endregion
|
|
557
|
+
//#region src/core/dotenv.d.ts
|
|
558
|
+
/**
|
|
559
|
+
* Serialize resolved credential entries to `.env` (dotenv) format.
|
|
560
|
+
*
|
|
561
|
+
* Unlike `env export` (shell `export VAR=...` for `eval`) this emits the bare
|
|
562
|
+
* `KEY=value` syntax that the broad `.env`-consuming ecosystem auto-discovers:
|
|
563
|
+
* Wrangler, Docker `--env-file`, Vite/Next/Astro, many GitHub Actions, direnv.
|
|
564
|
+
*
|
|
565
|
+
* Pure and deterministic — no I/O, no timestamps — so regenerating a file
|
|
566
|
+
* produces identical output (clean diffs, reproducible CI).
|
|
567
|
+
*/
|
|
568
|
+
type DotenvEntry = {
|
|
569
|
+
readonly name: string;
|
|
570
|
+
readonly value: string;
|
|
571
|
+
readonly secret: boolean;
|
|
572
|
+
};
|
|
573
|
+
type FormatDotenvOptions = {
|
|
574
|
+
/** Write secret values into the output. Default true (matches `env export`/`env github`). */readonly includeSecrets?: boolean; /** A pre-formatted comment block (each line `#`-prefixed) placed at the top. */
|
|
575
|
+
readonly header?: string;
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* Quote a single value for dotenv output. Returns the value bare when safe,
|
|
579
|
+
* otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
|
|
580
|
+
* whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
|
|
581
|
+
*/
|
|
582
|
+
declare const quoteDotenvValue: (value: string) => string;
|
|
583
|
+
/** Serialize entries to dotenv text (no trailing newline). */
|
|
584
|
+
declare const formatDotenv: (entries: ReadonlyArray<DotenvEntry>, options?: FormatDotenvOptions) => string;
|
|
585
|
+
//#endregion
|
|
552
586
|
//#region src/core/toml-edit.d.ts
|
|
553
587
|
/**
|
|
554
588
|
* Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
|
|
@@ -634,4 +668,4 @@ type ToolDef = {
|
|
|
634
668
|
declare const toolDefinitions: readonly ToolDef[];
|
|
635
669
|
declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
|
|
636
670
|
//#endregion
|
|
637
|
-
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
671
|
+
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DotenvEntry, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatDotenvOptions, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -4,10 +4,10 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
|
4
4
|
import { $, Cond, Do, Either, Left, List, Map as Map$1, None, Option, Right, Set as Set$1, Some, Try } from "functype";
|
|
5
5
|
import { Env, Fs, Path, Platform } from "functype-os";
|
|
6
6
|
import { TomlDate, parse } from "smol-toml";
|
|
7
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
9
|
import { createDirectConsoleLogger, createDirectTestLogger, directSilentLogger, directSilentLogger as directSilentLogger$1 } from "functype-log/direct";
|
|
9
10
|
import { execFileSync } from "node:child_process";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -111,6 +111,7 @@ const EnvpktConfigSchema = Type.Object({
|
|
|
111
111
|
default: 1
|
|
112
112
|
}),
|
|
113
113
|
catalog: Type.Optional(Type.String({ description: "Path to shared secret catalog (relative to this config file)" })),
|
|
114
|
+
scope: Type.Optional(Type.Union([Type.Literal("shell"), Type.Literal("exec")], { description: "Whether `env export` emits this package's secrets for ambient/shell loading (`shell`) or withholds them so they are only available via `envpkt exec` (`exec`). Default `exec`. Never affects `envpkt exec`, which always injects everything." })),
|
|
114
115
|
namespace: Type.Optional(NamespaceSchema),
|
|
115
116
|
identity: Type.Optional(IdentitySchema),
|
|
116
117
|
secret: Type.Optional(Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" })),
|
|
@@ -182,11 +183,24 @@ const buildSearchPaths = () => {
|
|
|
182
183
|
const cloudPaths = Platform.cloudStorageDirs().toArray().map((cloud) => join(cloud.path, ".envpkt", CONFIG_FILENAME$1));
|
|
183
184
|
return [...homePaths, ...cloudPaths];
|
|
184
185
|
};
|
|
185
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* Walk up from `dir` to the filesystem root, returning the nearest `envpkt.toml`.
|
|
188
|
+
* Makes a project's config apply throughout its subtree (like git/.env/direnv finding
|
|
189
|
+
* their root file), not only in the directory that literally contains the file.
|
|
190
|
+
* The global package lives in `~/.envpkt/` (a subdir), so it is not matched by this
|
|
191
|
+
* walk — it is resolved by the search-path/Platform fallback below.
|
|
192
|
+
*/
|
|
193
|
+
const walkUpForConfig = (dir) => {
|
|
194
|
+
const candidate = join(dir, CONFIG_FILENAME$1);
|
|
195
|
+
if (Fs.existsSync(candidate)) return Option(candidate);
|
|
196
|
+
const parent = dirname(dir);
|
|
197
|
+
return parent === dir ? Option(void 0) : walkUpForConfig(parent);
|
|
198
|
+
};
|
|
199
|
+
/** Discover config by walking up from CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
|
|
186
200
|
const discoverConfig = (cwd) => {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
path:
|
|
201
|
+
const walked = walkUpForConfig(cwd ?? process.cwd()).orUndefined();
|
|
202
|
+
if (walked) return Option({
|
|
203
|
+
path: walked,
|
|
190
204
|
source: "cwd"
|
|
191
205
|
});
|
|
192
206
|
const customMatch = Env.get("ENVPKT_SEARCH_PATH").fold(() => [], (v) => v.split(":").filter(Boolean)).map((template) => ({
|
|
@@ -1926,6 +1940,65 @@ const resolveIdentityFilePath = (config, configDir, useDefaultFallback) => {
|
|
|
1926
1940
|
const defaultPath = resolveKeyPath();
|
|
1927
1941
|
return existsSync(defaultPath) ? Option(defaultPath) : Option(void 0);
|
|
1928
1942
|
};
|
|
1943
|
+
const noop = () => {};
|
|
1944
|
+
/** Write an inline age key to a private temp file so the `age` CLI (file-based) can use it. */
|
|
1945
|
+
const materializeInlineKey = (key) => {
|
|
1946
|
+
const dir = mkdtempSync(join(tmpdir(), "envpkt-age-"));
|
|
1947
|
+
const keyPath = join(dir, "age-key.txt");
|
|
1948
|
+
writeFileSync(keyPath, key.endsWith("\n") ? key : `${key}\n`);
|
|
1949
|
+
chmodSync(keyPath, 384);
|
|
1950
|
+
return {
|
|
1951
|
+
path: keyPath,
|
|
1952
|
+
dispose: () => rmSync(dir, {
|
|
1953
|
+
recursive: true,
|
|
1954
|
+
force: true
|
|
1955
|
+
})
|
|
1956
|
+
};
|
|
1957
|
+
};
|
|
1958
|
+
/**
|
|
1959
|
+
* Resolve an age identity for unsealing sealed packets. Precedence:
|
|
1960
|
+
* config.identity.key_file > ENVPKT_AGE_KEY_FILE > ENVPKT_AGE_KEY (inline) > ~/.envpkt/age-key.txt
|
|
1961
|
+
* The inline key (CI secret) ranks above the homedir default so an explicit
|
|
1962
|
+
* env var beats a stray local key. Inline keys are written to a 0600 temp file
|
|
1963
|
+
* the caller must dispose().
|
|
1964
|
+
*/
|
|
1965
|
+
const resolveSealIdentity = (config, configDir) => {
|
|
1966
|
+
if (config.identity?.key_file) {
|
|
1967
|
+
const keyFilePath = resolve(configDir, expandPath(config.identity.key_file));
|
|
1968
|
+
if (existsSync(keyFilePath)) return Option({
|
|
1969
|
+
path: keyFilePath,
|
|
1970
|
+
dispose: noop
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
|
|
1974
|
+
if (envFile && existsSync(envFile)) return Option({
|
|
1975
|
+
path: envFile,
|
|
1976
|
+
dispose: noop
|
|
1977
|
+
});
|
|
1978
|
+
const inlineKey = resolveInlineKey().orUndefined();
|
|
1979
|
+
if (inlineKey) return Option(materializeInlineKey(inlineKey));
|
|
1980
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1981
|
+
if (existsSync(defaultPath)) return Option({
|
|
1982
|
+
path: defaultPath,
|
|
1983
|
+
dispose: noop
|
|
1984
|
+
});
|
|
1985
|
+
return Option(void 0);
|
|
1986
|
+
};
|
|
1987
|
+
/** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
|
|
1988
|
+
const describeSealKeySearch = (config, configDir) => {
|
|
1989
|
+
const keyFile = config.identity?.key_file;
|
|
1990
|
+
const keyFileLine = keyFile ? `identity.key_file → ${resolve(configDir, expandPath(keyFile))} (${existsSync(resolve(configDir, expandPath(keyFile))) ? "found" : "missing"})` : `identity.key_file (not set)`;
|
|
1991
|
+
const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
|
|
1992
|
+
const envFileLine = envFile ? `ENVPKT_AGE_KEY_FILE → ${envFile} (${existsSync(envFile) ? "found" : "missing"})` : `ENVPKT_AGE_KEY_FILE (unset)`;
|
|
1993
|
+
const inlineLine = resolveInlineKey().isEmpty ? `ENVPKT_AGE_KEY (unset)` : `ENVPKT_AGE_KEY (set, inline)`;
|
|
1994
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1995
|
+
return [
|
|
1996
|
+
keyFileLine,
|
|
1997
|
+
envFileLine,
|
|
1998
|
+
inlineLine,
|
|
1999
|
+
`${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
2000
|
+
];
|
|
2001
|
+
};
|
|
1929
2002
|
const resolveIdentityKey = (config, configDir) => {
|
|
1930
2003
|
return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
|
|
1931
2004
|
};
|
|
@@ -1995,6 +2068,12 @@ const bootSafe = (options) => {
|
|
|
1995
2068
|
message: "unexpected"
|
|
1996
2069
|
}));
|
|
1997
2070
|
const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
|
|
2071
|
+
const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
|
|
2072
|
+
if (hasSealedValues && sealIdentity.isEmpty) return Left({
|
|
2073
|
+
_tag: "SealKeyUnavailable",
|
|
2074
|
+
sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
|
|
2075
|
+
searched: describeSealKeySearch(config, configDir)
|
|
2076
|
+
});
|
|
1998
2077
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1999
2078
|
const secrets = {};
|
|
2000
2079
|
const injected = [];
|
|
@@ -2008,23 +2087,25 @@ const bootSafe = (options) => {
|
|
|
2008
2087
|
process.env[envEnv(key)] = value;
|
|
2009
2088
|
});
|
|
2010
2089
|
const sealedKeys = /* @__PURE__ */ new Set();
|
|
2011
|
-
|
|
2012
|
-
if (hasSealedValues) identityFilePath.fold(() => {
|
|
2090
|
+
if (hasSealedValues) sealIdentity.fold(() => {
|
|
2013
2091
|
log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2092
|
+
}, ({ path: idPath, dispose }) => {
|
|
2093
|
+
try {
|
|
2094
|
+
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
2095
|
+
log.warn("phase.sealed.decrypt_failed", { message: err.message });
|
|
2096
|
+
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
2097
|
+
}, (unsealed) => {
|
|
2098
|
+
const unsealedEntries = Object.entries(unsealed);
|
|
2099
|
+
Object.assign(secrets, unsealed);
|
|
2100
|
+
injected.push(...unsealedEntries.map(([key]) => key));
|
|
2101
|
+
unsealedEntries.forEach(([key]) => {
|
|
2102
|
+
sealedKeys.add(key);
|
|
2103
|
+
log.debug("phase.sealed.resolved", { key });
|
|
2104
|
+
});
|
|
2026
2105
|
});
|
|
2027
|
-
}
|
|
2106
|
+
} finally {
|
|
2107
|
+
dispose();
|
|
2108
|
+
}
|
|
2028
2109
|
});
|
|
2029
2110
|
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
2030
2111
|
if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
@@ -2141,6 +2222,19 @@ const formatBootError = (error) => {
|
|
|
2141
2222
|
case "AgeNotFound": return `age not found: ${error.message}`;
|
|
2142
2223
|
case "DecryptFailed": return `Decrypt failed: ${error.message}`;
|
|
2143
2224
|
case "IdentityNotFound": return `Identity file not found: ${error.path}`;
|
|
2225
|
+
case "SealKeyUnavailable": {
|
|
2226
|
+
const keys = error.sealedKeys.length;
|
|
2227
|
+
const searched = error.searched.map((line) => ` • ${line}`).join("\n");
|
|
2228
|
+
return [
|
|
2229
|
+
`${keys} sealed secret(s) can't be decrypted — no age key found.`,
|
|
2230
|
+
`Searched (in order):`,
|
|
2231
|
+
searched,
|
|
2232
|
+
`Fix one:`,
|
|
2233
|
+
` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`,
|
|
2234
|
+
` • Re-provision from source: envpkt seal --edit <KEY>`,
|
|
2235
|
+
`Refusing to inject empty values for sealed secrets.`
|
|
2236
|
+
].join("\n");
|
|
2237
|
+
}
|
|
2144
2238
|
case "AliasInvalidSyntax":
|
|
2145
2239
|
case "AliasTargetMissing":
|
|
2146
2240
|
case "AliasSelfReference":
|
|
@@ -2188,6 +2282,30 @@ const resolveValues = async (keys, profile, agentKey) => {
|
|
|
2188
2282
|
return result;
|
|
2189
2283
|
};
|
|
2190
2284
|
//#endregion
|
|
2285
|
+
//#region src/core/dotenv.ts
|
|
2286
|
+
const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
2287
|
+
/**
|
|
2288
|
+
* Quote a single value for dotenv output. Returns the value bare when safe,
|
|
2289
|
+
* otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
|
|
2290
|
+
* whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
|
|
2291
|
+
*/
|
|
2292
|
+
const quoteDotenvValue = (value) => {
|
|
2293
|
+
if (value === "") return "";
|
|
2294
|
+
if (BARE_SAFE.test(value)) return value;
|
|
2295
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
2296
|
+
};
|
|
2297
|
+
const formatEntry = (entry, includeSecrets) => {
|
|
2298
|
+
if (entry.secret && !includeSecrets) return `# (secret value omitted — re-run without --no-secrets to include)\n${entry.name}=`;
|
|
2299
|
+
return `${entry.name}=${quoteDotenvValue(entry.value)}`;
|
|
2300
|
+
};
|
|
2301
|
+
/** Serialize entries to dotenv text (no trailing newline). */
|
|
2302
|
+
const formatDotenv = (entries, options) => {
|
|
2303
|
+
const includeSecrets = options?.includeSecrets ?? true;
|
|
2304
|
+
const body = entries.map((e) => formatEntry(e, includeSecrets)).join("\n");
|
|
2305
|
+
const header = options?.header;
|
|
2306
|
+
return header ? `${header}\n\n${body}` : body;
|
|
2307
|
+
};
|
|
2308
|
+
//#endregion
|
|
2191
2309
|
//#region src/core/toml-edit.ts
|
|
2192
2310
|
const SECTION_RE = /^\[.+\]\s*$/;
|
|
2193
2311
|
const MULTILINE_OPEN = "\"\"\"";
|
|
@@ -2686,4 +2804,4 @@ const startServer = async () => {
|
|
|
2686
2804
|
await server.connect(transport);
|
|
2687
2805
|
};
|
|
2688
2806
|
//#endregion
|
|
2689
|
-
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
2807
|
+
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "envpkt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Credential lifecycle and fleet management for AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credentials",
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
43
|
"@sinclair/typebox": "^0.34.49",
|
|
44
44
|
"commander": "^15.0.0",
|
|
45
|
-
"functype": "^1.3.
|
|
46
|
-
"functype-log": "^1.3.
|
|
47
|
-
"functype-os": "^1.3.
|
|
45
|
+
"functype": "^1.3.1",
|
|
46
|
+
"functype-log": "^1.3.1",
|
|
47
|
+
"functype-os": "^1.3.1",
|
|
48
48
|
"smol-toml": "^1.6.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^24.13.1",
|
|
52
|
-
"ts-builds": "^3.0.
|
|
52
|
+
"ts-builds": "^3.0.1",
|
|
53
53
|
"tsdown": "^0.22.2",
|
|
54
54
|
"tsx": "^4.22.4"
|
|
55
55
|
},
|
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
"description": "Path to shared secret catalog (relative to this config file)",
|
|
18
18
|
"type": "string"
|
|
19
19
|
},
|
|
20
|
+
"scope": {
|
|
21
|
+
"description": "Whether `env export` emits this package's secrets for ambient/shell loading (`shell`) or withholds them so they are only available via `envpkt exec` (`exec`). Default `exec`. Never affects `envpkt exec`, which always injects everything.",
|
|
22
|
+
"anyOf": [
|
|
23
|
+
{
|
|
24
|
+
"const": "shell",
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"const": "exec",
|
|
29
|
+
"type": "string"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
20
33
|
"namespace": {
|
|
21
34
|
"description": "Optional namespace/package prefix for injected environment variable names",
|
|
22
35
|
"type": "object",
|