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