envpkt 0.13.3 → 0.13.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
@@ -571,7 +571,7 @@ const ageVersion = () => Try(() => execFileSync("age", ["--version"], {
571
571
  "pipe"
572
572
  ],
573
573
  encoding: "utf-8"
574
- }).trim()).fold(() => Option.none(), (v) => Option(v));
574
+ }).trim()).toOption();
575
575
  /** Platform-aware instructions for installing the age CLI. */
576
576
  const ageInstallHint = () => {
577
577
  return `Install age:\n ${{
@@ -930,90 +930,6 @@ const runConfigPath = (options) => {
930
930
  });
931
931
  };
932
932
  //#endregion
933
- //#region src/core/diff.ts
934
- /** Normalize a metadata value to a comparable/displayable string (`undefined` = absent). */
935
- const serialize = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : JSON.stringify(value);
936
- /**
937
- * Field-level diff of two entries. `encrypted_value` is excluded from value comparison — the same
938
- * secret re-encrypts to different ciphertext, so diffing it is noise — but a change in *sealed
939
- * status* (present ↔ absent) is reported as a synthetic `sealed` field.
940
- */
941
- const metaDiff = (a, b) => {
942
- const ar = a;
943
- const br = b;
944
- const sealedChange = !!ar["encrypted_value"] === !!br["encrypted_value"] ? [] : [{
945
- field: "sealed",
946
- a: ar["encrypted_value"] ? "yes" : "no",
947
- b: br["encrypted_value"] ? "yes" : "no"
948
- }];
949
- const fieldChanges = [...Object.keys(ar), ...Object.keys(br)].filter((k, i, arr) => k !== "encrypted_value" && arr.indexOf(k) === i).flatMap((field) => {
950
- const av = serialize(ar[field]);
951
- const bv = serialize(br[field]);
952
- return av === bv ? [] : [{
953
- field,
954
- a: av,
955
- b: bv
956
- }];
957
- });
958
- return [...sealedChange, ...fieldChanges.sort((x, y) => x.field.localeCompare(y.field))];
959
- };
960
- const sectionDiff = (a, b) => {
961
- const aKeys = Object.keys(a);
962
- const bKeys = Object.keys(b);
963
- return {
964
- onlyA: aKeys.filter((k) => !(k in b)).sort(),
965
- onlyB: bKeys.filter((k) => !(k in a)).sort(),
966
- changed: aKeys.filter((k) => k in b).sort().flatMap((key) => {
967
- const changes = metaDiff(a[key], b[key]);
968
- return changes.length === 0 ? [] : [{
969
- key,
970
- changes
971
- }];
972
- })
973
- };
974
- };
975
- const isEmpty = (s) => s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0;
976
- /** Compare two configs by their `[secret.*]` and `[env.*]` entries (metadata, not ciphertext). */
977
- const diffConfigs = (a, b) => {
978
- const secret = sectionDiff(a.secret ?? {}, b.secret ?? {});
979
- const env = sectionDiff(a.env ?? {}, b.env ?? {});
980
- return {
981
- secret,
982
- env,
983
- identical: isEmpty(secret) && isEmpty(env)
984
- };
985
- };
986
- //#endregion
987
- //#region src/cli/commands/diff.ts
988
- const formatSection = (name, s) => {
989
- if (s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0) return [];
990
- return [
991
- `${BOLD}[${name}]${RESET}`,
992
- ...s.onlyA.map((k) => ` ${RED}- ${k}${RESET}`),
993
- ...s.onlyB.map((k) => ` ${GREEN}+ ${k}${RESET}`),
994
- ...s.changed.flatMap((c) => [` ${YELLOW}~ ${c.key}${RESET}`, ...c.changes.map((ch) => ` ${ch.field}: ${DIM}${ch.a ?? "∅"}${RESET} → ${DIM}${ch.b ?? "∅"}${RESET}`)])
995
- ];
996
- };
997
- const loadOrExit = (path, side) => loadConfig(path).fold((err) => {
998
- console.error(`${RED}Error${RESET} (${side} = ${path}): ${formatError(err)}`);
999
- process.exit(2);
1000
- }, (config) => config);
1001
- /**
1002
- * Compare two envpkt.toml files by their `[secret.*]` and `[env.*]` entries. Reports keys only in
1003
- * each side and field-level metadata changes for shared keys (ciphertext is ignored; sealed-status
1004
- * changes are reported). With `--exit-code`, exits non-zero when the configs differ.
1005
- */
1006
- const runDiff = (pathA, pathB, options) => {
1007
- const diff = diffConfigs(loadOrExit(pathA, "a"), loadOrExit(pathB, "b"));
1008
- if (options.format === "json") console.log(JSON.stringify(diff, null, 2));
1009
- else if (diff.identical) console.log(`${GREEN}✓${RESET} no differences`);
1010
- else {
1011
- const body = [...formatSection("secret", diff.secret), ...formatSection("env", diff.env)];
1012
- console.log(`${DIM}- ${pathA}\n+ ${pathB}${RESET}\n\n${body.join("\n")}`);
1013
- }
1014
- if (options.exitCode && !diff.identical) process.exit(1);
1015
- };
1016
- //#endregion
1017
933
  //#region src/fnox/cli.ts
1018
934
  /** Export all secrets from fnox as key=value pairs for a given profile */
1019
935
  const fnoxExport = (profile, agentKey) => {
@@ -1695,112 +1611,696 @@ const bootSafe = (options) => {
1695
1611
  }));
1696
1612
  };
1697
1613
  //#endregion
1698
- //#region src/cli/commands/doctor.ts
1699
- const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM}${detail}${RESET}`);
1700
- const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
1701
- const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
1702
- /** Print the resolution/key check, returning whether it passed. */
1703
- const reportResolution = (configPath) => bootSafe({
1704
- configPath,
1705
- inject: false,
1706
- warnOnly: true
1707
- }).fold((err) => {
1708
- if (err._tag === "SealKeyUnavailable") {
1709
- bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
1710
- err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
1711
- } else bad("config", `${err._tag}`);
1712
- return false;
1713
- }, (boot) => {
1714
- const resolved = Object.keys(boot.secrets).length;
1715
- ok("secrets", `${resolved} resolved, ${boot.skipped.length} skipped`);
1716
- const auditColor = boot.audit.status === "healthy" ? GREEN : YELLOW;
1717
- console.log(` ${auditColor}•${RESET} audit ${DIM}${boot.audit.status}${RESET}`);
1718
- return true;
1719
- });
1614
+ //#region src/core/copy.ts
1615
+ /** Escape a string for a TOML basic (double-quoted) string. */
1616
+ const tomlString = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n")}"`;
1617
+ const tomlStringArray = (arr) => `[${arr.map(tomlString).join(", ")}]`;
1618
+ const tomlInlineTable = (rec) => {
1619
+ const entries = Object.entries(rec);
1620
+ return entries.length === 0 ? "{}" : `{ ${entries.map(([k, v]) => `${k} = ${tomlString(v)}`).join(", ")} }`;
1621
+ };
1720
1622
  /**
1721
- * One-shot environment check: is age installed, is a config resolvable, and do its sealed
1722
- * secrets decrypt with an available key? Read-only; exits non-zero if any check fails.
1623
+ * The SecretMeta to write into the destination on copy.
1624
+ * - `created` is reset to today: the entry is new *here*, regardless of the source's age.
1625
+ * - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
1626
+ * - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
1627
+ * `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
1723
1628
  */
1724
- const runDoctor = (options) => {
1725
- console.log(`${BOLD}envpkt doctor${RESET}\n`);
1726
- const ageOk = ageVersion().fold(() => {
1727
- bad("age ", "not found on PATH");
1728
- console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
1729
- return false;
1730
- }, (version) => {
1731
- ok("age ", version);
1732
- return true;
1733
- });
1734
- const resolveOk = resolveConfigPath(options.config).fold(() => {
1735
- warn("config", "no envpkt.toml found for this directory");
1736
- return true;
1737
- }, ({ path }) => {
1738
- ok("config", path);
1739
- return reportResolution(path);
1740
- });
1741
- console.log("");
1742
- if (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
1743
- else {
1744
- console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
1745
- process.exit(1);
1746
- }
1629
+ const copyableSecretMeta = (meta, opts) => {
1630
+ const { last_rotated_at: _lra, encrypted_value: _ev, ...rest } = meta;
1631
+ return opts.encryptedValue.fold(() => ({
1632
+ ...rest,
1633
+ created: opts.today
1634
+ }), (cipher) => ({
1635
+ ...rest,
1636
+ created: opts.today,
1637
+ encrypted_value: cipher
1638
+ }));
1639
+ };
1640
+ /** Serialize a `[secret.<name>]` block from its metadata, round-trippable by the TOML parser. */
1641
+ const serializeSecretBlock = (name, meta) => {
1642
+ const lines = [`[secret.${name}]`];
1643
+ if (meta.service !== void 0) lines.push(`service = ${tomlString(meta.service)}`);
1644
+ if (meta.purpose !== void 0) lines.push(`purpose = ${tomlString(meta.purpose)}`);
1645
+ if (meta.comment !== void 0) lines.push(`comment = ${tomlString(meta.comment)}`);
1646
+ if (meta.created !== void 0) lines.push(`created = ${tomlString(meta.created)}`);
1647
+ if (meta.expires !== void 0) lines.push(`expires = ${tomlString(meta.expires)}`);
1648
+ if (meta.rotates !== void 0) lines.push(`rotates = ${tomlString(meta.rotates)}`);
1649
+ if (meta.rate_limit !== void 0) lines.push(`rate_limit = ${tomlString(meta.rate_limit)}`);
1650
+ if (meta.model_hint !== void 0) lines.push(`model_hint = ${tomlString(meta.model_hint)}`);
1651
+ if (meta.source !== void 0) lines.push(`source = ${tomlString(meta.source)}`);
1652
+ if (meta.rotation_url !== void 0) lines.push(`rotation_url = ${tomlString(meta.rotation_url)}`);
1653
+ if (meta.last_rotated_at !== void 0) lines.push(`last_rotated_at = ${tomlString(meta.last_rotated_at)}`);
1654
+ if (meta.required !== void 0) lines.push(`required = ${meta.required ? "true" : "false"}`);
1655
+ if (meta.capabilities !== void 0) lines.push(`capabilities = ${tomlStringArray(meta.capabilities)}`);
1656
+ if (meta.tags !== void 0) lines.push(`tags = ${tomlInlineTable(meta.tags)}`);
1657
+ if (meta.namespace !== void 0) lines.push(`namespace = ${tomlString(meta.namespace)}`);
1658
+ if (meta.from_key !== void 0) lines.push(`from_key = ${tomlString(meta.from_key)}`);
1659
+ if (meta.encrypted_value !== void 0 && meta.encrypted_value !== "") lines.push(`encrypted_value = """`, meta.encrypted_value, `"""`);
1660
+ return `${lines.join("\n")}\n`;
1661
+ };
1662
+ /** Serialize an `[env.<name>]` block from its metadata. */
1663
+ const serializeEnvBlock = (name, meta) => {
1664
+ const lines = [`[env.${name}]`];
1665
+ if (meta.value !== void 0) lines.push(`value = ${tomlString(meta.value)}`);
1666
+ if (meta.from_key !== void 0) lines.push(`from_key = ${tomlString(meta.from_key)}`);
1667
+ if (meta.purpose !== void 0) lines.push(`purpose = ${tomlString(meta.purpose)}`);
1668
+ if (meta.comment !== void 0) lines.push(`comment = ${tomlString(meta.comment)}`);
1669
+ if (meta.tags !== void 0) lines.push(`tags = ${tomlInlineTable(meta.tags)}`);
1670
+ if (meta.namespace !== void 0) lines.push(`namespace = ${tomlString(meta.namespace)}`);
1671
+ return `${lines.join("\n")}\n`;
1747
1672
  };
1748
1673
  //#endregion
1749
- //#region src/core/dotenv.ts
1750
- const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1674
+ //#region src/core/toml-edit.ts
1675
+ const SECTION_RE = /^\[.+\]\s*$/;
1676
+ const MULTILINE_OPEN = "\"\"\"";
1677
+ const scanSectionBoundary = (state, line, i) => {
1678
+ if (state.done) return state;
1679
+ if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
1680
+ ...state,
1681
+ inMultiline: false
1682
+ } : state;
1683
+ if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
1684
+ ...state,
1685
+ inMultiline: true
1686
+ } : state;
1687
+ return SECTION_RE.test(line) ? {
1688
+ ...state,
1689
+ end: i,
1690
+ done: true
1691
+ } : state;
1692
+ };
1751
1693
  /**
1752
- * Quote a single value for dotenv output. Returns the value bare when safe,
1753
- * otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
1754
- * whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
1694
+ * Find the line range [start, end) of a TOML section by its header string.
1695
+ * The range includes the header line through to (but not including) the next section header or EOF.
1696
+ * Handles multiline `"""..."""` values when scanning for section boundaries.
1755
1697
  */
1756
- const quoteDotenvValue = (value) => {
1757
- if (value === "") return "";
1758
- if (BARE_SAFE.test(value)) return value;
1759
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
1698
+ const findSectionRange = (lines, sectionHeader) => {
1699
+ const start = lines.findIndex((l) => l.trim() === sectionHeader);
1700
+ if (start === -1) return void 0;
1701
+ const initial = {
1702
+ end: lines.length,
1703
+ inMultiline: false,
1704
+ done: false
1705
+ };
1706
+ return {
1707
+ start,
1708
+ end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
1709
+ };
1760
1710
  };
1761
- const formatEntry = (entry, includeSecrets) => {
1762
- if (entry.secret && !includeSecrets) return `# (secret value omitted re-run without --no-secrets to include)\n${entry.name}=`;
1763
- return `${entry.name}=${quoteDotenvValue(entry.value)}`;
1711
+ /** Check whether a section header exists in the raw TOML */
1712
+ const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
1713
+ /**
1714
+ * Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
1715
+ * Strips trailing blank lines left behind.
1716
+ */
1717
+ const removeSection = (raw, sectionHeader) => {
1718
+ const lines = raw.split("\n");
1719
+ const range = findSectionRange(lines, sectionHeader);
1720
+ if (!range) return Either.left({
1721
+ _tag: "SectionNotFound",
1722
+ section: sectionHeader
1723
+ });
1724
+ const after = lines.slice(range.end);
1725
+ const beforeAll = lines.slice(0, range.start);
1726
+ const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
1727
+ const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
1728
+ return Either.right(result);
1764
1729
  };
1765
- /** Serialize entries to dotenv text (no trailing newline). */
1766
- const formatDotenv = (entries, options) => {
1767
- const includeSecrets = options?.includeSecrets ?? true;
1768
- const body = entries.map((e) => formatEntry(e, includeSecrets)).join("\n");
1769
- const header = options?.header;
1770
- return header ? `${header}\n\n${body}` : body;
1730
+ /**
1731
+ * Rename a TOML section header (e.g. `[secret.OLD]` `[secret.NEW]`).
1732
+ * Errors if old doesn't exist or new already exists.
1733
+ */
1734
+ const renameSection = (raw, oldHeader, newHeader) => {
1735
+ const lines = raw.split("\n");
1736
+ if (!sectionExists(lines, oldHeader)) return Either.left({
1737
+ _tag: "SectionNotFound",
1738
+ section: oldHeader
1739
+ });
1740
+ if (sectionExists(lines, newHeader)) return Either.left({
1741
+ _tag: "SectionAlreadyExists",
1742
+ section: newHeader
1743
+ });
1744
+ const result = lines.map((line) => line.trim() === oldHeader ? newHeader : line).join("\n");
1745
+ return Either.right(result);
1771
1746
  };
1772
- //#endregion
1773
- //#region src/core/patterns.ts
1774
- const EXCLUDED_VARS = Set$1([
1775
- "PATH",
1776
- "HOME",
1777
- "USER",
1778
- "SHELL",
1779
- "TERM",
1780
- "LANG",
1781
- "LC_ALL",
1782
- "LC_CTYPE",
1783
- "DISPLAY",
1784
- "EDITOR",
1785
- "VISUAL",
1786
- "PAGER",
1787
- "HOSTNAME",
1788
- "LOGNAME",
1789
- "MAIL",
1790
- "OLDPWD",
1791
- "PWD",
1792
- "SHLVL",
1793
- "TMPDIR",
1794
- "TZ",
1795
- "XDG_CACHE_HOME",
1796
- "XDG_CONFIG_HOME",
1797
- "XDG_DATA_HOME",
1798
- "XDG_RUNTIME_DIR",
1799
- "XDG_SESSION_TYPE",
1800
- "NODE_ENV",
1801
- "NODE_PATH",
1802
- "NODE_OPTIONS",
1803
- "NVM_DIR",
1747
+ /**
1748
+ * Update, add, or remove fields within an existing TOML section.
1749
+ * - A string value replaces or adds the field
1750
+ * - A null value removes the field
1751
+ * Does NOT re-serialize — operates on raw text lines.
1752
+ */
1753
+ const updateSectionFields = (raw, sectionHeader, updates) => {
1754
+ const lines = raw.split("\n");
1755
+ const range = findSectionRange(lines, sectionHeader);
1756
+ if (!range) return Either.left({
1757
+ _tag: "SectionNotFound",
1758
+ section: sectionHeader
1759
+ });
1760
+ const before = lines.slice(0, range.start + 1);
1761
+ const after = lines.slice(range.end);
1762
+ const sectionBody = lines.slice(range.start + 1, range.end);
1763
+ const findClosingMultiline = (fromIdx) => {
1764
+ const idx = sectionBody.findIndex((l, j) => j > fromIdx && l.includes(MULTILINE_OPEN));
1765
+ return idx === -1 ? sectionBody.length : idx;
1766
+ };
1767
+ const initial = {
1768
+ remaining: [],
1769
+ updatedKeys: Set$1.empty(),
1770
+ skipUntil: -1
1771
+ };
1772
+ const step = (state, line, i) => {
1773
+ if (i <= state.skipUntil) return state;
1774
+ const eqIdx = line.indexOf("=");
1775
+ const isFieldLine = eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[");
1776
+ const key = isFieldLine ? line.slice(0, eqIdx).trim() : "";
1777
+ if (isFieldLine && key in updates) {
1778
+ const afterEquals = line.slice(eqIdx + 1).trim();
1779
+ const skipUntil = afterEquals.includes(MULTILINE_OPEN) && (afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? findClosingMultiline(i) : state.skipUntil;
1780
+ const updatedKeys = state.updatedKeys.add(key);
1781
+ const value = updates[key];
1782
+ if (value === null) return {
1783
+ ...state,
1784
+ updatedKeys,
1785
+ skipUntil
1786
+ };
1787
+ return {
1788
+ remaining: [...state.remaining, `${key} = ${value}`],
1789
+ updatedKeys,
1790
+ skipUntil
1791
+ };
1792
+ }
1793
+ return {
1794
+ ...state,
1795
+ remaining: [...state.remaining, line]
1796
+ };
1797
+ };
1798
+ const final = List(sectionBody).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]));
1799
+ const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !final.updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
1800
+ const result = [
1801
+ ...before,
1802
+ ...final.remaining,
1803
+ ...newFields,
1804
+ ...after
1805
+ ].join("\n");
1806
+ return Either.right(result);
1807
+ };
1808
+ /**
1809
+ * Append a new TOML section block to the end of the file.
1810
+ * Ensures proper spacing (double newline before the block).
1811
+ */
1812
+ const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
1813
+ const ENV_HEADER_RE = /^\[env\.(.+)\]\s*$/;
1814
+ const SECRET_HEADER_RE = /^\[secret\.(.+)\]\s*$/;
1815
+ const ANY_HEADER_RE = /^\[.+\]\s*$/;
1816
+ /**
1817
+ * Find the end (exclusive) of a section starting at `start`, respecting
1818
+ * multiline `"""..."""` values so the scanner does not mistake content inside
1819
+ * a multiline string for a section header.
1820
+ */
1821
+ const findSectionEnd = (lines, start) => {
1822
+ const initial = {
1823
+ end: lines.length,
1824
+ inMultiline: false,
1825
+ done: false
1826
+ };
1827
+ return List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end;
1828
+ };
1829
+ /**
1830
+ * Walking backwards from `headerIdx`, return the index of the first line of the
1831
+ * "doc block" that should travel with this section. A doc block is a contiguous
1832
+ * run of `#`-comment lines *immediately* above the header (no blank line
1833
+ * between). A blank line acts as a paragraph break and stops the walk — so
1834
+ * `# Some heading\n\n[secret.X]` does NOT attach the heading to `[secret.X]`.
1835
+ */
1836
+ const findPreambleStart = (lines, headerIdx) => {
1837
+ const stopOffset = [...lines.slice(0, headerIdx)].reverse().findIndex((l) => !l.trim().startsWith("#"));
1838
+ return stopOffset === -1 ? 0 : headerIdx - stopOffset;
1839
+ };
1840
+ const classifyHeader = (line, idx) => {
1841
+ if (!ANY_HEADER_RE.test(line)) return Option.none();
1842
+ const envMatch = line.match(ENV_HEADER_RE);
1843
+ if (envMatch) return Option({
1844
+ idx,
1845
+ kind: "env",
1846
+ key: envMatch[1]
1847
+ });
1848
+ const secretMatch = line.match(SECRET_HEADER_RE);
1849
+ if (secretMatch) return Option({
1850
+ idx,
1851
+ kind: "secret",
1852
+ key: secretMatch[1]
1853
+ });
1854
+ return Option({
1855
+ idx,
1856
+ kind: "other",
1857
+ key: ""
1858
+ });
1859
+ };
1860
+ const scanHeader = (state, line, idx) => {
1861
+ if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
1862
+ ...state,
1863
+ inMultiline: false
1864
+ } : state;
1865
+ if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
1866
+ ...state,
1867
+ inMultiline: true
1868
+ } : state;
1869
+ return classifyHeader(line, idx).fold(() => state, (header) => ({
1870
+ ...state,
1871
+ headers: [...state.headers, header]
1872
+ }));
1873
+ };
1874
+ const partitionSections = (raw) => {
1875
+ const lines = raw.split("\n");
1876
+ const { headers } = List(lines).zipWithIndex().foldLeft({
1877
+ headers: [],
1878
+ inMultiline: false
1879
+ })((state, entry) => scanHeader(state, entry[0], entry[1]));
1880
+ const envSecretHeaders = headers.filter((h) => h.kind === "env" || h.kind === "secret");
1881
+ const trueBodyRange = (headerIdx) => {
1882
+ const naiveEnd = findSectionEnd(lines, headerIdx);
1883
+ const lastContent = lines.slice(headerIdx, naiveEnd).findLastIndex((l) => {
1884
+ const t = l.trim();
1885
+ return t !== "" && !t.startsWith("#");
1886
+ });
1887
+ return {
1888
+ start: headerIdx,
1889
+ end: lastContent === -1 ? headerIdx + 1 : headerIdx + lastContent + 1
1890
+ };
1891
+ };
1892
+ const sections = envSecretHeaders.map((h) => {
1893
+ const headerIdx = h.idx;
1894
+ const preambleStart = findPreambleStart(lines, headerIdx);
1895
+ const body = lines.slice(headerIdx, trueBodyRange(headerIdx).end);
1896
+ const preamble = lines.slice(preambleStart, headerIdx);
1897
+ return {
1898
+ kind: h.kind,
1899
+ key: h.key,
1900
+ body,
1901
+ preamble
1902
+ };
1903
+ });
1904
+ const claimedRanges = envSecretHeaders.map((h) => {
1905
+ const headerIdx = h.idx;
1906
+ return {
1907
+ start: findPreambleStart(lines, headerIdx),
1908
+ end: trueBodyRange(headerIdx).end
1909
+ };
1910
+ });
1911
+ const isClaimed = (idx) => claimedRanges.some((r) => idx >= r.start && idx < r.end);
1912
+ return {
1913
+ preambleLines: lines.map((l, idx) => isClaimed(idx) ? null : l).filter((l) => l !== null),
1914
+ envSections: sections.filter((s) => s.kind === "env"),
1915
+ secretSections: sections.filter((s) => s.kind === "secret")
1916
+ };
1917
+ };
1918
+ const emitSection = (s) => {
1919
+ return `${s.preamble.length > 0 ? `${s.preamble.join("\n")}\n` : ""}${s.body.join("\n")}`;
1920
+ };
1921
+ /**
1922
+ * Reformat a TOML config with `[env.*]` and `[secret.*]` sections grouped and
1923
+ * alphabetized. Top-level content (version key, `[identity]`, `[lifecycle]`,
1924
+ * `[callbacks]`, `[tools]`, etc.) stays in its original position. Comment
1925
+ * doc-blocks immediately above a section header travel with that section.
1926
+ *
1927
+ * Pure — no I/O. Returns the raw input unchanged when there is no env or
1928
+ * secret content to reorder.
1929
+ */
1930
+ const sortConfigToml = (raw) => {
1931
+ const { preambleLines, envSections, secretSections } = partitionSections(raw);
1932
+ if (envSections.length === 0 && secretSections.length === 0) return raw;
1933
+ const sortedEnv = [...envSections].sort((a, b) => a.key.localeCompare(b.key));
1934
+ const sortedSecret = [...secretSections].sort((a, b) => a.key.localeCompare(b.key));
1935
+ const trimTrailing = (xs) => {
1936
+ const lastNonBlank = xs.findLastIndex((l) => l.trim() !== "");
1937
+ return lastNonBlank === -1 ? [] : xs.slice(0, lastNonBlank + 1);
1938
+ };
1939
+ const collapseBlanks = (xs) => xs.reduce((acc, line) => {
1940
+ const isBlank = line.trim() === "";
1941
+ const prevBlank = acc.length > 0 && acc[acc.length - 1].trim() === "";
1942
+ if (isBlank && prevBlank) return acc;
1943
+ return [...acc, line];
1944
+ }, []);
1945
+ const preambleTrimmed = collapseBlanks(trimTrailing(preambleLines));
1946
+ const parts = [];
1947
+ if (preambleTrimmed.length > 0) parts.push(preambleTrimmed.join("\n"));
1948
+ if (sortedEnv.length > 0) parts.push(sortedEnv.map(emitSection).join("\n\n"));
1949
+ if (sortedSecret.length > 0) parts.push(sortedSecret.map(emitSection).join("\n\n"));
1950
+ return `${parts.join("\n\n")}\n`;
1951
+ };
1952
+ //#endregion
1953
+ //#region src/core/validate.ts
1954
+ /**
1955
+ * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
1956
+ *
1957
+ * Used by write-path CLI commands to verify the post-edit file would still be
1958
+ * structurally valid before persisting. Catalog resolution is intentionally
1959
+ * excluded — catalog issues depend on external files, not on the local edit,
1960
+ * and `envpkt validate` covers them as a separate explicit check.
1961
+ */
1962
+ const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
1963
+ /** Human-readable one-liner for any ValidationError tag. */
1964
+ const formatValidationError = (err) => {
1965
+ switch (err._tag) {
1966
+ case "FileNotFound": return `Config file not found: ${err.path}`;
1967
+ case "ParseError": return `TOML parse error: ${err.message}`;
1968
+ case "ValidationError": return `Schema validation failed: ${err.errors.toArray().join("; ")}`;
1969
+ case "ReadError": return `Read error: ${err.message}`;
1970
+ case "AliasInvalidSyntax":
1971
+ case "AliasTargetMissing":
1972
+ case "AliasSelfReference":
1973
+ case "AliasChained":
1974
+ case "AliasCrossType":
1975
+ case "AliasValueConflict": return formatAliasError(err);
1976
+ }
1977
+ };
1978
+ //#endregion
1979
+ //#region src/cli/write-gate.ts
1980
+ /**
1981
+ * Run structural validation against an in-memory TOML string.
1982
+ * On failure, prints the error, leaves the file untouched, and exits with code 1.
1983
+ * On success, returns — caller is responsible for writing.
1984
+ *
1985
+ * Use this when the write step has bespoke logic (e.g. wraps writeFileSync in Try,
1986
+ * has multi-line post-write output). Otherwise prefer `writeIfValid`.
1987
+ */
1988
+ const validateOrExit = (updated) => {
1989
+ validateRawConfig(updated).fold((err) => {
1990
+ console.error(`${RED}Error:${RESET} Aborted — change would produce an invalid config:`);
1991
+ console.error(` ${formatValidationError(err)}`);
1992
+ console.error(`${DIM}File unchanged.${RESET}`);
1993
+ process.exit(1);
1994
+ }, () => {});
1995
+ };
1996
+ /**
1997
+ * Validate then persist. Most mutating CLI commands use this — it bundles the
1998
+ * validate-or-exit gate with the writeFileSync + success log so each call site
1999
+ * stays two lines instead of five.
2000
+ */
2001
+ const writeIfValid = (configPath, updated, successMsg) => {
2002
+ validateOrExit(updated);
2003
+ writeFileSync(configPath, updated, "utf-8");
2004
+ console.log(successMsg);
2005
+ };
2006
+ /**
2007
+ * Validate then preview (no write) — the `--dry-run` counterpart of `writeIfValid`.
2008
+ * Runs the same structural validation the real write would, so a dry-run can never
2009
+ * show a result that the actual write would reject. On invalid output it prints the
2010
+ * same error and exits 1, exactly as the write path does.
2011
+ *
2012
+ * `display` lets callers preview a focused slice (e.g. just the new block for `add`)
2013
+ * while still validating the full resulting config.
2014
+ */
2015
+ const previewIfValid = (updated, display) => {
2016
+ validateOrExit(updated);
2017
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
2018
+ console.log(display ?? updated);
2019
+ };
2020
+ //#endregion
2021
+ //#region src/cli/commands/copy.ts
2022
+ /** Unwrap an Either, or print the formatted error and exit with `code`. */
2023
+ const orExit = (e, onErr, code) => e.fold((err) => {
2024
+ console.error(onErr(err));
2025
+ return process.exit(code);
2026
+ }, (a) => a);
2027
+ const todayIso$2 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2028
+ /**
2029
+ * Build the `[secret.<destName>]` block for a copy: unseal the source value with the
2030
+ * source identity and reseal it for the destination recipient. Secrets with no sealed
2031
+ * value are copied as metadata only (with a warning) — there's nothing to reseal.
2032
+ */
2033
+ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
2034
+ const meta = srcConfig.secret[key];
2035
+ const today = todayIso$2();
2036
+ if (meta.encrypted_value === void 0 || meta.encrypted_value === "") {
2037
+ console.error(`${YELLOW}Warning:${RESET} secret "${key}" has no sealed value — copying metadata only.`);
2038
+ return serializeSecretBlock(destName, copyableSecretMeta(meta, {
2039
+ today,
2040
+ encryptedValue: Option.none()
2041
+ }));
2042
+ }
2043
+ const identity = resolveSealIdentity(srcConfig, dirname(srcPath)).fold(() => {
2044
+ console.error(`${RED}Error:${RESET} cannot unseal "${key}" from source — no age key found.`);
2045
+ describeSealKeySearch(srcConfig, dirname(srcPath)).forEach((line) => console.error(`${DIM} ${line}${RESET}`));
2046
+ return process.exit(2);
2047
+ }, (id) => id);
2048
+ const recipient = destConfig.identity?.recipient;
2049
+ if (recipient === void 0) {
2050
+ identity.dispose();
2051
+ console.error(`${RED}Error:${RESET} destination needs identity.recipient to reseal "${destName}".`);
2052
+ console.error(`${DIM} Run ${CYAN}envpkt keygen${DIM} in the destination, or add an [identity] recipient.${RESET}`);
2053
+ return process.exit(1);
2054
+ }
2055
+ const unsealed = unsealSecrets({ [key]: meta }, identity.path);
2056
+ identity.dispose();
2057
+ const plaintext = orExit(unsealed, (err) => `${RED}Error:${RESET} decrypt failed for "${key}": ${err.message}`, 2)[key];
2058
+ if (plaintext === void 0) {
2059
+ console.error(`${RED}Error:${RESET} "${key}" produced no plaintext on unseal.`);
2060
+ return process.exit(2);
2061
+ }
2062
+ return serializeSecretBlock(destName, copyableSecretMeta(meta, {
2063
+ today,
2064
+ encryptedValue: Option(orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2))
2065
+ }));
2066
+ };
2067
+ const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
2068
+ const raw = readFileSync(destPath, "utf-8");
2069
+ const updated = appendSection(overwrite ? orExit(removeSection(raw, header), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : raw, block);
2070
+ if (dryRun) {
2071
+ previewIfValid(updated, block);
2072
+ return;
2073
+ }
2074
+ writeIfValid(destPath, updated, successMsg);
2075
+ };
2076
+ /**
2077
+ * Copy a secret or env entry from one config to another. Secrets are unsealed with the
2078
+ * source's age key and resealed for the destination's recipient automatically. The kind
2079
+ * (secret vs env) is detected from where the key lives in the source. `--from`/`--to`
2080
+ * default to the resolved config for the current directory.
2081
+ */
2082
+ const runCopy = (key, options) => {
2083
+ const src = orExit(resolveConfigPath(options.from), formatError, 2);
2084
+ const dest = orExit(resolveConfigPath(options.to), formatError, 2);
2085
+ const srcConfig = orExit(loadConfig(src.path), formatError, 2);
2086
+ const destConfig = orExit(loadConfig(dest.path), formatError, 2);
2087
+ console.error(`${DIM}copy: ${src.path} → ${dest.path}${RESET}`);
2088
+ const inSecret = srcConfig.secret?.[key] !== void 0;
2089
+ const inEnv = srcConfig.env?.[key] !== void 0;
2090
+ if (inSecret && inEnv) {
2091
+ console.error(`${RED}Error:${RESET} "${key}" exists as both a secret and an env entry in ${src.path}; copy them separately.`);
2092
+ process.exit(1);
2093
+ }
2094
+ if (!inSecret && !inEnv) {
2095
+ console.error(`${RED}Error:${RESET} "${key}" not found in ${src.path}`);
2096
+ process.exit(1);
2097
+ }
2098
+ const destName = options.as ?? key;
2099
+ const kindWord = inSecret ? "secret" : "env";
2100
+ if (src.path === dest.path && destName === key) {
2101
+ console.error(`${RED}Error:${RESET} source and destination are the same entry — use --as to copy under a new name.`);
2102
+ process.exit(1);
2103
+ }
2104
+ const existsInDest = inSecret ? destConfig.secret?.[destName] !== void 0 : destConfig.env?.[destName] !== void 0;
2105
+ if (existsInDest && options.force !== true) {
2106
+ console.error(`${RED}Error:${RESET} ${kindWord} "${destName}" already exists in ${dest.path}. Use --force to overwrite.`);
2107
+ process.exit(1);
2108
+ }
2109
+ const block = inSecret ? sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
2110
+ const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${destName !== key ? ` → ${BOLD}${destName}${RESET}` : ""} to ${CYAN}${dest.path}${RESET}`;
2111
+ writeBlock(dest.path, `[${kindWord}.${destName}]`, block, existsInDest, options.dryRun === true, successMsg);
2112
+ };
2113
+ //#endregion
2114
+ //#region src/core/diff.ts
2115
+ /** Normalize a metadata value to a comparable/displayable string (`undefined` = absent). */
2116
+ const serialize = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : JSON.stringify(value);
2117
+ /**
2118
+ * Field-level diff of two entries. `encrypted_value` is excluded from value comparison — the same
2119
+ * secret re-encrypts to different ciphertext, so diffing it is noise — but a change in *sealed
2120
+ * status* (present ↔ absent) is reported as a synthetic `sealed` field.
2121
+ */
2122
+ const metaDiff = (a, b) => {
2123
+ const ar = a;
2124
+ const br = b;
2125
+ const sealedChange = !!ar["encrypted_value"] === !!br["encrypted_value"] ? [] : [{
2126
+ field: "sealed",
2127
+ a: ar["encrypted_value"] ? "yes" : "no",
2128
+ b: br["encrypted_value"] ? "yes" : "no"
2129
+ }];
2130
+ const fieldChanges = [...Object.keys(ar), ...Object.keys(br)].filter((k, i, arr) => k !== "encrypted_value" && arr.indexOf(k) === i).flatMap((field) => {
2131
+ const av = serialize(ar[field]);
2132
+ const bv = serialize(br[field]);
2133
+ return av === bv ? [] : [{
2134
+ field,
2135
+ a: av,
2136
+ b: bv
2137
+ }];
2138
+ });
2139
+ return [...sealedChange, ...fieldChanges.sort((x, y) => x.field.localeCompare(y.field))];
2140
+ };
2141
+ const sectionDiff = (a, b) => {
2142
+ const aKeys = Object.keys(a);
2143
+ const bKeys = Object.keys(b);
2144
+ return {
2145
+ onlyA: aKeys.filter((k) => !(k in b)).sort(),
2146
+ onlyB: bKeys.filter((k) => !(k in a)).sort(),
2147
+ changed: aKeys.filter((k) => k in b).sort().flatMap((key) => {
2148
+ const changes = metaDiff(a[key], b[key]);
2149
+ return changes.length === 0 ? [] : [{
2150
+ key,
2151
+ changes
2152
+ }];
2153
+ })
2154
+ };
2155
+ };
2156
+ const isEmpty = (s) => s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0;
2157
+ /** Compare two configs by their `[secret.*]` and `[env.*]` entries (metadata, not ciphertext). */
2158
+ const diffConfigs = (a, b) => {
2159
+ const secret = sectionDiff(a.secret ?? {}, b.secret ?? {});
2160
+ const env = sectionDiff(a.env ?? {}, b.env ?? {});
2161
+ return {
2162
+ secret,
2163
+ env,
2164
+ identical: isEmpty(secret) && isEmpty(env)
2165
+ };
2166
+ };
2167
+ //#endregion
2168
+ //#region src/cli/commands/diff.ts
2169
+ const formatSection = (name, s) => {
2170
+ if (s.onlyA.length === 0 && s.onlyB.length === 0 && s.changed.length === 0) return [];
2171
+ return [
2172
+ `${BOLD}[${name}]${RESET}`,
2173
+ ...s.onlyA.map((k) => ` ${RED}- ${k}${RESET}`),
2174
+ ...s.onlyB.map((k) => ` ${GREEN}+ ${k}${RESET}`),
2175
+ ...s.changed.flatMap((c) => [` ${YELLOW}~ ${c.key}${RESET}`, ...c.changes.map((ch) => ` ${ch.field}: ${DIM}${ch.a ?? "∅"}${RESET} → ${DIM}${ch.b ?? "∅"}${RESET}`)])
2176
+ ];
2177
+ };
2178
+ const loadOrExit = (path, side) => loadConfig(path).fold((err) => {
2179
+ console.error(`${RED}Error${RESET} (${side} = ${path}): ${formatError(err)}`);
2180
+ process.exit(2);
2181
+ }, (config) => config);
2182
+ /**
2183
+ * Compare two envpkt.toml files by their `[secret.*]` and `[env.*]` entries. Reports keys only in
2184
+ * each side and field-level metadata changes for shared keys (ciphertext is ignored; sealed-status
2185
+ * changes are reported). With `--exit-code`, exits non-zero when the configs differ.
2186
+ */
2187
+ const runDiff = (pathA, pathB, options) => {
2188
+ const diff = diffConfigs(loadOrExit(pathA, "a"), loadOrExit(pathB, "b"));
2189
+ if (options.format === "json") console.log(JSON.stringify(diff, null, 2));
2190
+ else if (diff.identical) console.log(`${GREEN}✓${RESET} no differences`);
2191
+ else {
2192
+ const body = [...formatSection("secret", diff.secret), ...formatSection("env", diff.env)];
2193
+ console.log(`${DIM}- ${pathA}\n+ ${pathB}${RESET}\n\n${body.join("\n")}`);
2194
+ }
2195
+ if (options.exitCode && !diff.identical) process.exit(1);
2196
+ };
2197
+ //#endregion
2198
+ //#region src/cli/commands/doctor.ts
2199
+ const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM}${detail}${RESET}`);
2200
+ const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
2201
+ const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
2202
+ /** Print the resolution/key check, returning whether it passed. */
2203
+ const reportResolution = (configPath) => bootSafe({
2204
+ configPath,
2205
+ inject: false,
2206
+ warnOnly: true
2207
+ }).fold((err) => {
2208
+ if (err._tag === "SealKeyUnavailable") {
2209
+ bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
2210
+ err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
2211
+ } else bad("config", `${err._tag}`);
2212
+ return false;
2213
+ }, (boot) => {
2214
+ const resolved = Object.keys(boot.secrets).length;
2215
+ ok("secrets", `${resolved} resolved, ${boot.skipped.length} skipped`);
2216
+ const auditColor = boot.audit.status === "healthy" ? GREEN : YELLOW;
2217
+ console.log(` ${auditColor}•${RESET} audit ${DIM}${boot.audit.status}${RESET}`);
2218
+ return true;
2219
+ });
2220
+ /**
2221
+ * One-shot environment check: is age installed, is a config resolvable, and do its sealed
2222
+ * secrets decrypt with an available key? Read-only; exits non-zero if any check fails.
2223
+ */
2224
+ const runDoctor = (options) => {
2225
+ console.log(`${BOLD}envpkt doctor${RESET}\n`);
2226
+ const ageOk = ageVersion().fold(() => {
2227
+ bad("age ", "not found on PATH");
2228
+ console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
2229
+ return false;
2230
+ }, (version) => {
2231
+ ok("age ", version);
2232
+ return true;
2233
+ });
2234
+ const resolveOk = resolveConfigPath(options.config).fold(() => {
2235
+ warn("config", "no envpkt.toml found for this directory");
2236
+ return true;
2237
+ }, ({ path }) => {
2238
+ ok("config", path);
2239
+ return reportResolution(path);
2240
+ });
2241
+ console.log("");
2242
+ if (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
2243
+ else {
2244
+ console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
2245
+ process.exit(1);
2246
+ }
2247
+ };
2248
+ //#endregion
2249
+ //#region src/core/dotenv.ts
2250
+ const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
2251
+ /**
2252
+ * Quote a single value for dotenv output. Returns the value bare when safe,
2253
+ * otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
2254
+ * whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
2255
+ */
2256
+ const quoteDotenvValue = (value) => {
2257
+ if (value === "") return "";
2258
+ if (BARE_SAFE.test(value)) return value;
2259
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
2260
+ };
2261
+ const formatEntry = (entry, includeSecrets) => {
2262
+ if (entry.secret && !includeSecrets) return `# (secret value omitted — re-run without --no-secrets to include)\n${entry.name}=`;
2263
+ return `${entry.name}=${quoteDotenvValue(entry.value)}`;
2264
+ };
2265
+ /** Serialize entries to dotenv text (no trailing newline). */
2266
+ const formatDotenv = (entries, options) => {
2267
+ const includeSecrets = options?.includeSecrets ?? true;
2268
+ const body = entries.map((e) => formatEntry(e, includeSecrets)).join("\n");
2269
+ const header = options?.header;
2270
+ return header ? `${header}\n\n${body}` : body;
2271
+ };
2272
+ //#endregion
2273
+ //#region src/core/patterns.ts
2274
+ const EXCLUDED_VARS = Set$1([
2275
+ "PATH",
2276
+ "HOME",
2277
+ "USER",
2278
+ "SHELL",
2279
+ "TERM",
2280
+ "LANG",
2281
+ "LC_ALL",
2282
+ "LC_CTYPE",
2283
+ "DISPLAY",
2284
+ "EDITOR",
2285
+ "VISUAL",
2286
+ "PAGER",
2287
+ "HOSTNAME",
2288
+ "LOGNAME",
2289
+ "MAIL",
2290
+ "OLDPWD",
2291
+ "PWD",
2292
+ "SHLVL",
2293
+ "TMPDIR",
2294
+ "TZ",
2295
+ "XDG_CACHE_HOME",
2296
+ "XDG_CONFIG_HOME",
2297
+ "XDG_DATA_HOME",
2298
+ "XDG_RUNTIME_DIR",
2299
+ "XDG_SESSION_TYPE",
2300
+ "NODE_ENV",
2301
+ "NODE_PATH",
2302
+ "NODE_OPTIONS",
2303
+ "NVM_DIR",
1804
2304
  "NVM_BIN",
1805
2305
  "NVM_INC",
1806
2306
  "NVM_CD_FLAGS",
@@ -2295,626 +2795,279 @@ const VALUE_SHAPE_PATTERNS = [
2295
2795
  description: "GitHub server-to-server token"
2296
2796
  },
2297
2797
  {
2298
- prefix: "ghu_",
2299
- service: "github",
2300
- description: "GitHub user-to-server token"
2301
- },
2302
- {
2303
- prefix: "github_pat_",
2304
- service: "github",
2305
- description: "GitHub fine-grained PAT"
2306
- },
2307
- {
2308
- prefix: "xoxb-",
2309
- service: "slack",
2310
- description: "Slack bot token"
2311
- },
2312
- {
2313
- prefix: "xoxp-",
2314
- service: "slack",
2315
- description: "Slack user token"
2316
- },
2317
- {
2318
- prefix: "xoxa-",
2319
- service: "slack",
2320
- description: "Slack app token"
2321
- },
2322
- {
2323
- prefix: "xoxs-",
2324
- service: "slack",
2325
- description: "Slack legacy token"
2326
- },
2327
- {
2328
- prefix: "SG.",
2329
- service: "sendgrid",
2330
- description: "SendGrid API key"
2331
- },
2332
- {
2333
- prefix: "hf_",
2334
- service: "huggingface",
2335
- description: "Hugging Face token"
2336
- },
2337
- {
2338
- prefix: "r8_",
2339
- service: "replicate",
2340
- description: "Replicate API token"
2341
- },
2342
- {
2343
- prefix: "eyJ",
2344
- service: "jwt",
2345
- description: "JWT token"
2346
- },
2347
- {
2348
- prefix: "postgres://",
2349
- service: "postgresql",
2350
- description: "PostgreSQL connection string"
2351
- },
2352
- {
2353
- prefix: "postgresql://",
2354
- service: "postgresql",
2355
- description: "PostgreSQL connection string"
2356
- },
2357
- {
2358
- prefix: "mysql://",
2359
- service: "mysql",
2360
- description: "MySQL connection string"
2361
- },
2362
- {
2363
- prefix: "mongodb://",
2364
- service: "mongodb",
2365
- description: "MongoDB connection string"
2366
- },
2367
- {
2368
- prefix: "mongodb+srv://",
2369
- service: "mongodb",
2370
- description: "MongoDB SRV connection string"
2371
- },
2372
- {
2373
- prefix: "redis://",
2374
- service: "redis",
2375
- description: "Redis connection string"
2798
+ prefix: "ghu_",
2799
+ service: "github",
2800
+ description: "GitHub user-to-server token"
2376
2801
  },
2377
2802
  {
2378
- prefix: "rediss://",
2379
- service: "redis",
2380
- description: "Redis TLS connection string"
2803
+ prefix: "github_pat_",
2804
+ service: "github",
2805
+ description: "GitHub fine-grained PAT"
2381
2806
  },
2382
2807
  {
2383
- prefix: "amqp://",
2384
- service: "rabbitmq",
2385
- description: "RabbitMQ connection string"
2808
+ prefix: "xoxb-",
2809
+ service: "slack",
2810
+ description: "Slack bot token"
2386
2811
  },
2387
2812
  {
2388
- prefix: "amqps://",
2389
- service: "rabbitmq",
2390
- description: "RabbitMQ TLS connection string"
2391
- }
2392
- ];
2393
- /** Detect service from value prefix/shape */
2394
- const matchValueShape = (value) => {
2395
- return Option(VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix))).map((vp) => ({
2396
- service: vp.service,
2397
- description: vp.description
2398
- }));
2399
- };
2400
- /** Strip common suffixes and derive a service name from an env var name */
2401
- const deriveServiceFromName = (name) => {
2402
- const matchedSuffix = [
2403
- "_API_KEY",
2404
- "_SECRET_KEY",
2405
- "_ACCESS_KEY",
2406
- "_PRIVATE_KEY",
2407
- "_SIGNING_KEY",
2408
- "_AUTH_TOKEN",
2409
- "_ACCESS_TOKEN",
2410
- "_WEBHOOK_SECRET",
2411
- "_CONNECTION_STRING",
2412
- "_SECRET",
2413
- "_TOKEN",
2414
- "_PASSWORD",
2415
- "_PASS",
2416
- "_KEY",
2417
- "_DSN",
2418
- "_URL",
2419
- "_URI"
2420
- ].find((s) => name.endsWith(s));
2421
- return (matchedSuffix ? name.slice(0, -matchedSuffix.length) : name).toLowerCase().replace(/_/g, "-");
2422
- };
2423
- /** Match a single env var against all patterns */
2424
- const matchEnvVar = (name, value) => {
2425
- if (EXCLUDED_VARS.has(name)) return Option(void 0);
2426
- const exactMatch = EXACT_NAME_PATTERNS.find((p) => name === p.pattern);
2427
- if (exactMatch) return Option({
2428
- envVar: name,
2429
- value,
2430
- service: Option(exactMatch.service),
2431
- confidence: exactMatch.confidence,
2432
- matchedBy: `exact:${exactMatch.pattern}`
2433
- });
2434
- return matchValueShape(value).fold(() => {
2435
- return Option(SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix))).map((sp) => ({
2436
- envVar: name,
2437
- value,
2438
- service: Option(deriveServiceFromName(name)),
2439
- confidence: "medium",
2440
- matchedBy: `suffix:${sp.suffix}`
2441
- }));
2442
- }, (vm) => Option({
2443
- envVar: name,
2444
- value,
2445
- service: Option(vm.service),
2446
- confidence: "high",
2447
- matchedBy: `value:${vm.description}`
2448
- }));
2449
- };
2450
- /** Scan full env, sorted by confidence (high first) then alphabetically */
2451
- const scanEnv = (env) => {
2452
- const results = Object.entries(env).flatMap(([name, value]) => {
2453
- if (value === void 0 || value === "") return [];
2454
- return matchEnvVar(name, value).fold(() => [], (m) => [m]);
2455
- });
2456
- const confidenceOrder = {
2457
- high: 0,
2458
- medium: 1,
2459
- low: 2
2460
- };
2461
- results.sort((a, b) => {
2462
- const conf = confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
2463
- if (conf !== 0) return conf;
2464
- return a.envVar.localeCompare(b.envVar);
2465
- });
2466
- return results;
2467
- };
2468
- //#endregion
2469
- //#region src/core/env.ts
2470
- /** Scan env for credentials, returning structured results */
2471
- const envScan = (env, options) => {
2472
- const allMatches = scanEnv(env);
2473
- const discovered = options?.includeUnknown ? allMatches : allMatches.filter((m) => m.service.isSome());
2474
- const total_scanned = Object.keys(env).length;
2475
- const high_confidence = discovered.filter((m) => m.confidence === "high").length;
2476
- const medium_confidence = discovered.filter((m) => m.confidence === "medium").length;
2477
- const low_confidence = discovered.filter((m) => m.confidence === "low").length;
2478
- return {
2479
- discovered: List(discovered),
2480
- total_scanned,
2481
- high_confidence,
2482
- medium_confidence,
2483
- low_confidence
2484
- };
2485
- };
2486
- const parseAliasRef = (raw, expectedKind) => {
2487
- const match = raw.match(/^(secret|env)\.(.+)$/);
2488
- if (match?.[1] !== expectedKind) return Option(void 0);
2489
- return Option(match[2]);
2490
- };
2491
- /** Bidirectional drift detection between config and live environment */
2492
- const envCheck = (config, env) => {
2493
- const secretEntries = config.secret ?? {};
2494
- const metaKeys = Object.keys(secretEntries);
2495
- const metaKeysSet = Set$1(metaKeys);
2496
- const namer = makeEnvNamer(config);
2497
- const envDefaultsForWire = config.env ?? {};
2498
- const secretWire = (key) => namer(key, secretEntries[key]?.namespace);
2499
- const envWire = (key) => namer(key, envDefaultsForWire[key]?.namespace);
2500
- const isPresentAt = (wire) => env[wire] !== void 0 && env[wire] !== "";
2501
- const isSecretPresent = (key) => {
2502
- if (isPresentAt(secretWire(key))) return true;
2503
- const meta = secretEntries[key];
2504
- if (meta?.from_key === void 0) return false;
2505
- return parseAliasRef(meta.from_key, "secret").fold(() => false, (targetKey) => isPresentAt(secretWire(targetKey)));
2506
- };
2507
- const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
2508
- const present = isSecretPresent(key);
2509
- return {
2510
- envVar: key,
2511
- service: Option(meta.service),
2512
- status: present ? "tracked" : "missing_from_env",
2513
- confidence: Option(void 0)
2514
- };
2515
- });
2516
- const envDefaults = config.env ?? {};
2517
- const isEnvPresent = (key) => {
2518
- if (isPresentAt(envWire(key))) return true;
2519
- const meta = envDefaults[key];
2520
- if (meta?.from_key === void 0) return false;
2521
- return parseAliasRef(meta.from_key, "env").fold(() => false, (targetKey) => isPresentAt(envWire(targetKey)));
2522
- };
2523
- const envDefaultEntries = Object.keys(envDefaults).filter((key) => !metaKeysSet.has(key)).map((key) => {
2524
- const present = isEnvPresent(key);
2525
- return {
2526
- envVar: key,
2527
- service: Option(void 0),
2528
- status: present ? "tracked" : "missing_from_env",
2529
- confidence: Option(void 0)
2530
- };
2531
- });
2532
- const trackedKeys = Set$1([...metaKeys.map(secretWire), ...envDefaultEntries.map((e) => envWire(e.envVar))]);
2533
- const untrackedEntries = scanEnv(env).filter((match) => !trackedKeys.has(match.envVar)).map((match) => ({
2534
- envVar: match.envVar,
2535
- service: match.service,
2536
- status: "untracked",
2537
- confidence: Option(match.confidence)
2538
- }));
2539
- const entries = [
2540
- ...secretDriftEntries,
2541
- ...envDefaultEntries,
2542
- ...untrackedEntries
2543
- ];
2544
- const tracked_and_present = entries.filter((e) => e.status === "tracked").length;
2545
- const missing_from_env = entries.filter((e) => e.status === "missing_from_env").length;
2546
- const untracked_credentials = entries.filter((e) => e.status === "untracked").length;
2547
- return {
2548
- entries: List(entries),
2549
- tracked_and_present,
2550
- missing_from_env,
2551
- untracked_credentials,
2552
- is_clean: missing_from_env === 0 && untracked_credentials === 0
2553
- };
2554
- };
2555
- const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2556
- /** Generate TOML [secret.*] blocks from scan results, mirroring init.ts pattern */
2557
- const generateTomlFromScan = (matches) => {
2558
- return matches.map((match) => {
2559
- const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
2560
- return `[secret.${match.envVar}]
2561
- service = "${svc}"
2562
- # purpose = "" # Why: what this secret enables
2563
- # capabilities = [] # What operations this grants
2564
- created = "${todayIso$1()}"
2565
- # expires = "" # When: YYYY-MM-DD expiration date
2566
- # rotation_url = "" # URL for rotation procedure
2567
- # source = "" # Where the value originates (e.g. vault, ci)
2568
- # tags = {}
2569
- `;
2570
- }).join("\n");
2571
- };
2572
- //#endregion
2573
- //#region src/core/toml-edit.ts
2574
- const SECTION_RE = /^\[.+\]\s*$/;
2575
- const MULTILINE_OPEN = "\"\"\"";
2576
- const scanSectionBoundary = (state, line, i) => {
2577
- if (state.done) return state;
2578
- if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2579
- ...state,
2580
- inMultiline: false
2581
- } : state;
2582
- if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2583
- ...state,
2584
- inMultiline: true
2585
- } : state;
2586
- return SECTION_RE.test(line) ? {
2587
- ...state,
2588
- end: i,
2589
- done: true
2590
- } : state;
2591
- };
2592
- /**
2593
- * Find the line range [start, end) of a TOML section by its header string.
2594
- * The range includes the header line through to (but not including) the next section header or EOF.
2595
- * Handles multiline `"""..."""` values when scanning for section boundaries.
2596
- */
2597
- const findSectionRange = (lines, sectionHeader) => {
2598
- const start = lines.findIndex((l) => l.trim() === sectionHeader);
2599
- if (start === -1) return void 0;
2600
- const initial = {
2601
- end: lines.length,
2602
- inMultiline: false,
2603
- done: false
2604
- };
2605
- return {
2606
- start,
2607
- end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
2608
- };
2609
- };
2610
- /** Check whether a section header exists in the raw TOML */
2611
- const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
2612
- /**
2613
- * Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
2614
- * Strips trailing blank lines left behind.
2615
- */
2616
- const removeSection = (raw, sectionHeader) => {
2617
- const lines = raw.split("\n");
2618
- const range = findSectionRange(lines, sectionHeader);
2619
- if (!range) return Either.left({
2620
- _tag: "SectionNotFound",
2621
- section: sectionHeader
2622
- });
2623
- const after = lines.slice(range.end);
2624
- const beforeAll = lines.slice(0, range.start);
2625
- const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
2626
- const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
2627
- return Either.right(result);
2813
+ prefix: "xoxp-",
2814
+ service: "slack",
2815
+ description: "Slack user token"
2816
+ },
2817
+ {
2818
+ prefix: "xoxa-",
2819
+ service: "slack",
2820
+ description: "Slack app token"
2821
+ },
2822
+ {
2823
+ prefix: "xoxs-",
2824
+ service: "slack",
2825
+ description: "Slack legacy token"
2826
+ },
2827
+ {
2828
+ prefix: "SG.",
2829
+ service: "sendgrid",
2830
+ description: "SendGrid API key"
2831
+ },
2832
+ {
2833
+ prefix: "hf_",
2834
+ service: "huggingface",
2835
+ description: "Hugging Face token"
2836
+ },
2837
+ {
2838
+ prefix: "r8_",
2839
+ service: "replicate",
2840
+ description: "Replicate API token"
2841
+ },
2842
+ {
2843
+ prefix: "eyJ",
2844
+ service: "jwt",
2845
+ description: "JWT token"
2846
+ },
2847
+ {
2848
+ prefix: "postgres://",
2849
+ service: "postgresql",
2850
+ description: "PostgreSQL connection string"
2851
+ },
2852
+ {
2853
+ prefix: "postgresql://",
2854
+ service: "postgresql",
2855
+ description: "PostgreSQL connection string"
2856
+ },
2857
+ {
2858
+ prefix: "mysql://",
2859
+ service: "mysql",
2860
+ description: "MySQL connection string"
2861
+ },
2862
+ {
2863
+ prefix: "mongodb://",
2864
+ service: "mongodb",
2865
+ description: "MongoDB connection string"
2866
+ },
2867
+ {
2868
+ prefix: "mongodb+srv://",
2869
+ service: "mongodb",
2870
+ description: "MongoDB SRV connection string"
2871
+ },
2872
+ {
2873
+ prefix: "redis://",
2874
+ service: "redis",
2875
+ description: "Redis connection string"
2876
+ },
2877
+ {
2878
+ prefix: "rediss://",
2879
+ service: "redis",
2880
+ description: "Redis TLS connection string"
2881
+ },
2882
+ {
2883
+ prefix: "amqp://",
2884
+ service: "rabbitmq",
2885
+ description: "RabbitMQ connection string"
2886
+ },
2887
+ {
2888
+ prefix: "amqps://",
2889
+ service: "rabbitmq",
2890
+ description: "RabbitMQ TLS connection string"
2891
+ }
2892
+ ];
2893
+ /** Detect service from value prefix/shape */
2894
+ const matchValueShape = (value) => {
2895
+ return Option(VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix))).map((vp) => ({
2896
+ service: vp.service,
2897
+ description: vp.description
2898
+ }));
2628
2899
  };
2629
- /**
2630
- * Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
2631
- * Errors if old doesn't exist or new already exists.
2632
- */
2633
- const renameSection = (raw, oldHeader, newHeader) => {
2634
- const lines = raw.split("\n");
2635
- if (!sectionExists(lines, oldHeader)) return Either.left({
2636
- _tag: "SectionNotFound",
2637
- section: oldHeader
2638
- });
2639
- if (sectionExists(lines, newHeader)) return Either.left({
2640
- _tag: "SectionAlreadyExists",
2641
- section: newHeader
2642
- });
2643
- const result = lines.map((line) => line.trim() === oldHeader ? newHeader : line).join("\n");
2644
- return Either.right(result);
2900
+ /** Strip common suffixes and derive a service name from an env var name */
2901
+ const deriveServiceFromName = (name) => {
2902
+ const matchedSuffix = [
2903
+ "_API_KEY",
2904
+ "_SECRET_KEY",
2905
+ "_ACCESS_KEY",
2906
+ "_PRIVATE_KEY",
2907
+ "_SIGNING_KEY",
2908
+ "_AUTH_TOKEN",
2909
+ "_ACCESS_TOKEN",
2910
+ "_WEBHOOK_SECRET",
2911
+ "_CONNECTION_STRING",
2912
+ "_SECRET",
2913
+ "_TOKEN",
2914
+ "_PASSWORD",
2915
+ "_PASS",
2916
+ "_KEY",
2917
+ "_DSN",
2918
+ "_URL",
2919
+ "_URI"
2920
+ ].find((s) => name.endsWith(s));
2921
+ return (matchedSuffix ? name.slice(0, -matchedSuffix.length) : name).toLowerCase().replace(/_/g, "-");
2645
2922
  };
2646
- /**
2647
- * Update, add, or remove fields within an existing TOML section.
2648
- * - A string value replaces or adds the field
2649
- * - A null value removes the field
2650
- * Does NOT re-serialize — operates on raw text lines.
2651
- */
2652
- const updateSectionFields = (raw, sectionHeader, updates) => {
2653
- const lines = raw.split("\n");
2654
- const range = findSectionRange(lines, sectionHeader);
2655
- if (!range) return Either.left({
2656
- _tag: "SectionNotFound",
2657
- section: sectionHeader
2923
+ /** Match a single env var against all patterns */
2924
+ const matchEnvVar = (name, value) => {
2925
+ if (EXCLUDED_VARS.has(name)) return Option(void 0);
2926
+ const exactMatch = EXACT_NAME_PATTERNS.find((p) => name === p.pattern);
2927
+ if (exactMatch) return Option({
2928
+ envVar: name,
2929
+ value,
2930
+ service: Option(exactMatch.service),
2931
+ confidence: exactMatch.confidence,
2932
+ matchedBy: `exact:${exactMatch.pattern}`
2658
2933
  });
2659
- const before = lines.slice(0, range.start + 1);
2660
- const after = lines.slice(range.end);
2661
- const sectionBody = lines.slice(range.start + 1, range.end);
2662
- const findClosingMultiline = (fromIdx) => {
2663
- const idx = sectionBody.findIndex((l, j) => j > fromIdx && l.includes(MULTILINE_OPEN));
2664
- return idx === -1 ? sectionBody.length : idx;
2665
- };
2666
- const initial = {
2667
- remaining: [],
2668
- updatedKeys: Set$1.empty(),
2669
- skipUntil: -1
2670
- };
2671
- const step = (state, line, i) => {
2672
- if (i <= state.skipUntil) return state;
2673
- const eqIdx = line.indexOf("=");
2674
- const isFieldLine = eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[");
2675
- const key = isFieldLine ? line.slice(0, eqIdx).trim() : "";
2676
- if (isFieldLine && key in updates) {
2677
- const afterEquals = line.slice(eqIdx + 1).trim();
2678
- const skipUntil = afterEquals.includes(MULTILINE_OPEN) && (afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? findClosingMultiline(i) : state.skipUntil;
2679
- const updatedKeys = state.updatedKeys.add(key);
2680
- const value = updates[key];
2681
- if (value === null) return {
2682
- ...state,
2683
- updatedKeys,
2684
- skipUntil
2685
- };
2686
- return {
2687
- remaining: [...state.remaining, `${key} = ${value}`],
2688
- updatedKeys,
2689
- skipUntil
2690
- };
2691
- }
2692
- return {
2693
- ...state,
2694
- remaining: [...state.remaining, line]
2695
- };
2696
- };
2697
- const final = List(sectionBody).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]));
2698
- const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !final.updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
2699
- const result = [
2700
- ...before,
2701
- ...final.remaining,
2702
- ...newFields,
2703
- ...after
2704
- ].join("\n");
2705
- return Either.right(result);
2706
- };
2707
- /**
2708
- * Append a new TOML section block to the end of the file.
2709
- * Ensures proper spacing (double newline before the block).
2710
- */
2711
- const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
2712
- const ENV_HEADER_RE = /^\[env\.(.+)\]\s*$/;
2713
- const SECRET_HEADER_RE = /^\[secret\.(.+)\]\s*$/;
2714
- const ANY_HEADER_RE = /^\[.+\]\s*$/;
2715
- /**
2716
- * Find the end (exclusive) of a section starting at `start`, respecting
2717
- * multiline `"""..."""` values so the scanner does not mistake content inside
2718
- * a multiline string for a section header.
2719
- */
2720
- const findSectionEnd = (lines, start) => {
2721
- const initial = {
2722
- end: lines.length,
2723
- inMultiline: false,
2724
- done: false
2725
- };
2726
- return List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end;
2727
- };
2728
- /**
2729
- * Walking backwards from `headerIdx`, return the index of the first line of the
2730
- * "doc block" that should travel with this section. A doc block is a contiguous
2731
- * run of `#`-comment lines *immediately* above the header (no blank line
2732
- * between). A blank line acts as a paragraph break and stops the walk — so
2733
- * `# Some heading\n\n[secret.X]` does NOT attach the heading to `[secret.X]`.
2734
- */
2735
- const findPreambleStart = (lines, headerIdx) => {
2736
- const stopOffset = [...lines.slice(0, headerIdx)].reverse().findIndex((l) => !l.trim().startsWith("#"));
2737
- return stopOffset === -1 ? 0 : headerIdx - stopOffset;
2934
+ return matchValueShape(value).fold(() => {
2935
+ return Option(SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix))).map((sp) => ({
2936
+ envVar: name,
2937
+ value,
2938
+ service: Option(deriveServiceFromName(name)),
2939
+ confidence: "medium",
2940
+ matchedBy: `suffix:${sp.suffix}`
2941
+ }));
2942
+ }, (vm) => Option({
2943
+ envVar: name,
2944
+ value,
2945
+ service: Option(vm.service),
2946
+ confidence: "high",
2947
+ matchedBy: `value:${vm.description}`
2948
+ }));
2738
2949
  };
2739
- const classifyHeader = (line, idx) => {
2740
- if (!ANY_HEADER_RE.test(line)) return Option.none();
2741
- const envMatch = line.match(ENV_HEADER_RE);
2742
- if (envMatch) return Option({
2743
- idx,
2744
- kind: "env",
2745
- key: envMatch[1]
2746
- });
2747
- const secretMatch = line.match(SECRET_HEADER_RE);
2748
- if (secretMatch) return Option({
2749
- idx,
2750
- kind: "secret",
2751
- key: secretMatch[1]
2950
+ /** Scan full env, sorted by confidence (high first) then alphabetically */
2951
+ const scanEnv = (env) => {
2952
+ const results = Object.entries(env).flatMap(([name, value]) => {
2953
+ if (value === void 0 || value === "") return [];
2954
+ return matchEnvVar(name, value).fold(() => [], (m) => [m]);
2752
2955
  });
2753
- return Option({
2754
- idx,
2755
- kind: "other",
2756
- key: ""
2956
+ const confidenceOrder = {
2957
+ high: 0,
2958
+ medium: 1,
2959
+ low: 2
2960
+ };
2961
+ results.sort((a, b) => {
2962
+ const conf = confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
2963
+ if (conf !== 0) return conf;
2964
+ return a.envVar.localeCompare(b.envVar);
2757
2965
  });
2966
+ return results;
2758
2967
  };
2759
- const scanHeader = (state, line, idx) => {
2760
- if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2761
- ...state,
2762
- inMultiline: false
2763
- } : state;
2764
- if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2765
- ...state,
2766
- inMultiline: true
2767
- } : state;
2768
- return classifyHeader(line, idx).fold(() => state, (header) => ({
2769
- ...state,
2770
- headers: [...state.headers, header]
2771
- }));
2968
+ //#endregion
2969
+ //#region src/core/env.ts
2970
+ /** Scan env for credentials, returning structured results */
2971
+ const envScan = (env, options) => {
2972
+ const allMatches = scanEnv(env);
2973
+ const discovered = options?.includeUnknown ? allMatches : allMatches.filter((m) => m.service.isSome());
2974
+ const total_scanned = Object.keys(env).length;
2975
+ const high_confidence = discovered.filter((m) => m.confidence === "high").length;
2976
+ const medium_confidence = discovered.filter((m) => m.confidence === "medium").length;
2977
+ const low_confidence = discovered.filter((m) => m.confidence === "low").length;
2978
+ return {
2979
+ discovered: List(discovered),
2980
+ total_scanned,
2981
+ high_confidence,
2982
+ medium_confidence,
2983
+ low_confidence
2984
+ };
2772
2985
  };
2773
- const partitionSections = (raw) => {
2774
- const lines = raw.split("\n");
2775
- const { headers } = List(lines).zipWithIndex().foldLeft({
2776
- headers: [],
2777
- inMultiline: false
2778
- })((state, entry) => scanHeader(state, entry[0], entry[1]));
2779
- const envSecretHeaders = headers.filter((h) => h.kind === "env" || h.kind === "secret");
2780
- const trueBodyRange = (headerIdx) => {
2781
- const naiveEnd = findSectionEnd(lines, headerIdx);
2782
- const lastContent = lines.slice(headerIdx, naiveEnd).findLastIndex((l) => {
2783
- const t = l.trim();
2784
- return t !== "" && !t.startsWith("#");
2785
- });
2786
- return {
2787
- start: headerIdx,
2788
- end: lastContent === -1 ? headerIdx + 1 : headerIdx + lastContent + 1
2789
- };
2986
+ const parseAliasRef = (raw, expectedKind) => {
2987
+ const match = raw.match(/^(secret|env)\.(.+)$/);
2988
+ if (match?.[1] !== expectedKind) return Option(void 0);
2989
+ return Option(match[2]);
2990
+ };
2991
+ /** Bidirectional drift detection between config and live environment */
2992
+ const envCheck = (config, env) => {
2993
+ const secretEntries = config.secret ?? {};
2994
+ const metaKeys = Object.keys(secretEntries);
2995
+ const metaKeysSet = Set$1(metaKeys);
2996
+ const namer = makeEnvNamer(config);
2997
+ const envDefaultsForWire = config.env ?? {};
2998
+ const secretWire = (key) => namer(key, secretEntries[key]?.namespace);
2999
+ const envWire = (key) => namer(key, envDefaultsForWire[key]?.namespace);
3000
+ const isPresentAt = (wire) => env[wire] !== void 0 && env[wire] !== "";
3001
+ const isSecretPresent = (key) => {
3002
+ if (isPresentAt(secretWire(key))) return true;
3003
+ const meta = secretEntries[key];
3004
+ if (meta?.from_key === void 0) return false;
3005
+ return parseAliasRef(meta.from_key, "secret").fold(() => false, (targetKey) => isPresentAt(secretWire(targetKey)));
2790
3006
  };
2791
- const sections = envSecretHeaders.map((h) => {
2792
- const headerIdx = h.idx;
2793
- const preambleStart = findPreambleStart(lines, headerIdx);
2794
- const body = lines.slice(headerIdx, trueBodyRange(headerIdx).end);
2795
- const preamble = lines.slice(preambleStart, headerIdx);
3007
+ const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
3008
+ const present = isSecretPresent(key);
2796
3009
  return {
2797
- kind: h.kind,
2798
- key: h.key,
2799
- body,
2800
- preamble
3010
+ envVar: key,
3011
+ service: Option(meta.service),
3012
+ status: present ? "tracked" : "missing_from_env",
3013
+ confidence: Option(void 0)
2801
3014
  };
2802
3015
  });
2803
- const claimedRanges = envSecretHeaders.map((h) => {
2804
- const headerIdx = h.idx;
3016
+ const envDefaults = config.env ?? {};
3017
+ const isEnvPresent = (key) => {
3018
+ if (isPresentAt(envWire(key))) return true;
3019
+ const meta = envDefaults[key];
3020
+ if (meta?.from_key === void 0) return false;
3021
+ return parseAliasRef(meta.from_key, "env").fold(() => false, (targetKey) => isPresentAt(envWire(targetKey)));
3022
+ };
3023
+ const envDefaultEntries = Object.keys(envDefaults).filter((key) => !metaKeysSet.has(key)).map((key) => {
3024
+ const present = isEnvPresent(key);
2805
3025
  return {
2806
- start: findPreambleStart(lines, headerIdx),
2807
- end: trueBodyRange(headerIdx).end
3026
+ envVar: key,
3027
+ service: Option(void 0),
3028
+ status: present ? "tracked" : "missing_from_env",
3029
+ confidence: Option(void 0)
2808
3030
  };
2809
3031
  });
2810
- const isClaimed = (idx) => claimedRanges.some((r) => idx >= r.start && idx < r.end);
3032
+ const trackedKeys = Set$1([...metaKeys.map(secretWire), ...envDefaultEntries.map((e) => envWire(e.envVar))]);
3033
+ const untrackedEntries = scanEnv(env).filter((match) => !trackedKeys.has(match.envVar)).map((match) => ({
3034
+ envVar: match.envVar,
3035
+ service: match.service,
3036
+ status: "untracked",
3037
+ confidence: Option(match.confidence)
3038
+ }));
3039
+ const entries = [
3040
+ ...secretDriftEntries,
3041
+ ...envDefaultEntries,
3042
+ ...untrackedEntries
3043
+ ];
3044
+ const tracked_and_present = entries.filter((e) => e.status === "tracked").length;
3045
+ const missing_from_env = entries.filter((e) => e.status === "missing_from_env").length;
3046
+ const untracked_credentials = entries.filter((e) => e.status === "untracked").length;
2811
3047
  return {
2812
- preambleLines: lines.map((l, idx) => isClaimed(idx) ? null : l).filter((l) => l !== null),
2813
- envSections: sections.filter((s) => s.kind === "env"),
2814
- secretSections: sections.filter((s) => s.kind === "secret")
2815
- };
2816
- };
2817
- const emitSection = (s) => {
2818
- return `${s.preamble.length > 0 ? `${s.preamble.join("\n")}\n` : ""}${s.body.join("\n")}`;
2819
- };
2820
- /**
2821
- * Reformat a TOML config with `[env.*]` and `[secret.*]` sections grouped and
2822
- * alphabetized. Top-level content (version key, `[identity]`, `[lifecycle]`,
2823
- * `[callbacks]`, `[tools]`, etc.) stays in its original position. Comment
2824
- * doc-blocks immediately above a section header travel with that section.
2825
- *
2826
- * Pure — no I/O. Returns the raw input unchanged when there is no env or
2827
- * secret content to reorder.
2828
- */
2829
- const sortConfigToml = (raw) => {
2830
- const { preambleLines, envSections, secretSections } = partitionSections(raw);
2831
- if (envSections.length === 0 && secretSections.length === 0) return raw;
2832
- const sortedEnv = [...envSections].sort((a, b) => a.key.localeCompare(b.key));
2833
- const sortedSecret = [...secretSections].sort((a, b) => a.key.localeCompare(b.key));
2834
- const trimTrailing = (xs) => {
2835
- const lastNonBlank = xs.findLastIndex((l) => l.trim() !== "");
2836
- return lastNonBlank === -1 ? [] : xs.slice(0, lastNonBlank + 1);
3048
+ entries: List(entries),
3049
+ tracked_and_present,
3050
+ missing_from_env,
3051
+ untracked_credentials,
3052
+ is_clean: missing_from_env === 0 && untracked_credentials === 0
2837
3053
  };
2838
- const collapseBlanks = (xs) => xs.reduce((acc, line) => {
2839
- const isBlank = line.trim() === "";
2840
- const prevBlank = acc.length > 0 && acc[acc.length - 1].trim() === "";
2841
- if (isBlank && prevBlank) return acc;
2842
- return [...acc, line];
2843
- }, []);
2844
- const preambleTrimmed = collapseBlanks(trimTrailing(preambleLines));
2845
- const parts = [];
2846
- if (preambleTrimmed.length > 0) parts.push(preambleTrimmed.join("\n"));
2847
- if (sortedEnv.length > 0) parts.push(sortedEnv.map(emitSection).join("\n\n"));
2848
- if (sortedSecret.length > 0) parts.push(sortedSecret.map(emitSection).join("\n\n"));
2849
- return `${parts.join("\n\n")}\n`;
2850
- };
2851
- //#endregion
2852
- //#region src/core/validate.ts
2853
- /**
2854
- * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
2855
- *
2856
- * Used by write-path CLI commands to verify the post-edit file would still be
2857
- * structurally valid before persisting. Catalog resolution is intentionally
2858
- * excluded — catalog issues depend on external files, not on the local edit,
2859
- * and `envpkt validate` covers them as a separate explicit check.
2860
- */
2861
- const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
2862
- /** Human-readable one-liner for any ValidationError tag. */
2863
- const formatValidationError = (err) => {
2864
- switch (err._tag) {
2865
- case "FileNotFound": return `Config file not found: ${err.path}`;
2866
- case "ParseError": return `TOML parse error: ${err.message}`;
2867
- case "ValidationError": return `Schema validation failed: ${err.errors.toArray().join("; ")}`;
2868
- case "ReadError": return `Read error: ${err.message}`;
2869
- case "AliasInvalidSyntax":
2870
- case "AliasTargetMissing":
2871
- case "AliasSelfReference":
2872
- case "AliasChained":
2873
- case "AliasCrossType":
2874
- case "AliasValueConflict": return formatAliasError(err);
2875
- }
2876
- };
2877
- //#endregion
2878
- //#region src/cli/write-gate.ts
2879
- /**
2880
- * Run structural validation against an in-memory TOML string.
2881
- * On failure, prints the error, leaves the file untouched, and exits with code 1.
2882
- * On success, returns — caller is responsible for writing.
2883
- *
2884
- * Use this when the write step has bespoke logic (e.g. wraps writeFileSync in Try,
2885
- * has multi-line post-write output). Otherwise prefer `writeIfValid`.
2886
- */
2887
- const validateOrExit = (updated) => {
2888
- validateRawConfig(updated).fold((err) => {
2889
- console.error(`${RED}Error:${RESET} Aborted — change would produce an invalid config:`);
2890
- console.error(` ${formatValidationError(err)}`);
2891
- console.error(`${DIM}File unchanged.${RESET}`);
2892
- process.exit(1);
2893
- }, () => {});
2894
- };
2895
- /**
2896
- * Validate then persist. Most mutating CLI commands use this — it bundles the
2897
- * validate-or-exit gate with the writeFileSync + success log so each call site
2898
- * stays two lines instead of five.
2899
- */
2900
- const writeIfValid = (configPath, updated, successMsg) => {
2901
- validateOrExit(updated);
2902
- writeFileSync(configPath, updated, "utf-8");
2903
- console.log(successMsg);
2904
3054
  };
2905
- /**
2906
- * Validate then preview (no write) the `--dry-run` counterpart of `writeIfValid`.
2907
- * Runs the same structural validation the real write would, so a dry-run can never
2908
- * show a result that the actual write would reject. On invalid output it prints the
2909
- * same error and exits 1, exactly as the write path does.
2910
- *
2911
- * `display` lets callers preview a focused slice (e.g. just the new block for `add`)
2912
- * while still validating the full resulting config.
2913
- */
2914
- const previewIfValid = (updated, display) => {
2915
- validateOrExit(updated);
2916
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
2917
- console.log(display ?? updated);
3055
+ const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3056
+ /** Generate TOML [secret.*] blocks from scan results, mirroring init.ts pattern */
3057
+ const generateTomlFromScan = (matches) => {
3058
+ return matches.map((match) => {
3059
+ const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
3060
+ return `[secret.${match.envVar}]
3061
+ service = "${svc}"
3062
+ # purpose = "" # Why: what this secret enables
3063
+ # capabilities = [] # What operations this grants
3064
+ created = "${todayIso$1()}"
3065
+ # expires = "" # When: YYYY-MM-DD expiration date
3066
+ # rotation_url = "" # URL for rotation procedure
3067
+ # source = "" # Where the value originates (e.g. vault, ci)
3068
+ # tags = {}
3069
+ `;
3070
+ }).join("\n");
2918
3071
  };
2919
3072
  //#endregion
2920
3073
  //#region src/cli/commands/env.ts
@@ -5230,6 +5383,9 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
5230
5383
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
5231
5384
  runUpgrade();
5232
5385
  });
5386
+ program.command("copy").description("Copy a secret or env entry from one config to another (auto unseal → reseal for secrets)").argument("<key>", "The secret or env key to copy").option("--from <path>", "Source config path (default: resolved config for this directory)").option("--to <path>", "Destination config path (default: resolved config for this directory)").option("--as <newKey>", "Copy under a different key name in the destination").option("--force", "Overwrite the entry if it already exists in the destination").option("--dry-run", "Preview the change without writing").action((key, options) => {
5387
+ runCopy(key, options);
5388
+ });
5233
5389
  program.command("diff").description("Compare two envpkt.toml configs by their secret/env entries (keys + metadata)").argument("<a>", "First config path").argument("<b>", "Second config path").option("--format <format>", "Output format: text | json", "text").option("--exit-code", "Exit non-zero when the configs differ (for CI drift gates)").action((a, b, options) => {
5234
5390
  runDiff(a, b, options);
5235
5391
  });