@seekrit/cli 1.0.0 → 1.2.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 +1001 -279
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { z } from "zod";
5
5
  import { Command } from "commander";
6
6
  import { arch, homedir, hostname, platform, tmpdir, userInfo } from "node:os";
@@ -8,6 +8,7 @@ import { dirname, join, parse, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
10
10
  import { createHash } from "node:crypto";
11
+ import { fileURLToPath } from "node:url";
11
12
  /** Default lifetime of a published bundle (7 days), in seconds. */
12
13
  const POLICY_DEFAULT_TTL_SECONDS = 10080 * 60;
13
14
  /** Bounds on a bundle's lifetime: an hour at the short end, 90 days at the long. */
@@ -1906,6 +1907,8 @@ const AUDIT_ACTIONS = [
1906
1907
  "org.mfa_policy_changed",
1907
1908
  "org.exported",
1908
1909
  "user.keys_updated",
1910
+ "user.passkey_enrolled",
1911
+ "user.passkey_removed",
1909
1912
  "user.notification_prefs_updated",
1910
1913
  "app.created",
1911
1914
  "app.updated",
@@ -2150,6 +2153,16 @@ z.object({
2150
2153
  */
2151
2154
  encryptedPrivateKey: z.string().min(1)
2152
2155
  });
2156
+ z.object({
2157
+ /** Base64url WebAuthn credential id — what `allowCredentials` is built from. */
2158
+ credentialId: z.string().min(1).max(512),
2159
+ /** Human label for the device, e.g. "MacBook Touch ID". Display only. */
2160
+ label: z.string().min(1).max(64),
2161
+ /** Base64url PRF evaluation input for this credential; not a secret. */
2162
+ prfInput: z.string().min(1).max(256),
2163
+ /** The private key wrapped to this passkey's PRF output. */
2164
+ encryptedPrivateKey: z.string().min(1).max(8192).refine((v) => v.startsWith("pk2."), { message: "expected a pk2. passkey wrap" })
2165
+ });
2153
2166
  const grantEnvironmentKeySchema = z.object({
2154
2167
  principalType: principalTypeSchema,
2155
2168
  principalId: z.string().min(1),
@@ -2482,7 +2495,8 @@ const SYNC_PROVIDER_KINDS = [
2482
2495
  "github-actions",
2483
2496
  "gcp-secret-manager",
2484
2497
  "langgraph-platform",
2485
- "azure-key-vault"
2498
+ "azure-key-vault",
2499
+ "huggingface-spaces"
2486
2500
  ];
2487
2501
  z.enum(SYNC_PROVIDER_KINDS);
2488
2502
  /**
@@ -2871,6 +2885,21 @@ const azureKeyVaultConnectionConfigSchema = z.object({
2871
2885
  /** Which Azure cloud the tenant and its vaults live in. */
2872
2886
  cloud: z.enum(AZURE_CLOUDS).default("public")
2873
2887
  });
2888
+ /**
2889
+ * Hugging Face account scope — empty, as Render's and Fly's are.
2890
+ *
2891
+ * Neither half of "which account, which thing" needs stating. A Hub user access
2892
+ * token belongs to one user and carries their write access to every Space they
2893
+ * or their organizations own; a Space is addressed by `owner/name`, which is
2894
+ * globally unique. So the token plus the destination is the whole address.
2895
+ *
2896
+ * There is deliberately no `baseUrl` twin of the GitHub Enterprise Server
2897
+ * field. `HF_ENDPOINT` exists in the Python client for Hub *mirrors*, which
2898
+ * serve repository content — not the settings API this connector writes, and
2899
+ * not something a mirror is expected to accept a write on. Adding the field
2900
+ * would invite pointing a connection at a host that silently swallows secrets.
2901
+ */
2902
+ const huggingfaceSpacesConnectionConfigSchema = z.object({ provider: z.literal("huggingface-spaces") });
2874
2903
  const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
2875
2904
  vercelConnectionConfigSchema,
2876
2905
  cloudflareWorkersConnectionConfigSchema,
@@ -2889,7 +2918,8 @@ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
2889
2918
  githubActionsConnectionConfigSchema,
2890
2919
  gcpSecretManagerConnectionConfigSchema,
2891
2920
  langgraphPlatformConnectionConfigSchema,
2892
- azureKeyVaultConnectionConfigSchema
2921
+ azureKeyVaultConnectionConfigSchema,
2922
+ huggingfaceSpacesConnectionConfigSchema
2893
2923
  ]);
2894
2924
  /** Vercel's three deployment targets. A binding writes to one or more. */
2895
2925
  const VERCEL_TARGETS = [
@@ -3680,6 +3710,40 @@ const azureKeyVaultDestinationSchema = z.object({
3680
3710
  /** What to do with a name Key Vault cannot store — see {@link AZURE_KEY_VAULT_NAME_MODES}. */
3681
3711
  nameMode: z.enum(AZURE_KEY_VAULT_NAME_MODES).default("dash")
3682
3712
  });
3713
+ /**
3714
+ * The Space whose secrets a binding owns, addressed the way the Hub addresses
3715
+ * every repository: `owner/name`, where `owner` is a user or an organization.
3716
+ *
3717
+ * A Space has **one** secret set, shared by every replica — there is no
3718
+ * per-target split to state, the way Vercel and Pages have one. The Hub's
3719
+ * convention is that staging and production are separate Spaces
3720
+ * (`acme/demo`, `acme/demo-staging`), so pointing at an environment means
3721
+ * naming that Space, exactly as a Fly environment means naming its own app.
3722
+ *
3723
+ * Validated by shape because the two habitual slips both fail *late*: pasting
3724
+ * the browser URL (`https://huggingface.co/spaces/acme/demo`) or the bare name
3725
+ * without its owner. Either one is a 404 from the Hub inside an alarm with
3726
+ * nobody watching, and a 404 says nothing about which half was wrong. The
3727
+ * leading character is held to alphanumeric, which is also what rejects the
3728
+ * `.` and `..` that no repository may be called.
3729
+ */
3730
+ const huggingfaceRepoIdSchema = z.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/, "must be a Space ID as owner/name, e.g. acme/support-demo — not a URL");
3731
+ /**
3732
+ * One Hugging Face **Space**, whose secrets arrive in the app's container as
3733
+ * environment variables.
3734
+ *
3735
+ * Only secrets. A Space also has *variables*, and they are not a second lane
3736
+ * seekrit could use: the Hub calls them "non-sensitive configuration values",
3737
+ * they are "publicly accessible and viewable", and they are copied into every
3738
+ * Space duplicated from this one. Writing a seekrit secret there would publish
3739
+ * it, so this connector has no variables mode — a binding that wants one is
3740
+ * asking for the wrong thing.
3741
+ */
3742
+ const huggingfaceSpacesDestinationSchema = z.object({
3743
+ provider: z.literal("huggingface-spaces"),
3744
+ /** Space ID as `owner/name`, e.g. `acme/support-demo`. */
3745
+ repoId: huggingfaceRepoIdSchema
3746
+ });
3683
3747
  const syncDestinationSchema = z.discriminatedUnion("provider", [
3684
3748
  vercelDestinationSchema,
3685
3749
  cloudflareWorkersDestinationSchema,
@@ -3698,7 +3762,8 @@ const syncDestinationSchema = z.discriminatedUnion("provider", [
3698
3762
  githubActionsDestinationSchema,
3699
3763
  gcpSecretManagerDestinationSchema,
3700
3764
  langgraphPlatformDestinationSchema,
3701
- azureKeyVaultDestinationSchema
3765
+ azureKeyVaultDestinationSchema,
3766
+ huggingfaceSpacesDestinationSchema
3702
3767
  ]);
3703
3768
  /**
3704
3769
  * How seekrit secret names become destination key names. Applied in order:
@@ -4986,7 +5051,7 @@ async function createAgentTaskToken() {
4986
5051
  }
4987
5052
  //#endregion
4988
5053
  //#region package.json
4989
- var version = "1.0.0";
5054
+ var version = "1.2.0";
4990
5055
  //#endregion
4991
5056
  //#region ../../packages/api-client/src/index.ts
4992
5057
  var SeekritApiError = class extends Error {
@@ -5059,6 +5124,22 @@ var SeekritClient = class {
5059
5124
  revokeCliSession(sessionId) {
5060
5125
  return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
5061
5126
  }
5127
+ /**
5128
+ * The passkeys enrolled to unlock this user's keyring, each with the `pk2.`
5129
+ * blob its PRF output decrypts. One call is everything the unlock ceremony
5130
+ * needs; the blobs are opaque without the authenticator.
5131
+ */
5132
+ listMyPasskeys() {
5133
+ return this.request("GET", "/v1/me/passkeys");
5134
+ }
5135
+ /** File a private key already wrapped, client-side, to a passkey's PRF output. */
5136
+ enrollMyPasskey(input) {
5137
+ return this.request("POST", "/v1/me/passkeys", input);
5138
+ }
5139
+ /** Stop a passkey unlocking the keyring. The key itself is untouched. */
5140
+ deleteMyPasskey(passkeyId) {
5141
+ return this.request("DELETE", `/v1/me/passkeys/${passkeyId}`);
5142
+ }
5062
5143
  /** What a pending login request is asking for — for the approval screen. */
5063
5144
  getCliLoginRequest(code) {
5064
5145
  return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
@@ -9584,6 +9665,224 @@ function registerGroupCommands(program) {
9584
9665
  });
9585
9666
  }
9586
9667
  //#endregion
9668
+ //#region src/hermes.ts
9669
+ /**
9670
+ * `seekrit hermes` — wire seekrit into [Hermes Agent](https://hermes-agent.nousresearch.com)
9671
+ * as a **secret source**.
9672
+ *
9673
+ * Hermes reads credentials from `~/.hermes/.env` and the process environment, and
9674
+ * a *secret source* is the documented way to fill that environment from
9675
+ * somewhere else at startup. Implementing one is Python, and it lives in the
9676
+ * Python SDK (`seekrit.hermes`) rather than here — a `SecretSource` subclass and
9677
+ * the `register(ctx)` hook Hermes calls. This command exists for the two parts
9678
+ * that are not Python: the `config.yaml` block, and the plugin directory for a
9679
+ * Hermes install that cannot see the SDK's entry point.
9680
+ *
9681
+ * **`config.yaml` is printed, not written.** It is YAML that belongs to
9682
+ * somebody's agent, with comments and anchors this CLI carries no parser for;
9683
+ * a rewrite through a JSON round trip would delete them. The plugin directory
9684
+ * *is* written, because those two files are entirely ours.
9685
+ *
9686
+ * There are two sources, and which one to use is a real choice rather than a
9687
+ * default. `seekrit` is **bulk**: one environment, whole, nothing to enumerate.
9688
+ * `seekrit_refs` is **mapped**: explicit `VAR: skt://NAME` bindings, which is
9689
+ * what you need to rename a secret, read more than one environment, or win a
9690
+ * contested variable — Hermes lets a mapped claim beat a bulk one.
9691
+ */
9692
+ /** The two source names `seekrit.hermes` registers. */
9693
+ const BULK_SOURCE = "seekrit";
9694
+ const MAPPED_SOURCE = "seekrit_refs";
9695
+ /** Hermes' home: `$HERMES_HOME`, else `~/.hermes`. */
9696
+ function hermesHome(env = process.env) {
9697
+ const home = env.HERMES_HOME?.trim();
9698
+ return home && home.length > 0 ? resolve(home) : join(homedir(), ".hermes");
9699
+ }
9700
+ const NAME_RE$1 = /^[A-Za-z_][A-Za-z0-9_]*$/;
9701
+ /**
9702
+ * Build an `skt://` reference.
9703
+ *
9704
+ * A service token is bound to one environment, so a reference names a *token*
9705
+ * (by the alias its `tokens:` map gives it) and a secret in that token's
9706
+ * environment — not an arbitrary app and env, which nothing on the read path
9707
+ * could resolve.
9708
+ */
9709
+ function reference(name, tokenAlias) {
9710
+ return `skt://${tokenAlias ? `${tokenAlias}/` : ""}${name}`;
9711
+ }
9712
+ /**
9713
+ * The `secrets:` block for `~/.hermes/config.yaml`.
9714
+ *
9715
+ * Hand-rolled rather than serialized, because the shape is fixed and small and
9716
+ * the output is meant to be *read* — a generic emitter would quote keys and
9717
+ * flow-fold lists in ways that make a config someone has to paste look foreign.
9718
+ */
9719
+ function configBlock(options = {}) {
9720
+ const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
9721
+ const lines = [
9722
+ "secrets:",
9723
+ ` sources: [${source}]`,
9724
+ ` ${source}:`,
9725
+ " enabled: true"
9726
+ ];
9727
+ if (options.tokenEnv && options.tokenEnv !== "SEEKRIT_TOKEN") lines.push(` token_env: ${options.tokenEnv}`);
9728
+ if (options.mapped) {
9729
+ const tokens = Object.entries(options.tokens ?? {});
9730
+ if (tokens.length > 0) {
9731
+ lines.push(" tokens:");
9732
+ for (const [alias, variable] of tokens) lines.push(` ${alias}: ${variable}`);
9733
+ }
9734
+ lines.push(" env:");
9735
+ const bindings = options.bindings ?? [];
9736
+ if (bindings.length === 0) lines.push(` OPENAI_API_KEY: ${reference("OPENAI_API_KEY")}`);
9737
+ else for (const b of bindings) lines.push(` ${b.variable}: ${reference(b.name, b.tokenAlias)}`);
9738
+ }
9739
+ return `${lines.join("\n")}\n`;
9740
+ }
9741
+ /** `plugin.yaml` for the directory-install path. */
9742
+ function pluginYaml() {
9743
+ return [
9744
+ "name: seekrit",
9745
+ "description: >-",
9746
+ " Resolve Hermes provider credentials from seekrit — end-to-end encrypted",
9747
+ " secrets, decrypted in this process by your own service token.",
9748
+ ""
9749
+ ].join("\n");
9750
+ }
9751
+ /**
9752
+ * `__init__.py` for the directory-install path.
9753
+ *
9754
+ * Re-exporting `register` is the whole file: the implementation lives in the
9755
+ * published SDK, so a plugin scaffolded once keeps up with SDK releases instead
9756
+ * of pinning a copy of the resolver into somebody's home directory.
9757
+ */
9758
+ function pluginInit() {
9759
+ return [
9760
+ "\"\"\"seekrit secret sources for Hermes Agent.",
9761
+ "",
9762
+ "Hermes calls `register(ctx)` from this module. The implementation lives in",
9763
+ "the `seekrit` package (`pip install seekrit`), so this file stays a re-export",
9764
+ "and never has to be regenerated.",
9765
+ "\"\"\"",
9766
+ "",
9767
+ "from seekrit.hermes import register # noqa: F401",
9768
+ ""
9769
+ ].join("\n");
9770
+ }
9771
+ /**
9772
+ * Parse a `VAR=NAME` or `VAR=alias/NAME` binding for `--bind`.
9773
+ *
9774
+ * The left side is the environment variable Hermes will set; the right side is
9775
+ * the seekrit secret it comes from. They differ often enough — that is most of
9776
+ * why the mapped source exists — that inferring one from the other would be
9777
+ * wrong more than it was convenient.
9778
+ */
9779
+ function parseBinding(raw) {
9780
+ const eq = raw.indexOf("=");
9781
+ if (eq <= 0) return void 0;
9782
+ const variable = raw.slice(0, eq).trim();
9783
+ const target = raw.slice(eq + 1).trim();
9784
+ if (!NAME_RE$1.test(variable) || target.length === 0) return void 0;
9785
+ const slash = target.indexOf("/");
9786
+ if (slash === -1) return NAME_RE$1.test(target) ? {
9787
+ variable,
9788
+ name: target
9789
+ } : void 0;
9790
+ const tokenAlias = target.slice(0, slash).trim();
9791
+ const name = target.slice(slash + 1).trim();
9792
+ if (tokenAlias.length === 0 || !NAME_RE$1.test(name)) return void 0;
9793
+ return {
9794
+ variable,
9795
+ name,
9796
+ tokenAlias
9797
+ };
9798
+ }
9799
+ /** Parse a `--token alias=VAR` pair. */
9800
+ function parseTokenAlias(raw) {
9801
+ const eq = raw.indexOf("=");
9802
+ if (eq <= 0) return void 0;
9803
+ const alias = raw.slice(0, eq).trim();
9804
+ const variable = raw.slice(eq + 1).trim();
9805
+ if (alias.length === 0 || !NAME_RE$1.test(variable)) return void 0;
9806
+ return {
9807
+ alias,
9808
+ variable
9809
+ };
9810
+ }
9811
+ function registerHermesCommands(program) {
9812
+ const hermes = program.command("hermes").description("wire seekrit into a Hermes Agent as a secret source (`seekrit hermes --help`)");
9813
+ hermes.command("init").description("print the Hermes config that resolves credentials from seekrit").option("--mapped", `use the ${MAPPED_SOURCE} source (explicit VAR -> secret bindings)`).option("--token-env <var>", "variable holding the service token", "SEEKRIT_TOKEN").option("--bind <VAR=NAME...>", "a mapped binding, e.g. OPENAI_API_KEY=OPENAI_KEY").option("--token <alias=VAR...>", "name a second token, e.g. billing=SEEKRIT_TOKEN_BILLING").option("--plugin-dir", "also scaffold $HERMES_HOME/plugins/seekrit (for a pip-less install)").option("--home <path>", "Hermes home (default: $HERMES_HOME or ~/.hermes)").option("--json", "machine-readable output").action((options) => {
9814
+ const bindings = (options.bind ?? []).map((raw) => {
9815
+ const parsed = parseBinding(raw);
9816
+ if (!parsed) fail(`--bind must be VAR=NAME or VAR=alias/NAME (got "${raw}")`);
9817
+ return parsed;
9818
+ });
9819
+ const tokens = {};
9820
+ for (const raw of options.token ?? []) {
9821
+ const parsed = parseTokenAlias(raw);
9822
+ if (!parsed) fail(`--token must be alias=VARIABLE (got "${raw}")`);
9823
+ tokens[parsed.alias] = parsed.variable;
9824
+ }
9825
+ for (const binding of bindings) if (binding.tokenAlias && !tokens[binding.tokenAlias]) fail(`--bind ${binding.variable} names token alias "${binding.tokenAlias}", so pass --token ${binding.tokenAlias}=<VARIABLE> too`);
9826
+ if (!options.mapped && (bindings.length > 0 || Object.keys(tokens).length > 0)) fail("--bind and --token describe the mapped source: pass --mapped as well");
9827
+ const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
9828
+ const block = configBlock({
9829
+ mapped: options.mapped,
9830
+ tokenEnv: options.tokenEnv,
9831
+ bindings,
9832
+ tokens
9833
+ });
9834
+ const home = options.home ? resolve(options.home) : hermesHome();
9835
+ const pluginDir = join(home, "plugins", "seekrit");
9836
+ const written = [];
9837
+ if (options.pluginDir) {
9838
+ mkdirSync(pluginDir, { recursive: true });
9839
+ const files = [["plugin.yaml", pluginYaml()], ["__init__.py", pluginInit()]];
9840
+ for (const [name, contents] of files) {
9841
+ const path = join(pluginDir, name);
9842
+ writeFileSync(path, contents);
9843
+ written.push(path);
9844
+ }
9845
+ }
9846
+ emit(options, {
9847
+ source,
9848
+ configPath: join(home, "config.yaml"),
9849
+ block,
9850
+ written
9851
+ }, () => {
9852
+ section("hermes secret source");
9853
+ printFields([
9854
+ ["source", source],
9855
+ ["shape", options.mapped ? "mapped (explicit bindings)" : "bulk (whole environment)"],
9856
+ ["config", join(home, "config.yaml")],
9857
+ ["plugin dir", options.pluginDir ? pluginDir : "not scaffolded"]
9858
+ ]);
9859
+ console.log();
9860
+ console.log("1. Install the SDK into the environment Hermes runs in:");
9861
+ console.log(" pip install seekrit");
9862
+ console.log();
9863
+ console.log("2. Put the service token in ~/.hermes/.env (not config.yaml):");
9864
+ console.log(` ${options.tokenEnv ?? "SEEKRIT_TOKEN"}=skt_...`);
9865
+ console.log();
9866
+ console.log("3. Enable the plugin and merge this into ~/.hermes/config.yaml:");
9867
+ console.log(" hermes plugins enable seekrit");
9868
+ console.log();
9869
+ console.log(block.trimEnd());
9870
+ console.log();
9871
+ if (written.length > 0) {
9872
+ console.log("Scaffolded:");
9873
+ for (const path of written) console.log(` ${path}`);
9874
+ console.log();
9875
+ }
9876
+ console.log("The source overrides values already in .env or your shell, so a rotation wins — list a variable under `preserve_existing` when a local override is the point.");
9877
+ });
9878
+ });
9879
+ hermes.command("ref <name>").description("print the skt:// reference to bind a Hermes variable to").option("--token-alias <alias>", "resolve through a second token named in `tokens:`").option("--json", "machine-readable output").action((name, options) => {
9880
+ if (!NAME_RE$1.test(name)) fail(`not a valid secret name: "${name}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
9881
+ const ref = reference(name, options.tokenAlias);
9882
+ emit(options, { reference: ref }, () => console.log(ref));
9883
+ });
9884
+ }
9885
+ //#endregion
9587
9886
  //#region src/honey.ts
9588
9887
  /**
9589
9888
  * Honey tokens — decoy credentials that unlock nothing and alert when used.
@@ -10092,100 +10391,689 @@ function collect$3(value, acc) {
10092
10391
  acc.push(value);
10093
10392
  return acc;
10094
10393
  }
10095
- //#endregion
10096
- //#region src/orgs.ts
10097
10394
  /**
10098
- * Counts for `org show`. Each list is admin-gated to a different degree, so a
10099
- * member who can't read one still gets the rest: a failed call reports as
10100
- * `null`, which `printFields` drops and JSON preserves as "not visible to you".
10395
+ * Fetch + decrypt every secret in a single environment.
10396
+ *
10397
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
10398
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
10399
+ * scope here — a reference to a secret inherited from a composed group is left
10400
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
10401
+ * fully-layered view.
10101
10402
  */
10102
- async function countOrNull(load) {
10403
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
10404
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
10405
+ const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
10406
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
10407
+ }
10408
+ /**
10409
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
10410
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
10411
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
10412
+ */
10413
+ function interpolateValues(values, enabled = true) {
10414
+ if (!enabled) return {
10415
+ values,
10416
+ interpolated: [],
10417
+ unresolvedRefs: []
10418
+ };
10103
10419
  try {
10104
- return await load();
10105
- } catch {
10106
- return null;
10420
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
10421
+ return {
10422
+ values: expandedValues,
10423
+ interpolated: expanded,
10424
+ unresolvedRefs: unresolved
10425
+ };
10426
+ } catch (err) {
10427
+ return fail(err instanceof Error ? err.message : String(err));
10107
10428
  }
10108
10429
  }
10109
- async function orgOverview(ctx, orgId) {
10110
- const [apps, groups, members, tokens] = await Promise.all([
10111
- countOrNull(async () => (await ctx.client.listApps(orgId)).apps.length),
10112
- countOrNull(async () => (await ctx.client.listGroups(orgId)).groups.length),
10113
- countOrNull(async () => (await ctx.client.listMembers(orgId)).members.length),
10114
- countOrNull(async () => (await ctx.client.listTokens(orgId)).tokens.length)
10115
- ]);
10430
+ /**
10431
+ * Decrypt one historical version of a secret. Ciphertext is bound to
10432
+ * `(envId, name)` as AAD and neither changes across versions, so an old blob
10433
+ * opens with the environment's current data key — no special handling needed.
10434
+ */
10435
+ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
10436
+ const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
10437
+ const row = versions.find((v) => v.version === version);
10438
+ if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
10439
+ return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
10440
+ }
10441
+ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
10442
+ const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
10443
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
10444
+ }
10445
+ /**
10446
+ * Encrypt and store many secrets into one environment. The DEK is fetched once
10447
+ * (so user auth prompts for the passphrase a single time, not per variable),
10448
+ * then each value is encrypted locally and written. Existing names are
10449
+ * overwritten; the result splits them into created vs. updated for a summary.
10450
+ * Callers validate the names first — a rejected name aborts before any write.
10451
+ */
10452
+ async function importSecrets(ctx, orgId, envId, entries) {
10453
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
10454
+ const existing = new Set(secrets.map((s) => s.name));
10455
+ const result = {
10456
+ created: [],
10457
+ updated: []
10458
+ };
10459
+ for (const [name, value] of Object.entries(entries)) {
10460
+ const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
10461
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
10462
+ (existing.has(name) ? result.updated : result.created).push(name);
10463
+ }
10464
+ return result;
10465
+ }
10466
+ /**
10467
+ * Resolve the full, layered environment for a running app: composed group
10468
+ * secrets (lowest precedence) → the app env's own secrets → `.env` files.
10469
+ * Each layer's DEK is unwrapped once with the principal's private key and its
10470
+ * ciphertext decrypted locally. `process.env` is NOT applied here — callers
10471
+ * that spawn a process layer it on top so the live shell always wins.
10472
+ *
10473
+ * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
10474
+ * reference always resolves to whichever layer won the name.
10475
+ */
10476
+ async function materializeEnv(ctx, opts) {
10477
+ const query = {};
10478
+ if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
10479
+ if (opts.branch) query.branch = opts.branch;
10480
+ if (!isTokenAuth(ctx)) {
10481
+ if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
10482
+ query.env = opts.envId;
10483
+ }
10484
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
10485
+ const privateKey = await getPrivateKey(ctx);
10486
+ const values = {};
10487
+ const provenance = {};
10488
+ for (const layer of layers) {
10489
+ const dek = await unwrapDek(layer.wrappedDek, privateKey);
10490
+ let label;
10491
+ if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
10492
+ else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
10493
+ else label = `app:${scope.appSlug}/${layer.slug}`;
10494
+ for (const secret of layer.secrets) {
10495
+ values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
10496
+ provenance[secret.name] = label;
10497
+ }
10498
+ }
10499
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
10116
10500
  return {
10117
- apps,
10118
- groups,
10119
- members,
10120
- tokens
10501
+ ...interpolateValues(values, opts.interpolate !== false),
10502
+ provenance,
10503
+ scope,
10504
+ loadedEnvFiles
10121
10505
  };
10122
10506
  }
10123
- function registerOrgCommands(program) {
10124
- const org = program.command("org").description("manage organizations");
10125
- org.command("list").alias("ls").description("list the organizations you can access").option("--json", "print the raw API response").action(async (options) => {
10126
- const { orgs } = await buildContext().client.listOrgs();
10127
- emit(options, { orgs }, () => printTable(orgs, [
10128
- col("slug", (o) => o.slug),
10129
- col("name", (o) => o.name),
10130
- col("role", (o) => o.role),
10131
- col("id", (o) => o.id)
10132
- ], "no organizations create one with `seekrit org create`"));
10133
- });
10134
- org.command("show [slug]").description("show an organization and what it contains").option("--org <slug>", "organization slug (or pass it as the argument)").option("--json", "print the raw API response").action(async (slug, options) => {
10135
- const ctx = buildContext();
10136
- const ref = await resolveOrg(ctx, slug ?? options.org);
10137
- const { org: row } = await ctx.client.getOrg(ref.id);
10138
- const counts = await orgOverview(ctx, ref.id);
10139
- const shown = (n) => n === null ? "—" : n;
10140
- emit(options, {
10141
- org: row,
10142
- counts
10143
- }, () => {
10144
- printFields([
10145
- ["slug", row.slug],
10146
- ["name", row.name],
10147
- ["id", row.id],
10148
- ["your role", row.role],
10149
- ["created", row.createdAt],
10150
- ["applications", shown(counts.apps)],
10151
- ["groups", shown(counts.groups)],
10152
- ["members", shown(counts.members)],
10153
- ["service tokens", shown(counts.tokens)]
10154
- ]);
10155
- });
10156
- });
10157
- org.command("create").description("create an organization (you become its owner)").requiredOption("--name <name>", "display name").requiredOption("--slug <slug>", "url-safe identifier (permanent)").action(async (options) => {
10158
- const created = await buildContext().client.createOrg({
10159
- name: options.name,
10160
- slug: options.slug
10161
- });
10162
- console.error(`created org ${created.org.slug} (${created.org.id})`);
10163
- });
10164
- org.command("rename").description("change an organization's display name (the slug is permanent)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--name <name>", "new display name").action(async (options) => {
10165
- const ctx = buildContext();
10166
- const ref = await resolveOrg(ctx, options.org);
10167
- const { org: row } = await ctx.client.updateOrg(ref.id, { name: options.name });
10168
- console.error(`renamed ${row.slug} to "${row.name}"`);
10169
- });
10170
- org.command("member").description("view organization members").command("list").alias("ls").description("list members, and whether each has finished key setup").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
10171
- const ctx = buildContext();
10172
- const ref = await resolveOrg(ctx, options.org);
10173
- const { members } = await ctx.client.listMembers(ref.id);
10174
- emit(options, { members }, () => printTable(members, [
10175
- col("email", (m) => m.email),
10176
- col("role", (m) => m.role),
10177
- col("name", (m) => m.name),
10178
- col("keys", (m) => m.publicKeyJwk ? "ready" : "pending"),
10179
- col("id", (m) => m.userId)
10180
- ], "no members"));
10181
- });
10182
- const invite = org.command("invite").description("manage pending invitations");
10183
- invite.command("list").alias("ls").description("list outstanding invitations").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
10184
- const ctx = buildContext();
10185
- const ref = await resolveOrg(ctx, options.org);
10186
- const { invites } = await ctx.client.listInvites(ref.id);
10187
- emit(options, { invites }, () => printTable(invites, [
10188
- col("email", (i) => i.email),
10507
+ /**
10508
+ * Resolve, going through the last-known-good cache when one is configured.
10509
+ *
10510
+ * Always live first: the cache exists for when the call cannot land, not to
10511
+ * save a round trip, so a recovered network is picked up on the very next
10512
+ * invocation. A *refused* resolve (401/403/…) drops the entry rather than
10513
+ * falling back to it — otherwise revoking a token would keep working offline
10514
+ * until the entry aged out.
10515
+ */
10516
+ async function resolveWithCache(ctx, query, cache) {
10517
+ if (!cache) return ctx.client.resolve(query);
10518
+ try {
10519
+ const response = await ctx.client.resolve(query);
10520
+ try {
10521
+ cache.write(JSON.stringify(response));
10522
+ } catch (err) {
10523
+ warn(`could not update the cache: ${errorMessage(err)}`);
10524
+ }
10525
+ return response;
10526
+ } catch (err) {
10527
+ if (!mayFallBack(err)) {
10528
+ cache.invalidate();
10529
+ throw err;
10530
+ }
10531
+ const found = cache.read();
10532
+ if (found.kind === "hit") {
10533
+ warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
10534
+ return JSON.parse(found.body);
10535
+ }
10536
+ if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
10537
+ else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
10538
+ throw err;
10539
+ }
10540
+ }
10541
+ function warn(text) {
10542
+ process.stderr.write(`seekrit: ${text}\n`);
10543
+ }
10544
+ function errorMessage(err) {
10545
+ return err instanceof Error ? err.message : String(err);
10546
+ }
10547
+ /**
10548
+ * Overlay `.env` files onto an existing value/provenance set (later files win).
10549
+ * Missing files are skipped. Returns the files that were actually loaded. Used
10550
+ * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
10551
+ * managed secrets are unavailable but `.env` should still apply.
10552
+ */
10553
+ function overlayEnvFiles(values, provenance, envFiles) {
10554
+ const loaded = [];
10555
+ for (const file of envFiles) {
10556
+ if (!existsSync(file)) continue;
10557
+ loaded.push(file);
10558
+ for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
10559
+ values[name] = value;
10560
+ provenance[name] = `dotenv:${file}`;
10561
+ }
10562
+ }
10563
+ return loaded;
10564
+ }
10565
+ /**
10566
+ * Print a name → source table to stderr (never the secret values). Names whose
10567
+ * value had references expanded are marked, and dangling references are called
10568
+ * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
10569
+ * deliberately passed through as literal text.
10570
+ */
10571
+ function printExplain(provenance, refs = {}) {
10572
+ const interpolated = new Set(refs.interpolated ?? []);
10573
+ const names = Object.keys(provenance).sort();
10574
+ const width = names.reduce((w, n) => Math.max(w, n.length), 0);
10575
+ for (const name of names) {
10576
+ const marker = interpolated.has(name) ? " (interpolated)" : "";
10577
+ process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
10578
+ }
10579
+ if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
10580
+ }
10581
+ /** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
10582
+ const PLUGIN_ID = "seekrit";
10583
+ const PLUGIN_INTEGRATION_ID = "seekrit";
10584
+ /** The provider alias written into `secrets.providers`. */
10585
+ const PROVIDER_ALIAS = "seekrit";
10586
+ /**
10587
+ * Environment the resolver needs, and nothing else.
10588
+ *
10589
+ * OpenClaw hands an exec provider an empty environment apart from this
10590
+ * allowlist, which is a feature: it is the difference between "the resolver can
10591
+ * read the credential it needs" and "the resolver inherits the whole gateway
10592
+ * environment". `PATH` is here because Node's own startup needs it, `HOME` for
10593
+ * the CLI's login session, and the `SEEKRIT_*` set is how a machine credential
10594
+ * reaches a process nobody gets to pass flags to.
10595
+ */
10596
+ const PASS_ENV = [
10597
+ "PATH",
10598
+ "HOME",
10599
+ "USERPROFILE",
10600
+ "APPDATA",
10601
+ "LOCALAPPDATA",
10602
+ "TEMP",
10603
+ "TMP",
10604
+ "SYSTEMROOT",
10605
+ "WINDIR",
10606
+ "XDG_CONFIG_HOME",
10607
+ "XDG_CACHE_HOME",
10608
+ "NODE_EXTRA_CA_CERTS",
10609
+ "SEEKRIT_TOKEN",
10610
+ "SEEKRIT_CLIENT_ID",
10611
+ "SEEKRIT_CLIENT_SECRET",
10612
+ "SEEKRIT_API_URL",
10613
+ "SEEKRIT_ORG",
10614
+ "SEEKRIT_APP",
10615
+ "SEEKRIT_ENV",
10616
+ "SEEKRIT_BRANCH"
10617
+ ];
10618
+ /**
10619
+ * Generous, because the first resolve of a cold start does real work — an M2M
10620
+ * token mint, a resolve, and a key unwrap — and OpenClaw fails startup rather
10621
+ * than degrading when this expires. The 1Password integration picks 90s for the
10622
+ * same reason (its CLI may prompt for biometrics); 30s is enough here because
10623
+ * nothing in this path is interactive.
10624
+ */
10625
+ const TIMEOUT_MS = 3e4;
10626
+ /** A seekrit secret name: what `seekrit secrets set` accepts. */
10627
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10628
+ /** Slugs as the API spells them. */
10629
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
10630
+ /**
10631
+ * Parse an exec SecretRef id.
10632
+ *
10633
+ * Two shapes, mirroring 1Password's `op://vault/item/field` — a bare name for
10634
+ * the environment the credential already points at, and an explicit
10635
+ * `app/env/NAME` triple for a provider serving more than one environment:
10636
+ *
10637
+ * OPENAI_API_KEY
10638
+ * billing-api/production/STRIPE_SECRET_KEY
10639
+ *
10640
+ * A bare name is the common case and the one a service token wants, since the
10641
+ * token is already bound to an environment and there is nothing to disambiguate.
10642
+ */
10643
+ function parseSecretId(id) {
10644
+ const trimmed = id.trim();
10645
+ if (trimmed.length === 0) return void 0;
10646
+ const parts = trimmed.split("/");
10647
+ if (parts.length === 1) {
10648
+ const [name = ""] = parts;
10649
+ return NAME_RE.test(name) ? { name } : void 0;
10650
+ }
10651
+ if (parts.length !== 3) return void 0;
10652
+ const [app = "", env = "", name = ""] = parts;
10653
+ if (!SLUG_RE.test(app) || !SLUG_RE.test(env) || !NAME_RE.test(name)) return void 0;
10654
+ return {
10655
+ app,
10656
+ env,
10657
+ name
10658
+ };
10659
+ }
10660
+ /** The (app, env) pair an id resolves against — `""` for the credential's own. */
10661
+ function scopeKey(id) {
10662
+ return id.app && id.env ? `${id.app}/${id.env}` : "";
10663
+ }
10664
+ /**
10665
+ * Parse a request, tolerantly in exactly one direction.
10666
+ *
10667
+ * Non-string and empty ids are dropped rather than rejected — a request whose
10668
+ * shape we do not recognize should still resolve the ids we do, because the
10669
+ * alternative fails OpenClaw's whole startup over one malformed entry. A body
10670
+ * that is not an object with an `ids` array is a different matter: there is
10671
+ * nothing to answer, so it throws.
10672
+ */
10673
+ function parseExecRequest(input) {
10674
+ const parsed = JSON.parse(input);
10675
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("exec SecretRef request must be a JSON object");
10676
+ const ids = parsed.ids;
10677
+ if (!Array.isArray(ids)) throw new Error("exec SecretRef request must carry an `ids` array");
10678
+ return { ids: ids.filter((id) => typeof id === "string" && id.trim().length > 0) };
10679
+ }
10680
+ /** Assemble a response, omitting `errors` entirely when everything resolved. */
10681
+ function buildExecResponse(values, errors) {
10682
+ return {
10683
+ protocolVersion: 1,
10684
+ values,
10685
+ ...Object.keys(errors).length > 0 ? { errors } : {}
10686
+ };
10687
+ }
10688
+ /**
10689
+ * Resolve every requested id, reading each distinct environment exactly once.
10690
+ *
10691
+ * A batch is normally one environment, but a provider may serve several, and
10692
+ * resolving per id would mean an unwrap per id. Grouping keeps a 40-variable
10693
+ * gateway to one round trip.
10694
+ *
10695
+ * `resolveScope` is injected so the protocol can be tested without a network,
10696
+ * an API, or a key.
10697
+ */
10698
+ async function resolveIds(ids, resolveScope) {
10699
+ const values = {};
10700
+ const errors = {};
10701
+ const byScope = /* @__PURE__ */ new Map();
10702
+ for (const id of ids) {
10703
+ const parsed = parseSecretId(id);
10704
+ if (!parsed) {
10705
+ errors[id] = { code: "INVALID_ID" };
10706
+ continue;
10707
+ }
10708
+ const key = scopeKey(parsed);
10709
+ const bucket = byScope.get(key) ?? {
10710
+ scope: parsed,
10711
+ ids: []
10712
+ };
10713
+ bucket.ids.push({
10714
+ id,
10715
+ name: parsed.name
10716
+ });
10717
+ byScope.set(key, bucket);
10718
+ }
10719
+ for (const { scope, ids: wanted } of byScope.values()) {
10720
+ let resolved;
10721
+ try {
10722
+ resolved = await resolveScope(scope);
10723
+ } catch (err) {
10724
+ const code = classify(err);
10725
+ for (const { id } of wanted) errors[id] = { code };
10726
+ continue;
10727
+ }
10728
+ for (const { id, name } of wanted) {
10729
+ const value = resolved[name];
10730
+ if (value === void 0) errors[id] = { code: "NOT_FOUND" };
10731
+ else values[id] = value;
10732
+ }
10733
+ }
10734
+ return buildExecResponse(values, errors);
10735
+ }
10736
+ /**
10737
+ * Bucket a failure into a code an operator can act on.
10738
+ *
10739
+ * Deliberately coarse and deliberately message-free: it reads the error's own
10740
+ * text to pick a bucket and then throws that text away, because the text may
10741
+ * quote a token.
10742
+ */
10743
+ function classify(err) {
10744
+ const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
10745
+ if (/unauthor|forbidden|token|credential|passphrase|decrypt|sign in|log in|login/.test(message)) return "UNAUTHORIZED";
10746
+ return "UNAVAILABLE";
10747
+ }
10748
+ /** OpenClaw's config directory: `$OPENCLAW_HOME`, else `~/.openclaw`. */
10749
+ function openclawHome(env = process.env) {
10750
+ const home = env.OPENCLAW_HOME?.trim();
10751
+ return home && home.length > 0 ? resolve(home) : join(homedir(), ".openclaw");
10752
+ }
10753
+ /**
10754
+ * The provider block for an installed `@seekrit/openclaw-plugin`.
10755
+ *
10756
+ * Preferred over the exec shape below for one reason that matters on upgrade:
10757
+ * OpenClaw reads the command out of the plugin's manifest at every startup, so
10758
+ * a plugin release that moves its resolver keeps working. A copied command is a
10759
+ * path pinned in someone's config forever.
10760
+ */
10761
+ function pluginProvider() {
10762
+ return {
10763
+ source: "exec",
10764
+ pluginIntegration: {
10765
+ pluginId: PLUGIN_ID,
10766
+ integrationId: PLUGIN_INTEGRATION_ID
10767
+ }
10768
+ };
10769
+ }
10770
+ /**
10771
+ * The provider block for a plain CLI install, with no plugin.
10772
+ *
10773
+ * `realpathSync` on both paths is not tidiness. OpenClaw's guard rejects a
10774
+ * symlinked command outright, and a Node installed by a version manager is
10775
+ * almost always reached through one — so resolving here is the difference
10776
+ * between a provider that works and a startup failure whose message points at
10777
+ * the config rather than at the shim.
10778
+ */
10779
+ function execProvider(cliEntry, nodeBinary = process.execPath) {
10780
+ const command = realTo(nodeBinary);
10781
+ const entry = realTo(cliEntry);
10782
+ return {
10783
+ source: "exec",
10784
+ command,
10785
+ args: [
10786
+ entry,
10787
+ "openclaw",
10788
+ "resolve"
10789
+ ],
10790
+ passEnv: [...PASS_ENV],
10791
+ jsonOnly: true,
10792
+ timeoutMs: TIMEOUT_MS,
10793
+ noOutputTimeoutMs: TIMEOUT_MS,
10794
+ trustedDirs: [.../* @__PURE__ */ new Set([dirname(command), dirname(entry)])]
10795
+ };
10796
+ }
10797
+ function realTo(path) {
10798
+ try {
10799
+ return realpathSync(path);
10800
+ } catch {
10801
+ return resolve(path);
10802
+ }
10803
+ }
10804
+ /** This CLI's own entry file — what `execProvider` hands to Node. */
10805
+ function cliEntryPath() {
10806
+ return fileURLToPath(new URL("index.js", import.meta.url));
10807
+ }
10808
+ /**
10809
+ * Add the seekrit provider to a parsed config without disturbing anything else.
10810
+ *
10811
+ * An existing `seekrit` entry that differs is left alone unless forced, for the
10812
+ * same reason `seekrit paperclip init` leaves an existing MCP server alone:
10813
+ * someone who pinned a timeout or narrowed `passEnv` did it deliberately, and
10814
+ * silently reverting a security narrowing is the worst kind of helpful.
10815
+ */
10816
+ function mergeProvider(existing, provider, force) {
10817
+ const secrets = { ...existing.secrets ?? {} };
10818
+ const providers = { ...secrets.providers ?? {} };
10819
+ const current = providers[PROVIDER_ALIAS];
10820
+ if (current && !force && JSON.stringify(current) !== JSON.stringify(provider)) return {
10821
+ merged: existing,
10822
+ changed: false
10823
+ };
10824
+ if (current && JSON.stringify(current) === JSON.stringify(provider)) return {
10825
+ merged: existing,
10826
+ changed: false
10827
+ };
10828
+ providers[PROVIDER_ALIAS] = provider;
10829
+ return {
10830
+ merged: {
10831
+ ...existing,
10832
+ secrets: {
10833
+ ...secrets,
10834
+ providers
10835
+ }
10836
+ },
10837
+ changed: true
10838
+ };
10839
+ }
10840
+ /** A SecretRef pointing at this provider — what replaces a plaintext key. */
10841
+ function secretRef(id) {
10842
+ return {
10843
+ source: "exec",
10844
+ provider: PROVIDER_ALIAS,
10845
+ id
10846
+ };
10847
+ }
10848
+ /**
10849
+ * Read `openclaw.json`, or decline to.
10850
+ *
10851
+ * The file is JSON5: comments and trailing commas are legal, and a parse/write
10852
+ * round trip through `JSON` would delete every comment in someone's gateway
10853
+ * config. So a file that is not also valid strict JSON is not edited at all —
10854
+ * the caller prints the block to paste instead. Losing a config's comments to
10855
+ * be helpful is worse than asking for one paste.
10856
+ */
10857
+ function readOpenclawConfig(path) {
10858
+ if (!existsSync(path)) return { config: {} };
10859
+ let raw;
10860
+ try {
10861
+ raw = readFileSync(path, "utf8");
10862
+ } catch (err) {
10863
+ fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10864
+ }
10865
+ if (raw.trim().length === 0) return { config: {} };
10866
+ try {
10867
+ const parsed = JSON.parse(raw);
10868
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { json5: true };
10869
+ return { config: parsed };
10870
+ } catch {
10871
+ return { json5: true };
10872
+ }
10873
+ }
10874
+ function registerOpenclawCommands(program) {
10875
+ const openclaw = program.command("openclaw").description("wire seekrit into OpenClaw as a SecretRef provider (`seekrit openclaw --help`)");
10876
+ openclaw.command("init").description("add the seekrit exec SecretRef provider to openclaw.json").option("--config <path>", "path to openclaw.json (default: $OPENCLAW_HOME/openclaw.json)").option("--plugin", "point at an installed @seekrit/openclaw-plugin (recommended)").option("--exec", "point directly at this CLI, with no plugin installed").option("--write", "write the file instead of printing the block to paste").option("--force", "replace a seekrit provider entry that already differs").option("--json", "machine-readable output").action((options) => {
10877
+ if (options.plugin && options.exec) fail("--plugin and --exec are mutually exclusive");
10878
+ const usePlugin = options.exec !== true;
10879
+ let provider;
10880
+ if (usePlugin) provider = pluginProvider();
10881
+ else {
10882
+ const entry = cliEntryPath();
10883
+ if (!existsSync(entry)) fail(`cannot find this CLI's entry file at ${entry} — --exec needs an installed seekrit (npm i -g @seekrit/cli), or use --plugin`);
10884
+ provider = execProvider(entry);
10885
+ }
10886
+ const path = options.config ? resolve(options.config) : join(openclawHome(), "openclaw.json");
10887
+ const read = readOpenclawConfig(path);
10888
+ const json5 = "json5" in read;
10889
+ const merged = json5 ? void 0 : mergeProvider(read.config, provider, options.force === true);
10890
+ const wrote = Boolean(options.write) && merged?.changed === true;
10891
+ if (wrote && merged) writeFileSync(path, `${JSON.stringify(merged.merged, null, 2)}\n`, { mode: 384 });
10892
+ emit(options, {
10893
+ path,
10894
+ provider,
10895
+ wrote,
10896
+ json5
10897
+ }, () => {
10898
+ section("openclaw secret provider");
10899
+ printFields([
10900
+ ["config", path],
10901
+ ["provider", PROVIDER_ALIAS],
10902
+ ["shape", usePlugin ? "plugin integration" : "exec (this CLI)"],
10903
+ ["written", wrote ? "yes" : "no"]
10904
+ ]);
10905
+ console.log();
10906
+ if (json5) {
10907
+ console.log(`${path} is JSON5 (comments or trailing commas), which a rewrite here would delete.`);
10908
+ console.log("Merge this into it by hand:");
10909
+ } else if (!options.write) console.log("Merge this into your config, or re-run with --write:");
10910
+ else if (!wrote) console.log(`A different \`${PROVIDER_ALIAS}\` provider is already configured — left as it is. Re-run with --force to replace it.`);
10911
+ else console.log("Written. The block now in your config:");
10912
+ console.log();
10913
+ console.log(JSON.stringify({ secrets: { providers: { [PROVIDER_ALIAS]: provider } } }, null, 2));
10914
+ console.log();
10915
+ if (usePlugin) {
10916
+ console.log("Install the plugin, if you have not already:");
10917
+ console.log(" openclaw plugins install npm:@seekrit/openclaw-plugin");
10918
+ console.log(" openclaw plugins enable seekrit");
10919
+ console.log();
10920
+ }
10921
+ console.log("Then replace a plaintext credential with a ref, e.g.:");
10922
+ console.log(` ${JSON.stringify(secretRef("OPENAI_API_KEY"))}`);
10923
+ console.log();
10924
+ console.log("And check your work:");
10925
+ console.log(" openclaw secrets audit --check --allow-exec");
10926
+ console.log();
10927
+ console.log("Resolution is eager: OpenClaw reads every ref once at startup. After a rotation, run `openclaw secrets reload`.");
10928
+ });
10929
+ });
10930
+ openclaw.command("ref <name>").description("print the SecretRef object to paste in place of a plaintext credential").option("--app <slug>", "address a specific application (with --env)").option("--env <slug>", "address a specific environment (with --app)").option("--json", "machine-readable output").action((name, options) => {
10931
+ if (Boolean(options.app) !== Boolean(options.env)) fail("--app and --env go together (an id names both, or neither)");
10932
+ const id = options.app && options.env ? `${options.app}/${options.env}/${name}` : name;
10933
+ if (!parseSecretId(id)) fail(`not a valid SecretRef id: "${id}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
10934
+ const ref = secretRef(id);
10935
+ emit(options, ref, () => console.log(JSON.stringify(ref)));
10936
+ });
10937
+ openclaw.command("resolve").description("resolve exec SecretRef ids from stdin (OpenClaw calls this; not for humans)").action(async () => {
10938
+ setFailThrows(true);
10939
+ let request;
10940
+ try {
10941
+ request = parseExecRequest(await readStdin());
10942
+ } catch (err) {
10943
+ process.stderr.write(`seekrit: ${err instanceof Error ? err.message : String(err)}\n`);
10944
+ process.exitCode = 2;
10945
+ return;
10946
+ }
10947
+ const scopes = /* @__PURE__ */ new Map();
10948
+ let ctx;
10949
+ const response = await resolveIds(request.ids, (scope) => {
10950
+ const key = scopeKey(scope);
10951
+ const cached = scopes.get(key);
10952
+ if (cached) return cached;
10953
+ ctx ??= buildContext();
10954
+ const pending = resolveEnvironment(ctx, scope);
10955
+ scopes.set(key, pending);
10956
+ return pending;
10957
+ });
10958
+ process.stdout.write(`${JSON.stringify(response)}\n`);
10959
+ });
10960
+ }
10961
+ /**
10962
+ * Resolve one environment's full variable set.
10963
+ *
10964
+ * `.env` overlays are deliberately switched off (`envFiles: []`). Everywhere
10965
+ * else in this CLI they are a convenience for a developer's shell; here the
10966
+ * working directory belongs to the OpenClaw gateway, and letting a file in it
10967
+ * override a gateway credential would make a stray `.env` a privilege
10968
+ * escalation. Reference expansion stays on — a `${OTHER_SECRET}` reference is
10969
+ * stored data, not a local override.
10970
+ */
10971
+ async function resolveEnvironment(ctx, scope) {
10972
+ let envId;
10973
+ if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, {
10974
+ app: scope.app,
10975
+ env: scope.env
10976
+ })).envId;
10977
+ const { values } = await materializeEnv(ctx, {
10978
+ envId,
10979
+ envFiles: []
10980
+ });
10981
+ return values;
10982
+ }
10983
+ //#endregion
10984
+ //#region src/orgs.ts
10985
+ /**
10986
+ * Counts for `org show`. Each list is admin-gated to a different degree, so a
10987
+ * member who can't read one still gets the rest: a failed call reports as
10988
+ * `null`, which `printFields` drops and JSON preserves as "not visible to you".
10989
+ */
10990
+ async function countOrNull(load) {
10991
+ try {
10992
+ return await load();
10993
+ } catch {
10994
+ return null;
10995
+ }
10996
+ }
10997
+ async function orgOverview(ctx, orgId) {
10998
+ const [apps, groups, members, tokens] = await Promise.all([
10999
+ countOrNull(async () => (await ctx.client.listApps(orgId)).apps.length),
11000
+ countOrNull(async () => (await ctx.client.listGroups(orgId)).groups.length),
11001
+ countOrNull(async () => (await ctx.client.listMembers(orgId)).members.length),
11002
+ countOrNull(async () => (await ctx.client.listTokens(orgId)).tokens.length)
11003
+ ]);
11004
+ return {
11005
+ apps,
11006
+ groups,
11007
+ members,
11008
+ tokens
11009
+ };
11010
+ }
11011
+ function registerOrgCommands(program) {
11012
+ const org = program.command("org").description("manage organizations");
11013
+ org.command("list").alias("ls").description("list the organizations you can access").option("--json", "print the raw API response").action(async (options) => {
11014
+ const { orgs } = await buildContext().client.listOrgs();
11015
+ emit(options, { orgs }, () => printTable(orgs, [
11016
+ col("slug", (o) => o.slug),
11017
+ col("name", (o) => o.name),
11018
+ col("role", (o) => o.role),
11019
+ col("id", (o) => o.id)
11020
+ ], "no organizations — create one with `seekrit org create`"));
11021
+ });
11022
+ org.command("show [slug]").description("show an organization and what it contains").option("--org <slug>", "organization slug (or pass it as the argument)").option("--json", "print the raw API response").action(async (slug, options) => {
11023
+ const ctx = buildContext();
11024
+ const ref = await resolveOrg(ctx, slug ?? options.org);
11025
+ const { org: row } = await ctx.client.getOrg(ref.id);
11026
+ const counts = await orgOverview(ctx, ref.id);
11027
+ const shown = (n) => n === null ? "—" : n;
11028
+ emit(options, {
11029
+ org: row,
11030
+ counts
11031
+ }, () => {
11032
+ printFields([
11033
+ ["slug", row.slug],
11034
+ ["name", row.name],
11035
+ ["id", row.id],
11036
+ ["your role", row.role],
11037
+ ["created", row.createdAt],
11038
+ ["applications", shown(counts.apps)],
11039
+ ["groups", shown(counts.groups)],
11040
+ ["members", shown(counts.members)],
11041
+ ["service tokens", shown(counts.tokens)]
11042
+ ]);
11043
+ });
11044
+ });
11045
+ org.command("create").description("create an organization (you become its owner)").requiredOption("--name <name>", "display name").requiredOption("--slug <slug>", "url-safe identifier (permanent)").action(async (options) => {
11046
+ const created = await buildContext().client.createOrg({
11047
+ name: options.name,
11048
+ slug: options.slug
11049
+ });
11050
+ console.error(`created org ${created.org.slug} (${created.org.id})`);
11051
+ });
11052
+ org.command("rename").description("change an organization's display name (the slug is permanent)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--name <name>", "new display name").action(async (options) => {
11053
+ const ctx = buildContext();
11054
+ const ref = await resolveOrg(ctx, options.org);
11055
+ const { org: row } = await ctx.client.updateOrg(ref.id, { name: options.name });
11056
+ console.error(`renamed ${row.slug} to "${row.name}"`);
11057
+ });
11058
+ org.command("member").description("view organization members").command("list").alias("ls").description("list members, and whether each has finished key setup").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
11059
+ const ctx = buildContext();
11060
+ const ref = await resolveOrg(ctx, options.org);
11061
+ const { members } = await ctx.client.listMembers(ref.id);
11062
+ emit(options, { members }, () => printTable(members, [
11063
+ col("email", (m) => m.email),
11064
+ col("role", (m) => m.role),
11065
+ col("name", (m) => m.name),
11066
+ col("keys", (m) => m.publicKeyJwk ? "ready" : "pending"),
11067
+ col("id", (m) => m.userId)
11068
+ ], "no members"));
11069
+ });
11070
+ const invite = org.command("invite").description("manage pending invitations");
11071
+ invite.command("list").alias("ls").description("list outstanding invitations").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
11072
+ const ctx = buildContext();
11073
+ const ref = await resolveOrg(ctx, options.org);
11074
+ const { invites } = await ctx.client.listInvites(ref.id);
11075
+ emit(options, { invites }, () => printTable(invites, [
11076
+ col("email", (i) => i.email),
10189
11077
  col("role", (i) => i.role),
10190
11078
  col("invited", (i) => i.createdAt),
10191
11079
  col("id", (i) => i.id)
@@ -11968,193 +12856,6 @@ function registerRotationCommands(program) {
11968
12856
  console.error(`disabled rotation of ${r.secretName}${rotatorRevoked ? " — rotator key access revoked for this environment" : ""}`);
11969
12857
  });
11970
12858
  }
11971
- /**
11972
- * Fetch + decrypt every secret in a single environment.
11973
- *
11974
- * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
11975
- * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
11976
- * scope here — a reference to a secret inherited from a composed group is left
11977
- * literal, because the group layers aren't fetched. `materializeEnv` is the
11978
- * fully-layered view.
11979
- */
11980
- async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
11981
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
11982
- const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
11983
- return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
11984
- }
11985
- /**
11986
- * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
11987
- * the CLI's standard fatal exit — it is a config bug with no correct value to
11988
- * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
11989
- */
11990
- function interpolateValues(values, enabled = true) {
11991
- if (!enabled) return {
11992
- values,
11993
- interpolated: [],
11994
- unresolvedRefs: []
11995
- };
11996
- try {
11997
- const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
11998
- return {
11999
- values: expandedValues,
12000
- interpolated: expanded,
12001
- unresolvedRefs: unresolved
12002
- };
12003
- } catch (err) {
12004
- return fail(err instanceof Error ? err.message : String(err));
12005
- }
12006
- }
12007
- /**
12008
- * Decrypt one historical version of a secret. Ciphertext is bound to
12009
- * `(envId, name)` as AAD and neither changes across versions, so an old blob
12010
- * opens with the environment's current data key — no special handling needed.
12011
- */
12012
- async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
12013
- const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
12014
- const row = versions.find((v) => v.version === version);
12015
- if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
12016
- return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
12017
- }
12018
- async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
12019
- const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
12020
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
12021
- }
12022
- /**
12023
- * Encrypt and store many secrets into one environment. The DEK is fetched once
12024
- * (so user auth prompts for the passphrase a single time, not per variable),
12025
- * then each value is encrypted locally and written. Existing names are
12026
- * overwritten; the result splits them into created vs. updated for a summary.
12027
- * Callers validate the names first — a rejected name aborts before any write.
12028
- */
12029
- async function importSecrets(ctx, orgId, envId, entries) {
12030
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
12031
- const existing = new Set(secrets.map((s) => s.name));
12032
- const result = {
12033
- created: [],
12034
- updated: []
12035
- };
12036
- for (const [name, value] of Object.entries(entries)) {
12037
- const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
12038
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
12039
- (existing.has(name) ? result.updated : result.created).push(name);
12040
- }
12041
- return result;
12042
- }
12043
- /**
12044
- * Resolve the full, layered environment for a running app: composed group
12045
- * secrets (lowest precedence) → the app env's own secrets → `.env` files.
12046
- * Each layer's DEK is unwrapped once with the principal's private key and its
12047
- * ciphertext decrypted locally. `process.env` is NOT applied here — callers
12048
- * that spawn a process layer it on top so the live shell always wins.
12049
- *
12050
- * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
12051
- * reference always resolves to whichever layer won the name.
12052
- */
12053
- async function materializeEnv(ctx, opts) {
12054
- const query = {};
12055
- if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
12056
- if (opts.branch) query.branch = opts.branch;
12057
- if (!isTokenAuth(ctx)) {
12058
- if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
12059
- query.env = opts.envId;
12060
- }
12061
- const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
12062
- const privateKey = await getPrivateKey(ctx);
12063
- const values = {};
12064
- const provenance = {};
12065
- for (const layer of layers) {
12066
- const dek = await unwrapDek(layer.wrappedDek, privateKey);
12067
- let label;
12068
- if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
12069
- else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
12070
- else label = `app:${scope.appSlug}/${layer.slug}`;
12071
- for (const secret of layer.secrets) {
12072
- values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
12073
- provenance[secret.name] = label;
12074
- }
12075
- }
12076
- const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
12077
- return {
12078
- ...interpolateValues(values, opts.interpolate !== false),
12079
- provenance,
12080
- scope,
12081
- loadedEnvFiles
12082
- };
12083
- }
12084
- /**
12085
- * Resolve, going through the last-known-good cache when one is configured.
12086
- *
12087
- * Always live first: the cache exists for when the call cannot land, not to
12088
- * save a round trip, so a recovered network is picked up on the very next
12089
- * invocation. A *refused* resolve (401/403/…) drops the entry rather than
12090
- * falling back to it — otherwise revoking a token would keep working offline
12091
- * until the entry aged out.
12092
- */
12093
- async function resolveWithCache(ctx, query, cache) {
12094
- if (!cache) return ctx.client.resolve(query);
12095
- try {
12096
- const response = await ctx.client.resolve(query);
12097
- try {
12098
- cache.write(JSON.stringify(response));
12099
- } catch (err) {
12100
- warn(`could not update the cache: ${errorMessage(err)}`);
12101
- }
12102
- return response;
12103
- } catch (err) {
12104
- if (!mayFallBack(err)) {
12105
- cache.invalidate();
12106
- throw err;
12107
- }
12108
- const found = cache.read();
12109
- if (found.kind === "hit") {
12110
- warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
12111
- return JSON.parse(found.body);
12112
- }
12113
- if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
12114
- else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
12115
- throw err;
12116
- }
12117
- }
12118
- function warn(text) {
12119
- process.stderr.write(`seekrit: ${text}\n`);
12120
- }
12121
- function errorMessage(err) {
12122
- return err instanceof Error ? err.message : String(err);
12123
- }
12124
- /**
12125
- * Overlay `.env` files onto an existing value/provenance set (later files win).
12126
- * Missing files are skipped. Returns the files that were actually loaded. Used
12127
- * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
12128
- * managed secrets are unavailable but `.env` should still apply.
12129
- */
12130
- function overlayEnvFiles(values, provenance, envFiles) {
12131
- const loaded = [];
12132
- for (const file of envFiles) {
12133
- if (!existsSync(file)) continue;
12134
- loaded.push(file);
12135
- for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
12136
- values[name] = value;
12137
- provenance[name] = `dotenv:${file}`;
12138
- }
12139
- }
12140
- return loaded;
12141
- }
12142
- /**
12143
- * Print a name → source table to stderr (never the secret values). Names whose
12144
- * value had references expanded are marked, and dangling references are called
12145
- * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
12146
- * deliberately passed through as literal text.
12147
- */
12148
- function printExplain(provenance, refs = {}) {
12149
- const interpolated = new Set(refs.interpolated ?? []);
12150
- const names = Object.keys(provenance).sort();
12151
- const width = names.reduce((w, n) => Math.max(w, n.length), 0);
12152
- for (const name of names) {
12153
- const marker = interpolated.has(name) ? " (interpolated)" : "";
12154
- process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
12155
- }
12156
- if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
12157
- }
12158
12859
  //#endregion
12159
12860
  //#region src/ssh.ts
12160
12861
  /**
@@ -12328,6 +13029,18 @@ function assertLanggraphDeploymentId(value) {
12328
13029
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) fail(`--langgraph-deployment should be the deployment UUID from its dashboard URL, not "${id}"`);
12329
13030
  return id;
12330
13031
  }
13032
+ /**
13033
+ * A Hugging Face Space is addressed as `owner/name`. The two habitual slips are
13034
+ * pasting the browser URL and giving the bare name without its owner; the first
13035
+ * is recovered rather than refused, since a Space URL has exactly one shape.
13036
+ */
13037
+ function assertHuggingfaceSpace(value) {
13038
+ if (!value) fail("--hf-space is required for huggingface-spaces (owner/name)");
13039
+ const raw = value.trim();
13040
+ const id = raw.match(/^https?:\/\/(?:[^/]*\.)?huggingface\.co\/spaces\/([^/?#]+\/[^/?#]+)(?:[/?#]|$)/i)?.[1] ?? raw.replace(/^\/+|\/+$/g, "");
13041
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(id)) fail(`--hf-space should be the Space ID as owner/name, e.g. acme/support-demo, not "${raw}"`);
13042
+ return id;
13043
+ }
12331
13044
  /** Reject a single value outside a known set, naming the choices. */
12332
13045
  function assertMember(value, allowed, flag, fallback) {
12333
13046
  if (value === void 0) return fallback;
@@ -12454,6 +13167,7 @@ function credentialNoun(provider) {
12454
13167
  if (provider === "gcp-secret-manager") return "service-account key JSON";
12455
13168
  if (provider === "langgraph-platform") return "LangSmith API key";
12456
13169
  if (provider === "azure-key-vault") return "client secret";
13170
+ if (provider === "huggingface-spaces") return "user access token";
12457
13171
  return "API token";
12458
13172
  }
12459
13173
  /**
@@ -12542,6 +13256,7 @@ function buildConfig(provider, options) {
12542
13256
  cloud: cloud ?? "public"
12543
13257
  };
12544
13258
  }
13259
+ case "huggingface-spaces": return { provider: "huggingface-spaces" };
12545
13260
  }
12546
13261
  }
12547
13262
  /** Where inside the platform a binding writes. */
@@ -12755,6 +13470,10 @@ function buildDestination(provider, options) {
12755
13470
  nameMode: nameMode ?? "dash"
12756
13471
  };
12757
13472
  }
13473
+ case "huggingface-spaces": return {
13474
+ provider: "huggingface-spaces",
13475
+ repoId: assertHuggingfaceSpace(options.hfSpace)
13476
+ };
12758
13477
  }
12759
13478
  }
12760
13479
  /** One-line description of a destination, for list output. */
@@ -12777,6 +13496,7 @@ function describeDestination(destination) {
12777
13496
  case "gcp-secret-manager": return destination.layout === "json-bundle" ? `${destination.secretId} · json` : `${destination.idPrefix ?? ""}* · one per name`;
12778
13497
  case "langgraph-platform": return `deployment ${destination.deploymentId}`;
12779
13498
  case "azure-key-vault": return `${destination.vault}${destination.prefix ? ` (${destination.prefix}*)` : ""}`;
13499
+ case "huggingface-spaces": return `space ${destination.repoId}`;
12780
13500
  case "github-actions": switch (destination.kind) {
12781
13501
  case "repo": return `${destination.owner}/${destination.repo}`;
12782
13502
  case "environment": return `${destination.owner}/${destination.repo} @${destination.environment}`;
@@ -12792,7 +13512,7 @@ function describeDestination(destination) {
12792
13512
  * application whose environment the binding reads from.
12793
13513
  */
12794
13514
  function destinationOptions(command) {
12795
- return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug · bunnyshell: project ID (writes variables new environments inherit)").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager, azure-key-vault: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)").option("--netlify-site <id>", "netlify: site API ID (the UUID under Project configuration)").option("--no-netlify-secret", "netlify: create readable variables instead of Netlify secrets (write-only)").option("--bunnyshell-environment <id>", "bunnyshell: environment ID (omit to write a project)").option("--no-bunnyshell-secret", "bunnyshell: create variables visible in the dashboard instead of secret ones").option("--gh-repo <owner/name>", "github-actions: repository, e.g. acme/storefront").option("--gh-environment <name>", "github-actions: write to one deployment environment's secrets (needs --gh-repo)").option("--gh-org <login>", "github-actions: write organization secrets instead of a repo's").option("--gh-visibility <v>", `github-actions org secrets: ${GITHUB_ACTIONS_VISIBILITIES.join(" | ")}`).option("--gh-repo-ids <ids>", "github-actions: comma-separated numeric repository IDs for --gh-visibility selected").option("--langgraph-deployment <id>", "langgraph-platform: deployment UUID (the one in its dashboard URL)").option("--gcp-prefix <prefix>", "gcp: prepended to every secret ID, e.g. prod-storefront-").option("--gcp-replication <policy>", `gcp: ${GCP_REPLICATION_POLICIES.join(" | ")}`, "automatic").option("--gcp-locations <list>", "gcp: regions for user-managed replication, e.g. us-east1").option("--gcp-kms-key <name>", "gcp: Cloud KMS key (projects/…/cryptoKeys/…)").option("--gcp-prune-versions", "gcp: destroy the version each push supersedes, keeping one active version").option("--vault <name>", "azure-key-vault: vault name, e.g. acme-prod").option("--name-mode <mode>", `azure-key-vault: ${AZURE_KEY_VAULT_NAME_MODES.join(" | ")} — Key Vault stores no underscores, so DATABASE_URL becomes DATABASE-URL unless you reject instead`);
13515
+ return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug · bunnyshell: project ID (writes variables new environments inherit)").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager, azure-key-vault: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)").option("--netlify-site <id>", "netlify: site API ID (the UUID under Project configuration)").option("--no-netlify-secret", "netlify: create readable variables instead of Netlify secrets (write-only)").option("--bunnyshell-environment <id>", "bunnyshell: environment ID (omit to write a project)").option("--no-bunnyshell-secret", "bunnyshell: create variables visible in the dashboard instead of secret ones").option("--gh-repo <owner/name>", "github-actions: repository, e.g. acme/storefront").option("--gh-environment <name>", "github-actions: write to one deployment environment's secrets (needs --gh-repo)").option("--gh-org <login>", "github-actions: write organization secrets instead of a repo's").option("--gh-visibility <v>", `github-actions org secrets: ${GITHUB_ACTIONS_VISIBILITIES.join(" | ")}`).option("--gh-repo-ids <ids>", "github-actions: comma-separated numeric repository IDs for --gh-visibility selected").option("--langgraph-deployment <id>", "langgraph-platform: deployment UUID (the one in its dashboard URL)").option("--gcp-prefix <prefix>", "gcp: prepended to every secret ID, e.g. prod-storefront-").option("--gcp-replication <policy>", `gcp: ${GCP_REPLICATION_POLICIES.join(" | ")}`, "automatic").option("--gcp-locations <list>", "gcp: regions for user-managed replication, e.g. us-east1").option("--gcp-kms-key <name>", "gcp: Cloud KMS key (projects/…/cryptoKeys/…)").option("--gcp-prune-versions", "gcp: destroy the version each push supersedes, keeping one active version").option("--vault <name>", "azure-key-vault: vault name, e.g. acme-prod").option("--hf-space <owner/name>", "huggingface-spaces: Space ID, e.g. acme/support-demo (its owner and name, not a URL)").option("--name-mode <mode>", `azure-key-vault: ${AZURE_KEY_VAULT_NAME_MODES.join(" | ")} — Key Vault stores no underscores, so DATABASE_URL becomes DATABASE-URL unless you reject instead`);
12796
13516
  }
12797
13517
  /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
12798
13518
  async function resolveConnection(ctx, orgId, ref) {
@@ -13689,6 +14409,8 @@ registerProvisionerCommands(program);
13689
14409
  registerProxyCommands(program);
13690
14410
  registerAgentCommands(program);
13691
14411
  registerPaperclipCommands(program);
14412
+ registerOpenclawCommands(program);
14413
+ registerHermesCommands(program);
13692
14414
  registerSshCommands(program);
13693
14415
  registerAwsCommands(program);
13694
14416
  registerGcpCommands(program);