@seekrit/cli 0.29.0 → 0.31.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/dist/index.js +478 -40
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
7
7
|
import { dirname, join, parse } from "node:path";
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
/** All catalog keys as a runtime array (for iteration / zod enums). */
|
|
11
12
|
const ENTITLEMENT_KEYS = Object.keys({
|
|
12
13
|
"feature.kms": {
|
|
@@ -210,34 +211,127 @@ function parseBranchTtl(input) {
|
|
|
210
211
|
//#endregion
|
|
211
212
|
//#region ../../packages/core/src/dotenv.ts
|
|
212
213
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
214
|
+
* `.env` parsing and serialization — the canonical definition of what a `.env`
|
|
215
|
+
* file means to seekrit. `apps/run/src/dotenv.rs` is a faithful port of the
|
|
216
|
+
* parser; the two must be changed together.
|
|
217
|
+
*
|
|
218
|
+
* Supported: `KEY=VALUE`, `#` comments, an optional `export` prefix, and
|
|
219
|
+
* single/double-quoted values. A quoted value may span lines — it runs to its
|
|
220
|
+
* closing quote, wherever that lands — which is what makes a pretty-printed
|
|
221
|
+
* JSON credential (a Google service-account key, say) storable in a `.env`
|
|
222
|
+
* file. Single quotes are literal; double quotes honor `\n \r \t \" \\`.
|
|
223
|
+
* Unquoted values are single-line and drop a trailing ` # comment`.
|
|
224
|
+
*
|
|
225
|
+
* {@link parseDotenv} and {@link dotenvQuote} are inverses: anything the
|
|
226
|
+
* serializer emits parses back to the identical string, including values that
|
|
227
|
+
* contain literal backslash escapes (`"private_key": "…\n…"` in a JSON blob).
|
|
228
|
+
*/
|
|
229
|
+
/** Values that can go on the line bare — no quoting needed to survive a parse. */
|
|
230
|
+
function needsQuoting(value) {
|
|
231
|
+
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Quote one value for a `.env` line so it parses back byte-for-byte.
|
|
235
|
+
*
|
|
236
|
+
* Single quotes are preferred whenever they are safe, because they are literal:
|
|
237
|
+
* a JSON credential full of `\"` and `\n` escapes stays readable and survives
|
|
238
|
+
* the round-trip untouched. Values containing a single quote (or a real
|
|
239
|
+
* newline, which many other `.env` readers can't span) fall back to double
|
|
240
|
+
* quotes with every escape written out.
|
|
241
|
+
*/
|
|
242
|
+
function dotenvQuote(value) {
|
|
243
|
+
if (!needsQuoting(value)) return value;
|
|
244
|
+
if (!value.includes("'") && !/[\n\r]/.test(value)) return `'${value}'`;
|
|
245
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll("\r", "\\r")}"`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Apply double-quote escapes in a single left-to-right pass.
|
|
249
|
+
*
|
|
250
|
+
* A pass per escape (`\n` → newline, then `\\` → `\`, …) is wrong: in `\\n` the
|
|
251
|
+
* first pass matches the trailing `\n` and yields a real newline, corrupting
|
|
252
|
+
* every literal backslash-n a JSON credential is made of. Scanning once means a
|
|
253
|
+
* backslash consumes the character after it and can never be re-read.
|
|
254
|
+
*/
|
|
255
|
+
function unescapeDoubleQuoted(text) {
|
|
256
|
+
let out = "";
|
|
257
|
+
for (let i = 0; i < text.length; i++) {
|
|
258
|
+
if (text[i] !== "\\" || i === text.length - 1) {
|
|
259
|
+
out += text[i];
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const next = text[++i];
|
|
263
|
+
if (next === "n") out += "\n";
|
|
264
|
+
else if (next === "r") out += "\r";
|
|
265
|
+
else if (next === "t") out += " ";
|
|
266
|
+
else if (next === "\"" || next === "\\") out += next;
|
|
267
|
+
else out += `\\${next}`;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Find the index of the quote that closes a value opened at `start`.
|
|
273
|
+
*
|
|
274
|
+
* Inside double quotes a `\"` is an escaped quote, not the terminator (and a
|
|
275
|
+
* `\\` immediately before the quote *is* a terminator, since the backslash is
|
|
276
|
+
* itself escaped) — so the scan tracks escapes rather than searching for the
|
|
277
|
+
* next bare quote. Single quotes have no escapes: the next one closes. Returns
|
|
278
|
+
* -1 when the value is never closed.
|
|
279
|
+
*/
|
|
280
|
+
function findClosingQuote(content, start, quote) {
|
|
281
|
+
for (let i = start; i < content.length; i++) {
|
|
282
|
+
if (quote === "\"" && content[i] === "\\") {
|
|
283
|
+
i++;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (content[i] === quote) return i;
|
|
287
|
+
}
|
|
288
|
+
return -1;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Parse `.env` text into variables. Later assignments win, matching the
|
|
292
|
+
* object-assignment semantics every `.env` reader has.
|
|
217
293
|
*/
|
|
218
294
|
function parseDotenv(content) {
|
|
219
295
|
const out = {};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
296
|
+
let cursor = 0;
|
|
297
|
+
while (cursor < content.length) {
|
|
298
|
+
const newline = content.indexOf("\n", cursor);
|
|
299
|
+
const lineEnd = newline === -1 ? content.length : newline;
|
|
300
|
+
const lineStart = cursor;
|
|
301
|
+
const trimmedEnd = lineEnd > lineStart && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
302
|
+
cursor = lineEnd + 1;
|
|
303
|
+
let i = skipSpace(content, lineStart, trimmedEnd);
|
|
304
|
+
if (i === trimmedEnd || content[i] === "#") continue;
|
|
305
|
+
if (content.startsWith("export ", i)) i = skipSpace(content, i + 7, trimmedEnd);
|
|
306
|
+
const eq = content.indexOf("=", i);
|
|
307
|
+
if (eq === -1 || eq >= trimmedEnd) continue;
|
|
308
|
+
const key = content.slice(i, eq).trim();
|
|
227
309
|
if (!key) continue;
|
|
228
|
-
|
|
229
|
-
const quote =
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
310
|
+
const valueStart = skipSpace(content, eq + 1, trimmedEnd);
|
|
311
|
+
const quote = content[valueStart];
|
|
312
|
+
if (valueStart === trimmedEnd || quote !== "\"" && quote !== "'") {
|
|
313
|
+
const rest = content.slice(valueStart, trimmedEnd).trimEnd();
|
|
314
|
+
const comment = rest.indexOf(" #");
|
|
315
|
+
out[key] = comment === -1 ? rest : rest.slice(0, comment).trimEnd();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const close = findClosingQuote(content, valueStart + 1, quote);
|
|
319
|
+
const valueEnd = close === -1 ? content.length : close;
|
|
320
|
+
const value = content.slice(valueStart + 1, valueEnd);
|
|
321
|
+
out[key] = quote === "\"" ? unescapeDoubleQuoted(value) : value;
|
|
322
|
+
if (valueEnd >= cursor) {
|
|
323
|
+
const after = content.indexOf("\n", valueEnd);
|
|
324
|
+
cursor = after === -1 ? content.length : after + 1;
|
|
236
325
|
}
|
|
237
|
-
out[key] = value;
|
|
238
326
|
}
|
|
239
327
|
return out;
|
|
240
328
|
}
|
|
329
|
+
/** First index at or after `from` (and before `end`) that isn't a space or tab. */
|
|
330
|
+
function skipSpace(content, from, end) {
|
|
331
|
+
let i = from;
|
|
332
|
+
while (i < end && (content[i] === " " || content[i] === " ")) i++;
|
|
333
|
+
return i;
|
|
334
|
+
}
|
|
241
335
|
//#endregion
|
|
242
336
|
//#region ../../packages/core/src/ids.ts
|
|
243
337
|
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
@@ -402,6 +496,104 @@ function interpolateSecrets(values) {
|
|
|
402
496
|
unresolved: [...unresolved].sort()
|
|
403
497
|
};
|
|
404
498
|
}
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region ../../packages/core/src/json-value.ts
|
|
501
|
+
/**
|
|
502
|
+
* Whether a value is worth *offering* a JSON view for: an object or array.
|
|
503
|
+
*
|
|
504
|
+
* Deliberately narrow. A bare `123`, `true`, or `"quoted string"` is legal JSON
|
|
505
|
+
* but is far more likely to be an ordinary secret that happens to look like a
|
|
506
|
+
* literal, and badging those as JSON would be noise on almost every row.
|
|
507
|
+
*/
|
|
508
|
+
function looksLikeJson(value) {
|
|
509
|
+
const trimmed = value.trim();
|
|
510
|
+
return trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
511
|
+
}
|
|
512
|
+
/** Strip one layer of matching surrounding quotes, if the value has them. */
|
|
513
|
+
function unquote(value) {
|
|
514
|
+
const trimmed = value.trim();
|
|
515
|
+
const quote = trimmed[0];
|
|
516
|
+
if (trimmed.length < 2) return null;
|
|
517
|
+
if (quote !== "'" && quote !== "\"" || trimmed.at(-1) !== quote) return null;
|
|
518
|
+
return trimmed.slice(1, -1);
|
|
519
|
+
}
|
|
520
|
+
function isParseable(value) {
|
|
521
|
+
try {
|
|
522
|
+
JSON.parse(value);
|
|
523
|
+
return true;
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The two ways a copied credential usually arrives broken.
|
|
530
|
+
*
|
|
531
|
+
* Both come from a shell habit rather than a typo: the quotes that made the
|
|
532
|
+
* value survive `.env` or a command line get pasted along with it, or a value
|
|
533
|
+
* that was escaped for a double-quoted context is pasted still escaped.
|
|
534
|
+
*/
|
|
535
|
+
function findRepair(value) {
|
|
536
|
+
const inner = unquote(value);
|
|
537
|
+
if (inner !== null && looksLikeJson(inner) && isParseable(inner)) return {
|
|
538
|
+
label: "remove the quotes",
|
|
539
|
+
reason: "the surrounding quotes were pasted along with the value",
|
|
540
|
+
value: inner
|
|
541
|
+
};
|
|
542
|
+
if (value.includes("\\\"")) {
|
|
543
|
+
const unescaped = value.replaceAll("\\\"", "\"").replaceAll("\\\\", "\\");
|
|
544
|
+
const candidate = unquote(unescaped) ?? unescaped;
|
|
545
|
+
if (looksLikeJson(candidate) && isParseable(candidate)) return {
|
|
546
|
+
label: "unescape the quotes",
|
|
547
|
+
reason: "the value is still escaped for a shell or a double-quoted string",
|
|
548
|
+
value: candidate
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Classify a decrypted value as JSON, nearly-JSON, or ordinary text.
|
|
555
|
+
*
|
|
556
|
+
* Only object- and array-shaped values are considered (see
|
|
557
|
+
* {@link looksLikeJson}), so an ordinary secret is never reported as malformed
|
|
558
|
+
* JSON just because it starts with a brace-free string.
|
|
559
|
+
*/
|
|
560
|
+
function inspectJsonValue(value) {
|
|
561
|
+
if (!looksLikeJson(value)) {
|
|
562
|
+
const repair = findRepair(value);
|
|
563
|
+
return repair ? {
|
|
564
|
+
kind: "malformed",
|
|
565
|
+
error: repair.reason,
|
|
566
|
+
repair
|
|
567
|
+
} : { kind: "text" };
|
|
568
|
+
}
|
|
569
|
+
let data;
|
|
570
|
+
try {
|
|
571
|
+
data = JSON.parse(value);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
return {
|
|
574
|
+
kind: "malformed",
|
|
575
|
+
error: err instanceof Error ? err.message : "not valid JSON",
|
|
576
|
+
repair: findRepair(value)
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const pretty = JSON.stringify(data, null, 2);
|
|
580
|
+
return {
|
|
581
|
+
kind: "json",
|
|
582
|
+
pretty,
|
|
583
|
+
minified: JSON.stringify(data),
|
|
584
|
+
isPretty: value.trim() === pretty,
|
|
585
|
+
size: Array.isArray(data) ? data.length : Object.keys(data).length,
|
|
586
|
+
shape: Array.isArray(data) ? "array" : "object"
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Pretty-print a value if it is JSON, otherwise hand it back unchanged — for
|
|
591
|
+
* output paths that want to be helpful without ever altering a non-JSON secret.
|
|
592
|
+
*/
|
|
593
|
+
function prettyJsonOrRaw(value) {
|
|
594
|
+
const inspection = inspectJsonValue(value);
|
|
595
|
+
return inspection.kind === "json" ? inspection.pretty : value;
|
|
596
|
+
}
|
|
405
597
|
z.enum([
|
|
406
598
|
"postgres",
|
|
407
599
|
"mysql",
|
|
@@ -2528,7 +2720,7 @@ function isCliSessionToken(value) {
|
|
|
2528
2720
|
}
|
|
2529
2721
|
//#endregion
|
|
2530
2722
|
//#region package.json
|
|
2531
|
-
var version = "0.
|
|
2723
|
+
var version = "0.31.0";
|
|
2532
2724
|
//#endregion
|
|
2533
2725
|
//#region ../../packages/api-client/src/index.ts
|
|
2534
2726
|
var SeekritApiError = class extends Error {
|
|
@@ -3215,7 +3407,8 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
3215
3407
|
auth,
|
|
3216
3408
|
client: CLI_CLIENT
|
|
3217
3409
|
}),
|
|
3218
|
-
auth
|
|
3410
|
+
auth,
|
|
3411
|
+
apiUrl
|
|
3219
3412
|
};
|
|
3220
3413
|
}
|
|
3221
3414
|
function buildContext() {
|
|
@@ -4621,15 +4814,165 @@ function registerBranchCommands(program) {
|
|
|
4621
4814
|
console.error(`deleted branch ${app.slug}#${target.slug}`);
|
|
4622
4815
|
});
|
|
4623
4816
|
}
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4817
|
+
/** Domain separators — must match `crates/seekrit-cache`. */
|
|
4818
|
+
const KEY_DOMAIN = "seekrit-lkg-cache-v1";
|
|
4819
|
+
const TOKEN_DOMAIN = "seekrit-token-fp-v1";
|
|
4820
|
+
/** 24h: long enough to ride out an outage, short enough to bound the tail. */
|
|
4821
|
+
const DEFAULT_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
4822
|
+
/**
|
|
4823
|
+
* Derive the key for one resolve request. Overrides are sorted, so flag order
|
|
4824
|
+
* never splits the cache. Byte-for-byte identical to `CacheKey::new` in
|
|
4825
|
+
* `crates/seekrit-cache`.
|
|
4826
|
+
*/
|
|
4827
|
+
function cacheKey(apiUrl, token, branch, overrides = {}) {
|
|
4828
|
+
const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
|
|
4829
|
+
return {
|
|
4830
|
+
key: createHash("sha256").update([
|
|
4831
|
+
KEY_DOMAIN,
|
|
4832
|
+
apiUrl.replace(/\/+$/, ""),
|
|
4833
|
+
token,
|
|
4834
|
+
branch ?? "",
|
|
4835
|
+
pairs.join(",")
|
|
4836
|
+
].join("\n")).digest("hex"),
|
|
4837
|
+
tokenFingerprint: tokenFingerprint(token)
|
|
4838
|
+
};
|
|
4628
4839
|
}
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
return
|
|
4840
|
+
/** The hex SHA-256 identifying a token without storing it. */
|
|
4841
|
+
function tokenFingerprint(token) {
|
|
4842
|
+
return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
|
|
4843
|
+
}
|
|
4844
|
+
/**
|
|
4845
|
+
* `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
|
|
4846
|
+
* directory holding the credential, never a shared temp directory.
|
|
4847
|
+
*/
|
|
4848
|
+
function defaultCacheDir() {
|
|
4849
|
+
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
|
|
4632
4850
|
}
|
|
4851
|
+
/** Render an age for a log line, rounded to its largest whole unit. */
|
|
4852
|
+
function humanize(ms) {
|
|
4853
|
+
const secs = Math.floor(ms / 1e3);
|
|
4854
|
+
if (secs < 60) return `${secs}s`;
|
|
4855
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
4856
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
|
|
4857
|
+
return `${Math.floor(secs / 86400)}d`;
|
|
4858
|
+
}
|
|
4859
|
+
/**
|
|
4860
|
+
* Whether a failed resolve means the API was *unreachable* (the cache may stand
|
|
4861
|
+
* in) rather than *refusing us* (it must not). A refusal is an answer, and
|
|
4862
|
+
* revocation is supposed to take effect the moment it arrives.
|
|
4863
|
+
*/
|
|
4864
|
+
function mayFallBack(err) {
|
|
4865
|
+
if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
|
|
4866
|
+
return true;
|
|
4867
|
+
}
|
|
4868
|
+
/** An opened cache, bound to one resolve request. */
|
|
4869
|
+
var LkgCache = class {
|
|
4870
|
+
dir;
|
|
4871
|
+
maxAgeMs;
|
|
4872
|
+
id;
|
|
4873
|
+
constructor(id, options = {}) {
|
|
4874
|
+
this.id = id;
|
|
4875
|
+
this.dir = options.dir ?? defaultCacheDir();
|
|
4876
|
+
this.maxAgeMs = options.maxAgeMs ?? 864e5;
|
|
4877
|
+
}
|
|
4878
|
+
path() {
|
|
4879
|
+
return join(this.dir, `${this.id.key}.json`);
|
|
4880
|
+
}
|
|
4881
|
+
read() {
|
|
4882
|
+
const path = this.path();
|
|
4883
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
4884
|
+
let envelope;
|
|
4885
|
+
try {
|
|
4886
|
+
envelope = JSON.parse(readFileSync(path, "utf8"));
|
|
4887
|
+
} catch (err) {
|
|
4888
|
+
return {
|
|
4889
|
+
kind: "unusable",
|
|
4890
|
+
reason: `corrupt cache entry: ${message(err)}`
|
|
4891
|
+
};
|
|
4892
|
+
}
|
|
4893
|
+
if (envelope.version !== 1) return {
|
|
4894
|
+
kind: "unusable",
|
|
4895
|
+
reason: `cache entry is format v${envelope.version}, this build reads v1`
|
|
4896
|
+
};
|
|
4897
|
+
if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
|
|
4898
|
+
kind: "unusable",
|
|
4899
|
+
reason: "cache entry belongs to a different token"
|
|
4900
|
+
};
|
|
4901
|
+
if (typeof envelope.body !== "string") return {
|
|
4902
|
+
kind: "unusable",
|
|
4903
|
+
reason: "cache entry has no body"
|
|
4904
|
+
};
|
|
4905
|
+
const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
|
|
4906
|
+
if (ageMs >= this.maxAgeMs) return {
|
|
4907
|
+
kind: "expired",
|
|
4908
|
+
ageMs
|
|
4909
|
+
};
|
|
4910
|
+
return {
|
|
4911
|
+
kind: "hit",
|
|
4912
|
+
body: envelope.body,
|
|
4913
|
+
ageMs
|
|
4914
|
+
};
|
|
4915
|
+
}
|
|
4916
|
+
/**
|
|
4917
|
+
* Record a freshly-fetched response. Written to a temporary file and renamed,
|
|
4918
|
+
* so a concurrent reader sees either the old entry or the new one — never a
|
|
4919
|
+
* half-written file. Throws only on genuinely unexpected I/O; callers treat a
|
|
4920
|
+
* failed write as a warning, never as a failed resolve.
|
|
4921
|
+
*/
|
|
4922
|
+
write(body) {
|
|
4923
|
+
mkdirSync(this.dir, {
|
|
4924
|
+
recursive: true,
|
|
4925
|
+
mode: 448
|
|
4926
|
+
});
|
|
4927
|
+
const envelope = {
|
|
4928
|
+
version: 1,
|
|
4929
|
+
fetchedAt: Math.floor(Date.now() / 1e3),
|
|
4930
|
+
tokenFingerprint: this.id.tokenFingerprint,
|
|
4931
|
+
body
|
|
4932
|
+
};
|
|
4933
|
+
const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
|
|
4934
|
+
try {
|
|
4935
|
+
writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
|
|
4936
|
+
renameSync(tmp, this.path());
|
|
4937
|
+
} catch (err) {
|
|
4938
|
+
rmSync(tmp, { force: true });
|
|
4939
|
+
throw err;
|
|
4940
|
+
}
|
|
4941
|
+
this.pruneExpired();
|
|
4942
|
+
}
|
|
4943
|
+
/** Drop this entry — used when the API refuses the token. */
|
|
4944
|
+
invalidate() {
|
|
4945
|
+
rmSync(this.path(), { force: true });
|
|
4946
|
+
}
|
|
4947
|
+
/**
|
|
4948
|
+
* Delete entries past their usefulness (a rotated token or a changed
|
|
4949
|
+
* `--with` leaves one nothing will read again). Best-effort and silent, and
|
|
4950
|
+
* it only ever touches files this cache named: `<64 hex>.json`.
|
|
4951
|
+
*/
|
|
4952
|
+
pruneExpired() {
|
|
4953
|
+
let names;
|
|
4954
|
+
try {
|
|
4955
|
+
names = readdirSync(this.dir);
|
|
4956
|
+
} catch {
|
|
4957
|
+
return;
|
|
4958
|
+
}
|
|
4959
|
+
for (const name of names) {
|
|
4960
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
|
|
4961
|
+
const path = join(this.dir, name);
|
|
4962
|
+
let expired = true;
|
|
4963
|
+
try {
|
|
4964
|
+
const envelope = JSON.parse(readFileSync(path, "utf8"));
|
|
4965
|
+
expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
|
|
4966
|
+
} catch {}
|
|
4967
|
+
if (expired) rmSync(path, { force: true });
|
|
4968
|
+
}
|
|
4969
|
+
}
|
|
4970
|
+
};
|
|
4971
|
+
function message(err) {
|
|
4972
|
+
return err instanceof Error ? err.message : String(err);
|
|
4973
|
+
}
|
|
4974
|
+
//#endregion
|
|
4975
|
+
//#region src/format.ts
|
|
4633
4976
|
function shellQuote(value) {
|
|
4634
4977
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4635
4978
|
}
|
|
@@ -5886,7 +6229,7 @@ async function materializeEnv(ctx, opts) {
|
|
|
5886
6229
|
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
5887
6230
|
query.env = opts.envId;
|
|
5888
6231
|
}
|
|
5889
|
-
const { scope, layers } = await ctx.
|
|
6232
|
+
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
5890
6233
|
const privateKey = await getPrivateKey(ctx);
|
|
5891
6234
|
const values = {};
|
|
5892
6235
|
const provenance = {};
|
|
@@ -5910,6 +6253,46 @@ async function materializeEnv(ctx, opts) {
|
|
|
5910
6253
|
};
|
|
5911
6254
|
}
|
|
5912
6255
|
/**
|
|
6256
|
+
* Resolve, going through the last-known-good cache when one is configured.
|
|
6257
|
+
*
|
|
6258
|
+
* Always live first: the cache exists for when the call cannot land, not to
|
|
6259
|
+
* save a round trip, so a recovered network is picked up on the very next
|
|
6260
|
+
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
6261
|
+
* falling back to it — otherwise revoking a token would keep working offline
|
|
6262
|
+
* until the entry aged out.
|
|
6263
|
+
*/
|
|
6264
|
+
async function resolveWithCache(ctx, query, cache) {
|
|
6265
|
+
if (!cache) return ctx.client.resolve(query);
|
|
6266
|
+
try {
|
|
6267
|
+
const response = await ctx.client.resolve(query);
|
|
6268
|
+
try {
|
|
6269
|
+
cache.write(JSON.stringify(response));
|
|
6270
|
+
} catch (err) {
|
|
6271
|
+
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
6272
|
+
}
|
|
6273
|
+
return response;
|
|
6274
|
+
} catch (err) {
|
|
6275
|
+
if (!mayFallBack(err)) {
|
|
6276
|
+
cache.invalidate();
|
|
6277
|
+
throw err;
|
|
6278
|
+
}
|
|
6279
|
+
const found = cache.read();
|
|
6280
|
+
if (found.kind === "hit") {
|
|
6281
|
+
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
6282
|
+
return JSON.parse(found.body);
|
|
6283
|
+
}
|
|
6284
|
+
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
6285
|
+
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
6286
|
+
throw err;
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
function warn(text) {
|
|
6290
|
+
process.stderr.write(`seekrit: ${text}\n`);
|
|
6291
|
+
}
|
|
6292
|
+
function errorMessage(err) {
|
|
6293
|
+
return err instanceof Error ? err.message : String(err);
|
|
6294
|
+
}
|
|
6295
|
+
/**
|
|
5913
6296
|
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
5914
6297
|
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
5915
6298
|
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
@@ -6582,20 +6965,27 @@ withTarget(secrets.command("list").alias("ls").description("list secret names (n
|
|
|
6582
6965
|
col("updated", (s) => s.updatedAt)
|
|
6583
6966
|
], `no secrets in ${label}`));
|
|
6584
6967
|
});
|
|
6585
|
-
withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
|
|
6968
|
+
withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one").option("--pretty", "re-indent the value if it is JSON (left alone if it is not)")).action(async (name, options) => {
|
|
6586
6969
|
const ctx = buildContext();
|
|
6587
6970
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
6588
6971
|
let value;
|
|
6589
6972
|
if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId, { raw: options.raw }))[name];
|
|
6590
6973
|
else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
|
|
6591
6974
|
if (value === void 0) fail(`no secret named ${name}`);
|
|
6592
|
-
process.stdout.write(value);
|
|
6975
|
+
process.stdout.write(options.pretty ? prettyJsonOrRaw(value) : value);
|
|
6593
6976
|
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
6594
6977
|
});
|
|
6595
|
-
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')")).action(async (name, value, options) => {
|
|
6978
|
+
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')").option("--file <path>", "read the value from a file — the way to store a JSON credential or a PEM key without fighting shell quoting")).action(async (name, value, options) => {
|
|
6979
|
+
if (options.file !== void 0 && value !== void 0) fail("pass a value or --file, not both");
|
|
6596
6980
|
const ctx = buildContext();
|
|
6597
6981
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
6598
|
-
|
|
6982
|
+
let plaintext;
|
|
6983
|
+
if (options.file !== void 0) {
|
|
6984
|
+
if (!existsSync(options.file)) fail(`no such file: ${options.file}`);
|
|
6985
|
+
plaintext = readFileSync(options.file, "utf8").replace(/\n$/, "");
|
|
6986
|
+
} else if (value === void 0 || value === "-") plaintext = (await readStdin()).replace(/\n$/, "");
|
|
6987
|
+
else plaintext = value;
|
|
6988
|
+
await encryptAndSetSecret(ctx, orgId, envId, name, plaintext);
|
|
6599
6989
|
console.error(`${name} saved`);
|
|
6600
6990
|
});
|
|
6601
6991
|
withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing")).action(async (file, options) => {
|
|
@@ -6649,8 +7039,55 @@ withTarget(secrets.command("rm <name>").description("delete a secret")).action(a
|
|
|
6649
7039
|
await ctx.client.deleteSecret(orgId, envId, name);
|
|
6650
7040
|
console.error(`${name} deleted`);
|
|
6651
7041
|
});
|
|
7042
|
+
/**
|
|
7043
|
+
* Open the last-known-good cache for this request, or `undefined` when it is
|
|
7044
|
+
* off. `dotenvVars` carries `SEEKRIT_*` values read from a `.env` file, so the
|
|
7045
|
+
* flags follow the same `flag > env > .env` precedence as the credentials.
|
|
7046
|
+
*
|
|
7047
|
+
* **Service tokens only.** A user session's private key is fetched from the API
|
|
7048
|
+
* and unlocked with a passphrase, so caching the resolve response alone would
|
|
7049
|
+
* not make an offline run work — and the token is what the cache key is built
|
|
7050
|
+
* from. `--cache` under user auth is a no-op we say out loud rather than a
|
|
7051
|
+
* silent one.
|
|
7052
|
+
*/
|
|
7053
|
+
function openCache(ctx, options, dotenvVars = {}) {
|
|
7054
|
+
const lookup = (key) => process.env[key] ?? dotenvVars[key];
|
|
7055
|
+
const truthy = (value) => [
|
|
7056
|
+
"1",
|
|
7057
|
+
"true",
|
|
7058
|
+
"yes",
|
|
7059
|
+
"on"
|
|
7060
|
+
].includes((value ?? "").trim().toLowerCase());
|
|
7061
|
+
if (!(options.cache ?? truthy(lookup("SEEKRIT_CACHE")))) return void 0;
|
|
7062
|
+
if (!isTokenAuth(ctx)) {
|
|
7063
|
+
process.stderr.write("seekrit: --cache needs a service token (SEEKRIT_TOKEN); continuing without it\n");
|
|
7064
|
+
return;
|
|
7065
|
+
}
|
|
7066
|
+
const token = ctx.auth.type === "bearer" ? ctx.auth.token : "";
|
|
7067
|
+
const maxAgeRaw = options.cacheMaxAge ?? lookup("SEEKRIT_CACHE_MAX_AGE");
|
|
7068
|
+
const maxAgeMs = maxAgeRaw ? parseDurationMs(maxAgeRaw) : DEFAULT_MAX_AGE_MS;
|
|
7069
|
+
const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
|
|
7070
|
+
return new LkgCache(cacheKey(ctx.apiUrl, token, branch, options.with), {
|
|
7071
|
+
dir: options.cacheDir ?? lookup("SEEKRIT_CACHE_DIR"),
|
|
7072
|
+
maxAgeMs
|
|
7073
|
+
});
|
|
7074
|
+
}
|
|
7075
|
+
/** `30s` / `15m` / `24h` / `7d` / bare seconds → ms. Matches the Rust parser. */
|
|
7076
|
+
function parseDurationMs(raw) {
|
|
7077
|
+
const match = /^(\d+)\s*([smhd]?)$/.exec(raw.trim());
|
|
7078
|
+
if (!match) fail(`--cache-max-age: not a duration: "${raw}" (try 15m, 24h, 7d)`);
|
|
7079
|
+
const value = Number(match[1]);
|
|
7080
|
+
if (value === 0) fail("--cache-max-age: must be greater than zero");
|
|
7081
|
+
return value * ({
|
|
7082
|
+
"": 1,
|
|
7083
|
+
s: 1,
|
|
7084
|
+
m: 60,
|
|
7085
|
+
h: 3600,
|
|
7086
|
+
d: 86400
|
|
7087
|
+
}[match[2] ?? ""] ?? 1) * 1e3;
|
|
7088
|
+
}
|
|
6652
7089
|
/** Resolve the layered environment for the current principal. */
|
|
6653
|
-
async function materialize(ctx, options) {
|
|
7090
|
+
async function materialize(ctx, options, dotenvVars = {}) {
|
|
6654
7091
|
let envId;
|
|
6655
7092
|
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
|
|
6656
7093
|
return materializeEnv(ctx, {
|
|
@@ -6658,7 +7095,8 @@ async function materialize(ctx, options) {
|
|
|
6658
7095
|
branch: options.branch ?? process.env.SEEKRIT_BRANCH,
|
|
6659
7096
|
with: options.with,
|
|
6660
7097
|
envFiles: options.envFile ?? [".env"],
|
|
6661
|
-
interpolate: options.interpolate
|
|
7098
|
+
interpolate: options.interpolate,
|
|
7099
|
+
cache: openCache(ctx, options, dotenvVars)
|
|
6662
7100
|
});
|
|
6663
7101
|
}
|
|
6664
7102
|
/**
|
|
@@ -6680,7 +7118,7 @@ async function materializeForRun(options) {
|
|
|
6680
7118
|
return await materialize(ctx, {
|
|
6681
7119
|
...options,
|
|
6682
7120
|
branch
|
|
6683
|
-
});
|
|
7121
|
+
}, dotenvVars);
|
|
6684
7122
|
} catch (err) {
|
|
6685
7123
|
const message = err instanceof Error ? err.message : String(err);
|
|
6686
7124
|
console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
|
|
@@ -6803,7 +7241,7 @@ async function reapStragglers(pids, signal) {
|
|
|
6803
7241
|
process.kill(pid, "SIGKILL");
|
|
6804
7242
|
} catch {}
|
|
6805
7243
|
}
|
|
6806
|
-
program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
|
|
7244
|
+
program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
|
|
6807
7245
|
const [cmd, ...args] = commandParts;
|
|
6808
7246
|
if (!cmd) fail("no command given");
|
|
6809
7247
|
const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
|
|
@@ -6855,7 +7293,7 @@ program.command("run").description("run a command with decrypted secrets injecte
|
|
|
6855
7293
|
});
|
|
6856
7294
|
child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
|
|
6857
7295
|
});
|
|
6858
|
-
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
7296
|
+
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
6859
7297
|
if (![
|
|
6860
7298
|
"dotenv",
|
|
6861
7299
|
"json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -26,13 +26,15 @@
|
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.0",
|
|
28
28
|
"tsdown": "^0.22.3",
|
|
29
|
-
"
|
|
29
|
+
"vitest": "^4.1.9",
|
|
30
30
|
"@seekrit/core": "0.0.1",
|
|
31
|
+
"@seekrit/api-client": "0.0.1",
|
|
31
32
|
"@seekrit/crypto": "0.0.1"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"build": "tsdown",
|
|
35
36
|
"dev": "tsdown --watch",
|
|
37
|
+
"test": "vitest run",
|
|
36
38
|
"typecheck": "tsc --noEmit"
|
|
37
39
|
}
|
|
38
40
|
}
|