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/README.md +33 -0
- package/dist/cli.js +928 -685
- package/dist/index.d.ts +44 -1
- package/dist/index.js +115 -1
- package/package.json +7 -7
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()).
|
|
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/
|
|
1615
|
-
|
|
1616
|
-
const
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
*
|
|
1638
|
-
*
|
|
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
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
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
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
/**
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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: "
|
|
2295
|
-
service: "
|
|
2296
|
-
description: "
|
|
2803
|
+
prefix: "github_pat_",
|
|
2804
|
+
service: "github",
|
|
2805
|
+
description: "GitHub fine-grained PAT"
|
|
2297
2806
|
},
|
|
2298
2807
|
{
|
|
2299
|
-
prefix: "
|
|
2300
|
-
service: "
|
|
2301
|
-
description: "
|
|
2808
|
+
prefix: "xoxb-",
|
|
2809
|
+
service: "slack",
|
|
2810
|
+
description: "Slack bot token"
|
|
2302
2811
|
},
|
|
2303
2812
|
{
|
|
2304
|
-
prefix: "
|
|
2305
|
-
service: "
|
|
2306
|
-
description: "
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
"
|
|
2320
|
-
"
|
|
2321
|
-
"
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
"
|
|
2325
|
-
"
|
|
2326
|
-
"
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
"
|
|
2330
|
-
"
|
|
2331
|
-
"
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
"
|
|
2335
|
-
"
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
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
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
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
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
-
|
|
2656
|
-
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
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
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
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
|
|
2690
|
-
const
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
const
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
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
|
|
2708
|
-
const
|
|
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
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
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
|
|
2720
|
-
|
|
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
|
-
|
|
2723
|
-
|
|
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
|
|
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
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
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
|
});
|