@seekrit/cli 0.30.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 +256 -9
  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": {
@@ -2719,7 +2720,7 @@ function isCliSessionToken(value) {
2719
2720
  }
2720
2721
  //#endregion
2721
2722
  //#region package.json
2722
- var version = "0.30.0";
2723
+ var version = "0.31.0";
2723
2724
  //#endregion
2724
2725
  //#region ../../packages/api-client/src/index.ts
2725
2726
  var SeekritApiError = class extends Error {
@@ -3406,7 +3407,8 @@ function tryBuildContext(dotenvVars = {}) {
3406
3407
  auth,
3407
3408
  client: CLI_CLIENT
3408
3409
  }),
3409
- auth
3410
+ auth,
3411
+ apiUrl
3410
3412
  };
3411
3413
  }
3412
3414
  function buildContext() {
@@ -4812,6 +4814,163 @@ function registerBranchCommands(program) {
4812
4814
  console.error(`deleted branch ${app.slug}#${target.slug}`);
4813
4815
  });
4814
4816
  }
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
+ };
4839
+ }
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");
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
+ }
4815
4974
  //#endregion
4816
4975
  //#region src/format.ts
4817
4976
  function shellQuote(value) {
@@ -6070,7 +6229,7 @@ async function materializeEnv(ctx, opts) {
6070
6229
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
6071
6230
  query.env = opts.envId;
6072
6231
  }
6073
- const { scope, layers } = await ctx.client.resolve(query);
6232
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
6074
6233
  const privateKey = await getPrivateKey(ctx);
6075
6234
  const values = {};
6076
6235
  const provenance = {};
@@ -6094,6 +6253,46 @@ async function materializeEnv(ctx, opts) {
6094
6253
  };
6095
6254
  }
6096
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
+ /**
6097
6296
  * Overlay `.env` files onto an existing value/provenance set (later files win).
6098
6297
  * Missing files are skipped. Returns the files that were actually loaded. Used
6099
6298
  * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
@@ -6840,8 +7039,55 @@ withTarget(secrets.command("rm <name>").description("delete a secret")).action(a
6840
7039
  await ctx.client.deleteSecret(orgId, envId, name);
6841
7040
  console.error(`${name} deleted`);
6842
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
+ }
6843
7089
  /** Resolve the layered environment for the current principal. */
6844
- async function materialize(ctx, options) {
7090
+ async function materialize(ctx, options, dotenvVars = {}) {
6845
7091
  let envId;
6846
7092
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
6847
7093
  return materializeEnv(ctx, {
@@ -6849,7 +7095,8 @@ async function materialize(ctx, options) {
6849
7095
  branch: options.branch ?? process.env.SEEKRIT_BRANCH,
6850
7096
  with: options.with,
6851
7097
  envFiles: options.envFile ?? [".env"],
6852
- interpolate: options.interpolate
7098
+ interpolate: options.interpolate,
7099
+ cache: openCache(ctx, options, dotenvVars)
6853
7100
  });
6854
7101
  }
6855
7102
  /**
@@ -6871,7 +7118,7 @@ async function materializeForRun(options) {
6871
7118
  return await materialize(ctx, {
6872
7119
  ...options,
6873
7120
  branch
6874
- });
7121
+ }, dotenvVars);
6875
7122
  } catch (err) {
6876
7123
  const message = err instanceof Error ? err.message : String(err);
6877
7124
  console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
@@ -6994,7 +7241,7 @@ async function reapStragglers(pids, signal) {
6994
7241
  process.kill(pid, "SIGKILL");
6995
7242
  } catch {}
6996
7243
  }
6997
- 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) => {
6998
7245
  const [cmd, ...args] = commandParts;
6999
7246
  if (!cmd) fail("no command given");
7000
7247
  const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
@@ -7046,7 +7293,7 @@ program.command("run").description("run a command with decrypted secrets injecte
7046
7293
  });
7047
7294
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
7048
7295
  });
7049
- 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) => {
7050
7297
  if (![
7051
7298
  "dotenv",
7052
7299
  "json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.30.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
  }