akm-cli 0.4.1 → 0.5.0-rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vault.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Vault asset type — secret storage backed by `.env` files.
3
+ *
4
+ * Invariant: vault values must never be written to stdout, returned through
5
+ * the indexer, the `akm show` renderer, or any structured output channel.
6
+ * The supported load paths are:
7
+ *
8
+ * - `eval "$(akm vault load vault:<name>)"` — `vault load` parses the vault
9
+ * with dotenv (no shell expansion, no code execution), writes a safely
10
+ * single-quote-escaped `export KEY='value'` script to a mode-0600 temp
11
+ * file, and emits `. <tmp>; rm -f <tmp>` on stdout. Values reach bash
12
+ * only via the temp file, never via akm's stdout.
13
+ * - `injectIntoEnv(vaultPath, target)` — programmatic API for modules that
14
+ * need values in a process environment.
15
+ *
16
+ * Value parsing is delegated to the `dotenv` package — we deliberately do not
17
+ * implement our own quoting/escaping rules for security-sensitive content.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import dotenv from "dotenv";
22
+ /** Matches a KEY=value assignment line, capturing only the key. */
23
+ const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
24
+ /** Scan lines and return KEY names in file order, without duplicates. */
25
+ function scanKeys(text) {
26
+ const keys = [];
27
+ const seen = new Set();
28
+ for (const line of text.split(/\r?\n/)) {
29
+ const m = line.match(ASSIGN_RE);
30
+ if (!m)
31
+ continue;
32
+ const key = m[1];
33
+ if (seen.has(key))
34
+ continue;
35
+ seen.add(key);
36
+ keys.push(key);
37
+ }
38
+ return keys;
39
+ }
40
+ /**
41
+ * Scan lines and return start-of-line `#` comments (with the leading `#` and
42
+ * any leading whitespace stripped). Inline/trailing `#` after an assignment is
43
+ * never extracted.
44
+ */
45
+ function scanComments(text) {
46
+ const comments = [];
47
+ for (const line of text.split(/\r?\n/)) {
48
+ const trimmed = line.trimStart();
49
+ if (trimmed.startsWith("#")) {
50
+ comments.push(trimmed.slice(1).trimStart());
51
+ }
52
+ }
53
+ return comments;
54
+ }
55
+ /**
56
+ * Read and return ONLY non-secret metadata (keys + start-of-line comments).
57
+ *
58
+ * The function reads the whole file into memory (same as any dotenv parser)
59
+ * but deliberately does not parse values — the LHS-only regex scanners above
60
+ * ensure no value content is retained or returned. The guarantee is that
61
+ * values never leave this function.
62
+ */
63
+ export function listKeys(vaultPath) {
64
+ if (!fs.existsSync(vaultPath))
65
+ return { keys: [], comments: [] };
66
+ const text = fs.readFileSync(vaultPath, "utf8");
67
+ return { keys: scanKeys(text), comments: scanComments(text) };
68
+ }
69
+ /**
70
+ * Read all KEY=value pairs from a vault file. Intended for programmatic
71
+ * callers that need to inject values into a process environment. Callers
72
+ * MUST NOT write the returned values to stdout or any logged output.
73
+ *
74
+ * Value parsing (quoting, escapes, multi-line, etc.) is delegated to dotenv.
75
+ */
76
+ export function loadEnv(vaultPath) {
77
+ if (!fs.existsSync(vaultPath))
78
+ return {};
79
+ const buf = fs.readFileSync(vaultPath);
80
+ return dotenv.parse(buf);
81
+ }
82
+ /**
83
+ * Load a vault and assign its values into `target` (defaults to `process.env`).
84
+ * Returns the list of keys that were set so the caller can log/observe without
85
+ * touching values.
86
+ *
87
+ * Existing keys in `target` are overwritten — callers who want to preserve
88
+ * pre-existing environment variables should filter before calling.
89
+ */
90
+ export function injectIntoEnv(vaultPath, target = process.env) {
91
+ const env = loadEnv(vaultPath);
92
+ for (const [key, value] of Object.entries(env)) {
93
+ target[key] = value;
94
+ }
95
+ return Object.keys(env);
96
+ }
97
+ /**
98
+ * Serialise a vault's values as a POSIX shell script of `export KEY='value'`
99
+ * lines, with single-quote escaping (`'\''`). Every line is an assignment of
100
+ * a literal string — there is no expansion, command substitution, or
101
+ * non-assignment content, so sourcing the output is safe regardless of what
102
+ * the vault file contains.
103
+ *
104
+ * Intended for use by `akm vault load`, which writes this to a mode-0600
105
+ * temp file and emits only the path (never values) on stdout.
106
+ */
107
+ export function buildShellExportScript(vaultPath) {
108
+ const env = loadEnv(vaultPath);
109
+ const lines = [];
110
+ for (const [key, value] of Object.entries(env)) {
111
+ // Defence in depth: dotenv already validates key shape, but reject any
112
+ // key we wouldn't be able to export safely.
113
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
114
+ continue;
115
+ const escaped = value.replace(/'/g, "'\\''");
116
+ lines.push(`export ${key}='${escaped}'`);
117
+ }
118
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
119
+ }
120
+ /**
121
+ * Set a key in the vault file, preserving line order and comments. Creates
122
+ * the file (and parent directory) if it does not exist.
123
+ *
124
+ * `quoteValue` picks the safest representation that dotenv round-trips:
125
+ * single-quoted when the value has no `'`, double-quoted when it has `'` but
126
+ * no `"` and no literal `\n`/`\r` escape sequences, and unquoted only for
127
+ * values that contain no characters requiring escaping (see quoteValue for
128
+ * the full rule set). Values containing newlines or both quote types are
129
+ * rejected outright. Round-trip safety is enforced by the test suite.
130
+ *
131
+ * When `comment` is provided it is written as a `# <comment>` line
132
+ * immediately before the `KEY=value` line:
133
+ * - New key: the comment line is inserted just before the appended key.
134
+ * - Existing key: if the preceding line is already a comment it is replaced
135
+ * with the new comment; otherwise a new comment line is inserted.
136
+ * When `comment` is absent the surrounding comment lines are left unchanged.
137
+ */
138
+ export function setKey(vaultPath, key, value, comment) {
139
+ validateKeyName(key);
140
+ ensureParentDir(vaultPath);
141
+ const existing = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, "utf8") : "";
142
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
143
+ const formatted = `${key}=${quoteValue(value)}`;
144
+ let replaced = false;
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const m = lines[i].match(ASSIGN_RE);
147
+ if (m && m[1] === key) {
148
+ lines[i] = formatted;
149
+ replaced = true;
150
+ if (comment !== undefined) {
151
+ const commentLine = `# ${comment}`;
152
+ const prevIsComment = i > 0 && lines[i - 1].trimStart().startsWith("#");
153
+ if (prevIsComment) {
154
+ lines[i - 1] = commentLine;
155
+ }
156
+ else {
157
+ lines.splice(i, 0, commentLine);
158
+ }
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ if (!replaced) {
164
+ if (comment !== undefined) {
165
+ const commentLine = `# ${comment}`;
166
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
167
+ lines[lines.length - 1] = commentLine;
168
+ lines.push(formatted);
169
+ lines.push("");
170
+ }
171
+ else {
172
+ lines.push(commentLine);
173
+ lines.push(formatted);
174
+ }
175
+ }
176
+ else if (lines.length > 0 && lines[lines.length - 1] === "") {
177
+ lines[lines.length - 1] = formatted;
178
+ lines.push("");
179
+ }
180
+ else {
181
+ lines.push(formatted);
182
+ }
183
+ }
184
+ let out = lines.join("\n");
185
+ if (!out.endsWith("\n"))
186
+ out += "\n";
187
+ writeFileAtomic(vaultPath, out);
188
+ }
189
+ /** Remove a key from the vault file. Returns true if the key was present. */
190
+ export function unsetKey(vaultPath, key) {
191
+ if (!fs.existsSync(vaultPath))
192
+ return false;
193
+ const text = fs.readFileSync(vaultPath, "utf8");
194
+ const lines = text.split(/\r?\n/);
195
+ const kept = [];
196
+ let removed = false;
197
+ for (const line of lines) {
198
+ const m = line.match(ASSIGN_RE);
199
+ if (m && m[1] === key) {
200
+ removed = true;
201
+ continue;
202
+ }
203
+ kept.push(line);
204
+ }
205
+ if (!removed)
206
+ return false;
207
+ let out = kept.join("\n");
208
+ if (out.length > 0 && !out.endsWith("\n"))
209
+ out += "\n";
210
+ writeFileAtomic(vaultPath, out);
211
+ return true;
212
+ }
213
+ /** Create an empty vault file (does nothing if it already exists). */
214
+ export function createVault(vaultPath) {
215
+ ensureParentDir(vaultPath);
216
+ if (fs.existsSync(vaultPath))
217
+ return;
218
+ writeFileAtomic(vaultPath, "");
219
+ }
220
+ /**
221
+ * Characters that are safe in an UNquoted dotenv value AND are not
222
+ * metacharacters in POSIX shells. Anything outside this set forces quoting,
223
+ * which is defense-in-depth for any caller that might ever `source` the
224
+ * vault file directly instead of going through `akm vault load`.
225
+ */
226
+ const UNQUOTED_SAFE_RE = /^[A-Za-z0-9_.:/@%+,-]+$/;
227
+ /**
228
+ * Quote a value for safe storage in a .env file that round-trips through
229
+ * `dotenv.parse` AND is safe if the file is ever `source`d by a POSIX shell.
230
+ *
231
+ * Strategy:
232
+ * - empty → empty
233
+ * - all-safe chars (alnum + `_.:/@%+,-`) → unquoted
234
+ * - no `'` → single-quote (dotenv and shell both treat single-quoted
235
+ * content literally: no expansion, no escapes)
236
+ * - no `"` and no literal `\n`/`\r` escape sequence → double-quote
237
+ * (dotenv unescapes `\n`/`\r` on read, so we
238
+ * can't double-quote a value that contains
239
+ * those literal sequences)
240
+ * - newlines or both quote types → reject
241
+ *
242
+ * dotenv intentionally does NOT support `\"` inside double-quoted values, so
243
+ * we never produce that pattern.
244
+ */
245
+ function quoteValue(value) {
246
+ if (value.length === 0)
247
+ return "";
248
+ if (/[\n\r]/.test(value)) {
249
+ throw new Error("Vault values cannot contain literal newlines.");
250
+ }
251
+ if (UNQUOTED_SAFE_RE.test(value))
252
+ return value;
253
+ if (!value.includes("'"))
254
+ return `'${value}'`;
255
+ if (!value.includes('"') && !/\\[nr]/.test(value))
256
+ return `"${value}"`;
257
+ throw new Error("Vault value contains both single and double quote characters; not supported.");
258
+ }
259
+ function validateKeyName(key) {
260
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
261
+ throw new Error(`Invalid vault key name: "${key}". Must match [A-Za-z_][A-Za-z0-9_]*`);
262
+ }
263
+ }
264
+ function ensureParentDir(filePath) {
265
+ const dir = path.dirname(filePath);
266
+ if (!fs.existsSync(dir))
267
+ fs.mkdirSync(dir, { recursive: true });
268
+ }
269
+ function writeFileAtomic(filePath, content) {
270
+ const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
271
+ try {
272
+ fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
273
+ fs.renameSync(tmp, filePath);
274
+ try {
275
+ fs.chmodSync(filePath, 0o600);
276
+ }
277
+ catch {
278
+ /* best-effort on platforms without chmod */
279
+ }
280
+ }
281
+ catch (err) {
282
+ try {
283
+ fs.unlinkSync(tmp);
284
+ }
285
+ catch {
286
+ /* ignore cleanup failure */
287
+ }
288
+ throw err;
289
+ }
290
+ }