@modootoday/envs 0.1.0
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/.agent/skills/env-value-store/SKILL.md +350 -0
- package/LICENSE +93 -0
- package/NOTICE +24 -0
- package/README.md +97 -0
- package/dist/chunk-47XO3SDE.js +4591 -0
- package/dist/chunk-STHFIZRP.js +1310 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +10 -0
- package/dist/config-DKLCgBZX.d.cts +116 -0
- package/dist/config-DKLCgBZX.d.ts +116 -0
- package/dist/config.cjs +940 -0
- package/dist/config.d.cts +5 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +10 -0
- package/dist/index.cjs +5915 -0
- package/dist/index.d.cts +418 -0
- package/dist/index.d.ts +418 -0
- package/dist/index.js +112 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
// src/format/parse.ts
|
|
2
|
+
var KEY_PATTERN = /^[\w.-]+$/;
|
|
3
|
+
var EXPORT_PREFIX = /^export\s+/;
|
|
4
|
+
function expandDoubleQuoted(raw) {
|
|
5
|
+
return raw.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
|
|
6
|
+
}
|
|
7
|
+
function isQuote(ch) {
|
|
8
|
+
return ch === '"' || ch === "'" || ch === "`";
|
|
9
|
+
}
|
|
10
|
+
function scanQuoted(text, start, quote) {
|
|
11
|
+
let i = start + 1;
|
|
12
|
+
let out = "";
|
|
13
|
+
while (i < text.length) {
|
|
14
|
+
const ch = text[i];
|
|
15
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
16
|
+
out += ch + text[i + 1];
|
|
17
|
+
i += 2;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (ch === quote) {
|
|
21
|
+
return {
|
|
22
|
+
value: quote === '"' ? expandDoubleQuoted(out) : out,
|
|
23
|
+
quote,
|
|
24
|
+
next: i + 1,
|
|
25
|
+
unterminated: false
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
out += ch;
|
|
29
|
+
i += 1;
|
|
30
|
+
}
|
|
31
|
+
return { value: out, quote, next: text.length, unterminated: true };
|
|
32
|
+
}
|
|
33
|
+
function scanBare(text, start) {
|
|
34
|
+
let i = start;
|
|
35
|
+
while (i < text.length && text[i] !== "\n" && text[i] !== "#") i += 1;
|
|
36
|
+
return {
|
|
37
|
+
value: text.slice(start, i).trim(),
|
|
38
|
+
quote: null,
|
|
39
|
+
next: i,
|
|
40
|
+
unterminated: false
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function countLines(text, from, to) {
|
|
44
|
+
let n = 0;
|
|
45
|
+
for (let i = from; i < to; i += 1) if (text[i] === "\n") n += 1;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
function restOfLineIsClean(text, from) {
|
|
49
|
+
let i = from;
|
|
50
|
+
while (i < text.length && text[i] !== "\n") {
|
|
51
|
+
const ch = text[i];
|
|
52
|
+
if (ch === "#") return true;
|
|
53
|
+
if (ch !== " " && ch !== " ") return false;
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function parseEnv(input) {
|
|
59
|
+
const text = input.replace(/\r\n?/g, "\n");
|
|
60
|
+
const entries = [];
|
|
61
|
+
const findings = [];
|
|
62
|
+
let i = 0;
|
|
63
|
+
let line = 1;
|
|
64
|
+
const skipToNextLine = (from) => {
|
|
65
|
+
const nl = text.indexOf("\n", from);
|
|
66
|
+
return nl === -1 ? text.length : nl + 1;
|
|
67
|
+
};
|
|
68
|
+
while (i < text.length) {
|
|
69
|
+
const eol = text.indexOf("\n", i);
|
|
70
|
+
const lineEnd = eol === -1 ? text.length : eol;
|
|
71
|
+
const raw = text.slice(i, lineEnd);
|
|
72
|
+
const trimmed = raw.trim();
|
|
73
|
+
if (trimmed === "") {
|
|
74
|
+
entries.push({ kind: "blank", line, endLine: line });
|
|
75
|
+
i = lineEnd + 1;
|
|
76
|
+
line += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (trimmed.startsWith("#")) {
|
|
80
|
+
entries.push({ kind: "comment", line, endLine: line });
|
|
81
|
+
i = lineEnd + 1;
|
|
82
|
+
line += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const lead = raw.length - raw.trimStart().length;
|
|
86
|
+
const body = raw.slice(lead);
|
|
87
|
+
const exported = EXPORT_PREFIX.test(body);
|
|
88
|
+
const afterExport = exported ? body.replace(EXPORT_PREFIX, "") : body;
|
|
89
|
+
const keyOffset = i + lead + (body.length - afterExport.length);
|
|
90
|
+
const eq = afterExport.indexOf("=");
|
|
91
|
+
if (eq === -1) {
|
|
92
|
+
findings.push({ code: "NOT_ENV_LINE", line });
|
|
93
|
+
i = lineEnd + 1;
|
|
94
|
+
line += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const key = afterExport.slice(0, eq).trim();
|
|
98
|
+
if (key === "") {
|
|
99
|
+
findings.push({ code: "EMPTY_KEY", line });
|
|
100
|
+
i = lineEnd + 1;
|
|
101
|
+
line += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!KEY_PATTERN.test(key)) {
|
|
105
|
+
findings.push({ code: "INVALID_KEY", line });
|
|
106
|
+
i = lineEnd + 1;
|
|
107
|
+
line += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
let cursor = keyOffset + eq + 1;
|
|
111
|
+
while (cursor < text.length && (text[cursor] === " " || text[cursor] === " ")) {
|
|
112
|
+
cursor += 1;
|
|
113
|
+
}
|
|
114
|
+
const ch = text[cursor];
|
|
115
|
+
const scan = ch !== void 0 && isQuote(ch) ? scanQuoted(text, cursor, ch) : scanBare(text, cursor);
|
|
116
|
+
if (scan.unterminated) {
|
|
117
|
+
findings.push({ code: "UNTERMINATED_QUOTE", line });
|
|
118
|
+
i = text.length;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const endLine = line + countLines(text, i, scan.next);
|
|
122
|
+
if (!restOfLineIsClean(text, scan.next)) {
|
|
123
|
+
findings.push({ code: "TRAILING_CONTENT", line: endLine });
|
|
124
|
+
i = skipToNextLine(scan.next);
|
|
125
|
+
line = endLine + 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
entries.push({
|
|
129
|
+
kind: "assignment",
|
|
130
|
+
line,
|
|
131
|
+
endLine,
|
|
132
|
+
key,
|
|
133
|
+
value: scan.value,
|
|
134
|
+
quote: scan.quote,
|
|
135
|
+
exported
|
|
136
|
+
});
|
|
137
|
+
i = skipToNextLine(scan.next);
|
|
138
|
+
line = endLine + 1;
|
|
139
|
+
}
|
|
140
|
+
return { ok: findings.length === 0, entries, findings };
|
|
141
|
+
}
|
|
142
|
+
function toRecord(result) {
|
|
143
|
+
if (!result.ok) return null;
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const entry of result.entries) {
|
|
146
|
+
if (entry.kind === "assignment") out[entry.key] = entry.value;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/sqlite/provider.ts
|
|
152
|
+
var onBun = () => typeof globalThis["Bun"] !== "undefined";
|
|
153
|
+
var PROVIDERS = [
|
|
154
|
+
{
|
|
155
|
+
backend: "bun",
|
|
156
|
+
specifier: "bun:sqlite",
|
|
157
|
+
exportName: "Database",
|
|
158
|
+
builtIn: true,
|
|
159
|
+
// Rejects {} and {readonly:false}; a writable handle needs create.
|
|
160
|
+
openOptions: (readOnly) => readOnly ? { readonly: true } : { readonly: false, create: true },
|
|
161
|
+
eligible: onBun,
|
|
162
|
+
bindKey: (_bare, token) => token
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
backend: "node",
|
|
166
|
+
specifier: "node:sqlite",
|
|
167
|
+
exportName: "DatabaseSync",
|
|
168
|
+
builtIn: true,
|
|
169
|
+
// Spells it readOnly and silently ignores the lowercase name, which would
|
|
170
|
+
// hand back a writable database.
|
|
171
|
+
openOptions: (readOnly) => ({ readOnly }),
|
|
172
|
+
eligible: () => !onBun(),
|
|
173
|
+
bindKey: (_bare, token) => token
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
backend: "better-sqlite3",
|
|
177
|
+
specifier: "better-sqlite3",
|
|
178
|
+
exportName: "default",
|
|
179
|
+
builtIn: false,
|
|
180
|
+
// Wants the lowercase name and rejects the other as a misspelling.
|
|
181
|
+
openOptions: (readOnly) => ({ readonly: readOnly }),
|
|
182
|
+
// Never attempted on bun: the load is a process panic, not an exception.
|
|
183
|
+
eligible: () => !onBun(),
|
|
184
|
+
bindKey: (bare) => bare
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
function providerFor(backend) {
|
|
188
|
+
return PROVIDERS.find((provider) => provider.backend === backend);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/sqlite/open.ts
|
|
192
|
+
import { createRequire } from "module";
|
|
193
|
+
var SqliteUnavailableError = class extends Error {
|
|
194
|
+
constructor(attempts) {
|
|
195
|
+
super(
|
|
196
|
+
`no sqlite backend available; tried ${attempts.map(([spec, reason]) => `${spec} (${reason})`).join(
|
|
197
|
+
", "
|
|
198
|
+
)}. On a runtime without node:sqlite, install better-sqlite3.`
|
|
199
|
+
);
|
|
200
|
+
this.attempts = attempts;
|
|
201
|
+
this.name = "SqliteUnavailableError";
|
|
202
|
+
}
|
|
203
|
+
attempts;
|
|
204
|
+
};
|
|
205
|
+
var SqliteBindError = class extends Error {
|
|
206
|
+
constructor(message) {
|
|
207
|
+
super(message);
|
|
208
|
+
this.name = "SqliteBindError";
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
var NUL = String.fromCharCode(0);
|
|
212
|
+
var bindings = /* @__PURE__ */ new Map();
|
|
213
|
+
async function importModule(specifier) {
|
|
214
|
+
return await import(
|
|
215
|
+
/* @vite-ignore */
|
|
216
|
+
specifier
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
function readEnv(name) {
|
|
220
|
+
return globalThis.process?.env?.[name];
|
|
221
|
+
}
|
|
222
|
+
function shortlist(pinned) {
|
|
223
|
+
const name = pinned ?? readEnv("ENVS_SQLITE_BACKEND");
|
|
224
|
+
if (name === void 0 || name === "") return PROVIDERS;
|
|
225
|
+
const provider = providerFor(name);
|
|
226
|
+
if (provider === void 0) {
|
|
227
|
+
throw new SqliteUnavailableError([
|
|
228
|
+
[
|
|
229
|
+
"backend pin",
|
|
230
|
+
`"${name}" is not one of ${PROVIDERS.map((p) => p.backend).join(", ")}`
|
|
231
|
+
]
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
return [provider];
|
|
235
|
+
}
|
|
236
|
+
async function loadBinding(pinned) {
|
|
237
|
+
const attempts = [];
|
|
238
|
+
for (const provider of shortlist(pinned)) {
|
|
239
|
+
if (!provider.eligible()) {
|
|
240
|
+
attempts.push([provider.specifier, "not eligible in this runtime"]);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const already = bindings.get(provider.backend);
|
|
244
|
+
if (already) return already;
|
|
245
|
+
try {
|
|
246
|
+
const mod = await importModule(provider.specifier);
|
|
247
|
+
const ctor = mod[provider.exportName];
|
|
248
|
+
if (typeof ctor !== "function") {
|
|
249
|
+
attempts.push([provider.specifier, `no ${provider.exportName} export`]);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const binding = { provider, ctor };
|
|
253
|
+
bindings.set(provider.backend, binding);
|
|
254
|
+
return binding;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
attempts.push([provider.specifier, error.message]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new SqliteUnavailableError(attempts);
|
|
260
|
+
}
|
|
261
|
+
function normalise(value, where) {
|
|
262
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
263
|
+
if (value === void 0) {
|
|
264
|
+
throw new SqliteBindError(
|
|
265
|
+
`undefined bound at ${where}; pass null to mean NULL`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (typeof value === "string" && value.includes(NUL)) {
|
|
269
|
+
throw new SqliteBindError(
|
|
270
|
+
`value at ${where} contains a NUL byte, which sqlite TEXT truncates; store it as bytes instead`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
function scanPlaceholders(sql) {
|
|
276
|
+
const found = /* @__PURE__ */ new Map();
|
|
277
|
+
let i = 0;
|
|
278
|
+
while (i < sql.length) {
|
|
279
|
+
const ch = sql[i];
|
|
280
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
281
|
+
i += 1;
|
|
282
|
+
while (i < sql.length && sql[i] !== ch) i += 1;
|
|
283
|
+
i += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
287
|
+
while (i < sql.length && sql[i] !== "\n") i += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
291
|
+
i += 2;
|
|
292
|
+
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i += 1;
|
|
293
|
+
i += 2;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (ch === "$" || ch === ":" || ch === "@") {
|
|
297
|
+
let j = i + 1;
|
|
298
|
+
while (j < sql.length && /[A-Za-z0-9_]/.test(sql[j])) j += 1;
|
|
299
|
+
if (j > i + 1) {
|
|
300
|
+
const token = sql.slice(i, j);
|
|
301
|
+
found.set(token.slice(1), token);
|
|
302
|
+
i = j;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
i += 1;
|
|
307
|
+
}
|
|
308
|
+
return found;
|
|
309
|
+
}
|
|
310
|
+
function bindArgs(params, placeholders, provider) {
|
|
311
|
+
if (params === void 0) return [];
|
|
312
|
+
if (Array.isArray(params)) {
|
|
313
|
+
return params.map(
|
|
314
|
+
(value, index) => normalise(value, `position ${index + 1}`)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const out = {};
|
|
318
|
+
const seen = /* @__PURE__ */ new Set();
|
|
319
|
+
for (const [key, value] of Object.entries(
|
|
320
|
+
params
|
|
321
|
+
)) {
|
|
322
|
+
const bare = key.replace(/^[$:@]/, "");
|
|
323
|
+
const token = placeholders.get(bare);
|
|
324
|
+
if (token === void 0) {
|
|
325
|
+
throw new SqliteBindError(
|
|
326
|
+
`named parameter "${key}" has no placeholder in the statement`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (seen.has(bare)) {
|
|
330
|
+
throw new SqliteBindError(`named parameter "${bare}" given twice`);
|
|
331
|
+
}
|
|
332
|
+
seen.add(bare);
|
|
333
|
+
out[provider.bindKey(bare, token)] = normalise(value, `parameter ${key}`);
|
|
334
|
+
}
|
|
335
|
+
for (const bare of placeholders.keys()) {
|
|
336
|
+
if (!seen.has(bare)) {
|
|
337
|
+
throw new SqliteBindError(`placeholder "${bare}" has no value`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return [out];
|
|
341
|
+
}
|
|
342
|
+
function wrapStatement(raw, sql, provider) {
|
|
343
|
+
const placeholders = scanPlaceholders(sql);
|
|
344
|
+
const args = (params) => bindArgs(params, placeholders, provider);
|
|
345
|
+
return {
|
|
346
|
+
// Measured: with no matching row bun returns null and node undefined, so a
|
|
347
|
+
// caller's `!== undefined` check passes on one runtime and dereferences
|
|
348
|
+
// null on the other. One absent value for both.
|
|
349
|
+
get: (params) => raw.get(...args(params)) ?? void 0,
|
|
350
|
+
all: (params) => raw.all(...args(params)),
|
|
351
|
+
run: (params) => raw.run(...args(params))
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
var IN_MEMORY = /* @__PURE__ */ new Set([":memory:", ""]);
|
|
355
|
+
var JOURNAL_SQL = Object.freeze({
|
|
356
|
+
WAL: "PRAGMA journal_mode = WAL",
|
|
357
|
+
DELETE: "PRAGMA journal_mode = DELETE",
|
|
358
|
+
MEMORY: "PRAGMA journal_mode = MEMORY"
|
|
359
|
+
});
|
|
360
|
+
function build(binding, path, options) {
|
|
361
|
+
const { provider, ctor } = binding;
|
|
362
|
+
const readOnly = options.readOnly ?? false;
|
|
363
|
+
const raw = new ctor(path, provider.openOptions(readOnly));
|
|
364
|
+
const journalMode = options.journalMode ?? (IN_MEMORY.has(path) ? "MEMORY" : "WAL");
|
|
365
|
+
const journalSql = JOURNAL_SQL[journalMode];
|
|
366
|
+
if (journalSql === void 0) {
|
|
367
|
+
throw new SqliteBindError(`unknown journal mode ${String(journalMode)}`);
|
|
368
|
+
}
|
|
369
|
+
if (!readOnly) raw.exec(journalSql);
|
|
370
|
+
let inTransaction = false;
|
|
371
|
+
return {
|
|
372
|
+
backend: provider.backend,
|
|
373
|
+
exec: (sql) => raw.exec(sql),
|
|
374
|
+
prepare: (sql) => wrapStatement(raw.prepare(sql), sql, provider),
|
|
375
|
+
transaction(fn) {
|
|
376
|
+
if (inTransaction) throw new Error("transaction() is not nestable");
|
|
377
|
+
inTransaction = true;
|
|
378
|
+
raw.exec("BEGIN");
|
|
379
|
+
try {
|
|
380
|
+
const result = fn();
|
|
381
|
+
raw.exec("COMMIT");
|
|
382
|
+
return result;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
raw.exec("ROLLBACK");
|
|
385
|
+
throw error;
|
|
386
|
+
} finally {
|
|
387
|
+
inTransaction = false;
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
close: () => raw.close()
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async function openDatabase(path, options = {}) {
|
|
394
|
+
return build(await loadBinding(options.backend), path, options);
|
|
395
|
+
}
|
|
396
|
+
function moduleBase() {
|
|
397
|
+
return typeof __filename === "string" ? __filename : import.meta.url;
|
|
398
|
+
}
|
|
399
|
+
function loadBindingSync(pinned) {
|
|
400
|
+
const attempts = [];
|
|
401
|
+
const require2 = createRequire(moduleBase());
|
|
402
|
+
for (const provider of shortlist(pinned)) {
|
|
403
|
+
if (!provider.eligible()) {
|
|
404
|
+
attempts.push([provider.specifier, "not eligible in this runtime"]);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const already = bindings.get(provider.backend);
|
|
408
|
+
if (already) return already;
|
|
409
|
+
try {
|
|
410
|
+
const mod = require2(provider.specifier);
|
|
411
|
+
const ctor = provider.exportName === "default" ? mod.default ?? mod : mod[provider.exportName];
|
|
412
|
+
if (typeof ctor !== "function") {
|
|
413
|
+
attempts.push([provider.specifier, `no ${provider.exportName} export`]);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const binding = { provider, ctor };
|
|
417
|
+
bindings.set(provider.backend, binding);
|
|
418
|
+
return binding;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
attempts.push([provider.specifier, error.message]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
throw new SqliteUnavailableError(attempts);
|
|
424
|
+
}
|
|
425
|
+
function openDatabaseSync(path, options = {}) {
|
|
426
|
+
return build(loadBindingSync(options.backend), path, options);
|
|
427
|
+
}
|
|
428
|
+
async function availableBackends() {
|
|
429
|
+
const found = [];
|
|
430
|
+
for (const provider of PROVIDERS) {
|
|
431
|
+
if (!provider.eligible()) continue;
|
|
432
|
+
try {
|
|
433
|
+
await loadBinding(provider.backend);
|
|
434
|
+
found.push(provider.backend);
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return found;
|
|
439
|
+
}
|
|
440
|
+
function resetBindingCache() {
|
|
441
|
+
bindings.clear();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/crypto/envelope.ts
|
|
445
|
+
import {
|
|
446
|
+
createCipheriv,
|
|
447
|
+
createDecipheriv,
|
|
448
|
+
createHmac,
|
|
449
|
+
randomBytes
|
|
450
|
+
} from "crypto";
|
|
451
|
+
var MAGIC = new Uint8Array([69, 78, 86, 67]);
|
|
452
|
+
var FORMAT_AES_256_GCM = 1;
|
|
453
|
+
var IV_BYTES = 12;
|
|
454
|
+
var TAG_BYTES = 16;
|
|
455
|
+
var KEK_BYTES = 32;
|
|
456
|
+
var HEADER_FIXED = MAGIC.length + 1 + 4 + 1;
|
|
457
|
+
var EnvelopeFormatError = class extends Error {
|
|
458
|
+
constructor(message) {
|
|
459
|
+
super(message);
|
|
460
|
+
this.name = "EnvelopeFormatError";
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
var EnvelopeAuthError = class extends Error {
|
|
464
|
+
constructor(message) {
|
|
465
|
+
super(message);
|
|
466
|
+
this.name = "EnvelopeAuthError";
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
function concat(parts) {
|
|
470
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
471
|
+
const out = new Uint8Array(total);
|
|
472
|
+
let at = 0;
|
|
473
|
+
for (const part of parts) {
|
|
474
|
+
out.set(part, at);
|
|
475
|
+
at += part.length;
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
function bytesEqual(a, b) {
|
|
480
|
+
if (a.length !== b.length) return false;
|
|
481
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
function checkKek(kek) {
|
|
485
|
+
if (kek.length !== KEK_BYTES) {
|
|
486
|
+
throw new EnvelopeFormatError(
|
|
487
|
+
`KEK must be ${KEK_BYTES} bytes for AES-256-GCM, got ${kek.length}`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function encodeHeader(header) {
|
|
492
|
+
const out = new Uint8Array(HEADER_FIXED + header.iv.length);
|
|
493
|
+
out.set(MAGIC, 0);
|
|
494
|
+
out[MAGIC.length] = header.format;
|
|
495
|
+
new DataView(out.buffer).setUint32(
|
|
496
|
+
MAGIC.length + 1,
|
|
497
|
+
header.kekVersion,
|
|
498
|
+
false
|
|
499
|
+
);
|
|
500
|
+
out[MAGIC.length + 5] = header.iv.length;
|
|
501
|
+
out.set(header.iv, HEADER_FIXED);
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
function decodeHeader(blob) {
|
|
505
|
+
if (blob.length < HEADER_FIXED) {
|
|
506
|
+
throw new EnvelopeFormatError("blob shorter than the envelope header");
|
|
507
|
+
}
|
|
508
|
+
if (!bytesEqual(blob.subarray(0, MAGIC.length), MAGIC)) {
|
|
509
|
+
throw new EnvelopeFormatError("not an ENVC envelope");
|
|
510
|
+
}
|
|
511
|
+
const format = blob[MAGIC.length];
|
|
512
|
+
if (format !== FORMAT_AES_256_GCM) {
|
|
513
|
+
throw new EnvelopeFormatError(`unknown envelope format ${format}`);
|
|
514
|
+
}
|
|
515
|
+
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
516
|
+
const kekVersion = view.getUint32(MAGIC.length + 1, false);
|
|
517
|
+
const ivLen = blob[MAGIC.length + 5];
|
|
518
|
+
const headerLength = HEADER_FIXED + ivLen;
|
|
519
|
+
if (blob.length < headerLength + TAG_BYTES) {
|
|
520
|
+
throw new EnvelopeFormatError("blob too short for its declared iv and tag");
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
header: {
|
|
524
|
+
format,
|
|
525
|
+
kekVersion,
|
|
526
|
+
iv: blob.subarray(HEADER_FIXED, headerLength)
|
|
527
|
+
},
|
|
528
|
+
headerBytes: blob.subarray(0, headerLength),
|
|
529
|
+
// The tag trails the ciphertext, as Web Crypto lays it out; node:crypto
|
|
530
|
+
// keeps the two apart, so the split happens here and nowhere else.
|
|
531
|
+
ciphertext: blob.subarray(headerLength, blob.length - TAG_BYTES),
|
|
532
|
+
tag: blob.subarray(blob.length - TAG_BYTES)
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function seal(input) {
|
|
536
|
+
checkKek(input.kek);
|
|
537
|
+
const iv = input.iv ?? new Uint8Array(randomBytes(IV_BYTES));
|
|
538
|
+
if (iv.length === 0 || iv.length > 255) {
|
|
539
|
+
throw new EnvelopeFormatError(`iv length ${iv.length} is not encodable`);
|
|
540
|
+
}
|
|
541
|
+
const headerBytes = encodeHeader({
|
|
542
|
+
format: FORMAT_AES_256_GCM,
|
|
543
|
+
kekVersion: input.kekVersion,
|
|
544
|
+
iv
|
|
545
|
+
});
|
|
546
|
+
const cipher = createCipheriv("aes-256-gcm", input.kek, iv);
|
|
547
|
+
cipher.setAAD(concat([headerBytes, input.context]));
|
|
548
|
+
const body = new Uint8Array(
|
|
549
|
+
Buffer.concat([cipher.update(input.plaintext), cipher.final()])
|
|
550
|
+
);
|
|
551
|
+
return concat([headerBytes, body, new Uint8Array(cipher.getAuthTag())]);
|
|
552
|
+
}
|
|
553
|
+
function open(input) {
|
|
554
|
+
checkKek(input.kek);
|
|
555
|
+
const { header, headerBytes, ciphertext, tag } = decodeHeader(input.blob);
|
|
556
|
+
try {
|
|
557
|
+
const decipher = createDecipheriv("aes-256-gcm", input.kek, header.iv);
|
|
558
|
+
decipher.setAAD(concat([headerBytes, input.context]));
|
|
559
|
+
decipher.setAuthTag(tag);
|
|
560
|
+
return new Uint8Array(
|
|
561
|
+
Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
562
|
+
);
|
|
563
|
+
} catch {
|
|
564
|
+
throw new EnvelopeAuthError(
|
|
565
|
+
"envelope did not authenticate: wrong key, wrong context, or altered bytes"
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function readHeader(blob) {
|
|
570
|
+
return decodeHeader(blob).header;
|
|
571
|
+
}
|
|
572
|
+
function hashKeyName(kek, name) {
|
|
573
|
+
checkKek(kek);
|
|
574
|
+
const ns = createHmac("sha256", kek).update("envs:key-name:v1").digest();
|
|
575
|
+
return new Uint8Array(createHmac("sha256", ns).update(name, "utf8").digest());
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/crypto/keyring.ts
|
|
579
|
+
import {
|
|
580
|
+
hkdfSync,
|
|
581
|
+
randomBytes as randomBytes2,
|
|
582
|
+
scryptSync,
|
|
583
|
+
timingSafeEqual
|
|
584
|
+
} from "crypto";
|
|
585
|
+
var DEK_BYTES = 32;
|
|
586
|
+
var SALT_BYTES = 16;
|
|
587
|
+
var CODE_BITS = 130;
|
|
588
|
+
var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
589
|
+
var GROUP = 5;
|
|
590
|
+
function kdfFor(method) {
|
|
591
|
+
return method === "recovery" ? "scrypt" : "hkdf";
|
|
592
|
+
}
|
|
593
|
+
var RecoveryCodeError = class extends Error {
|
|
594
|
+
constructor(message) {
|
|
595
|
+
super(message);
|
|
596
|
+
this.name = "RecoveryCodeError";
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
var KeyringLockedError = class extends Error {
|
|
600
|
+
constructor(tried) {
|
|
601
|
+
super(
|
|
602
|
+
`none of the ${tried} wraps opened with the secret given; try another recovery code or the KEK`
|
|
603
|
+
);
|
|
604
|
+
this.tried = tried;
|
|
605
|
+
this.name = "KeyringLockedError";
|
|
606
|
+
}
|
|
607
|
+
tried;
|
|
608
|
+
};
|
|
609
|
+
var SCRYPT = Object.freeze({ n: 1 << 15, r: 8, p: 1 });
|
|
610
|
+
function formatRecoveryCode(raw) {
|
|
611
|
+
const groups = [];
|
|
612
|
+
for (let i = 0; i < raw.length; i += GROUP)
|
|
613
|
+
groups.push(raw.slice(i, i + GROUP));
|
|
614
|
+
return groups.join("-");
|
|
615
|
+
}
|
|
616
|
+
function generateRecoveryCode() {
|
|
617
|
+
const chars = Math.ceil(CODE_BITS / 5);
|
|
618
|
+
const source = randomBytes2(chars);
|
|
619
|
+
let out = "";
|
|
620
|
+
for (let i = 0; i < chars; i += 1) {
|
|
621
|
+
out += ALPHABET[source[i] % ALPHABET.length];
|
|
622
|
+
}
|
|
623
|
+
return formatRecoveryCode(out);
|
|
624
|
+
}
|
|
625
|
+
function normaliseRecoveryCode(input) {
|
|
626
|
+
const cleaned = input.toUpperCase().replace(/[\s-]/g, "").replace(/[IL]/g, "1").replace(/O/g, "0");
|
|
627
|
+
if (cleaned.length === 0) {
|
|
628
|
+
throw new RecoveryCodeError("recovery code is empty");
|
|
629
|
+
}
|
|
630
|
+
for (const ch of cleaned) {
|
|
631
|
+
if (!ALPHABET.includes(ch)) {
|
|
632
|
+
throw new RecoveryCodeError(
|
|
633
|
+
`recovery code contains "${ch}", which is not in the alphabet`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return cleaned;
|
|
638
|
+
}
|
|
639
|
+
function wrapKeyFrom(secret, salt, method, cost) {
|
|
640
|
+
if (kdfFor(method) === "hkdf") {
|
|
641
|
+
return new Uint8Array(
|
|
642
|
+
hkdfSync("sha256", secret, salt, "envs:dek-wrap:v1", DEK_BYTES)
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return new Uint8Array(
|
|
646
|
+
scryptSync(secret, salt, DEK_BYTES, {
|
|
647
|
+
N: cost.n,
|
|
648
|
+
r: cost.r,
|
|
649
|
+
p: cost.p,
|
|
650
|
+
// scrypt's default cap is below what N=32768 needs.
|
|
651
|
+
maxmem: 256 * cost.n * cost.r
|
|
652
|
+
})
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
var encoder = new TextEncoder();
|
|
656
|
+
function wrapContext(wrapId, method) {
|
|
657
|
+
return encoder.encode(`envs:dek-wrap:v1:${method}:${wrapId}`);
|
|
658
|
+
}
|
|
659
|
+
function makeWrap(dek, secret, method, wrapId) {
|
|
660
|
+
const salt = new Uint8Array(randomBytes2(SALT_BYTES));
|
|
661
|
+
const key = wrapKeyFrom(secret, salt, method, SCRYPT);
|
|
662
|
+
return {
|
|
663
|
+
wrapId,
|
|
664
|
+
method,
|
|
665
|
+
salt,
|
|
666
|
+
n: SCRYPT.n,
|
|
667
|
+
r: SCRYPT.r,
|
|
668
|
+
p: SCRYPT.p,
|
|
669
|
+
envelope: seal({
|
|
670
|
+
kek: key,
|
|
671
|
+
kekVersion: 1,
|
|
672
|
+
plaintext: dek,
|
|
673
|
+
context: wrapContext(wrapId, method)
|
|
674
|
+
})
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function createKeyring(options) {
|
|
678
|
+
const dek = options.dek ?? new Uint8Array(randomBytes2(DEK_BYTES));
|
|
679
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
680
|
+
const newCode = options.newCode ?? generateRecoveryCode;
|
|
681
|
+
const wraps = [makeWrap(dek, options.kek, "kek", newId())];
|
|
682
|
+
const codes = [];
|
|
683
|
+
for (let i = 0; i < (options.recoveryCodes ?? 0); i += 1) {
|
|
684
|
+
const code = newCode();
|
|
685
|
+
codes.push(code);
|
|
686
|
+
wraps.push(
|
|
687
|
+
makeWrap(
|
|
688
|
+
dek,
|
|
689
|
+
encoder.encode(normaliseRecoveryCode(code)),
|
|
690
|
+
"recovery",
|
|
691
|
+
newId()
|
|
692
|
+
)
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return { dek, wraps, recoveryCodes: codes };
|
|
696
|
+
}
|
|
697
|
+
function unlockDek(wraps, unlock) {
|
|
698
|
+
const method = "kek" in unlock ? "kek" : "recovery";
|
|
699
|
+
const secret = "kek" in unlock ? unlock.kek : encoder.encode(normaliseRecoveryCode(unlock.recoveryCode));
|
|
700
|
+
const candidates = wraps.filter((wrap) => wrap.method === method);
|
|
701
|
+
for (const wrap of candidates) {
|
|
702
|
+
const key = wrapKeyFrom(secret, wrap.salt, wrap.method, wrap);
|
|
703
|
+
try {
|
|
704
|
+
return open({
|
|
705
|
+
kek: key,
|
|
706
|
+
blob: wrap.envelope,
|
|
707
|
+
context: wrapContext(wrap.wrapId, wrap.method)
|
|
708
|
+
});
|
|
709
|
+
} catch (error) {
|
|
710
|
+
if (!(error instanceof EnvelopeAuthError)) throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
throw new KeyringLockedError(candidates.length);
|
|
714
|
+
}
|
|
715
|
+
function addWrap(dek, unlock, wrapId) {
|
|
716
|
+
const method = "kek" in unlock ? "kek" : "recovery";
|
|
717
|
+
const secret = "kek" in unlock ? unlock.kek : encoder.encode(normaliseRecoveryCode(unlock.recoveryCode));
|
|
718
|
+
return makeWrap(dek, secret, method, wrapId);
|
|
719
|
+
}
|
|
720
|
+
function sameKey(a, b) {
|
|
721
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/catalog/schema.ts
|
|
725
|
+
var SCHEMA_VERSION = 4;
|
|
726
|
+
var V1 = [
|
|
727
|
+
`CREATE TABLE schema_meta (
|
|
728
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
729
|
+
version INTEGER NOT NULL,
|
|
730
|
+
catalog_id TEXT NOT NULL,
|
|
731
|
+
created_at TEXT NOT NULL
|
|
732
|
+
)`,
|
|
733
|
+
`CREATE TABLE sources (
|
|
734
|
+
source_id TEXT PRIMARY KEY,
|
|
735
|
+
path TEXT NOT NULL UNIQUE,
|
|
736
|
+
alias TEXT NOT NULL UNIQUE,
|
|
737
|
+
kind TEXT NOT NULL,
|
|
738
|
+
digest TEXT,
|
|
739
|
+
added_at TEXT NOT NULL,
|
|
740
|
+
last_seen_at TEXT,
|
|
741
|
+
retired_at TEXT
|
|
742
|
+
)`,
|
|
743
|
+
// A retired alias is never reused: the same name must not come to mean a
|
|
744
|
+
// different file for a script that already refers to it.
|
|
745
|
+
`CREATE TABLE alias_retired (
|
|
746
|
+
alias TEXT PRIMARY KEY,
|
|
747
|
+
source_id TEXT NOT NULL,
|
|
748
|
+
retired_at TEXT NOT NULL
|
|
749
|
+
)`,
|
|
750
|
+
// Names are stored as HMACs. Opening the file without the key shows neither
|
|
751
|
+
// values nor which keys exist.
|
|
752
|
+
`CREATE TABLE keys (
|
|
753
|
+
key_hash BLOB PRIMARY KEY,
|
|
754
|
+
first_seen_at TEXT NOT NULL
|
|
755
|
+
)`,
|
|
756
|
+
`CREATE TABLE releases (
|
|
757
|
+
revision_id TEXT PRIMARY KEY,
|
|
758
|
+
created_at TEXT NOT NULL,
|
|
759
|
+
note TEXT
|
|
760
|
+
)`,
|
|
761
|
+
`CREATE TABLE items (
|
|
762
|
+
source_id TEXT NOT NULL REFERENCES sources (source_id),
|
|
763
|
+
key_hash BLOB NOT NULL REFERENCES keys (key_hash),
|
|
764
|
+
revision_id TEXT NOT NULL REFERENCES releases (revision_id),
|
|
765
|
+
envelope BLOB NOT NULL,
|
|
766
|
+
kek_version INTEGER NOT NULL,
|
|
767
|
+
created_at TEXT NOT NULL,
|
|
768
|
+
PRIMARY KEY (source_id, key_hash, revision_id)
|
|
769
|
+
)`,
|
|
770
|
+
`CREATE INDEX items_by_revision ON items (revision_id)`,
|
|
771
|
+
`CREATE INDEX items_by_key ON items (key_hash)`,
|
|
772
|
+
// One row. The pointer is what a rollback moves.
|
|
773
|
+
`CREATE TABLE pointer (
|
|
774
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
775
|
+
revision_id TEXT NOT NULL REFERENCES releases (revision_id),
|
|
776
|
+
updated_at TEXT NOT NULL
|
|
777
|
+
)`,
|
|
778
|
+
`CREATE TABLE audit (
|
|
779
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
780
|
+
at TEXT NOT NULL,
|
|
781
|
+
action TEXT NOT NULL,
|
|
782
|
+
subject TEXT,
|
|
783
|
+
detail TEXT
|
|
784
|
+
)`,
|
|
785
|
+
`CREATE TABLE kek_history (
|
|
786
|
+
version INTEGER PRIMARY KEY,
|
|
787
|
+
fingerprint TEXT NOT NULL,
|
|
788
|
+
created_at TEXT NOT NULL,
|
|
789
|
+
retired_at TEXT
|
|
790
|
+
)`,
|
|
791
|
+
// One row per way of recovering the DEK: the KEK, and each recovery code.
|
|
792
|
+
// Only the wrapped DEK is here. A recovery code is printed once at init and
|
|
793
|
+
// never stored, so this table cannot give one back.
|
|
794
|
+
`CREATE TABLE dek_wraps (
|
|
795
|
+
wrap_id TEXT PRIMARY KEY,
|
|
796
|
+
method TEXT NOT NULL CHECK (method IN ('kek', 'recovery')),
|
|
797
|
+
salt BLOB NOT NULL,
|
|
798
|
+
scrypt_n INTEGER NOT NULL,
|
|
799
|
+
scrypt_r INTEGER NOT NULL,
|
|
800
|
+
scrypt_p INTEGER NOT NULL,
|
|
801
|
+
envelope BLOB NOT NULL,
|
|
802
|
+
created_at TEXT NOT NULL,
|
|
803
|
+
last_used_at TEXT,
|
|
804
|
+
retired_at TEXT
|
|
805
|
+
)`
|
|
806
|
+
];
|
|
807
|
+
var V2 = [
|
|
808
|
+
`CREATE TABLE watch_targets (
|
|
809
|
+
target_id TEXT PRIMARY KEY,
|
|
810
|
+
pattern TEXT NOT NULL UNIQUE,
|
|
811
|
+
mode TEXT NOT NULL CHECK (mode IN ('include', 'exclude')),
|
|
812
|
+
added_at TEXT NOT NULL
|
|
813
|
+
)`
|
|
814
|
+
];
|
|
815
|
+
var V3 = [`ALTER TABLE keys ADD COLUMN sensitivity TEXT`];
|
|
816
|
+
var V4 = [
|
|
817
|
+
`CREATE TABLE template_ref (
|
|
818
|
+
name TEXT PRIMARY KEY,
|
|
819
|
+
version INTEGER NOT NULL,
|
|
820
|
+
digest TEXT NOT NULL,
|
|
821
|
+
payload TEXT NOT NULL,
|
|
822
|
+
applied_at TEXT NOT NULL
|
|
823
|
+
)`
|
|
824
|
+
];
|
|
825
|
+
var MIGRATIONS = [
|
|
826
|
+
{ version: 1, statements: V1 },
|
|
827
|
+
{ version: 2, statements: V2 },
|
|
828
|
+
{ version: 3, statements: V3 },
|
|
829
|
+
{ version: 4, statements: V4 }
|
|
830
|
+
];
|
|
831
|
+
var CatalogVersionError = class extends Error {
|
|
832
|
+
constructor(found, supported, message) {
|
|
833
|
+
super(message);
|
|
834
|
+
this.found = found;
|
|
835
|
+
this.supported = supported;
|
|
836
|
+
this.name = "CatalogVersionError";
|
|
837
|
+
}
|
|
838
|
+
found;
|
|
839
|
+
supported;
|
|
840
|
+
};
|
|
841
|
+
function tableExists(db, name) {
|
|
842
|
+
const row = db.prepare(
|
|
843
|
+
"SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name = $name"
|
|
844
|
+
).get({ name });
|
|
845
|
+
return (row?.n ?? 0) > 0;
|
|
846
|
+
}
|
|
847
|
+
function readMeta(db) {
|
|
848
|
+
if (!tableExists(db, "schema_meta")) return void 0;
|
|
849
|
+
const row = db.prepare(
|
|
850
|
+
"SELECT version, catalog_id, created_at FROM schema_meta WHERE id = 1"
|
|
851
|
+
).get();
|
|
852
|
+
if (row === void 0) return void 0;
|
|
853
|
+
return {
|
|
854
|
+
version: Number(row.version),
|
|
855
|
+
catalogId: row.catalog_id,
|
|
856
|
+
createdAt: row.created_at
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
function applyMigrations(db, from, to) {
|
|
860
|
+
for (const migration of MIGRATIONS) {
|
|
861
|
+
if (migration.version <= from || migration.version > to) continue;
|
|
862
|
+
for (const statement of migration.statements) db.exec(statement);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function createSchema(db, options = {}) {
|
|
866
|
+
const catalogId = options.catalogId ?? crypto.randomUUID();
|
|
867
|
+
const createdAt = (options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
|
|
868
|
+
db.transaction(() => {
|
|
869
|
+
applyMigrations(db, 0, SCHEMA_VERSION);
|
|
870
|
+
db.prepare(
|
|
871
|
+
"INSERT INTO schema_meta (id, version, catalog_id, created_at) VALUES (1, $version, $catalogId, $createdAt)"
|
|
872
|
+
).run({ version: SCHEMA_VERSION, catalogId, createdAt });
|
|
873
|
+
});
|
|
874
|
+
return { version: SCHEMA_VERSION, catalogId, createdAt };
|
|
875
|
+
}
|
|
876
|
+
function checkVersion(meta) {
|
|
877
|
+
if (meta.version > SCHEMA_VERSION) {
|
|
878
|
+
throw new CatalogVersionError(
|
|
879
|
+
meta.version,
|
|
880
|
+
SCHEMA_VERSION,
|
|
881
|
+
`catalog is at schema ${meta.version} and this build understands ${SCHEMA_VERSION}; upgrade @modootoday/envs`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (meta.version < SCHEMA_VERSION) {
|
|
885
|
+
throw new CatalogVersionError(
|
|
886
|
+
meta.version,
|
|
887
|
+
SCHEMA_VERSION,
|
|
888
|
+
`catalog is at schema ${meta.version} and this build is at ${SCHEMA_VERSION}; run "envs migrate" to upgrade it`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function openCatalog(path, options = {}) {
|
|
893
|
+
const db = await openDatabase(path, options);
|
|
894
|
+
let meta = readMeta(db);
|
|
895
|
+
if (meta === void 0) {
|
|
896
|
+
if (options.create !== true) {
|
|
897
|
+
db.close();
|
|
898
|
+
throw new CatalogVersionError(
|
|
899
|
+
0,
|
|
900
|
+
SCHEMA_VERSION,
|
|
901
|
+
`no catalog at ${path}; run "envs init" first`
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
meta = createSchema(db, options);
|
|
905
|
+
} else {
|
|
906
|
+
try {
|
|
907
|
+
checkVersion(meta);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
db.close();
|
|
910
|
+
throw error;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return { db, meta };
|
|
914
|
+
}
|
|
915
|
+
function migrate(db) {
|
|
916
|
+
const meta = readMeta(db);
|
|
917
|
+
if (meta === void 0) {
|
|
918
|
+
throw new CatalogVersionError(0, SCHEMA_VERSION, "no catalog to migrate");
|
|
919
|
+
}
|
|
920
|
+
if (meta.version > SCHEMA_VERSION) {
|
|
921
|
+
throw new CatalogVersionError(
|
|
922
|
+
meta.version,
|
|
923
|
+
SCHEMA_VERSION,
|
|
924
|
+
`catalog is at schema ${meta.version}, newer than this build`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
if (meta.version === SCHEMA_VERSION) return meta;
|
|
928
|
+
db.transaction(() => {
|
|
929
|
+
applyMigrations(db, meta.version, SCHEMA_VERSION);
|
|
930
|
+
db.prepare("UPDATE schema_meta SET version = $version WHERE id = 1").run({
|
|
931
|
+
version: SCHEMA_VERSION
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
return { ...meta, version: SCHEMA_VERSION };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/loader/locate.ts
|
|
938
|
+
import { existsSync } from "fs";
|
|
939
|
+
import { homedir } from "os";
|
|
940
|
+
import { dirname, join, resolve } from "path";
|
|
941
|
+
var DIR_NAME = ".envs";
|
|
942
|
+
var CATALOG_FILE = "catalog.sqlite";
|
|
943
|
+
var ROOT_MARKERS = [
|
|
944
|
+
"bun.lock",
|
|
945
|
+
"bun.lockb",
|
|
946
|
+
"pnpm-workspace.yaml",
|
|
947
|
+
"package-lock.json",
|
|
948
|
+
"yarn.lock",
|
|
949
|
+
".git",
|
|
950
|
+
"package.json"
|
|
951
|
+
];
|
|
952
|
+
function findProjectRoot(from) {
|
|
953
|
+
let dir = resolve(from);
|
|
954
|
+
for (; ; ) {
|
|
955
|
+
for (const marker of ROOT_MARKERS) {
|
|
956
|
+
if (existsSync(join(dir, marker))) return dir;
|
|
957
|
+
}
|
|
958
|
+
const parent = dirname(dir);
|
|
959
|
+
if (parent === dir) return void 0;
|
|
960
|
+
dir = parent;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function globalDir(home = homedir()) {
|
|
964
|
+
return join(home, DIR_NAME);
|
|
965
|
+
}
|
|
966
|
+
function locateCatalogs(options = {}) {
|
|
967
|
+
const env = options.env ?? process.env;
|
|
968
|
+
const cwd = options.cwd ?? process.cwd();
|
|
969
|
+
const home = options.home ?? env["HOME"] ?? homedir();
|
|
970
|
+
const explicit = env["ENVS_CATALOG_PATH"];
|
|
971
|
+
if (explicit !== void 0 && explicit !== "") {
|
|
972
|
+
return { project: resolve(explicit), source: "explicit" };
|
|
973
|
+
}
|
|
974
|
+
const globalPath = join(globalDir(home), CATALOG_FILE);
|
|
975
|
+
const root = findProjectRoot(cwd);
|
|
976
|
+
if (root === void 0) {
|
|
977
|
+
return { project: globalPath, source: "global" };
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
project: join(root, DIR_NAME, CATALOG_FILE),
|
|
981
|
+
global: globalPath,
|
|
982
|
+
projectRoot: root,
|
|
983
|
+
source: "project"
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
function cacheDir(options = {}) {
|
|
987
|
+
const argv1 = process.argv[1] ?? "";
|
|
988
|
+
const ephemeral = /[\\/]_npx[\\/]/.test(argv1);
|
|
989
|
+
const root = findProjectRoot(options.cwd ?? process.cwd());
|
|
990
|
+
if (!ephemeral && root !== void 0) {
|
|
991
|
+
return join(root, "node_modules", ".cache", "envs");
|
|
992
|
+
}
|
|
993
|
+
return join(globalDir(options.home ?? homedir()), "cache");
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// src/crypto/digest.ts
|
|
997
|
+
function toHex(bytes) {
|
|
998
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
999
|
+
""
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
async function sha256Hex(bytes) {
|
|
1003
|
+
const copy = bytes.slice();
|
|
1004
|
+
return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", copy)));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/loader/read.ts
|
|
1008
|
+
var NoReleaseError = class extends Error {
|
|
1009
|
+
constructor(message) {
|
|
1010
|
+
super(message);
|
|
1011
|
+
this.name = "NoReleaseError";
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
var decoder = new TextDecoder();
|
|
1015
|
+
var encoder2 = new TextEncoder();
|
|
1016
|
+
function itemContext(sourceId, keyHash, revisionId) {
|
|
1017
|
+
return encoder2.encode(
|
|
1018
|
+
`envs:item:v1:${sourceId}:${toHex(keyHash)}:${revisionId}`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
function assertReadable(db) {
|
|
1022
|
+
const meta = readMeta(db);
|
|
1023
|
+
if (meta !== void 0 && meta.version > SCHEMA_VERSION) {
|
|
1024
|
+
throw new CatalogVersionError(
|
|
1025
|
+
meta.version,
|
|
1026
|
+
SCHEMA_VERSION,
|
|
1027
|
+
`this catalog was written by a newer envs (schema ${String(meta.version)}, this build reads ${String(SCHEMA_VERSION)}); upgrade @modootoday/envs`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function readWraps(db) {
|
|
1032
|
+
assertReadable(db);
|
|
1033
|
+
return db.prepare(
|
|
1034
|
+
"SELECT wrap_id, method, salt, scrypt_n, scrypt_r, scrypt_p, envelope FROM dek_wraps WHERE retired_at IS NULL"
|
|
1035
|
+
).all().map((row) => ({
|
|
1036
|
+
wrapId: row.wrap_id,
|
|
1037
|
+
method: row.method === "kek" ? "kek" : "recovery",
|
|
1038
|
+
salt: new Uint8Array(row.salt),
|
|
1039
|
+
n: Number(row.scrypt_n),
|
|
1040
|
+
r: Number(row.scrypt_r),
|
|
1041
|
+
p: Number(row.scrypt_p),
|
|
1042
|
+
envelope: new Uint8Array(row.envelope)
|
|
1043
|
+
}));
|
|
1044
|
+
}
|
|
1045
|
+
function currentRevision(db) {
|
|
1046
|
+
return db.prepare(
|
|
1047
|
+
"SELECT revision_id FROM pointer WHERE id = 1"
|
|
1048
|
+
).get()?.revision_id;
|
|
1049
|
+
}
|
|
1050
|
+
function readEntries(db, options) {
|
|
1051
|
+
const revision = options.revisionId ?? currentRevision(db);
|
|
1052
|
+
if (revision === void 0) {
|
|
1053
|
+
throw new NoReleaseError(
|
|
1054
|
+
'catalog has no current release; run "envs pull" or "envs load" first'
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
const dek = unlockDek(readWraps(db), options.unlock);
|
|
1058
|
+
const rows = db.prepare(
|
|
1059
|
+
`SELECT i.source_id, s.alias, s.path, i.key_hash, i.envelope
|
|
1060
|
+
FROM items i
|
|
1061
|
+
JOIN sources s ON s.source_id = i.source_id
|
|
1062
|
+
WHERE i.revision_id = $revision
|
|
1063
|
+
AND s.retired_at IS NULL
|
|
1064
|
+
ORDER BY s.added_at, s.source_id, i.rowid`
|
|
1065
|
+
).all({ revision });
|
|
1066
|
+
const wanted = options.aliases === void 0 ? void 0 : new Set(options.aliases);
|
|
1067
|
+
const entries = [];
|
|
1068
|
+
for (const row of rows) {
|
|
1069
|
+
if (wanted !== void 0 && !wanted.has(row.alias)) continue;
|
|
1070
|
+
const plain = open({
|
|
1071
|
+
kek: dek,
|
|
1072
|
+
blob: new Uint8Array(row.envelope),
|
|
1073
|
+
context: itemContext(
|
|
1074
|
+
row.source_id,
|
|
1075
|
+
new Uint8Array(row.key_hash),
|
|
1076
|
+
revision
|
|
1077
|
+
)
|
|
1078
|
+
});
|
|
1079
|
+
const text = decoder.decode(plain);
|
|
1080
|
+
const split = text.indexOf("=");
|
|
1081
|
+
if (split <= 0) {
|
|
1082
|
+
throw new Error(
|
|
1083
|
+
`item in source ${row.alias} does not carry a key name; the catalog is corrupt`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
entries.push({
|
|
1087
|
+
key: text.slice(0, split),
|
|
1088
|
+
value: text.slice(split + 1),
|
|
1089
|
+
sourceId: row.source_id,
|
|
1090
|
+
alias: row.alias,
|
|
1091
|
+
path: row.path
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (options.aliases === void 0) return entries;
|
|
1095
|
+
const rank = new Map(options.aliases.map((alias, index) => [alias, index]));
|
|
1096
|
+
return [...entries].sort(
|
|
1097
|
+
(a, b) => (rank.get(a.alias) ?? 0) - (rank.get(b.alias) ?? 0)
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/loader/config.ts
|
|
1102
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
1103
|
+
var ConflictError = class extends Error {
|
|
1104
|
+
constructor(key, first, second) {
|
|
1105
|
+
super(
|
|
1106
|
+
`key "${key}" is declared by ${first.layer}:${first.from} and ${second.layer}:${second.from}`
|
|
1107
|
+
);
|
|
1108
|
+
this.key = key;
|
|
1109
|
+
this.first = first;
|
|
1110
|
+
this.second = second;
|
|
1111
|
+
this.name = "ConflictError";
|
|
1112
|
+
}
|
|
1113
|
+
key;
|
|
1114
|
+
first;
|
|
1115
|
+
second;
|
|
1116
|
+
};
|
|
1117
|
+
var KekMissingError = class extends Error {
|
|
1118
|
+
constructor() {
|
|
1119
|
+
super(
|
|
1120
|
+
"no key available: set ENVS_KEK, or pass unlock explicitly. Values stay sealed without it."
|
|
1121
|
+
);
|
|
1122
|
+
this.name = "KekMissingError";
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
function decodeKek(raw) {
|
|
1126
|
+
const bytes = new Uint8Array(Buffer.from(raw, "base64"));
|
|
1127
|
+
if (bytes.length !== 32) {
|
|
1128
|
+
throw new KekMissingError();
|
|
1129
|
+
}
|
|
1130
|
+
return bytes;
|
|
1131
|
+
}
|
|
1132
|
+
function resolveUnlock(options, env) {
|
|
1133
|
+
if (options.unlock !== void 0) return options.unlock;
|
|
1134
|
+
const raw = env["ENVS_KEK"];
|
|
1135
|
+
if (raw === void 0 || raw === "") throw new KekMissingError();
|
|
1136
|
+
return { kek: decodeKek(raw) };
|
|
1137
|
+
}
|
|
1138
|
+
function isTruthy(value) {
|
|
1139
|
+
return value !== void 0 && value !== "" && value !== "0" && value !== "false";
|
|
1140
|
+
}
|
|
1141
|
+
function fileEntries(paths, encoding) {
|
|
1142
|
+
const entries = [];
|
|
1143
|
+
for (const path of paths) {
|
|
1144
|
+
if (!existsSync2(path)) continue;
|
|
1145
|
+
const parsed = parseEnv(readFileSync(path, encoding));
|
|
1146
|
+
const record = toRecord(parsed);
|
|
1147
|
+
if (record === null) {
|
|
1148
|
+
return {
|
|
1149
|
+
entries,
|
|
1150
|
+
error: new Error(
|
|
1151
|
+
`${path} is not env format: ${parsed.findings.map((f) => `${f.code} at line ${f.line}`).join(", ")}`
|
|
1152
|
+
)
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1156
|
+
entries.push({ key, value, sourceId: path, alias: path, path });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return { entries };
|
|
1160
|
+
}
|
|
1161
|
+
function catalogEntries(path, options, unlock) {
|
|
1162
|
+
if (!existsSync2(path)) return [];
|
|
1163
|
+
let db;
|
|
1164
|
+
try {
|
|
1165
|
+
db = openDatabaseSync(path, { readOnly: true });
|
|
1166
|
+
return readEntries(db, {
|
|
1167
|
+
unlock,
|
|
1168
|
+
...options.aliases ? { aliases: options.aliases } : {}
|
|
1169
|
+
});
|
|
1170
|
+
} finally {
|
|
1171
|
+
db?.close();
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function merge(ordered, override, onConflict, debug) {
|
|
1175
|
+
const parsed = {};
|
|
1176
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const { entry, layer } of ordered) {
|
|
1178
|
+
const here = { key: entry.key, layer, from: entry.alias };
|
|
1179
|
+
const earlier = seen.get(entry.key);
|
|
1180
|
+
if (earlier === void 0) {
|
|
1181
|
+
parsed[entry.key] = entry.value;
|
|
1182
|
+
seen.set(entry.key, here);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (onConflict === "throw")
|
|
1186
|
+
throw new ConflictError(entry.key, earlier, here);
|
|
1187
|
+
if (onConflict === "warn") {
|
|
1188
|
+
debug(
|
|
1189
|
+
`[envs] "${entry.key}" declared by ${earlier.layer}:${earlier.from} and ${here.layer}:${here.from}`
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
if (override) {
|
|
1193
|
+
parsed[entry.key] = entry.value;
|
|
1194
|
+
seen.set(entry.key, here);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return { parsed, provenance: [...seen.values()] };
|
|
1198
|
+
}
|
|
1199
|
+
function config(options = {}) {
|
|
1200
|
+
const env = options.env ?? process.env;
|
|
1201
|
+
const target = options.processEnv ?? process.env;
|
|
1202
|
+
const override = options.override ?? false;
|
|
1203
|
+
const onConflict = options.onConflict ?? "ignore";
|
|
1204
|
+
const quiet = options.quiet ?? false;
|
|
1205
|
+
const debugOn = options.debug ?? false;
|
|
1206
|
+
const debug = (message) => {
|
|
1207
|
+
if (debugOn || !quiet) process.stderr.write(`${message}
|
|
1208
|
+
`);
|
|
1209
|
+
};
|
|
1210
|
+
try {
|
|
1211
|
+
const locateOptions = {
|
|
1212
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
1213
|
+
...options.home ? { home: options.home } : {},
|
|
1214
|
+
env
|
|
1215
|
+
};
|
|
1216
|
+
const located = locateCatalogs(locateOptions);
|
|
1217
|
+
const useGlobal = (options.global ?? true) && !isTruthy(env["ENVS_NO_GLOBAL"]);
|
|
1218
|
+
const ordered = [];
|
|
1219
|
+
if (options.path !== void 0) {
|
|
1220
|
+
const paths = typeof options.path === "string" ? [options.path] : options.path;
|
|
1221
|
+
const read = fileEntries(paths, options.encoding ?? "utf8");
|
|
1222
|
+
if (read.error) return { error: read.error };
|
|
1223
|
+
for (const entry of read.entries) ordered.push({ entry, layer: "file" });
|
|
1224
|
+
}
|
|
1225
|
+
const sawCatalog = existsSync2(located.project) || useGlobal && located.global !== void 0 && existsSync2(located.global);
|
|
1226
|
+
if (!sawCatalog && options.path === void 0) {
|
|
1227
|
+
return {
|
|
1228
|
+
error: new Error(
|
|
1229
|
+
`no catalog at ${located.project}; run "envs init" first`
|
|
1230
|
+
)
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
if (sawCatalog) {
|
|
1234
|
+
const unlock = resolveUnlock(options, env);
|
|
1235
|
+
for (const entry of catalogEntries(located.project, options, unlock)) {
|
|
1236
|
+
ordered.push({ entry, layer: "project" });
|
|
1237
|
+
}
|
|
1238
|
+
if (useGlobal && located.global !== void 0) {
|
|
1239
|
+
for (const entry of catalogEntries(located.global, options, unlock)) {
|
|
1240
|
+
ordered.push({ entry, layer: "global" });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const { parsed, provenance } = merge(ordered, override, onConflict, debug);
|
|
1245
|
+
const applied = [];
|
|
1246
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1247
|
+
const present = Object.prototype.hasOwnProperty.call(target, key);
|
|
1248
|
+
if (present && !override) {
|
|
1249
|
+
applied.push({ key, layer: "process", from: "process.env" });
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
target[key] = value;
|
|
1253
|
+
applied.push(provenance.find((p) => p.key === key));
|
|
1254
|
+
}
|
|
1255
|
+
return { parsed, provenance: applied };
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
return { error };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export {
|
|
1262
|
+
parseEnv,
|
|
1263
|
+
toRecord,
|
|
1264
|
+
PROVIDERS,
|
|
1265
|
+
SqliteUnavailableError,
|
|
1266
|
+
SqliteBindError,
|
|
1267
|
+
scanPlaceholders,
|
|
1268
|
+
openDatabase,
|
|
1269
|
+
openDatabaseSync,
|
|
1270
|
+
availableBackends,
|
|
1271
|
+
resetBindingCache,
|
|
1272
|
+
FORMAT_AES_256_GCM,
|
|
1273
|
+
EnvelopeFormatError,
|
|
1274
|
+
EnvelopeAuthError,
|
|
1275
|
+
seal,
|
|
1276
|
+
open,
|
|
1277
|
+
readHeader,
|
|
1278
|
+
hashKeyName,
|
|
1279
|
+
DEK_BYTES,
|
|
1280
|
+
RecoveryCodeError,
|
|
1281
|
+
KeyringLockedError,
|
|
1282
|
+
formatRecoveryCode,
|
|
1283
|
+
generateRecoveryCode,
|
|
1284
|
+
normaliseRecoveryCode,
|
|
1285
|
+
createKeyring,
|
|
1286
|
+
unlockDek,
|
|
1287
|
+
addWrap,
|
|
1288
|
+
sameKey,
|
|
1289
|
+
SCHEMA_VERSION,
|
|
1290
|
+
MIGRATIONS,
|
|
1291
|
+
CatalogVersionError,
|
|
1292
|
+
readMeta,
|
|
1293
|
+
createSchema,
|
|
1294
|
+
checkVersion,
|
|
1295
|
+
openCatalog,
|
|
1296
|
+
migrate,
|
|
1297
|
+
findProjectRoot,
|
|
1298
|
+
globalDir,
|
|
1299
|
+
locateCatalogs,
|
|
1300
|
+
cacheDir,
|
|
1301
|
+
sha256Hex,
|
|
1302
|
+
NoReleaseError,
|
|
1303
|
+
itemContext,
|
|
1304
|
+
readWraps,
|
|
1305
|
+
currentRevision,
|
|
1306
|
+
readEntries,
|
|
1307
|
+
ConflictError,
|
|
1308
|
+
KekMissingError,
|
|
1309
|
+
config
|
|
1310
|
+
};
|