envpkt 0.11.2 → 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.
package/dist/cli.js CHANGED
@@ -27,20 +27,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
27
27
  const issues = [];
28
28
  const created = Option(meta.created).flatMap(parseDate);
29
29
  const expires = Option(meta.expires).flatMap(parseDate);
30
+ const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
30
31
  const rotationUrl = Option(meta.rotation_url);
31
32
  const purpose = Option(meta.purpose);
32
33
  const service = Option(meta.service);
33
34
  const daysRemaining = expires.map((exp) => daysBetween(today, exp));
34
- const daysSinceCreated = created.map((c) => daysBetween(c, today));
35
+ const staleFromRotation = lastRotated.isSome();
36
+ const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
35
37
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
36
38
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
37
- const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
39
+ const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
38
40
  const hasSealed = !!meta.encrypted_value;
39
41
  const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
40
42
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
41
43
  if (isExpired) issues.push("Secret has expired");
42
44
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
43
- if (isStale) issues.push("Secret is stale (no rotation detected)");
45
+ if (isStale) {
46
+ const since = daysSinceRotation.fold(() => "?", (d) => String(d));
47
+ const label = staleFromRotation ? "last rotated" : "created";
48
+ issues.push(`Secret is stale (${label} ${since} days ago)`);
49
+ }
44
50
  if (isMissing) issues.push("Key not found in fnox");
45
51
  if (isMissingMetadata) {
46
52
  if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
@@ -55,6 +61,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
55
61
  purpose,
56
62
  created: Option(meta.created),
57
63
  expires: Option(meta.expires),
64
+ last_rotated_at: Option(meta.last_rotated_at),
58
65
  issues: List(issues),
59
66
  alias_of: Option(void 0)
60
67
  };
@@ -73,6 +80,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
73
80
  purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
74
81
  created: targetHealth.created,
75
82
  expires: targetHealth.expires,
83
+ last_rotated_at: targetHealth.last_rotated_at,
76
84
  issues: List([]),
77
85
  alias_of: Option(targetRef)
78
86
  });
@@ -106,6 +114,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
106
114
  purpose: Option(meta.purpose),
107
115
  created: Option(meta.created),
108
116
  expires: Option(meta.expires),
117
+ last_rotated_at: Option(meta.last_rotated_at),
109
118
  issues: List(["Alias target not resolvable"]),
110
119
  alias_of: Option(targetRef)
111
120
  }), (health) => classifyAlias(key, meta, health, targetRef));
@@ -205,6 +214,10 @@ const SecretMetaSchema = Type.Object({
205
214
  format: "date",
206
215
  description: "Date the secret was provisioned (YYYY-MM-DD)"
207
216
  })),
217
+ last_rotated_at: Type.Optional(Type.String({
218
+ format: "date",
219
+ description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
220
+ })),
208
221
  rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
209
222
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
210
223
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
@@ -765,9 +778,13 @@ const runAuditOnConfig = (config, options) => {
765
778
  ...afterStatus,
766
779
  secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
767
780
  }));
768
- if (options.format === "json") console.log(formatAuditJson(filtered));
769
- else if (options.format === "minimal") console.log(formatAuditMinimal(filtered));
770
- 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));
771
788
  if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
772
789
  else formatEnvAuditTable(config);
773
790
  const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
@@ -2347,6 +2364,145 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
2347
2364
  * Ensures proper spacing (double newline before the block).
2348
2365
  */
2349
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
+ };
2350
2506
  //#endregion
2351
2507
  //#region src/core/validate.ts
2352
2508
  /**
@@ -2987,6 +3143,7 @@ const printSecretMeta = (meta, indent) => {
2987
3143
  console.log(`${indent}${DIM}tags:${RESET} ${tagStr}`);
2988
3144
  }
2989
3145
  };
3146
+ const sortedIfRequested = (entries, sort) => sort ? [...entries].sort((a, b) => a[0].localeCompare(b[0])) : entries;
2990
3147
  const printConfig = (config, path, resolveResult, opts) => {
2991
3148
  console.log(`${BOLD}envpkt.toml${RESET} ${DIM}(${path})${RESET}`);
2992
3149
  if (resolveResult?.catalogPath) console.log(`${DIM}Catalog: ${CYAN}${resolveResult.catalogPath}${RESET}`);
@@ -3004,7 +3161,7 @@ const printConfig = (config, path, resolveResult, opts) => {
3004
3161
  }
3005
3162
  const secretEntries = config.secret ?? {};
3006
3163
  console.log(`${BOLD}Secrets:${RESET} ${Object.keys(secretEntries).length}`);
3007
- Object.entries(secretEntries).forEach(([key, meta]) => {
3164
+ sortedIfRequested(Object.entries(secretEntries), opts?.sort).forEach(([key, meta]) => {
3008
3165
  const valueSuffix = Option(opts?.secrets?.[key]).fold(() => "", (secretValue) => ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}`);
3009
3166
  const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
3010
3167
  console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
@@ -3015,7 +3172,7 @@ const printConfig = (config, path, resolveResult, opts) => {
3015
3172
  if (envKeys.length > 0) {
3016
3173
  console.log("");
3017
3174
  console.log(`${BOLD}Environment Defaults:${RESET} ${envKeys.length}`);
3018
- Object.entries(envEntries).forEach(([key, entry]) => {
3175
+ sortedIfRequested(Object.entries(envEntries), opts?.sort).forEach(([key, entry]) => {
3019
3176
  console.log(` ${BOLD}${key}${RESET} = ${GREEN}"${entry.value}"${RESET}`);
3020
3177
  if (entry.purpose) console.log(` ${DIM}purpose:${RESET} ${entry.purpose}`);
3021
3178
  if (entry.comment) console.log(` ${DIM}comment:${RESET} ${DIM}${entry.comment}${RESET}`);
@@ -3079,7 +3236,10 @@ const runInspect = (options) => {
3079
3236
  secretDisplay: options.plaintext ? "plaintext" : "encrypted"
3080
3237
  };
3081
3238
  })() : void 0;
3082
- printConfig(showConfig, path, showResolved ? resolveResult : void 0, printOpts);
3239
+ printConfig(showConfig, path, showResolved ? resolveResult : void 0, {
3240
+ ...printOpts ?? {},
3241
+ sort: options.sort
3242
+ });
3083
3243
  });
3084
3244
  });
3085
3245
  });
@@ -3544,9 +3704,13 @@ const META_SECTION_RE = /^\[secret\.(.+)\]\s*$/;
3544
3704
  const ENCRYPTED_VALUE_RE = /^encrypted_value\s*=/;
3545
3705
  const NEW_SECTION_RE = /^\[/;
3546
3706
  const MULTILINE_DELIM = "\"\"\"";
3547
- /** Write sealed values back into the TOML file, preserving structure. */
3548
- const writeSealedToml = (configPath, sealedMeta) => {
3549
- const lines = readFileSync(configPath, "utf-8").split("\n");
3707
+ /**
3708
+ * In-memory transform: inject sealed encrypted_value blocks into raw TOML,
3709
+ * replacing existing encrypted_value lines (single or multiline) or appending
3710
+ * a new block at the end of the section. Pure — no I/O.
3711
+ */
3712
+ const applySealedToml = (raw, sealedMeta) => {
3713
+ const lines = raw.split("\n");
3550
3714
  const getSeal = (key) => Option(sealedMeta[key]?.encrypted_value);
3551
3715
  const isPending = (state, key) => !getSeal(key).isEmpty && !state.consumedKeys.has(key);
3552
3716
  const sealLinesFor = (key) => getSeal(key).fold(() => [], (v) => [
@@ -3628,7 +3792,11 @@ const writeSealedToml = (configPath, sealedMeta) => {
3628
3792
  consumedKeys: Set$1.empty(),
3629
3793
  skipUntil: -1
3630
3794
  };
3631
- const finalContent = flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3795
+ return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3796
+ };
3797
+ /** Write sealed values back into the TOML file, preserving structure. */
3798
+ const writeSealedToml = (configPath, sealedMeta) => {
3799
+ const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
3632
3800
  validateOrExit(finalContent);
3633
3801
  writeFileSync(configPath, finalContent);
3634
3802
  };
@@ -4005,6 +4173,98 @@ const runSecretAlias = (name, options) => {
4005
4173
  });
4006
4174
  });
4007
4175
  };
4176
+ const readNewValue = async (key) => {
4177
+ if (!process.stdin.isTTY) {
4178
+ const chunks = [];
4179
+ return new Promise((resolveP, rejectP) => {
4180
+ process.stdin.on("data", (c) => chunks.push(Buffer.from(c)));
4181
+ process.stdin.on("end", () => resolveP(Buffer.concat(chunks).toString("utf-8").replace(/\r?\n$/, "")));
4182
+ process.stdin.on("error", rejectP);
4183
+ });
4184
+ }
4185
+ const rl = createInterface({
4186
+ input: process.stdin,
4187
+ output: process.stderr
4188
+ });
4189
+ return new Promise((resolveP) => {
4190
+ rl.question(`Enter new value for ${key}: `, (answer) => {
4191
+ rl.close();
4192
+ resolveP(answer);
4193
+ });
4194
+ });
4195
+ };
4196
+ const runSecretRotate = async (name, options) => {
4197
+ const { configPath, source } = resolveConfigPath(options.config).fold((err) => {
4198
+ console.error(formatError(err));
4199
+ process.exit(2);
4200
+ return {
4201
+ configPath: "",
4202
+ source: "flag"
4203
+ };
4204
+ }, ({ path, source: s }) => ({
4205
+ configPath: path,
4206
+ source: s
4207
+ }));
4208
+ const sourceMsg = formatConfigSource(configPath, source);
4209
+ if (sourceMsg) console.error(sourceMsg);
4210
+ const config = loadConfig(configPath).fold((err) => {
4211
+ console.error(formatError(err));
4212
+ process.exit(2);
4213
+ }, (c) => c);
4214
+ const meta = config.secret?.[name];
4215
+ if (!meta) {
4216
+ console.error(`${RED}Error:${RESET} Secret "${name}" not found in ${configPath}`);
4217
+ process.exit(1);
4218
+ }
4219
+ if (meta.from_key) {
4220
+ console.error(`${RED}Error:${RESET} "${name}" is an alias (from_key = "${meta.from_key}"). Rotate the target secret instead.`);
4221
+ process.exit(1);
4222
+ }
4223
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4224
+ const wasSealed = !!meta.encrypted_value;
4225
+ const raw = readFileSync(configPath, "utf-8");
4226
+ if (!wasSealed) {
4227
+ updateSectionFields(raw, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4228
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4229
+ process.exit(2);
4230
+ }, (result) => {
4231
+ if (options.dryRun) {
4232
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4233
+ console.log(result);
4234
+ return;
4235
+ }
4236
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
4237
+ });
4238
+ return;
4239
+ }
4240
+ if (!config.identity?.recipient) {
4241
+ console.error(`${RED}Error:${RESET} identity.recipient is required to rotate a sealed secret`);
4242
+ console.error(`${DIM}Run ${CYAN}envpkt keygen${DIM} to configure one.${RESET}`);
4243
+ process.exit(2);
4244
+ }
4245
+ const { recipient } = config.identity;
4246
+ const value = await readNewValue(name);
4247
+ if (value === "") {
4248
+ console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
4249
+ process.exit(1);
4250
+ }
4251
+ const ciphertext = ageEncrypt(value, recipient).fold((err) => {
4252
+ console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
4253
+ process.exit(2);
4254
+ return "";
4255
+ }, (ct) => ct);
4256
+ updateSectionFields(applySealedToml(raw, { [name]: { encrypted_value: ciphertext } }), `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4257
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4258
+ process.exit(2);
4259
+ }, (result) => {
4260
+ if (options.dryRun) {
4261
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4262
+ console.log(result);
4263
+ return;
4264
+ }
4265
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
4266
+ });
4267
+ };
4008
4268
  const addSecretFlags = (cmd) => cmd.option("--service <service>", "Service this secret authenticates to").option("--purpose <purpose>", "Why this secret exists").option("--comment <comment>", "Free-form annotation").option("--expires <date>", "Expiration date (YYYY-MM-DD)").option("--capabilities <caps>", "Comma-separated capabilities (e.g. read,write)").option("--rotates <schedule>", "Rotation schedule (e.g. 90d, quarterly)").option("--rate-limit <limit>", "Rate limit info (e.g. 1000/min)").option("--model-hint <hint>", "Suggested model or tier").option("--source <source>", "Where the value originates (e.g. vault, ci)").option("--rotation-url <url>", "URL for secret rotation procedure").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)");
4009
4269
  const registerSecretCommands = (program) => {
4010
4270
  const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
@@ -4020,6 +4280,9 @@ const registerSecretCommands = (program) => {
4020
4280
  secret.command("rename").description("Rename a secret entry, preserving all metadata").argument("<old>", "Current secret name").argument("<new>", "New secret name").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((oldName, newName, options) => {
4021
4281
  runSecretRename(oldName, newName, options);
4022
4282
  });
4283
+ secret.command("rotate").description("Rotate a secret's value (sealed: reseal + stamp; unsealed: stamp only)").argument("<name>", "Secret name to rotate").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action(async (name, options) => {
4284
+ await runSecretRotate(name, options);
4285
+ });
4023
4286
  secret.command("alias").description("Create an alias entry that reuses another secret's resolved value").argument("<name>", "Alias name (becomes the env var key)").requiredOption("--from <ref>", "Target reference — must be \"secret.<KEY>\"").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this alias exists (local metadata)").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--force", "Overwrite the entry if <name> already exists").option("--dry-run", "Preview the TOML block without writing").action((name, options) => {
4024
4287
  runSecretAlias(name, options);
4025
4288
  });
@@ -4066,6 +4329,45 @@ const runShellHook = (shell) => {
4066
4329
  }
4067
4330
  };
4068
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
4069
4371
  //#region src/cli/commands/upgrade.ts
4070
4372
  const getCurrentVersion = () => Try(() => execFileSync("npm", [
4071
4373
  "list",
@@ -4322,7 +4624,7 @@ program.command("init").description("Initialize a new envpkt.toml in the current
4322
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) => {
4323
4625
  runKeygen(options);
4324
4626
  });
4325
- 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) => {
4326
4628
  runAudit(options);
4327
4629
  });
4328
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) => {
@@ -4331,7 +4633,7 @@ program.command("fleet").description("Scan directory tree for envpkt.toml files
4331
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) => {
4332
4634
  runValidate(options);
4333
4635
  });
4334
- 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) => {
4335
4637
  runInspect(options);
4336
4638
  });
4337
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) => {
@@ -4348,6 +4650,9 @@ program.command("mcp").description("Start the envpkt MCP server (stdio transport
4348
4650
  });
4349
4651
  registerSecretCommands(program);
4350
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
+ });
4351
4656
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
4352
4657
  runUpgrade();
4353
4658
  });
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
40
40
  comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
41
41
  capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
42
42
  created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
43
+ last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
43
44
  rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
44
45
  rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
45
46
  model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -94,6 +95,7 @@ declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
94
95
  comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
95
96
  capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
96
97
  created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
98
+ last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
97
99
  rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
98
100
  rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
99
101
  model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -138,6 +140,7 @@ type SecretHealth = {
138
140
  readonly purpose: Option<string>;
139
141
  readonly created: Option<string>;
140
142
  readonly expires: Option<string>;
143
+ readonly last_rotated_at: Option<string>;
141
144
  readonly issues: List<string>; /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
142
145
  readonly alias_of: Option<string>;
143
146
  };
package/dist/index.js CHANGED
@@ -56,6 +56,10 @@ const SecretMetaSchema = Type.Object({
56
56
  format: "date",
57
57
  description: "Date the secret was provisioned (YYYY-MM-DD)"
58
58
  })),
59
+ last_rotated_at: Type.Optional(Type.String({
60
+ format: "date",
61
+ description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
62
+ })),
59
63
  rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
60
64
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
61
65
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
@@ -565,20 +569,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
565
569
  const issues = [];
566
570
  const created = Option(meta.created).flatMap(parseDate);
567
571
  const expires = Option(meta.expires).flatMap(parseDate);
572
+ const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
568
573
  const rotationUrl = Option(meta.rotation_url);
569
574
  const purpose = Option(meta.purpose);
570
575
  const service = Option(meta.service);
571
576
  const daysRemaining = expires.map((exp) => daysBetween(today, exp));
572
- const daysSinceCreated = created.map((c) => daysBetween(c, today));
577
+ const staleFromRotation = lastRotated.isSome();
578
+ const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
573
579
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
574
580
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
575
- const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
581
+ const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
576
582
  const hasSealed = !!meta.encrypted_value;
577
583
  const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
578
584
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
579
585
  if (isExpired) issues.push("Secret has expired");
580
586
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
581
- if (isStale) issues.push("Secret is stale (no rotation detected)");
587
+ if (isStale) {
588
+ const since = daysSinceRotation.fold(() => "?", (d) => String(d));
589
+ const label = staleFromRotation ? "last rotated" : "created";
590
+ issues.push(`Secret is stale (${label} ${since} days ago)`);
591
+ }
582
592
  if (isMissing) issues.push("Key not found in fnox");
583
593
  if (isMissingMetadata) {
584
594
  if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
@@ -593,6 +603,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
593
603
  purpose,
594
604
  created: Option(meta.created),
595
605
  expires: Option(meta.expires),
606
+ last_rotated_at: Option(meta.last_rotated_at),
596
607
  issues: List(issues),
597
608
  alias_of: Option(void 0)
598
609
  };
@@ -611,6 +622,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
611
622
  purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
612
623
  created: targetHealth.created,
613
624
  expires: targetHealth.expires,
625
+ last_rotated_at: targetHealth.last_rotated_at,
614
626
  issues: List([]),
615
627
  alias_of: Option(targetRef)
616
628
  });
@@ -644,6 +656,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
644
656
  purpose: Option(meta.purpose),
645
657
  created: Option(meta.created),
646
658
  expires: Option(meta.expires),
659
+ last_rotated_at: Option(meta.last_rotated_at),
647
660
  issues: List(["Alias target not resolvable"]),
648
661
  alias_of: Option(targetRef)
649
662
  }), (health) => classifyAlias(key, meta, health, targetRef));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -131,6 +131,11 @@
131
131
  "description": "Date the secret was provisioned (YYYY-MM-DD)",
132
132
  "type": "string"
133
133
  },
134
+ "last_rotated_at": {
135
+ "format": "date",
136
+ "description": "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness.",
137
+ "type": "string"
138
+ },
134
139
  "rotates": {
135
140
  "description": "Rotation schedule (e.g. '90d', 'quarterly')",
136
141
  "type": "string"