@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.
Files changed (2) hide show
  1. package/dist/index.js +478 -40
  2. 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
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
214
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
215
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
216
- * not supported — keep those in seekrit itself.
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
- for (const raw of content.split(/\r?\n/)) {
221
- let line = raw.trim();
222
- if (!line || line.startsWith("#")) continue;
223
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
224
- const eq = line.indexOf("=");
225
- if (eq === -1) continue;
226
- const key = line.slice(0, eq).trim();
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
- let value = line.slice(eq + 1).trim();
229
- const quote = value[0];
230
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
231
- value = value.slice(1, -1);
232
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
233
- } else {
234
- const comment = value.indexOf(" #");
235
- if (comment !== -1) value = value.slice(0, comment).trim();
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.29.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
- //#endregion
4625
- //#region src/format.ts
4626
- function needsQuoting(value) {
4627
- return /[\s"'`$\\#]/.test(value) || value === "";
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
- function dotenvQuote(value) {
4630
- if (!needsQuoting(value)) return value;
4631
- return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
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.client.resolve(query);
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
- await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
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.29.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
- "@seekrit/api-client": "0.0.1",
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
  }