envpkt 0.13.2 → 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 ${{
@@ -1611,105 +1611,689 @@ const bootSafe = (options) => {
1611
1611
  }));
1612
1612
  };
1613
1613
  //#endregion
1614
- //#region src/cli/commands/doctor.ts
1615
- const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM}${detail}${RESET}`);
1616
- const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
1617
- const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
1618
- /** Print the resolution/key check, returning whether it passed. */
1619
- const reportResolution = (configPath) => bootSafe({
1620
- configPath,
1621
- inject: false,
1622
- warnOnly: true
1623
- }).fold((err) => {
1624
- if (err._tag === "SealKeyUnavailable") {
1625
- bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
1626
- err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
1627
- } else bad("config", `${err._tag}`);
1628
- return false;
1629
- }, (boot) => {
1630
- const resolved = Object.keys(boot.secrets).length;
1631
- ok("secrets", `${resolved} resolved, ${boot.skipped.length} skipped`);
1632
- const auditColor = boot.audit.status === "healthy" ? GREEN : YELLOW;
1633
- console.log(` ${auditColor}•${RESET} audit ${DIM}${boot.audit.status}${RESET}`);
1634
- return true;
1635
- });
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
+ };
1636
1622
  /**
1637
- * One-shot environment check: is age installed, is a config resolvable, and do its sealed
1638
- * 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).
1639
1628
  */
1640
- const runDoctor = (options) => {
1641
- console.log(`${BOLD}envpkt doctor${RESET}\n`);
1642
- const ageOk = ageVersion().fold(() => {
1643
- bad("age ", "not found on PATH");
1644
- console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
1645
- return false;
1646
- }, (version) => {
1647
- ok("age ", version);
1648
- return true;
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`;
1672
+ };
1673
+ //#endregion
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
+ };
1693
+ /**
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.
1697
+ */
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
+ };
1710
+ };
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
1649
1723
  });
1650
- const resolveOk = resolveConfigPath(options.config).fold(() => {
1651
- warn("config", "no envpkt.toml found for this directory");
1652
- return true;
1653
- }, ({ path }) => {
1654
- ok("config", path);
1655
- return reportResolution(path);
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);
1729
+ };
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
1656
1739
  });
1657
- console.log("");
1658
- if (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
1659
- else {
1660
- console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
1661
- process.exit(1);
1662
- }
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);
1663
1746
  };
1664
- //#endregion
1665
- //#region src/core/dotenv.ts
1666
- const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1667
1747
  /**
1668
- * Quote a single value for dotenv output. Returns the value bare when safe,
1669
- * otherwise double-quoted with POSIX-shell-quote escaping (`\`, `"`, `$`) and
1670
- * whitespace collapsed to single-line escapes (`\n`, `\r`, `\t`) for portability.
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.
1671
1752
  */
1672
- const quoteDotenvValue = (value) => {
1673
- if (value === "") return "";
1674
- if (BARE_SAFE.test(value)) return value;
1675
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
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);
1676
1807
  };
1677
- const formatEntry = (entry, includeSecrets) => {
1678
- if (entry.secret && !includeSecrets) return `# (secret value omitted re-run without --no-secrets to include)\n${entry.name}=`;
1679
- return `${entry.name}=${quoteDotenvValue(entry.value)}`;
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;
1680
1828
  };
1681
- /** Serialize entries to dotenv text (no trailing newline). */
1682
- const formatDotenv = (entries, options) => {
1683
- const includeSecrets = options?.includeSecrets ?? true;
1684
- const body = entries.map((e) => formatEntry(e, includeSecrets)).join("\n");
1685
- const header = options?.header;
1686
- return header ? `${header}\n\n${body}` : body;
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;
1687
1839
  };
1688
- //#endregion
1689
- //#region src/core/patterns.ts
1690
- const EXCLUDED_VARS = Set$1([
1691
- "PATH",
1692
- "HOME",
1693
- "USER",
1694
- "SHELL",
1695
- "TERM",
1696
- "LANG",
1697
- "LC_ALL",
1698
- "LC_CTYPE",
1699
- "DISPLAY",
1700
- "EDITOR",
1701
- "VISUAL",
1702
- "PAGER",
1703
- "HOSTNAME",
1704
- "LOGNAME",
1705
- "MAIL",
1706
- "OLDPWD",
1707
- "PWD",
1708
- "SHLVL",
1709
- "TMPDIR",
1710
- "TZ",
1711
- "XDG_CACHE_HOME",
1712
- "XDG_CONFIG_HOME",
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",
1713
2297
  "XDG_DATA_HOME",
1714
2298
  "XDG_RUNTIME_DIR",
1715
2299
  "XDG_SESSION_TYPE",
@@ -2212,625 +2796,278 @@ const VALUE_SHAPE_PATTERNS = [
2212
2796
  },
2213
2797
  {
2214
2798
  prefix: "ghu_",
2215
- service: "github",
2216
- description: "GitHub user-to-server token"
2217
- },
2218
- {
2219
- prefix: "github_pat_",
2220
- service: "github",
2221
- description: "GitHub fine-grained PAT"
2222
- },
2223
- {
2224
- prefix: "xoxb-",
2225
- service: "slack",
2226
- description: "Slack bot token"
2227
- },
2228
- {
2229
- prefix: "xoxp-",
2230
- service: "slack",
2231
- description: "Slack user token"
2232
- },
2233
- {
2234
- prefix: "xoxa-",
2235
- service: "slack",
2236
- description: "Slack app token"
2237
- },
2238
- {
2239
- prefix: "xoxs-",
2240
- service: "slack",
2241
- description: "Slack legacy token"
2242
- },
2243
- {
2244
- prefix: "SG.",
2245
- service: "sendgrid",
2246
- description: "SendGrid API key"
2247
- },
2248
- {
2249
- prefix: "hf_",
2250
- service: "huggingface",
2251
- description: "Hugging Face token"
2252
- },
2253
- {
2254
- prefix: "r8_",
2255
- service: "replicate",
2256
- description: "Replicate API token"
2257
- },
2258
- {
2259
- prefix: "eyJ",
2260
- service: "jwt",
2261
- description: "JWT token"
2262
- },
2263
- {
2264
- prefix: "postgres://",
2265
- service: "postgresql",
2266
- description: "PostgreSQL connection string"
2267
- },
2268
- {
2269
- prefix: "postgresql://",
2270
- service: "postgresql",
2271
- description: "PostgreSQL connection string"
2272
- },
2273
- {
2274
- prefix: "mysql://",
2275
- service: "mysql",
2276
- description: "MySQL connection string"
2277
- },
2278
- {
2279
- prefix: "mongodb://",
2280
- service: "mongodb",
2281
- description: "MongoDB connection string"
2282
- },
2283
- {
2284
- prefix: "mongodb+srv://",
2285
- service: "mongodb",
2286
- description: "MongoDB SRV connection string"
2287
- },
2288
- {
2289
- prefix: "redis://",
2290
- service: "redis",
2291
- description: "Redis connection string"
2799
+ service: "github",
2800
+ description: "GitHub user-to-server token"
2292
2801
  },
2293
2802
  {
2294
- prefix: "rediss://",
2295
- service: "redis",
2296
- description: "Redis TLS connection string"
2803
+ prefix: "github_pat_",
2804
+ service: "github",
2805
+ description: "GitHub fine-grained PAT"
2297
2806
  },
2298
2807
  {
2299
- prefix: "amqp://",
2300
- service: "rabbitmq",
2301
- description: "RabbitMQ connection string"
2808
+ prefix: "xoxb-",
2809
+ service: "slack",
2810
+ description: "Slack bot token"
2302
2811
  },
2303
2812
  {
2304
- prefix: "amqps://",
2305
- service: "rabbitmq",
2306
- description: "RabbitMQ TLS connection string"
2307
- }
2308
- ];
2309
- /** Detect service from value prefix/shape */
2310
- const matchValueShape = (value) => {
2311
- return Option(VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix))).map((vp) => ({
2312
- service: vp.service,
2313
- description: vp.description
2314
- }));
2315
- };
2316
- /** Strip common suffixes and derive a service name from an env var name */
2317
- const deriveServiceFromName = (name) => {
2318
- const matchedSuffix = [
2319
- "_API_KEY",
2320
- "_SECRET_KEY",
2321
- "_ACCESS_KEY",
2322
- "_PRIVATE_KEY",
2323
- "_SIGNING_KEY",
2324
- "_AUTH_TOKEN",
2325
- "_ACCESS_TOKEN",
2326
- "_WEBHOOK_SECRET",
2327
- "_CONNECTION_STRING",
2328
- "_SECRET",
2329
- "_TOKEN",
2330
- "_PASSWORD",
2331
- "_PASS",
2332
- "_KEY",
2333
- "_DSN",
2334
- "_URL",
2335
- "_URI"
2336
- ].find((s) => name.endsWith(s));
2337
- return (matchedSuffix ? name.slice(0, -matchedSuffix.length) : name).toLowerCase().replace(/_/g, "-");
2338
- };
2339
- /** Match a single env var against all patterns */
2340
- const matchEnvVar = (name, value) => {
2341
- if (EXCLUDED_VARS.has(name)) return Option(void 0);
2342
- const exactMatch = EXACT_NAME_PATTERNS.find((p) => name === p.pattern);
2343
- if (exactMatch) return Option({
2344
- envVar: name,
2345
- value,
2346
- service: Option(exactMatch.service),
2347
- confidence: exactMatch.confidence,
2348
- matchedBy: `exact:${exactMatch.pattern}`
2349
- });
2350
- return matchValueShape(value).fold(() => {
2351
- return Option(SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix))).map((sp) => ({
2352
- envVar: name,
2353
- value,
2354
- service: Option(deriveServiceFromName(name)),
2355
- confidence: "medium",
2356
- matchedBy: `suffix:${sp.suffix}`
2357
- }));
2358
- }, (vm) => Option({
2359
- envVar: name,
2360
- value,
2361
- service: Option(vm.service),
2362
- confidence: "high",
2363
- matchedBy: `value:${vm.description}`
2364
- }));
2365
- };
2366
- /** Scan full env, sorted by confidence (high first) then alphabetically */
2367
- const scanEnv = (env) => {
2368
- const results = Object.entries(env).flatMap(([name, value]) => {
2369
- if (value === void 0 || value === "") return [];
2370
- return matchEnvVar(name, value).fold(() => [], (m) => [m]);
2371
- });
2372
- const confidenceOrder = {
2373
- high: 0,
2374
- medium: 1,
2375
- low: 2
2376
- };
2377
- results.sort((a, b) => {
2378
- const conf = confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
2379
- if (conf !== 0) return conf;
2380
- return a.envVar.localeCompare(b.envVar);
2381
- });
2382
- return results;
2383
- };
2384
- //#endregion
2385
- //#region src/core/env.ts
2386
- /** Scan env for credentials, returning structured results */
2387
- const envScan = (env, options) => {
2388
- const allMatches = scanEnv(env);
2389
- const discovered = options?.includeUnknown ? allMatches : allMatches.filter((m) => m.service.isSome());
2390
- const total_scanned = Object.keys(env).length;
2391
- const high_confidence = discovered.filter((m) => m.confidence === "high").length;
2392
- const medium_confidence = discovered.filter((m) => m.confidence === "medium").length;
2393
- const low_confidence = discovered.filter((m) => m.confidence === "low").length;
2394
- return {
2395
- discovered: List(discovered),
2396
- total_scanned,
2397
- high_confidence,
2398
- medium_confidence,
2399
- low_confidence
2400
- };
2401
- };
2402
- const parseAliasRef = (raw, expectedKind) => {
2403
- const match = raw.match(/^(secret|env)\.(.+)$/);
2404
- if (match?.[1] !== expectedKind) return Option(void 0);
2405
- return Option(match[2]);
2406
- };
2407
- /** Bidirectional drift detection between config and live environment */
2408
- const envCheck = (config, env) => {
2409
- const secretEntries = config.secret ?? {};
2410
- const metaKeys = Object.keys(secretEntries);
2411
- const metaKeysSet = Set$1(metaKeys);
2412
- const namer = makeEnvNamer(config);
2413
- const envDefaultsForWire = config.env ?? {};
2414
- const secretWire = (key) => namer(key, secretEntries[key]?.namespace);
2415
- const envWire = (key) => namer(key, envDefaultsForWire[key]?.namespace);
2416
- const isPresentAt = (wire) => env[wire] !== void 0 && env[wire] !== "";
2417
- const isSecretPresent = (key) => {
2418
- if (isPresentAt(secretWire(key))) return true;
2419
- const meta = secretEntries[key];
2420
- if (meta?.from_key === void 0) return false;
2421
- return parseAliasRef(meta.from_key, "secret").fold(() => false, (targetKey) => isPresentAt(secretWire(targetKey)));
2422
- };
2423
- const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
2424
- const present = isSecretPresent(key);
2425
- return {
2426
- envVar: key,
2427
- service: Option(meta.service),
2428
- status: present ? "tracked" : "missing_from_env",
2429
- confidence: Option(void 0)
2430
- };
2431
- });
2432
- const envDefaults = config.env ?? {};
2433
- const isEnvPresent = (key) => {
2434
- if (isPresentAt(envWire(key))) return true;
2435
- const meta = envDefaults[key];
2436
- if (meta?.from_key === void 0) return false;
2437
- return parseAliasRef(meta.from_key, "env").fold(() => false, (targetKey) => isPresentAt(envWire(targetKey)));
2438
- };
2439
- const envDefaultEntries = Object.keys(envDefaults).filter((key) => !metaKeysSet.has(key)).map((key) => {
2440
- const present = isEnvPresent(key);
2441
- return {
2442
- envVar: key,
2443
- service: Option(void 0),
2444
- status: present ? "tracked" : "missing_from_env",
2445
- confidence: Option(void 0)
2446
- };
2447
- });
2448
- const trackedKeys = Set$1([...metaKeys.map(secretWire), ...envDefaultEntries.map((e) => envWire(e.envVar))]);
2449
- const untrackedEntries = scanEnv(env).filter((match) => !trackedKeys.has(match.envVar)).map((match) => ({
2450
- envVar: match.envVar,
2451
- service: match.service,
2452
- status: "untracked",
2453
- confidence: Option(match.confidence)
2454
- }));
2455
- const entries = [
2456
- ...secretDriftEntries,
2457
- ...envDefaultEntries,
2458
- ...untrackedEntries
2459
- ];
2460
- const tracked_and_present = entries.filter((e) => e.status === "tracked").length;
2461
- const missing_from_env = entries.filter((e) => e.status === "missing_from_env").length;
2462
- const untracked_credentials = entries.filter((e) => e.status === "untracked").length;
2463
- return {
2464
- entries: List(entries),
2465
- tracked_and_present,
2466
- missing_from_env,
2467
- untracked_credentials,
2468
- is_clean: missing_from_env === 0 && untracked_credentials === 0
2469
- };
2470
- };
2471
- const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2472
- /** Generate TOML [secret.*] blocks from scan results, mirroring init.ts pattern */
2473
- const generateTomlFromScan = (matches) => {
2474
- return matches.map((match) => {
2475
- const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
2476
- return `[secret.${match.envVar}]
2477
- service = "${svc}"
2478
- # purpose = "" # Why: what this secret enables
2479
- # capabilities = [] # What operations this grants
2480
- created = "${todayIso$1()}"
2481
- # expires = "" # When: YYYY-MM-DD expiration date
2482
- # rotation_url = "" # URL for rotation procedure
2483
- # source = "" # Where the value originates (e.g. vault, ci)
2484
- # tags = {}
2485
- `;
2486
- }).join("\n");
2487
- };
2488
- //#endregion
2489
- //#region src/core/toml-edit.ts
2490
- const SECTION_RE = /^\[.+\]\s*$/;
2491
- const MULTILINE_OPEN = "\"\"\"";
2492
- const scanSectionBoundary = (state, line, i) => {
2493
- if (state.done) return state;
2494
- if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2495
- ...state,
2496
- inMultiline: false
2497
- } : state;
2498
- if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2499
- ...state,
2500
- inMultiline: true
2501
- } : state;
2502
- return SECTION_RE.test(line) ? {
2503
- ...state,
2504
- end: i,
2505
- done: true
2506
- } : state;
2507
- };
2508
- /**
2509
- * Find the line range [start, end) of a TOML section by its header string.
2510
- * The range includes the header line through to (but not including) the next section header or EOF.
2511
- * Handles multiline `"""..."""` values when scanning for section boundaries.
2512
- */
2513
- const findSectionRange = (lines, sectionHeader) => {
2514
- const start = lines.findIndex((l) => l.trim() === sectionHeader);
2515
- if (start === -1) return void 0;
2516
- const initial = {
2517
- end: lines.length,
2518
- inMultiline: false,
2519
- done: false
2520
- };
2521
- return {
2522
- start,
2523
- end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
2524
- };
2525
- };
2526
- /** Check whether a section header exists in the raw TOML */
2527
- const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
2528
- /**
2529
- * Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
2530
- * Strips trailing blank lines left behind.
2531
- */
2532
- const removeSection = (raw, sectionHeader) => {
2533
- const lines = raw.split("\n");
2534
- const range = findSectionRange(lines, sectionHeader);
2535
- if (!range) return Either.left({
2536
- _tag: "SectionNotFound",
2537
- section: sectionHeader
2538
- });
2539
- const after = lines.slice(range.end);
2540
- const beforeAll = lines.slice(0, range.start);
2541
- const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
2542
- const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
2543
- 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
+ }));
2544
2899
  };
2545
- /**
2546
- * Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
2547
- * Errors if old doesn't exist or new already exists.
2548
- */
2549
- const renameSection = (raw, oldHeader, newHeader) => {
2550
- const lines = raw.split("\n");
2551
- if (!sectionExists(lines, oldHeader)) return Either.left({
2552
- _tag: "SectionNotFound",
2553
- section: oldHeader
2554
- });
2555
- if (sectionExists(lines, newHeader)) return Either.left({
2556
- _tag: "SectionAlreadyExists",
2557
- section: newHeader
2558
- });
2559
- const result = lines.map((line) => line.trim() === oldHeader ? newHeader : line).join("\n");
2560
- 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, "-");
2561
2922
  };
2562
- /**
2563
- * Update, add, or remove fields within an existing TOML section.
2564
- * - A string value replaces or adds the field
2565
- * - A null value removes the field
2566
- * Does NOT re-serialize — operates on raw text lines.
2567
- */
2568
- const updateSectionFields = (raw, sectionHeader, updates) => {
2569
- const lines = raw.split("\n");
2570
- const range = findSectionRange(lines, sectionHeader);
2571
- if (!range) return Either.left({
2572
- _tag: "SectionNotFound",
2573
- 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}`
2574
2933
  });
2575
- const before = lines.slice(0, range.start + 1);
2576
- const after = lines.slice(range.end);
2577
- const sectionBody = lines.slice(range.start + 1, range.end);
2578
- const findClosingMultiline = (fromIdx) => {
2579
- const idx = sectionBody.findIndex((l, j) => j > fromIdx && l.includes(MULTILINE_OPEN));
2580
- return idx === -1 ? sectionBody.length : idx;
2581
- };
2582
- const initial = {
2583
- remaining: [],
2584
- updatedKeys: Set$1.empty(),
2585
- skipUntil: -1
2586
- };
2587
- const step = (state, line, i) => {
2588
- if (i <= state.skipUntil) return state;
2589
- const eqIdx = line.indexOf("=");
2590
- const isFieldLine = eqIdx > 0 && !line.trimStart().startsWith("#") && !line.trimStart().startsWith("[");
2591
- const key = isFieldLine ? line.slice(0, eqIdx).trim() : "";
2592
- if (isFieldLine && key in updates) {
2593
- const afterEquals = line.slice(eqIdx + 1).trim();
2594
- const skipUntil = afterEquals.includes(MULTILINE_OPEN) && (afterEquals.match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? findClosingMultiline(i) : state.skipUntil;
2595
- const updatedKeys = state.updatedKeys.add(key);
2596
- const value = updates[key];
2597
- if (value === null) return {
2598
- ...state,
2599
- updatedKeys,
2600
- skipUntil
2601
- };
2602
- return {
2603
- remaining: [...state.remaining, `${key} = ${value}`],
2604
- updatedKeys,
2605
- skipUntil
2606
- };
2607
- }
2608
- return {
2609
- ...state,
2610
- remaining: [...state.remaining, line]
2611
- };
2612
- };
2613
- const final = List(sectionBody).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]));
2614
- const newFields = Object.entries(updates).filter(([key, value]) => value !== null && !final.updatedKeys.has(key)).map(([key, value]) => `${key} = ${value}`);
2615
- const result = [
2616
- ...before,
2617
- ...final.remaining,
2618
- ...newFields,
2619
- ...after
2620
- ].join("\n");
2621
- return Either.right(result);
2622
- };
2623
- /**
2624
- * Append a new TOML section block to the end of the file.
2625
- * Ensures proper spacing (double newline before the block).
2626
- */
2627
- const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
2628
- const ENV_HEADER_RE = /^\[env\.(.+)\]\s*$/;
2629
- const SECRET_HEADER_RE = /^\[secret\.(.+)\]\s*$/;
2630
- const ANY_HEADER_RE = /^\[.+\]\s*$/;
2631
- /**
2632
- * Find the end (exclusive) of a section starting at `start`, respecting
2633
- * multiline `"""..."""` values so the scanner does not mistake content inside
2634
- * a multiline string for a section header.
2635
- */
2636
- const findSectionEnd = (lines, start) => {
2637
- const initial = {
2638
- end: lines.length,
2639
- inMultiline: false,
2640
- done: false
2641
- };
2642
- return List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end;
2643
- };
2644
- /**
2645
- * Walking backwards from `headerIdx`, return the index of the first line of the
2646
- * "doc block" that should travel with this section. A doc block is a contiguous
2647
- * run of `#`-comment lines *immediately* above the header (no blank line
2648
- * between). A blank line acts as a paragraph break and stops the walk — so
2649
- * `# Some heading\n\n[secret.X]` does NOT attach the heading to `[secret.X]`.
2650
- */
2651
- const findPreambleStart = (lines, headerIdx) => {
2652
- const stopOffset = [...lines.slice(0, headerIdx)].reverse().findIndex((l) => !l.trim().startsWith("#"));
2653
- 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
+ }));
2654
2949
  };
2655
- const classifyHeader = (line, idx) => {
2656
- if (!ANY_HEADER_RE.test(line)) return Option.none();
2657
- const envMatch = line.match(ENV_HEADER_RE);
2658
- if (envMatch) return Option({
2659
- idx,
2660
- kind: "env",
2661
- key: envMatch[1]
2662
- });
2663
- const secretMatch = line.match(SECRET_HEADER_RE);
2664
- if (secretMatch) return Option({
2665
- idx,
2666
- kind: "secret",
2667
- 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]);
2668
2955
  });
2669
- return Option({
2670
- idx,
2671
- kind: "other",
2672
- 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);
2673
2965
  });
2966
+ return results;
2674
2967
  };
2675
- const scanHeader = (state, line, idx) => {
2676
- if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
2677
- ...state,
2678
- inMultiline: false
2679
- } : state;
2680
- if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
2681
- ...state,
2682
- inMultiline: true
2683
- } : state;
2684
- return classifyHeader(line, idx).fold(() => state, (header) => ({
2685
- ...state,
2686
- headers: [...state.headers, header]
2687
- }));
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
+ };
2688
2985
  };
2689
- const partitionSections = (raw) => {
2690
- const lines = raw.split("\n");
2691
- const { headers } = List(lines).zipWithIndex().foldLeft({
2692
- headers: [],
2693
- inMultiline: false
2694
- })((state, entry) => scanHeader(state, entry[0], entry[1]));
2695
- const envSecretHeaders = headers.filter((h) => h.kind === "env" || h.kind === "secret");
2696
- const trueBodyRange = (headerIdx) => {
2697
- const naiveEnd = findSectionEnd(lines, headerIdx);
2698
- const lastContent = lines.slice(headerIdx, naiveEnd).findLastIndex((l) => {
2699
- const t = l.trim();
2700
- return t !== "" && !t.startsWith("#");
2701
- });
2702
- return {
2703
- start: headerIdx,
2704
- end: lastContent === -1 ? headerIdx + 1 : headerIdx + lastContent + 1
2705
- };
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)));
2706
3006
  };
2707
- const sections = envSecretHeaders.map((h) => {
2708
- const headerIdx = h.idx;
2709
- const preambleStart = findPreambleStart(lines, headerIdx);
2710
- const body = lines.slice(headerIdx, trueBodyRange(headerIdx).end);
2711
- const preamble = lines.slice(preambleStart, headerIdx);
3007
+ const secretDriftEntries = Object.entries(secretEntries).map(([key, meta]) => {
3008
+ const present = isSecretPresent(key);
2712
3009
  return {
2713
- kind: h.kind,
2714
- key: h.key,
2715
- body,
2716
- preamble
3010
+ envVar: key,
3011
+ service: Option(meta.service),
3012
+ status: present ? "tracked" : "missing_from_env",
3013
+ confidence: Option(void 0)
2717
3014
  };
2718
3015
  });
2719
- const claimedRanges = envSecretHeaders.map((h) => {
2720
- 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);
2721
3025
  return {
2722
- start: findPreambleStart(lines, headerIdx),
2723
- end: trueBodyRange(headerIdx).end
3026
+ envVar: key,
3027
+ service: Option(void 0),
3028
+ status: present ? "tracked" : "missing_from_env",
3029
+ confidence: Option(void 0)
2724
3030
  };
2725
3031
  });
2726
- 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;
2727
3047
  return {
2728
- preambleLines: lines.map((l, idx) => isClaimed(idx) ? null : l).filter((l) => l !== null),
2729
- envSections: sections.filter((s) => s.kind === "env"),
2730
- secretSections: sections.filter((s) => s.kind === "secret")
2731
- };
2732
- };
2733
- const emitSection = (s) => {
2734
- return `${s.preamble.length > 0 ? `${s.preamble.join("\n")}\n` : ""}${s.body.join("\n")}`;
2735
- };
2736
- /**
2737
- * Reformat a TOML config with `[env.*]` and `[secret.*]` sections grouped and
2738
- * alphabetized. Top-level content (version key, `[identity]`, `[lifecycle]`,
2739
- * `[callbacks]`, `[tools]`, etc.) stays in its original position. Comment
2740
- * doc-blocks immediately above a section header travel with that section.
2741
- *
2742
- * Pure — no I/O. Returns the raw input unchanged when there is no env or
2743
- * secret content to reorder.
2744
- */
2745
- const sortConfigToml = (raw) => {
2746
- const { preambleLines, envSections, secretSections } = partitionSections(raw);
2747
- if (envSections.length === 0 && secretSections.length === 0) return raw;
2748
- const sortedEnv = [...envSections].sort((a, b) => a.key.localeCompare(b.key));
2749
- const sortedSecret = [...secretSections].sort((a, b) => a.key.localeCompare(b.key));
2750
- const trimTrailing = (xs) => {
2751
- const lastNonBlank = xs.findLastIndex((l) => l.trim() !== "");
2752
- 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
2753
3053
  };
2754
- const collapseBlanks = (xs) => xs.reduce((acc, line) => {
2755
- const isBlank = line.trim() === "";
2756
- const prevBlank = acc.length > 0 && acc[acc.length - 1].trim() === "";
2757
- if (isBlank && prevBlank) return acc;
2758
- return [...acc, line];
2759
- }, []);
2760
- const preambleTrimmed = collapseBlanks(trimTrailing(preambleLines));
2761
- const parts = [];
2762
- if (preambleTrimmed.length > 0) parts.push(preambleTrimmed.join("\n"));
2763
- if (sortedEnv.length > 0) parts.push(sortedEnv.map(emitSection).join("\n\n"));
2764
- if (sortedSecret.length > 0) parts.push(sortedSecret.map(emitSection).join("\n\n"));
2765
- return `${parts.join("\n\n")}\n`;
2766
- };
2767
- //#endregion
2768
- //#region src/core/validate.ts
2769
- /**
2770
- * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
2771
- *
2772
- * Used by write-path CLI commands to verify the post-edit file would still be
2773
- * structurally valid before persisting. Catalog resolution is intentionally
2774
- * excluded — catalog issues depend on external files, not on the local edit,
2775
- * and `envpkt validate` covers them as a separate explicit check.
2776
- */
2777
- const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
2778
- /** Human-readable one-liner for any ValidationError tag. */
2779
- const formatValidationError = (err) => {
2780
- switch (err._tag) {
2781
- case "FileNotFound": return `Config file not found: ${err.path}`;
2782
- case "ParseError": return `TOML parse error: ${err.message}`;
2783
- case "ValidationError": return `Schema validation failed: ${err.errors.toArray().join("; ")}`;
2784
- case "ReadError": return `Read error: ${err.message}`;
2785
- case "AliasInvalidSyntax":
2786
- case "AliasTargetMissing":
2787
- case "AliasSelfReference":
2788
- case "AliasChained":
2789
- case "AliasCrossType":
2790
- case "AliasValueConflict": return formatAliasError(err);
2791
- }
2792
- };
2793
- //#endregion
2794
- //#region src/cli/write-gate.ts
2795
- /**
2796
- * Run structural validation against an in-memory TOML string.
2797
- * On failure, prints the error, leaves the file untouched, and exits with code 1.
2798
- * On success, returns — caller is responsible for writing.
2799
- *
2800
- * Use this when the write step has bespoke logic (e.g. wraps writeFileSync in Try,
2801
- * has multi-line post-write output). Otherwise prefer `writeIfValid`.
2802
- */
2803
- const validateOrExit = (updated) => {
2804
- validateRawConfig(updated).fold((err) => {
2805
- console.error(`${RED}Error:${RESET} Aborted — change would produce an invalid config:`);
2806
- console.error(` ${formatValidationError(err)}`);
2807
- console.error(`${DIM}File unchanged.${RESET}`);
2808
- process.exit(1);
2809
- }, () => {});
2810
- };
2811
- /**
2812
- * Validate then persist. Most mutating CLI commands use this — it bundles the
2813
- * validate-or-exit gate with the writeFileSync + success log so each call site
2814
- * stays two lines instead of five.
2815
- */
2816
- const writeIfValid = (configPath, updated, successMsg) => {
2817
- validateOrExit(updated);
2818
- writeFileSync(configPath, updated, "utf-8");
2819
- console.log(successMsg);
2820
3054
  };
2821
- /**
2822
- * Validate then preview (no write) the `--dry-run` counterpart of `writeIfValid`.
2823
- * Runs the same structural validation the real write would, so a dry-run can never
2824
- * show a result that the actual write would reject. On invalid output it prints the
2825
- * same error and exits 1, exactly as the write path does.
2826
- *
2827
- * `display` lets callers preview a focused slice (e.g. just the new block for `add`)
2828
- * while still validating the full resulting config.
2829
- */
2830
- const previewIfValid = (updated, display) => {
2831
- validateOrExit(updated);
2832
- console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
2833
- 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");
2834
3071
  };
2835
3072
  //#endregion
2836
3073
  //#region src/cli/commands/env.ts
@@ -5146,6 +5383,12 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
5146
5383
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
5147
5384
  runUpgrade();
5148
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
+ });
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) => {
5390
+ runDiff(a, b, options);
5391
+ });
5149
5392
  program.command("doctor").description("Check that age is installed and that the resolved config's sealed secrets can be decrypted").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
5150
5393
  runDoctor(options);
5151
5394
  });