envpkt 0.12.0 → 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 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` or `~/.bashrc`) for automatic secret loading. envpkt's [config discovery chain](#config-resolution) finds your config automatically — no platform-specific shell logic needed:
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
@@ -303,6 +303,7 @@ const EnvpktConfigSchema = Type.Object({
303
303
  default: 1
304
304
  }),
305
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." })),
306
307
  namespace: Type.Optional(NamespaceSchema),
307
308
  identity: Type.Optional(IdentitySchema),
308
309
  secret: Type.Optional(Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" })),
@@ -369,11 +370,24 @@ const buildSearchPaths = () => {
369
370
  const cloudPaths = Platform.cloudStorageDirs().toArray().map((cloud) => join(cloud.path, ".envpkt", CONFIG_FILENAME$2));
370
371
  return [...homePaths, ...cloudPaths];
371
372
  };
372
- /** Discover config by checking CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
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 */
373
387
  const discoverConfig = (cwd) => {
374
- const cwdCandidate = join(cwd ?? process.cwd(), CONFIG_FILENAME$2);
375
- if (Fs.existsSync(cwdCandidate)) return Option({
376
- path: cwdCandidate,
388
+ const walked = walkUpForConfig(cwd ?? process.cwd()).orUndefined();
389
+ if (walked) return Option({
390
+ path: walked,
377
391
  source: "cwd"
378
392
  });
379
393
  const customMatch = Env.get("ENVPKT_SEARCH_PATH").fold(() => [], (v) => v.split(":").filter(Boolean)).map((template) => ({
@@ -835,6 +849,19 @@ const runAuditOnConfig = (config, options) => {
835
849
  process.exit(code);
836
850
  };
837
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
838
865
  //#region src/fnox/cli.ts
839
866
  /** Export all secrets from fnox as key=value pairs for a given profile */
840
867
  const fnoxExport = (profile, agentKey) => {
@@ -1326,10 +1353,13 @@ const materializeInlineKey = (key) => {
1326
1353
  * the caller must dispose().
1327
1354
  */
1328
1355
  const resolveSealIdentity = (config, configDir) => {
1329
- if (config.identity?.key_file) return Option({
1330
- path: resolve(configDir, expandPath(config.identity.key_file)),
1331
- dispose: noop
1332
- });
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
+ }
1333
1363
  const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
1334
1364
  if (envFile && existsSync(envFile)) return Option({
1335
1365
  path: envFile,
@@ -1344,6 +1374,21 @@ const resolveSealIdentity = (config, configDir) => {
1344
1374
  });
1345
1375
  return Option(void 0);
1346
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
+ };
1347
1392
  const resolveIdentityKey = (config, configDir) => {
1348
1393
  return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
1349
1394
  };
@@ -1413,6 +1458,12 @@ const bootSafe = (options) => {
1413
1458
  message: "unexpected"
1414
1459
  }));
1415
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
+ });
1416
1467
  return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
1417
1468
  const secrets = {};
1418
1469
  const injected = [];
@@ -1426,9 +1477,8 @@ const bootSafe = (options) => {
1426
1477
  process.env[envEnv(key)] = value;
1427
1478
  });
1428
1479
  const sealedKeys = /* @__PURE__ */ new Set();
1429
- if (hasSealedValues) resolveSealIdentity(config, configDir).fold(() => {
1480
+ if (hasSealedValues) sealIdentity.fold(() => {
1430
1481
  log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
1431
- warnings.push("Sealed values found but no identity file available for decryption");
1432
1482
  }, ({ path: idPath, dispose }) => {
1433
1483
  try {
1434
1484
  unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
@@ -1533,6 +1583,30 @@ const bootSafe = (options) => {
1533
1583
  }));
1534
1584
  };
1535
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
1536
1610
  //#region src/core/patterns.ts
1537
1611
  const EXCLUDED_VARS = Set$1([
1538
1612
  "PATH",
@@ -2665,6 +2739,20 @@ const writeIfValid = (configPath, updated, successMsg) => {
2665
2739
  writeFileSync(configPath, updated, "utf-8");
2666
2740
  console.log(successMsg);
2667
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
+ };
2668
2756
  //#endregion
2669
2757
  //#region src/cli/commands/env.ts
2670
2758
  const printPostWriteGuidance = () => {
@@ -2799,9 +2887,15 @@ const runEnvExport = (options) => {
2799
2887
  process.exit(2);
2800
2888
  }, (boot) => {
2801
2889
  emitWarnings(boot);
2802
- collectEmitEntries(boot).forEach(({ name, value }) => {
2803
- console.log(`export ${name}='${shellEscape(value)}'`);
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)}'`);
2804
2897
  });
2898
+ if (options.track) console.log(`_ENVPKT_INJECTED='${emit.map((e) => e.name).join(" ")}'`);
2805
2899
  });
2806
2900
  };
2807
2901
  const runEnvGithub = (options) => {
@@ -2825,6 +2919,33 @@ const runEnvGithub = (options) => {
2825
2919
  if (options.strict) process.exit(exitCodeForAudit(boot.audit));
2826
2920
  });
2827
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)
2934
+ });
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.`);
2946
+ });
2947
+ });
2948
+ };
2828
2949
  const buildEnvBlock = (name, value, options) => {
2829
2950
  const lines = [`[env.${name}]`, `value = "${value}"`];
2830
2951
  if (options.purpose) lines.push(`purpose = "${options.purpose}"`);
@@ -3017,12 +3138,15 @@ const registerEnvCommands = (program) => {
3017
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) => {
3018
3139
  runEnvCheck(options);
3019
3140
  });
3020
- env.command("export").description("Output export statements for eval-ing secrets into the current shell. Usage: eval \"$(envpkt env export)\"").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit").action((options) => {
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) => {
3021
3142
  runEnvExport(options);
3022
3143
  });
3023
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) => {
3024
3145
  runEnvGithub(options);
3025
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
+ });
3026
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) => {
3027
3151
  runEnvAdd(name, value, options);
3028
3152
  });
@@ -3958,6 +4082,34 @@ const applySealedToml = (raw, sealedMeta) => {
3958
4082
  };
3959
4083
  return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3960
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
+ };
3961
4113
  /** Write sealed values back into the TOML file, preserving structure. */
3962
4114
  const writeSealedToml = (configPath, sealedMeta) => {
3963
4115
  const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
@@ -4034,15 +4186,9 @@ const runSeal = async (options) => {
4034
4186
  const prompt = (question) => new Promise((resolve) => {
4035
4187
  rl.question(question, (answer) => resolve(answer));
4036
4188
  });
4037
- const values = {};
4038
- for (const key of editKeys) {
4039
- const value = await prompt(`Enter new value for ${key}: `);
4040
- if (value === "") {
4041
- console.error(`${YELLOW}Skipped${RESET} ${key} (empty value)`);
4042
- continue;
4043
- }
4044
- values[key] = value;
4045
- }
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
+ });
4046
4192
  rl.close();
4047
4193
  if (Object.keys(values).length === 0) {
4048
4194
  console.error(`${RED}Error:${RESET} No values provided`);
@@ -4129,6 +4275,27 @@ const runSeal = async (options) => {
4129
4275
  };
4130
4276
  //#endregion
4131
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
+ ];
4132
4299
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
4133
4300
  const buildSecretBlock = (name, options) => {
4134
4301
  const lines = [`[secret.${name}]`];
@@ -4174,6 +4341,9 @@ const buildFieldUpdates = (options) => {
4174
4341
  const [k, v] = pair.split("=").map((s) => s.trim());
4175
4342
  return `${k} = "${v}"`;
4176
4343
  }).join(", ")} }`;
4344
+ options.unset?.forEach((field) => {
4345
+ updates[field] = null;
4346
+ });
4177
4347
  return updates;
4178
4348
  };
4179
4349
  const withConfig = (configFlag, fn) => {
@@ -4206,12 +4376,12 @@ const runSecretAdd = (name, options) => {
4206
4376
  process.exit(1);
4207
4377
  }
4208
4378
  const block = buildSecretBlock(name, options);
4379
+ const updated = appendSection(readFileSync(configPath, "utf-8"), block);
4209
4380
  if (options.dryRun) {
4210
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4211
- console.log(block);
4381
+ previewIfValid(updated, block);
4212
4382
  return;
4213
4383
  }
4214
- writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
4384
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
4215
4385
  });
4216
4386
  });
4217
4387
  };
@@ -4220,6 +4390,12 @@ const runSecretEdit = (name, options) => {
4220
4390
  console.error(`${RED}Error:${RESET} Invalid date format for --expires: "${options.expires}" (expected YYYY-MM-DD)`);
4221
4391
  process.exit(1);
4222
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
+ }
4223
4399
  withConfig(Option(options.config), (configPath, raw) => {
4224
4400
  loadConfig(configPath).fold((err) => {
4225
4401
  console.error(formatError(err));
@@ -4239,8 +4415,7 @@ const runSecretEdit = (name, options) => {
4239
4415
  process.exit(2);
4240
4416
  }, (updated) => {
4241
4417
  if (options.dryRun) {
4242
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4243
- console.log(updated);
4418
+ previewIfValid(updated);
4244
4419
  return;
4245
4420
  }
4246
4421
  writeIfValid(configPath, updated, `${GREEN}✓${RESET} Updated ${BOLD}${name}${RESET} in ${CYAN}${configPath}${RESET}`);
@@ -4255,8 +4430,7 @@ const runSecretRm = (name, options) => {
4255
4430
  process.exit(1);
4256
4431
  }, (updated) => {
4257
4432
  if (options.dryRun) {
4258
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4259
- console.log(updated);
4433
+ previewIfValid(updated);
4260
4434
  return;
4261
4435
  }
4262
4436
  writeIfValid(configPath, updated, `${GREEN}✓${RESET} Removed ${BOLD}${name}${RESET} from ${CYAN}${configPath}${RESET}`);
@@ -4270,8 +4444,7 @@ const runSecretRename = (oldName, newName, options) => {
4270
4444
  process.exit(1);
4271
4445
  }, (updated) => {
4272
4446
  if (options.dryRun) {
4273
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4274
- console.log(updated);
4447
+ previewIfValid(updated);
4275
4448
  return;
4276
4449
  }
4277
4450
  writeIfValid(configPath, updated, `${GREEN}✓${RESET} Renamed ${BOLD}${oldName}${RESET} → ${BOLD}${newName}${RESET} in ${CYAN}${configPath}${RESET}`);
@@ -4404,8 +4577,7 @@ const runSecretRotate = async (name, options) => {
4404
4577
  process.exit(2);
4405
4578
  }, (result) => {
4406
4579
  if (options.dryRun) {
4407
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4408
- console.log(result);
4580
+ previewIfValid(result);
4409
4581
  return;
4410
4582
  }
4411
4583
  writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
@@ -4433,8 +4605,7 @@ const runSecretRotate = async (name, options) => {
4433
4605
  process.exit(2);
4434
4606
  }, (result) => {
4435
4607
  if (options.dryRun) {
4436
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4437
- console.log(result);
4608
+ previewIfValid(result);
4438
4609
  return;
4439
4610
  }
4440
4611
  writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
@@ -4446,7 +4617,8 @@ const registerSecretCommands = (program) => {
4446
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) => {
4447
4618
  runSecretAdd(name, options);
4448
4619
  });
4449
- 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("--dry-run", "Preview the changes without writing")).action((name, options) => {
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) => {
4450
4622
  runSecretEdit(name, options);
4451
4623
  });
4452
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) => {
@@ -4464,31 +4636,77 @@ const registerSecretCommands = (program) => {
4464
4636
  };
4465
4637
  //#endregion
4466
4638
  //#region src/cli/commands/shell-hook.ts
4467
- const ZSH_HOOK = `# envpkt shell hook — add to your .zshrc
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
+
4468
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)"
4469
4666
  envpkt audit --format minimal 2>/dev/null
4470
4667
  }
4471
4668
 
4472
- if (( $+functions[add-zsh-hook] )); then
4473
- autoload -Uz add-zsh-hook
4474
- add-zsh-hook chpwd _envpkt_chpwd
4475
- else
4476
- autoload -Uz add-zsh-hook
4477
- add-zsh-hook chpwd _envpkt_chpwd
4478
- fi
4669
+ autoload -Uz add-zsh-hook
4670
+ add-zsh-hook chpwd _envpkt_chpwd
4671
+ _envpkt_chpwd
4479
4672
  `;
4480
- const BASH_HOOK = `# envpkt shell hook — add to your .bashrc
4481
- _envpkt_last_dir=""
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
+
4482
4692
  _envpkt_prompt() {
4483
- if [[ "$PWD" != "$_envpkt_last_dir" ]]; then
4484
- _envpkt_last_dir="$PWD"
4485
- envpkt audit --format minimal 2>/dev/null
4486
- fi
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
4487
4703
  }
4488
4704
 
4489
- if [[ ! "$PROMPT_COMMAND" == *"_envpkt_prompt"* ]]; then
4490
- PROMPT_COMMAND="_envpkt_prompt;$PROMPT_COMMAND"
4491
- fi
4705
+ case "$PROMPT_COMMAND" in
4706
+ *_envpkt_prompt*) ;;
4707
+ *) PROMPT_COMMAND="_envpkt_prompt\${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
4708
+ esac
4709
+ _envpkt_prompt
4492
4710
  `;
4493
4711
  const runShellHook = (shell) => {
4494
4712
  switch (shell) {
@@ -4831,6 +5049,9 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
4831
5049
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
4832
5050
  runUpgrade();
4833
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
+ });
4834
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) => {
4835
5056
  runShellHook(shell);
4836
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 checking CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
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
@@ -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
- /** Discover config by checking CWD, then ENVPKT_SEARCH_PATH, then dynamic Platform paths */
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 cwdCandidate = join(cwd ?? process.cwd(), CONFIG_FILENAME$1);
188
- if (Fs.existsSync(cwdCandidate)) return Option({
189
- path: cwdCandidate,
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) => ({
@@ -1949,10 +1963,13 @@ const materializeInlineKey = (key) => {
1949
1963
  * the caller must dispose().
1950
1964
  */
1951
1965
  const resolveSealIdentity = (config, configDir) => {
1952
- if (config.identity?.key_file) return Option({
1953
- path: resolve(configDir, expandPath(config.identity.key_file)),
1954
- dispose: noop
1955
- });
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
+ }
1956
1973
  const envFile = process.env["ENVPKT_AGE_KEY_FILE"];
1957
1974
  if (envFile && existsSync(envFile)) return Option({
1958
1975
  path: envFile,
@@ -1967,6 +1984,21 @@ const resolveSealIdentity = (config, configDir) => {
1967
1984
  });
1968
1985
  return Option(void 0);
1969
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
+ };
1970
2002
  const resolveIdentityKey = (config, configDir) => {
1971
2003
  return resolveIdentityFilePath(config, configDir, false).fold(() => Right(Option(void 0)), (path) => unwrapAgentKey(path).fold((err) => Left(err), (key) => Right(Option(key))));
1972
2004
  };
@@ -2036,6 +2068,12 @@ const bootSafe = (options) => {
2036
2068
  message: "unexpected"
2037
2069
  }));
2038
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
+ });
2039
2077
  return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
2040
2078
  const secrets = {};
2041
2079
  const injected = [];
@@ -2049,9 +2087,8 @@ const bootSafe = (options) => {
2049
2087
  process.env[envEnv(key)] = value;
2050
2088
  });
2051
2089
  const sealedKeys = /* @__PURE__ */ new Set();
2052
- if (hasSealedValues) resolveSealIdentity(config, configDir).fold(() => {
2090
+ if (hasSealedValues) sealIdentity.fold(() => {
2053
2091
  log.warn("phase.sealed.no_identity_file", { sealed_keys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value).length });
2054
- warnings.push("Sealed values found but no identity file available for decryption");
2055
2092
  }, ({ path: idPath, dispose }) => {
2056
2093
  try {
2057
2094
  unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
@@ -2185,6 +2222,19 @@ const formatBootError = (error) => {
2185
2222
  case "AgeNotFound": return `age not found: ${error.message}`;
2186
2223
  case "DecryptFailed": return `Decrypt failed: ${error.message}`;
2187
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
+ }
2188
2238
  case "AliasInvalidSyntax":
2189
2239
  case "AliasTargetMissing":
2190
2240
  case "AliasSelfReference":
@@ -2232,6 +2282,30 @@ const resolveValues = async (keys, profile, agentKey) => {
2232
2282
  return result;
2233
2283
  };
2234
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
2235
2309
  //#region src/core/toml-edit.ts
2236
2310
  const SECTION_RE = /^\[.+\]\s*$/;
2237
2311
  const MULTILINE_OPEN = "\"\"\"";
@@ -2730,4 +2804,4 @@ const startServer = async () => {
2730
2804
  await server.connect(transport);
2731
2805
  };
2732
2806
  //#endregion
2733
- 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.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -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",