envpkt 0.11.3 → 0.11.4

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.
Files changed (2) hide show
  1. package/dist/cli.js +197 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -778,9 +778,13 @@ const runAuditOnConfig = (config, options) => {
778
778
  ...afterStatus,
779
779
  secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
780
780
  }));
781
- if (options.format === "json") console.log(formatAuditJson(filtered));
782
- else if (options.format === "minimal") console.log(formatAuditMinimal(filtered));
783
- else console.log(formatAudit(filtered));
781
+ const sorted = options.sort ? {
782
+ ...filtered,
783
+ secrets: filtered.secrets.sortBy((s) => s.key)
784
+ } : filtered;
785
+ if (options.format === "json") console.log(formatAuditJson(sorted));
786
+ else if (options.format === "minimal") console.log(formatAuditMinimal(sorted));
787
+ else console.log(formatAudit(sorted));
784
788
  if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
785
789
  else formatEnvAuditTable(config);
786
790
  const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
@@ -2360,6 +2364,145 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2360
2364
  * Ensures proper spacing (double newline before the block).
2361
2365
  */
2362
2366
  const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
2367
+ const ENV_HEADER_RE = /^\[env\.(.+)\]\s*$/;
2368
+ const SECRET_HEADER_RE = /^\[secret\.(.+)\]\s*$/;
2369
+ const ANY_HEADER_RE = /^\[.+\]\s*$/;
2370
+ /**
2371
+ * Find the end (exclusive) of a section starting at `start`, respecting
2372
+ * multiline `"""..."""` values so the scanner does not mistake content inside
2373
+ * a multiline string for a section header.
2374
+ */
2375
+ const findSectionEnd = (lines, start) => {
2376
+ const initial = {
2377
+ end: lines.length,
2378
+ inMultiline: false,
2379
+ done: false
2380
+ };
2381
+ return List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end;
2382
+ };
2383
+ /**
2384
+ * Walking backwards from `headerIdx`, return the index of the first line of the
2385
+ * "doc block" that should travel with this section. A doc block is a contiguous
2386
+ * run of `#`-comment lines *immediately* above the header (no blank line
2387
+ * between). A blank line acts as a paragraph break and stops the walk — so
2388
+ * `# Some heading\n\n[secret.X]` does NOT attach the heading to `[secret.X]`.
2389
+ */
2390
+ const findPreambleStart = (lines, headerIdx) => {
2391
+ const stopOffset = [...lines.slice(0, headerIdx)].reverse().findIndex((l) => !l.trim().startsWith("#"));
2392
+ return stopOffset === -1 ? 0 : headerIdx - stopOffset;
2393
+ };
2394
+ const classifyHeader = (line, idx) => {
2395
+ if (!ANY_HEADER_RE.test(line)) return Option.none();
2396
+ const envMatch = line.match(ENV_HEADER_RE);
2397
+ if (envMatch) return Option({
2398
+ idx,
2399
+ kind: "env",
2400
+ key: envMatch[1]
2401
+ });
2402
+ const secretMatch = line.match(SECRET_HEADER_RE);
2403
+ if (secretMatch) return Option({
2404
+ idx,
2405
+ kind: "secret",
2406
+ key: secretMatch[1]
2407
+ });
2408
+ return Option({
2409
+ idx,
2410
+ kind: "other",
2411
+ key: ""
2412
+ });
2413
+ };
2414
+ const scanHeader = (state, line, idx) => {
2415
+ if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2416
+ ...state,
2417
+ inMultiline: false
2418
+ } : state;
2419
+ if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2420
+ ...state,
2421
+ inMultiline: true
2422
+ } : state;
2423
+ return classifyHeader(line, idx).fold(() => state, (header) => ({
2424
+ ...state,
2425
+ headers: [...state.headers, header]
2426
+ }));
2427
+ };
2428
+ const partitionSections = (raw) => {
2429
+ const lines = raw.split("\n");
2430
+ const { headers } = List(lines).zipWithIndex().foldLeft({
2431
+ headers: [],
2432
+ inMultiline: false
2433
+ })((state, entry) => scanHeader(state, entry[0], entry[1]));
2434
+ const envSecretHeaders = headers.filter((h) => h.kind === "env" || h.kind === "secret");
2435
+ const trueBodyRange = (headerIdx) => {
2436
+ const naiveEnd = findSectionEnd(lines, headerIdx);
2437
+ const lastContent = lines.slice(headerIdx, naiveEnd).findLastIndex((l) => {
2438
+ const t = l.trim();
2439
+ return t !== "" && !t.startsWith("#");
2440
+ });
2441
+ return {
2442
+ start: headerIdx,
2443
+ end: lastContent === -1 ? headerIdx + 1 : headerIdx + lastContent + 1
2444
+ };
2445
+ };
2446
+ const sections = envSecretHeaders.map((h) => {
2447
+ const headerIdx = h.idx;
2448
+ const preambleStart = findPreambleStart(lines, headerIdx);
2449
+ const body = lines.slice(headerIdx, trueBodyRange(headerIdx).end);
2450
+ const preamble = lines.slice(preambleStart, headerIdx);
2451
+ return {
2452
+ kind: h.kind,
2453
+ key: h.key,
2454
+ body,
2455
+ preamble
2456
+ };
2457
+ });
2458
+ const claimedRanges = envSecretHeaders.map((h) => {
2459
+ const headerIdx = h.idx;
2460
+ return {
2461
+ start: findPreambleStart(lines, headerIdx),
2462
+ end: trueBodyRange(headerIdx).end
2463
+ };
2464
+ });
2465
+ const isClaimed = (idx) => claimedRanges.some((r) => idx >= r.start && idx < r.end);
2466
+ return {
2467
+ preambleLines: lines.map((l, idx) => isClaimed(idx) ? null : l).filter((l) => l !== null),
2468
+ envSections: sections.filter((s) => s.kind === "env"),
2469
+ secretSections: sections.filter((s) => s.kind === "secret")
2470
+ };
2471
+ };
2472
+ const emitSection = (s) => {
2473
+ return `${s.preamble.length > 0 ? `${s.preamble.join("\n")}\n` : ""}${s.body.join("\n")}`;
2474
+ };
2475
+ /**
2476
+ * Reformat a TOML config with `[env.*]` and `[secret.*]` sections grouped and
2477
+ * alphabetized. Top-level content (version key, `[identity]`, `[lifecycle]`,
2478
+ * `[callbacks]`, `[tools]`, etc.) stays in its original position. Comment
2479
+ * doc-blocks immediately above a section header travel with that section.
2480
+ *
2481
+ * Pure — no I/O. Returns the raw input unchanged when there is no env or
2482
+ * secret content to reorder.
2483
+ */
2484
+ const sortConfigToml = (raw) => {
2485
+ const { preambleLines, envSections, secretSections } = partitionSections(raw);
2486
+ if (envSections.length === 0 && secretSections.length === 0) return raw;
2487
+ const sortedEnv = [...envSections].sort((a, b) => a.key.localeCompare(b.key));
2488
+ const sortedSecret = [...secretSections].sort((a, b) => a.key.localeCompare(b.key));
2489
+ const trimTrailing = (xs) => {
2490
+ const lastNonBlank = xs.findLastIndex((l) => l.trim() !== "");
2491
+ return lastNonBlank === -1 ? [] : xs.slice(0, lastNonBlank + 1);
2492
+ };
2493
+ const collapseBlanks = (xs) => xs.reduce((acc, line) => {
2494
+ const isBlank = line.trim() === "";
2495
+ const prevBlank = acc.length > 0 && acc[acc.length - 1].trim() === "";
2496
+ if (isBlank && prevBlank) return acc;
2497
+ return [...acc, line];
2498
+ }, []);
2499
+ const preambleTrimmed = collapseBlanks(trimTrailing(preambleLines));
2500
+ const parts = [];
2501
+ if (preambleTrimmed.length > 0) parts.push(preambleTrimmed.join("\n"));
2502
+ if (sortedEnv.length > 0) parts.push(sortedEnv.map(emitSection).join("\n\n"));
2503
+ if (sortedSecret.length > 0) parts.push(sortedSecret.map(emitSection).join("\n\n"));
2504
+ return `${parts.join("\n\n")}\n`;
2505
+ };
2363
2506
  //#endregion
2364
2507
  //#region src/core/validate.ts
2365
2508
  /**
@@ -3000,6 +3143,7 @@ const printSecretMeta = (meta, indent) => {
3000
3143
  console.log(`${indent}${DIM}tags:${RESET} ${tagStr}`);
3001
3144
  }
3002
3145
  };
3146
+ const sortedIfRequested = (entries, sort) => sort ? [...entries].sort((a, b) => a[0].localeCompare(b[0])) : entries;
3003
3147
  const printConfig = (config, path, resolveResult, opts) => {
3004
3148
  console.log(`${BOLD}envpkt.toml${RESET} ${DIM}(${path})${RESET}`);
3005
3149
  if (resolveResult?.catalogPath) console.log(`${DIM}Catalog: ${CYAN}${resolveResult.catalogPath}${RESET}`);
@@ -3017,7 +3161,7 @@ const printConfig = (config, path, resolveResult, opts) => {
3017
3161
  }
3018
3162
  const secretEntries = config.secret ?? {};
3019
3163
  console.log(`${BOLD}Secrets:${RESET} ${Object.keys(secretEntries).length}`);
3020
- Object.entries(secretEntries).forEach(([key, meta]) => {
3164
+ sortedIfRequested(Object.entries(secretEntries), opts?.sort).forEach(([key, meta]) => {
3021
3165
  const valueSuffix = Option(opts?.secrets?.[key]).fold(() => "", (secretValue) => ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}`);
3022
3166
  const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
3023
3167
  console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
@@ -3028,7 +3172,7 @@ const printConfig = (config, path, resolveResult, opts) => {
3028
3172
  if (envKeys.length > 0) {
3029
3173
  console.log("");
3030
3174
  console.log(`${BOLD}Environment Defaults:${RESET} ${envKeys.length}`);
3031
- Object.entries(envEntries).forEach(([key, entry]) => {
3175
+ sortedIfRequested(Object.entries(envEntries), opts?.sort).forEach(([key, entry]) => {
3032
3176
  console.log(` ${BOLD}${key}${RESET} = ${GREEN}"${entry.value}"${RESET}`);
3033
3177
  if (entry.purpose) console.log(` ${DIM}purpose:${RESET} ${entry.purpose}`);
3034
3178
  if (entry.comment) console.log(` ${DIM}comment:${RESET} ${DIM}${entry.comment}${RESET}`);
@@ -3092,7 +3236,10 @@ const runInspect = (options) => {
3092
3236
  secretDisplay: options.plaintext ? "plaintext" : "encrypted"
3093
3237
  };
3094
3238
  })() : void 0;
3095
- printConfig(showConfig, path, showResolved ? resolveResult : void 0, printOpts);
3239
+ printConfig(showConfig, path, showResolved ? resolveResult : void 0, {
3240
+ ...printOpts ?? {},
3241
+ sort: options.sort
3242
+ });
3096
3243
  });
3097
3244
  });
3098
3245
  });
@@ -4182,6 +4329,45 @@ const runShellHook = (shell) => {
4182
4329
  }
4183
4330
  };
4184
4331
  //#endregion
4332
+ //#region src/cli/commands/sort.ts
4333
+ const SECTION_HEADER_RE = /^\[(env|secret)\.(.+)\]\s*$/;
4334
+ const countSections = (raw) => raw.split("\n").reduce((acc, line) => {
4335
+ const m = SECTION_HEADER_RE.exec(line);
4336
+ if (!m) return acc;
4337
+ return m[1] === "env" ? {
4338
+ ...acc,
4339
+ env: acc.env + 1
4340
+ } : {
4341
+ ...acc,
4342
+ secret: acc.secret + 1
4343
+ };
4344
+ }, {
4345
+ env: 0,
4346
+ secret: 0
4347
+ });
4348
+ const runSort = (options) => {
4349
+ resolveConfigPath(options.config).fold((err) => {
4350
+ console.error(formatError(err));
4351
+ process.exit(2);
4352
+ }, ({ path: configPath, source }) => {
4353
+ const sourceMsg = formatConfigSource(configPath, source);
4354
+ if (sourceMsg) console.error(sourceMsg);
4355
+ const raw = readFileSync(configPath, "utf-8");
4356
+ const sorted = sortConfigToml(raw);
4357
+ const counts = countSections(sorted);
4358
+ if (sorted === raw) {
4359
+ console.log(`${GREEN}✓${RESET} ${BOLD}Already sorted${RESET} — ${counts.env} env, ${counts.secret} secret`);
4360
+ return;
4361
+ }
4362
+ if (options.dryRun) {
4363
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4364
+ console.log(sorted);
4365
+ return;
4366
+ }
4367
+ writeIfValid(configPath, sorted, `${GREEN}✓${RESET} Sorted ${BOLD}${counts.env}${RESET} env and ${BOLD}${counts.secret}${RESET} secret entries in ${CYAN}${configPath}${RESET}`);
4368
+ });
4369
+ };
4370
+ //#endregion
4185
4371
  //#region src/cli/commands/upgrade.ts
4186
4372
  const getCurrentVersion = () => Try(() => execFileSync("npm", [
4187
4373
  "list",
@@ -4438,7 +4624,7 @@ program.command("init").description("Initialize a new envpkt.toml in the current
4438
4624
  program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
4439
4625
  runKeygen(options);
4440
4626
  });
4441
- program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--external", "Show only secrets without encrypted_value").action((options) => {
4627
+ program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--external", "Show only secrets without encrypted_value").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
4442
4628
  runAudit(options);
4443
4629
  });
4444
4630
  program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
@@ -4447,7 +4633,7 @@ program.command("fleet").description("Scan directory tree for envpkt.toml files
4447
4633
  program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
4448
4634
  runValidate(options);
4449
4635
  });
4450
- program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").action((options) => {
4636
+ program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action((options) => {
4451
4637
  runInspect(options);
4452
4638
  });
4453
4639
  program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action((args, options) => {
@@ -4464,6 +4650,9 @@ program.command("mcp").description("Start the envpkt MCP server (stdio transport
4464
4650
  });
4465
4651
  registerSecretCommands(program);
4466
4652
  registerEnvCommands(program);
4653
+ program.command("sort").description("Group [env.*] and [secret.*] sections and alphabetize within each region").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((options) => {
4654
+ runSort(options);
4655
+ });
4467
4656
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
4468
4657
  runUpgrade();
4469
4658
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",