@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.
- package/dist/index.js +1001 -279
- 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.
|
|
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
|
-
*
|
|
10099
|
-
*
|
|
10100
|
-
* `
|
|
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
|
|
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
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
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
|
-
|
|
10110
|
-
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
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
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10501
|
+
...interpolateValues(values, opts.interpolate !== false),
|
|
10502
|
+
provenance,
|
|
10503
|
+
scope,
|
|
10504
|
+
loadedEnvFiles
|
|
10121
10505
|
};
|
|
10122
10506
|
}
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
10132
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
const
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
|
|
10187
|
-
|
|
10188
|
-
|
|
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);
|