@seekrit/cli 0.27.0 → 0.28.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 +1608 -286
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -39,6 +39,12 @@ const ENTITLEMENT_KEYS = Object.keys({
39
39
  description: "Single sign-on beyond the built-in providers.",
40
40
  default: true
41
41
  },
42
+ "feature.sync": {
43
+ kind: "feature",
44
+ label: "Third-party sync",
45
+ description: "Push environment secrets to external platforms like Vercel.",
46
+ default: true
47
+ },
42
48
  "apps.max": {
43
49
  kind: "limit",
44
50
  label: "Applications",
@@ -87,6 +93,12 @@ const ENTITLEMENT_KEYS = Object.keys({
87
93
  description: "Maximum registered temporary-access targets.",
88
94
  default: null
89
95
  },
96
+ "sync.connections.max": {
97
+ kind: "limit",
98
+ label: "Sync connections",
99
+ description: "Maximum registered third-party sync destinations.",
100
+ default: null
101
+ },
90
102
  members: {
91
103
  kind: "metered",
92
104
  label: "Members",
@@ -135,7 +147,8 @@ const PLAN_FAMILIES = {
135
147
  }
136
148
  };
137
149
  const PLAN_FAMILY_IDS = Object.keys(PLAN_FAMILIES);
138
- PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
150
+ /** Plan families a plan picker should render. Excludes families marked `hidden`. */
151
+ const VISIBLE_PLAN_FAMILY_IDS = PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
139
152
  //#endregion
140
153
  //#region ../../packages/core/src/billing.ts
141
154
  /**
@@ -195,6 +208,24 @@ function parseBranchTtl(input) {
195
208
  return value * multiplier;
196
209
  }
197
210
  //#endregion
211
+ //#region ../../packages/core/src/ids.ts
212
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
213
+ /**
214
+ * Generate a prefixed, URL-safe random ID, e.g. `org_h9K2x…`.
215
+ * Uses rejection sampling so every character is uniformly distributed.
216
+ */
217
+ function randomId(prefix, length = 24) {
218
+ let out = "";
219
+ while (out.length < length) {
220
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
221
+ for (const byte of bytes) {
222
+ if (byte < 248) out += ALPHABET[byte % 62];
223
+ if (out.length === length) break;
224
+ }
225
+ }
226
+ return `${prefix}_${out}`;
227
+ }
228
+ //#endregion
198
229
  //#region ../../packages/core/src/interpolate.ts
199
230
  /**
200
231
  * Secret references: `${OTHER_SECRET}` inside a secret value.
@@ -829,6 +860,84 @@ function sshHostSetupInstructions(config) {
829
860
  }
830
861
  //#endregion
831
862
  //#region ../../packages/core/src/types.ts
863
+ const AUDIT_ACTIONS = [
864
+ "org.created",
865
+ "org.updated",
866
+ "org.member_added",
867
+ "org.member_removed",
868
+ "org.member_invited",
869
+ "org.invite_revoked",
870
+ "org.mfa_policy_changed",
871
+ "user.keys_updated",
872
+ "user.notification_prefs_updated",
873
+ "app.created",
874
+ "app.updated",
875
+ "app.deleted",
876
+ "group.created",
877
+ "group.updated",
878
+ "group.deleted",
879
+ "env.created",
880
+ "env.updated",
881
+ "env.deleted",
882
+ "env.group_linked",
883
+ "env.group_unlinked",
884
+ "env.resolved",
885
+ "env.resolve_denied",
886
+ "env.key_granted",
887
+ "env.key_revoked",
888
+ "branch.created",
889
+ "branch.deleted",
890
+ "branch.expired",
891
+ "secret.created",
892
+ "secret.updated",
893
+ "secret.deleted",
894
+ "secret.read",
895
+ "secret.restored",
896
+ "token.created",
897
+ "token.revoked",
898
+ "token.deleted",
899
+ "cli_session.approved",
900
+ "cli_session.denied",
901
+ "cli_session.revoked",
902
+ "m2m.client_created",
903
+ "m2m.client_revoked",
904
+ "lease.target_registered",
905
+ "lease.target_deleted",
906
+ "lease.created",
907
+ "lease.revoked",
908
+ "lease.expired",
909
+ "log_sink.configured",
910
+ "log_sink.deleted",
911
+ "log_sink.test",
912
+ "subscription.updated",
913
+ "subscription.canceled",
914
+ "entitlement.override_set",
915
+ "entitlement.override_cleared",
916
+ "billing.checkout_started",
917
+ "billing.portal_opened",
918
+ "kms.key_created",
919
+ "kms.key_granted",
920
+ "kms.key_revoked",
921
+ "kms.key_rotated",
922
+ "kms.key_disabled",
923
+ "kms.key_deleted",
924
+ "recovery.configured",
925
+ "recovery.rotated",
926
+ "recovery.disabled",
927
+ "recovery.env_wrapped",
928
+ "recovery.requested",
929
+ "recovery.share_contributed",
930
+ "recovery.completed",
931
+ "recovery.canceled",
932
+ "sync.connection_created",
933
+ "sync.connection_updated",
934
+ "sync.connection_deleted",
935
+ "sync.binding_created",
936
+ "sync.binding_updated",
937
+ "sync.binding_deleted",
938
+ "sync.run_succeeded",
939
+ "sync.run_failed"
940
+ ];
832
941
  /**
833
942
  * Transactional notification emails seekrit can send. Each id is one
834
943
  * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
@@ -844,8 +953,57 @@ const NOTIFICATION_TYPES = [
844
953
  "resolve_denied",
845
954
  "org_welcome",
846
955
  "token_expiring",
847
- "lease_expired"
956
+ "lease_expired",
957
+ "sync_failed"
848
958
  ];
959
+ /** UI-facing copy + default for each notification type. */
960
+ const NOTIFICATION_TYPE_META = {
961
+ token_created: {
962
+ label: "Service token created",
963
+ description: "A new service token that can decrypt secrets was created in one of your organizations.",
964
+ defaultEnabled: true
965
+ },
966
+ token_revoked: {
967
+ label: "Service token revoked",
968
+ description: "A service token was revoked in one of your organizations.",
969
+ defaultEnabled: true
970
+ },
971
+ env_access_granted: {
972
+ label: "Environment access granted",
973
+ description: "You were granted access to decrypt an environment's secrets.",
974
+ defaultEnabled: true
975
+ },
976
+ env_access_revoked: {
977
+ label: "Environment access revoked",
978
+ description: "Your access to an environment's secrets was revoked.",
979
+ defaultEnabled: true
980
+ },
981
+ resolve_denied: {
982
+ label: "Denied-access alerts",
983
+ description: "A principal was denied when trying to resolve an environment's secrets (throttled).",
984
+ defaultEnabled: true
985
+ },
986
+ org_welcome: {
987
+ label: "Welcome & key setup",
988
+ description: "You joined an organization — a welcome with next steps for finishing encryption key setup.",
989
+ defaultEnabled: true
990
+ },
991
+ token_expiring: {
992
+ label: "Service token expiring",
993
+ description: "A service token in one of your organizations is expiring within 7 days.",
994
+ defaultEnabled: true
995
+ },
996
+ lease_expired: {
997
+ label: "Temporary credential expired",
998
+ description: "A temporary (leased) credential you own expired and was automatically revoked.",
999
+ defaultEnabled: true
1000
+ },
1001
+ sync_failed: {
1002
+ label: "Third-party sync failed",
1003
+ description: "A sync to an external destination failed repeatedly and stopped retrying. The destination is now holding stale values.",
1004
+ defaultEnabled: true
1005
+ }
1006
+ };
849
1007
  //#endregion
850
1008
  //#region ../../packages/core/src/schemas.ts
851
1009
  /** URL-safe identifier segment: `my-app`, `production`, … */
@@ -1081,6 +1239,128 @@ z.object({
1081
1239
  resourceType: z.string().optional()
1082
1240
  });
1083
1241
  //#endregion
1242
+ //#region ../../packages/core/src/sync.ts
1243
+ /**
1244
+ * Third-party sync: seekrit as source of truth, pushing an environment's
1245
+ * resolved secrets out to a platform that holds its own copy (Vercel project
1246
+ * env vars, GitHub Actions secrets, a cloud secret manager, …).
1247
+ *
1248
+ * This file is the transport-free vocabulary — provider kinds, connection and
1249
+ * destination shapes, the name-mapping rules, and the request schemas. The
1250
+ * execution side (the per-org `SyncEngine` Durable Object and the connectors)
1251
+ * lives in `apps/api`.
1252
+ *
1253
+ * ## Sync is the one place plaintext exists server-side
1254
+ *
1255
+ * Every other seekrit feature keeps decryption on the caller's machine. A sync
1256
+ * destination needs the plaintext value, and sync runs when nobody is logged
1257
+ * in, so the sync engine must be able to decrypt on its own. That capability is
1258
+ * granted exactly like any other: an `environment_keys` row of principal type
1259
+ * `sync`, whose `principalId` is the **connection id** and whose wrapped DEK is
1260
+ * computed client-side by a key holder. No grant, no sync — the control plane
1261
+ * cannot manufacture one.
1262
+ *
1263
+ * Keying per connection (not per org) is deliberate: one stray grant exposes
1264
+ * one named destination, and deleting a connection destroys its keypair, which
1265
+ * turns every grant to it into dead ciphertext.
1266
+ *
1267
+ * See `docs/third-party-sync.md`.
1268
+ */
1269
+ /** Platforms seekrit can push to. Append-only — persisted in `sync_connections.provider`. */
1270
+ const SYNC_PROVIDER_KINDS = ["vercel"];
1271
+ z.enum(SYNC_PROVIDER_KINDS);
1272
+ /**
1273
+ * Vercel account scope. The API token itself is never here — it is wrapped to
1274
+ * the connection's public key and stored as ciphertext.
1275
+ *
1276
+ * `teamId` is required for tokens scoped to a Vercel Team; personal-account
1277
+ * tokens omit it. Vercel rejects team-owned project calls that lack it with a
1278
+ * bare 403, so we pass it through as `?teamId=` on every request.
1279
+ */
1280
+ const vercelConnectionConfigSchema = z.object({
1281
+ provider: z.literal("vercel"),
1282
+ /** Vercel Team id (`team_…`). Omit for a personal account. */
1283
+ teamId: z.string().trim().min(1).max(128).optional()
1284
+ });
1285
+ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [vercelConnectionConfigSchema]);
1286
+ /** Vercel's three deployment targets. A binding writes to one or more. */
1287
+ const VERCEL_TARGETS = [
1288
+ "production",
1289
+ "preview",
1290
+ "development"
1291
+ ];
1292
+ const vercelDestinationSchema = z.object({
1293
+ provider: z.literal("vercel"),
1294
+ /** Vercel project id (`prj_…`) or project name. */
1295
+ projectId: z.string().trim().min(1).max(128),
1296
+ /** Which deployment targets receive these values. At least one. */
1297
+ targets: z.array(z.enum(VERCEL_TARGETS)).min(1),
1298
+ /**
1299
+ * Restrict `preview` writes to one git branch. Vercel only honors this when
1300
+ * `targets` includes `preview`; ignored otherwise.
1301
+ */
1302
+ gitBranch: z.string().trim().min(1).max(255).optional()
1303
+ });
1304
+ const syncDestinationSchema = z.discriminatedUnion("provider", [vercelDestinationSchema]);
1305
+ /**
1306
+ * How seekrit secret names become destination key names. Applied in order:
1307
+ * explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
1308
+ */
1309
+ const nameTransformSchema = z.object({
1310
+ prefix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1311
+ suffix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1312
+ case: z.enum([
1313
+ "preserve",
1314
+ "upper",
1315
+ "lower"
1316
+ ]).optional(),
1317
+ /** Exact per-secret overrides, seekrit name → destination name. */
1318
+ rename: z.record(secretNameSchema, secretNameSchema).optional()
1319
+ });
1320
+ z.object({
1321
+ /**
1322
+ * The id the client already fetched a public key for. Connections are a
1323
+ * two-step dance — mint the key, wrap the credential to it, then create the
1324
+ * row — so the id has to be chosen before the row exists. Omit it and the
1325
+ * server generates one (only useful when there is no credential to wrap yet).
1326
+ */
1327
+ id: z.string().regex(/^syc_[A-Za-z0-9]{24}$/, "must be a connection id from the public-key call").optional(),
1328
+ name: z.string().trim().min(1).max(128),
1329
+ config: syncConnectionConfigSchema,
1330
+ /**
1331
+ * The destination's API credential (a Vercel token), encrypted client-side to
1332
+ * the connection's public key (a `wd1.` wrap). The control plane stores only
1333
+ * this ciphertext; it is unwrapped transiently inside the sync engine DO.
1334
+ */
1335
+ wrappedCredential: z.string().min(1)
1336
+ });
1337
+ z.object({ destination: syncDestinationSchema });
1338
+ const globListSchema = z.array(z.string().trim().min(1).max(256)).max(100);
1339
+ z.object({
1340
+ connectionId: z.string().min(1),
1341
+ environmentId: z.string().min(1),
1342
+ destination: syncDestinationSchema,
1343
+ nameTransform: nameTransformSchema.optional(),
1344
+ include: globListSchema.optional(),
1345
+ exclude: globListSchema.optional(),
1346
+ onDelete: z.enum(["delete", "retain"]).default("delete"),
1347
+ mode: z.enum(["auto", "manual"]).default("auto"),
1348
+ wrappedDeks: z.array(z.object({
1349
+ environmentId: z.string().min(1),
1350
+ wrappedDek: z.string().min(1)
1351
+ })).min(1),
1352
+ acknowledgedDecryption: z.literal(true)
1353
+ });
1354
+ z.object({
1355
+ destination: syncDestinationSchema.optional(),
1356
+ nameTransform: nameTransformSchema.nullable().optional(),
1357
+ include: globListSchema.nullable().optional(),
1358
+ exclude: globListSchema.nullable().optional(),
1359
+ onDelete: z.enum(["delete", "retain"]).optional(),
1360
+ mode: z.enum(["auto", "manual"]).optional(),
1361
+ enabled: z.boolean().optional()
1362
+ });
1363
+ //#endregion
1084
1364
  //#region ../../packages/crypto/src/encoding.ts
1085
1365
  const CHUNK = 32768;
1086
1366
  /** Base64url (no padding) — portable across browsers, Workers, and Node. */
@@ -2217,7 +2497,7 @@ function isCliSessionToken(value) {
2217
2497
  }
2218
2498
  //#endregion
2219
2499
  //#region package.json
2220
- var version = "0.27.0";
2500
+ var version = "0.28.0";
2221
2501
  //#endregion
2222
2502
  //#region ../../packages/api-client/src/index.ts
2223
2503
  var SeekritApiError = class extends Error {
@@ -2582,6 +2862,54 @@ var SeekritClient = class {
2582
2862
  deleteLeaseTarget(orgId, targetId) {
2583
2863
  return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
2584
2864
  }
2865
+ listSyncConnections(orgId) {
2866
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections`);
2867
+ }
2868
+ /**
2869
+ * Mint (or re-read) the keypair for a connection id, *before* the connection
2870
+ * exists. Wrap the destination credential and every environment DEK to this
2871
+ * key, then pass the same id to {@link createSyncConnection}.
2872
+ */
2873
+ getSyncConnectionKey(orgId, connectionId) {
2874
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections/${connectionId}/public-key`);
2875
+ }
2876
+ createSyncConnection(orgId, input) {
2877
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections`, input);
2878
+ }
2879
+ /** Test the stored credential against a destination. Never throws on a bad token. */
2880
+ verifySyncConnection(orgId, connectionId, destination) {
2881
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections/${connectionId}/verify`, { destination });
2882
+ }
2883
+ /** Deletes the connection, its bindings, its key grants, and its keypair. */
2884
+ deleteSyncConnection(orgId, connectionId) {
2885
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/connections/${connectionId}`);
2886
+ }
2887
+ listSyncBindings(orgId) {
2888
+ return this.request("GET", `/v1/orgs/${orgId}/sync/bindings`);
2889
+ }
2890
+ /**
2891
+ * Enable sync for one environment. `wrappedDeks` must cover the target
2892
+ * environment *and* every group environment it composes, each wrapped to the
2893
+ * connection's public key — the API cannot compute these, which is what keeps
2894
+ * enabling sync a key-holder operation.
2895
+ */
2896
+ createSyncBinding(orgId, input) {
2897
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings`, input);
2898
+ }
2899
+ updateSyncBinding(orgId, bindingId, input) {
2900
+ return this.request("PATCH", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`, input);
2901
+ }
2902
+ deleteSyncBinding(orgId, bindingId) {
2903
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`);
2904
+ }
2905
+ /** Push now, synchronously. */
2906
+ runSyncBinding(orgId, bindingId) {
2907
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings/${bindingId}/run`);
2908
+ }
2909
+ listSyncRuns(orgId, bindingId) {
2910
+ const qs = bindingId ? `?bindingId=${encodeURIComponent(bindingId)}` : "";
2911
+ return this.request("GET", `/v1/orgs/${orgId}/sync/runs${qs}`);
2912
+ }
2585
2913
  listLeases(orgId) {
2586
2914
  return this.request("GET", `/v1/orgs/${orgId}/leases`);
2587
2915
  }
@@ -2788,6 +3116,31 @@ function promptEnter(question) {
2788
3116
  });
2789
3117
  });
2790
3118
  }
3119
+ function askYesNo(question) {
3120
+ const rl = createInterface({
3121
+ input: process.stdin,
3122
+ output: process.stderr,
3123
+ terminal: true
3124
+ });
3125
+ return new Promise((resolve) => {
3126
+ rl.question(`${question} [y/N] `, (answer) => {
3127
+ rl.close();
3128
+ resolve(/^y(es)?$/i.test(answer.trim()));
3129
+ });
3130
+ });
3131
+ }
3132
+ /**
3133
+ * Gate an irreversible action behind an explicit yes.
3134
+ *
3135
+ * The flag skips the question outright. Without it, an interactive run has to
3136
+ * answer; a non-interactive one (a script, CI, an agent) is refused and told
3137
+ * about the flag, because a destructive command must never proceed on silence.
3138
+ */
3139
+ async function confirmDestructive(confirmed, question, flag = "--yes") {
3140
+ if (confirmed) return;
3141
+ if (!process.stdin.isTTY) fail(`${question} Re-run with ${flag} to confirm.`);
3142
+ if (!await askYesNo(question)) fail("aborted");
3143
+ }
2791
3144
  /** Read all of stdin (for `seekrit secrets set NAME -` piping). */
2792
3145
  async function readStdin() {
2793
3146
  const chunks = [];
@@ -2861,6 +3214,69 @@ async function getDek(ctx, orgId, envId) {
2861
3214
  return unwrapDek(wrappedDek, privateKey);
2862
3215
  }
2863
3216
  //#endregion
3217
+ //#region src/output.ts
3218
+ /** Shorthand for a column whose header is fixed and value is one field. */
3219
+ function col(header, value) {
3220
+ return {
3221
+ header,
3222
+ value: (row) => stringify(value(row))
3223
+ };
3224
+ }
3225
+ function stringify(value) {
3226
+ if (value === null || value === void 0) return "-";
3227
+ return String(value);
3228
+ }
3229
+ /**
3230
+ * Print `data` as JSON when `--json` was passed, otherwise run `render`.
3231
+ * Keeps the JSON branch identical across commands (and machine-parseable even
3232
+ * for detail views, which have no table form).
3233
+ */
3234
+ function emit(options, data, render) {
3235
+ if (options.json) {
3236
+ console.log(JSON.stringify(data, null, 2));
3237
+ return;
3238
+ }
3239
+ render();
3240
+ }
3241
+ /**
3242
+ * Render rows as a table. `empty` is the stderr note shown when there is
3243
+ * nothing to list — an empty list is a normal answer, not an error, so it must
3244
+ * not go to stdout and must not exit non-zero.
3245
+ */
3246
+ function printTable(rows, columns, empty = "none") {
3247
+ if (rows.length === 0) {
3248
+ console.error(empty);
3249
+ return;
3250
+ }
3251
+ const cells = rows.map((row) => columns.map((c) => c.value(row)));
3252
+ if (!process.stdout.isTTY) {
3253
+ for (const line of cells) console.log(line.join(" "));
3254
+ return;
3255
+ }
3256
+ const widths = columns.map((c, i) => Math.max(c.header.length, ...cells.map((line) => (line[i] ?? "").length)));
3257
+ const format = (line) => line.map((cell, i) => i === line.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ");
3258
+ console.log(format(columns.map((c) => c.header.toUpperCase())));
3259
+ for (const line of cells) console.log(format(line));
3260
+ }
3261
+ /**
3262
+ * Render a detail view: one `label value` per line, labels aligned. Null and
3263
+ * undefined values are dropped rather than printed as blanks, so a caller can
3264
+ * build the list unconditionally and let absent fields disappear.
3265
+ */
3266
+ function printFields(fields) {
3267
+ const present = fields.filter(([, value]) => value !== null && value !== void 0);
3268
+ const width = Math.max(0, ...present.map(([label]) => label.length));
3269
+ for (const [label, value] of present) console.log(`${label.padEnd(width)} ${value}`);
3270
+ }
3271
+ /**
3272
+ * A titled break between the sections of a detail view. Detail views are for
3273
+ * reading — a script wanting to parse one should ask for `--json`.
3274
+ */
3275
+ function section(title) {
3276
+ console.log("");
3277
+ console.log(title);
3278
+ }
3279
+ //#endregion
2864
3280
  //#region src/target.ts
2865
3281
  /** Resolve the target org from a flag, the committed config, or a lone org. */
2866
3282
  async function resolveOrg(ctx, orgSlug) {
@@ -2976,144 +3392,149 @@ async function resolveGroup(ctx, opts) {
2976
3392
  };
2977
3393
  }
2978
3394
  //#endregion
2979
- //#region src/aws.ts
3395
+ //#region src/access.ts
2980
3396
  /**
2981
- * `seekrit aws` temporary AWS credentials via STS AssumeRole (Vault-style
2982
- * dynamic secrets, the tier-2 sibling of `seekrit pg`).
3397
+ * Human-readable labels for the principals holding a key grant. `grant list`
3398
+ * would otherwise be a column of opaque ids, which is exactly the question
3399
+ * ("who can read prod?") it exists to answer.
2983
3400
  *
2984
- * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
2985
- * keypair on THIS machine and sends only the public key; STS mints the
2986
- * credential and the broker returns it wrapped to that key, so the control plane
2987
- * only ever relays ciphertext and only this machine can unwrap it. Registering a
2988
- * target wraps the base IAM credential to the broker's public key locally, so
2989
- * the control plane never sees it either — its only needed permission is
2990
- * `sts:AssumeRole` on the target role.
3401
+ * Both lookups are best-effort: `listMembers` is open to any member but
3402
+ * `listTokens` may not be, and a grant can outlive the principal it names.
2991
3403
  */
2992
- /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2993
- function parseTtlSeconds$6(input) {
2994
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2995
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
2996
- return Number(m[1]) * ({
2997
- s: 1,
2998
- m: 60,
2999
- h: 3600,
3000
- d: 86400
3001
- }[m[2] || "s"] ?? 1);
3404
+ async function principalLabels(ctx, orgId) {
3405
+ const labels = /* @__PURE__ */ new Map();
3406
+ const [members, tokens] = await Promise.all([ctx.client.listMembers(orgId).then((r) => r.members).catch(() => []), ctx.client.listTokens(orgId).then((r) => r.tokens).catch(() => [])]);
3407
+ for (const m of members) labels.set(m.userId, m.email);
3408
+ for (const t of tokens) labels.set(t.id, t.name);
3409
+ return labels;
3410
+ }
3411
+ /** Turn `--user email` / `--token skt_…` into the principal to wrap a DEK to. */
3412
+ async function resolvePrincipal(ctx, orgId, options) {
3413
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
3414
+ if (options.user) {
3415
+ const { members } = await ctx.client.listMembers(orgId);
3416
+ const member = members.find((m) => m.email === options.user);
3417
+ if (!member) fail(`no member ${options.user}`);
3418
+ if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
3419
+ return {
3420
+ type: "user",
3421
+ id: member.userId,
3422
+ publicKeyJwk: member.publicKeyJwk
3423
+ };
3424
+ }
3425
+ const { tokens } = await ctx.client.listTokens(orgId);
3426
+ const token = tokens.find((t) => t.id === options.token);
3427
+ if (!token) fail(`no service token ${options.token}`);
3428
+ return {
3429
+ type: "service_token",
3430
+ id: token.id,
3431
+ publicKeyJwk: token.publicKeyJwk
3432
+ };
3433
+ }
3434
+ /** The `--org/--app/--group/--env` selection every grant command shares. */
3435
+ function withEnvTarget(cmd) {
3436
+ return cmd.option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>");
3002
3437
  }
3003
3438
  /**
3004
- * The base IAM credential the broker assumes the role with. From flags or the
3005
- * standard AWS env vars; JSON-serialized so the executor can parse it. Never the
3006
- * plaintext leaves this machine unwrapped it is wrapped to the broker key.
3439
+ * Environment key grants who can decrypt what.
3440
+ *
3441
+ * `seekrit grant` keeps working as the bare "give access" verb (it is the
3442
+ * default subcommand), with `list` and `rm` alongside it so access can be
3443
+ * audited and taken back from the CLI rather than only the dashboard.
3007
3444
  */
3008
- function resolveBaseCredential(opts) {
3009
- const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
3010
- const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
3011
- const sessionToken = process.env.AWS_SESSION_TOKEN;
3012
- if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
3013
- return JSON.stringify({
3014
- accessKeyId,
3015
- secretAccessKey,
3016
- ...sessionToken ? { sessionToken } : {}
3017
- });
3018
- }
3019
- function registerAwsCommands(program) {
3020
- const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
3021
- const target = aws.command("target").description("manage AWS role targets");
3022
- target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
3445
+ function registerAccessCommands(program) {
3446
+ const grant = program.command("grant").description("manage who can decrypt an environment (`seekrit grant --help`)");
3447
+ withEnvTarget(grant.command("add", { isDefault: true }).description("give a member or service token access to an environment's key").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)")).action(async (options) => {
3448
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
3023
3449
  const ctx = buildContext();
3024
- const org = await resolveOrg(ctx, options.org);
3025
- const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
3026
- const config = {
3027
- provider: "aws",
3028
- executor: "in_do",
3029
- roleArn: options.roleArn,
3030
- region: options.region,
3031
- ...options.externalId ? { externalId: options.externalId } : {},
3032
- ...sessionPolicy ? { sessionPolicy } : {},
3033
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
3034
- };
3035
- const baseCredential = resolveBaseCredential(options);
3036
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
3037
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
3038
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
3039
- name: options.name,
3040
- config,
3041
- wrappedAdminSecret
3450
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
3451
+ const principal = await resolvePrincipal(ctx, orgId, options);
3452
+ const dek = await getDek(ctx, orgId, envId);
3453
+ await ctx.client.grantEnvKey(orgId, envId, {
3454
+ principalType: principal.type,
3455
+ principalId: principal.id,
3456
+ wrappedDek: await wrapDek(dek, principal.publicKeyJwk)
3042
3457
  });
3043
- console.error(`registered AWS target ${created.name} (${created.id})`);
3044
- console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
3045
- console.log(awsTrustPolicyInstructions(config));
3458
+ console.error(`granted ${label} access to ${principal.id}`);
3046
3459
  });
3047
- target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
3460
+ withEnvTarget(grant.command("list").alias("ls").description("list who holds a key for an environment").option("--json", "print the raw API response")).action(async (options) => {
3048
3461
  const ctx = buildContext();
3049
- const org = await resolveOrg(ctx, options.org);
3050
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3051
- for (const t of targets) {
3052
- const cfg = t.config;
3053
- if (cfg.provider !== "aws") continue;
3054
- console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
3055
- }
3462
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
3463
+ const [{ grants }, labels] = await Promise.all([ctx.client.listEnvKeys(orgId, envId), principalLabels(ctx, orgId)]);
3464
+ emit(options, { grants }, () => printTable(grants, [
3465
+ col("principal", (g) => labels.get(g.principalId) ?? g.principalId),
3466
+ col("type", (g) => g.principalType === "service_token" ? "token" : "user"),
3467
+ col("granted", (g) => g.createdAt),
3468
+ col("id", (g) => g.principalId)
3469
+ ], "nobody holds a key for this environment"));
3056
3470
  });
3057
- target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
3471
+ withEnvTarget(grant.command("rm").alias("revoke").description("take away a member's or token's access to an environment's key").option("--user <email>", "revoke an org member by email").option("--token <tokenId>", "revoke a service token by id (skt_…)").option("--yes", "skip the confirmation prompt")).action(async (options) => {
3472
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
3058
3473
  const ctx = buildContext();
3059
- const org = await resolveOrg(ctx, options.org);
3060
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3061
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
3062
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
3063
- const cfg = t.config;
3064
- if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
3065
- console.log(awsTrustPolicyInstructions(cfg));
3474
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
3475
+ const { grants } = await ctx.client.listEnvKeys(orgId, envId);
3476
+ let principalId = options.token;
3477
+ if (options.user) {
3478
+ const { members } = await ctx.client.listMembers(orgId);
3479
+ principalId = members.find((m) => m.email === options.user)?.userId;
3480
+ if (!principalId) fail(`no member ${options.user}`);
3481
+ }
3482
+ const grantRow = grants.find((g) => g.principalId === principalId);
3483
+ if (!grantRow) fail(`${options.user ?? options.token} holds no key for ${label}`);
3484
+ await confirmDestructive(options.yes, `Revoke ${options.user ?? options.token}'s key for ${label}? Anything already decrypted stays decrypted.`);
3485
+ await ctx.client.revokeEnvKey(orgId, envId, grantRow.id);
3486
+ console.error(`revoked ${options.user ?? options.token}'s access to ${label}`);
3066
3487
  });
3067
- target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
3068
- const ctx = buildContext();
3069
- const org = await resolveOrg(ctx, options.org);
3070
- await ctx.client.deleteLeaseTarget(org.id, targetId);
3071
- console.error(`deleted ${targetId}`);
3488
+ }
3489
+ //#endregion
3490
+ //#region src/account.ts
3491
+ function sessionStatus(session) {
3492
+ if (session.revokedAt) return "revoked";
3493
+ if (session.expiresAt && Date.parse(session.expiresAt) < Date.now()) return "expired";
3494
+ return "active";
3495
+ }
3496
+ /**
3497
+ * Your own account: the devices signed in as you, and which emails seekrit
3498
+ * sends you. Both are per-user (not per-org), so neither takes `--org`.
3499
+ */
3500
+ function registerAccountCommands(program) {
3501
+ const session = program.command("session").description("manage the devices authorized by `seekrit login`");
3502
+ session.command("list").alias("ls").description("list CLI sessions authorized for your account").option("--all", "include revoked and expired sessions").option("--json", "print the raw API response").action(async (options) => {
3503
+ const { sessions, currentSessionId } = await buildContext().client.listCliSessions();
3504
+ const rows = options.all ? sessions : sessions.filter((s) => sessionStatus(s) === "active");
3505
+ emit(options, {
3506
+ sessions: rows,
3507
+ currentSessionId
3508
+ }, () => printTable(rows, [
3509
+ col("device", (s) => s.deviceLabel),
3510
+ col("client", (s) => s.client),
3511
+ col("status", (s) => sessionStatus(s)),
3512
+ col("last used", (s) => s.lastUsedAt),
3513
+ col("ip", (s) => s.ipAddress),
3514
+ col("id", (s) => `${s.id}${s.id === currentSessionId ? " (this one)" : ""}`)
3515
+ ], options.all ? "no CLI sessions" : "no active CLI sessions (try --all)"));
3072
3516
  });
3073
- aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
3517
+ session.command("revoke <sessionId>").description("sign a device out its token stops working immediately").option("--yes", "skip the confirmation prompt").action(async (sessionId, options) => {
3074
3518
  const ctx = buildContext();
3075
- const org = await resolveOrg(ctx, options.org);
3076
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3077
- const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
3078
- if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
3079
- const cfg = t.config;
3080
- if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
3081
- const ttlSeconds = parseTtlSeconds$6(options.ttl);
3082
- if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
3083
- const recipient = await generateAwsRecipientKeyPair();
3084
- const { aws: leased } = await ctx.client.mintLease(org.id, {
3085
- provider: "aws",
3086
- targetId: t.id,
3087
- recipientPublicKey: recipient.publicKeyJwk,
3088
- ttlSeconds
3089
- });
3090
- const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
3091
- console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
3092
- if (options.json) console.log(JSON.stringify({
3093
- ...cred,
3094
- roleArn: cfg.roleArn
3095
- }, null, 2));
3096
- else {
3097
- console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
3098
- console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
3099
- console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
3100
- console.log(`export AWS_REGION=${cred.region}`);
3101
- }
3519
+ const self = ctx.auth.type === "bearer" && isCliSessionToken(ctx.auth.token) && parseCliSessionToken(ctx.auth.token).sessionId === sessionId;
3520
+ await confirmDestructive(options.yes, self ? `${sessionId} is the session you are using right now — sign it out?` : `Sign out ${sessionId}?`);
3521
+ await ctx.client.revokeCliSession(sessionId);
3522
+ console.error(`${sessionId} revoked${self ? " — run `seekrit login` to sign back in" : ""}`);
3102
3523
  });
3103
- aws.command("leases").description("list AWS leases (the ledger never secret material)").option("--org <slug>").action(async (options) => {
3104
- const ctx = buildContext();
3105
- const org = await resolveOrg(ctx, options.org);
3106
- const { leases } = await ctx.client.listLeases(org.id);
3107
- for (const l of leases) {
3108
- if (l.provider !== "aws") continue;
3109
- console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
3110
- }
3524
+ const notifications = program.command("notifications").description("manage the emails seekrit sends you");
3525
+ notifications.command("list", { isDefault: true }).alias("ls").description("show your notification preferences").option("--json", "print the raw API response").action(async (options) => {
3526
+ const { prefs } = await buildContext().client.getMyNotificationPrefs();
3527
+ emit(options, { prefs }, () => printTable(NOTIFICATION_TYPES, [
3528
+ col("type", (t) => t),
3529
+ col("email", (t) => prefs[t] ? "on" : "off"),
3530
+ col("about", (t) => NOTIFICATION_TYPE_META[t].label)
3531
+ ]));
3111
3532
  });
3112
- aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
3113
- const ctx = buildContext();
3114
- const org = await resolveOrg(ctx, options.org);
3115
- await ctx.client.revokeLease(org.id, leaseId);
3116
- console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
3533
+ notifications.command("set <type> <state>").description("turn one notification on or off (`state` is on or off)").action(async (type, state) => {
3534
+ if (state !== "on" && state !== "off") fail("state must be on or off");
3535
+ if (!NOTIFICATION_TYPES.includes(type)) fail(`unknown notification "${type}" — one of: ${NOTIFICATION_TYPES.join(", ")}`);
3536
+ await buildContext().client.setMyNotificationPrefs({ prefs: { [type]: state === "on" } });
3537
+ console.error(`${type} emails ${state}`);
3117
3538
  });
3118
3539
  }
3119
3540
  //#endregion
@@ -3602,9 +4023,480 @@ function registerRecoveryCommands(program) {
3602
4023
  });
3603
4024
  }
3604
4025
  //#endregion
3605
- //#region src/branches.ts
4026
+ //#region src/apps.ts
3606
4027
  /**
3607
- * Resolve `--from` to the environment being branched.
4028
+ * Applications and their environments — the structure everything else hangs
4029
+ * off. Creating an environment is the one command here that does crypto: the
4030
+ * data key is generated locally and only ciphertext leaves the machine.
4031
+ */
4032
+ function registerAppCommands(program) {
4033
+ const app = program.command("app").description("manage applications");
4034
+ app.command("list").alias("ls").description("list applications in an organization").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4035
+ const ctx = buildContext();
4036
+ const ref = await resolveOrg(ctx, options.org);
4037
+ const { apps } = await ctx.client.listApps(ref.id);
4038
+ emit(options, { apps }, () => printTable(apps, [
4039
+ col("slug", (a) => a.slug),
4040
+ col("name", (a) => a.name),
4041
+ col("created", (a) => a.createdAt),
4042
+ col("id", (a) => a.id)
4043
+ ], "no applications — create one with `seekrit app create`"));
4044
+ });
4045
+ app.command("show [slug]").description("show an application, its environments, and your access to each").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--json", "print the raw API response").action(async (slug, options) => {
4046
+ const ctx = buildContext();
4047
+ const ref = await resolveApp(ctx, {
4048
+ org: options.org,
4049
+ app: slug ?? options.app
4050
+ });
4051
+ const [{ app: row, environments }, { branches }] = await Promise.all([ctx.client.getApp(ref.orgId, ref.id), ctx.client.listAppBranches(ref.orgId, ref.id)]);
4052
+ emit(options, {
4053
+ app: row,
4054
+ environments,
4055
+ branches
4056
+ }, () => {
4057
+ printFields([
4058
+ ["slug", row.slug],
4059
+ ["name", row.name],
4060
+ ["id", row.id],
4061
+ ["org", ref.orgSlug],
4062
+ ["created", row.createdAt]
4063
+ ]);
4064
+ section("environments");
4065
+ printTable(environments, [
4066
+ col("slug", (e) => e.slug),
4067
+ col("name", (e) => e.name),
4068
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4069
+ col("id", (e) => e.id)
4070
+ ], "no environments — create one with `seekrit env create`");
4071
+ if (branches.length > 0) {
4072
+ section("branches");
4073
+ printTable(branches, [
4074
+ col("slug", (b) => b.slug),
4075
+ col("expires", (b) => b.expiresAt ?? "never"),
4076
+ col("id", (b) => b.id)
4077
+ ]);
4078
+ }
4079
+ });
4080
+ });
4081
+ app.command("create").description("create an application").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4082
+ const ctx = buildContext();
4083
+ const orgRef = await resolveOrg(ctx, options.org);
4084
+ const created = await ctx.client.createApp(orgRef.id, {
4085
+ name: options.name,
4086
+ slug: options.slug
4087
+ });
4088
+ console.error(`created app ${created.app.slug} (${created.app.id})`);
4089
+ });
4090
+ app.command("rename [slug]").description("change an application's display name (the slug is permanent)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--name <name>", "new display name").action(async (slug, options) => {
4091
+ const ctx = buildContext();
4092
+ const ref = await resolveApp(ctx, {
4093
+ org: options.org,
4094
+ app: slug ?? options.app
4095
+ });
4096
+ const { app: row } = await ctx.client.updateApp(ref.orgId, ref.id, { name: options.name });
4097
+ console.error(`renamed ${row.slug} to "${row.name}"`);
4098
+ });
4099
+ app.command("rm <slug>").alias("delete").description("delete an application and every environment and secret in it").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (slug, options) => {
4100
+ const ctx = buildContext();
4101
+ const ref = await resolveApp(ctx, {
4102
+ org: options.org,
4103
+ app: slug
4104
+ });
4105
+ const { environments } = await ctx.client.getApp(ref.orgId, ref.id);
4106
+ await confirmDestructive(options.yes, `Delete ${ref.slug} and its ${environments.length} environment(s), with every secret in them?`);
4107
+ await ctx.client.deleteApp(ref.orgId, ref.id);
4108
+ console.error(`deleted app ${ref.slug}`);
4109
+ });
4110
+ const env = program.command("env").description("manage environments");
4111
+ env.command("list").alias("ls").description("list an application's environments").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--json", "print the raw API response").action(async (options) => {
4112
+ const ctx = buildContext();
4113
+ const ref = await resolveApp(ctx, options);
4114
+ const { environments } = await ctx.client.getApp(ref.orgId, ref.id);
4115
+ emit(options, { environments }, () => printTable(environments, [
4116
+ col("slug", (e) => e.slug),
4117
+ col("name", (e) => e.name),
4118
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4119
+ col("id", (e) => e.id)
4120
+ ], "no environments — create one with `seekrit env create`"));
4121
+ });
4122
+ env.command("show").description("show one environment: composed groups, who holds a key, secret count").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--env <slug>").option("--json", "print the raw API response").action(async (options) => {
4123
+ const ctx = buildContext();
4124
+ const target = await resolveAppEnv(ctx, options);
4125
+ const [{ environment }, { groups }, { secrets }, { branches }] = await Promise.all([
4126
+ ctx.client.getEnv(target.orgId, target.envId),
4127
+ ctx.client.listEnvGroups(target.orgId, target.envId),
4128
+ ctx.client.listSecrets(target.orgId, target.envId),
4129
+ ctx.client.listBranches(target.orgId, target.envId)
4130
+ ]);
4131
+ const grants = await ctx.client.listEnvKeys(target.orgId, target.envId).then((r) => r.grants).catch(() => null);
4132
+ emit(options, {
4133
+ environment,
4134
+ groups,
4135
+ secretCount: secrets.length,
4136
+ branches,
4137
+ grants
4138
+ }, () => {
4139
+ printFields([
4140
+ ["slug", environment.slug],
4141
+ ["name", environment.name],
4142
+ ["id", environment.id],
4143
+ ["app", target.appSlug],
4144
+ ["secrets", secrets.length],
4145
+ ["created", environment.createdAt]
4146
+ ]);
4147
+ section("composed groups (lowest precedence first)");
4148
+ printTable([...groups].sort((a, b) => a.position - b.position), [
4149
+ col("position", (g) => g.position),
4150
+ col("group", (g) => g.slug),
4151
+ col("name", (g) => g.name)
4152
+ ], "none — this environment's secrets are all its own");
4153
+ section("key holders");
4154
+ if (grants === null) console.error("(admin only)");
4155
+ else printTable(grants, [col("type", (g) => g.principalType), col("principal", (g) => g.principalId)], "none");
4156
+ if (branches.length > 0) {
4157
+ section("branches");
4158
+ printTable(branches, [col("slug", (b) => b.slug), col("expires", (b) => b.expiresAt ?? "never")]);
4159
+ }
4160
+ });
4161
+ });
4162
+ env.command("create").description("create an application environment (generates its data key locally)").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4163
+ const ctx = buildContext();
4164
+ const orgRef = await resolveOrg(ctx, options.org);
4165
+ const { apps } = await ctx.client.listApps(orgRef.id);
4166
+ const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
4167
+ if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
4168
+ const { user } = await ctx.client.me();
4169
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4170
+ const dek = generateDek();
4171
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4172
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
4173
+ const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
4174
+ name: options.name,
4175
+ slug: options.slug,
4176
+ wrappedDek,
4177
+ recoveryWrappedDek
4178
+ });
4179
+ console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
4180
+ });
4181
+ env.command("rm").alias("delete").description("delete an environment and every secret in it").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "delete a group environment instead of an app one").requiredOption("--env <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4182
+ const ctx = buildContext();
4183
+ const target = await resolveEnvTarget(ctx, options);
4184
+ const { secrets } = await ctx.client.listSecrets(target.orgId, target.envId);
4185
+ await confirmDestructive(options.yes, `Delete ${target.label} and the ${secrets.length} secret(s) in it?`);
4186
+ await ctx.client.deleteEnv(target.orgId, target.envId);
4187
+ console.error(`deleted ${target.label}`);
4188
+ });
4189
+ const envGroups = env.command("groups").description("compose shared groups into an application environment");
4190
+ envGroups.command("add").description("compose a group into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").option("--position <n>", "precedence among groups (higher wins)").action(async (options) => {
4191
+ const ctx = buildContext();
4192
+ const target = await resolveAppEnv(ctx, options);
4193
+ const group = await resolveGroup(ctx, {
4194
+ org: options.org,
4195
+ group: options.group
4196
+ });
4197
+ await ctx.client.linkEnvGroup(target.orgId, target.envId, {
4198
+ groupId: group.id,
4199
+ position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
4200
+ });
4201
+ console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
4202
+ });
4203
+ envGroups.command("list").alias("ls").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").option("--json", "print the raw API response").action(async (options) => {
4204
+ const ctx = buildContext();
4205
+ const target = await resolveAppEnv(ctx, options);
4206
+ const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
4207
+ emit(options, { groups }, () => printTable([...groups].sort((a, b) => a.position - b.position), [
4208
+ col("position", (g) => g.position),
4209
+ col("group", (g) => g.slug),
4210
+ col("name", (g) => g.name)
4211
+ ], "no groups composed into this environment"));
4212
+ });
4213
+ envGroups.command("rm").description("remove a group from an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").action(async (options) => {
4214
+ const ctx = buildContext();
4215
+ const target = await resolveAppEnv(ctx, options);
4216
+ const group = await resolveGroup(ctx, {
4217
+ org: options.org,
4218
+ group: options.group
4219
+ });
4220
+ await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
4221
+ console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
4222
+ });
4223
+ }
4224
+ //#endregion
4225
+ //#region src/audit.ts
4226
+ /** The API's per-page ceiling (`auditQuerySchema.limit`). */
4227
+ const MAX_PAGE = 200;
4228
+ function parseLimit(raw) {
4229
+ const n = Number(raw);
4230
+ if (!Number.isInteger(n) || n < 1) fail(`--limit must be a positive whole number, got "${raw}"`);
4231
+ return Math.min(n, MAX_PAGE);
4232
+ }
4233
+ /**
4234
+ * The org audit trail. Append-only and admin-only, so this is the record of
4235
+ * who did what — the CLI needs the same filters the dashboard has, plus
4236
+ * pagination, or "find the grant that shouldn't be there" means scrolling.
4237
+ */
4238
+ function registerAuditCommands(program) {
4239
+ const audit = program.command("audit").description("read the org audit trail");
4240
+ audit.command("list", { isDefault: true }).alias("ls").description("show the org audit trail").option("--org <slug>").option("--limit <n>", `entries per page (max ${MAX_PAGE})`, "50").option("--action <action>", "only this action, e.g. env.key_granted").option("--resource-type <type>", "only this resource type, e.g. environment").option("--cursor <cursor>", "continue from a previous page's cursor").option("--all", "page through the whole trail, not just the first page").option("--metadata", "include each entry's metadata as JSON").option("--json", "print the raw API response").action(async (options) => {
4241
+ if (options.action && !AUDIT_ACTIONS.includes(options.action)) fail(`unknown action "${options.action}" — see \`seekrit audit actions\``);
4242
+ const ctx = buildContext();
4243
+ const ref = await resolveOrg(ctx, options.org);
4244
+ const limit = parseLimit(options.limit);
4245
+ const entries = [];
4246
+ let cursor = options.cursor;
4247
+ let nextCursor = null;
4248
+ do {
4249
+ const page = await ctx.client.listAudit(ref.id, {
4250
+ limit,
4251
+ cursor,
4252
+ action: options.action,
4253
+ resourceType: options.resourceType
4254
+ });
4255
+ entries.push(...page.entries);
4256
+ nextCursor = page.nextCursor;
4257
+ cursor = page.nextCursor ?? void 0;
4258
+ } while (options.all && cursor);
4259
+ emit(options, {
4260
+ entries,
4261
+ nextCursor
4262
+ }, () => {
4263
+ printTable(entries, [
4264
+ col("when", (e) => e.createdAt),
4265
+ col("action", (e) => e.action),
4266
+ col("actor", (e) => `${e.actorType}:${e.actorId}`),
4267
+ col("resource", (e) => `${e.resourceType}${e.resourceId ? `:${e.resourceId}` : ""}`),
4268
+ ...options.metadata ? [col("metadata", (e) => e.metadata ? JSON.stringify(e.metadata) : "-")] : []
4269
+ ], "no audit entries match");
4270
+ if (nextCursor && !options.all) console.error(`more entries — continue with --cursor ${nextCursor} (or pass --all)`);
4271
+ });
4272
+ });
4273
+ audit.command("actions").description("list every action the audit trail can record (for `audit --action`)").option("--json", "print the raw API response").action((options) => {
4274
+ emit(options, { actions: AUDIT_ACTIONS }, () => {
4275
+ for (const action of AUDIT_ACTIONS) console.log(action);
4276
+ });
4277
+ });
4278
+ }
4279
+ //#endregion
4280
+ //#region src/aws.ts
4281
+ /**
4282
+ * `seekrit aws` — temporary AWS credentials via STS AssumeRole (Vault-style
4283
+ * dynamic secrets, the tier-2 sibling of `seekrit pg`).
4284
+ *
4285
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
4286
+ * keypair on THIS machine and sends only the public key; STS mints the
4287
+ * credential and the broker returns it wrapped to that key, so the control plane
4288
+ * only ever relays ciphertext and only this machine can unwrap it. Registering a
4289
+ * target wraps the base IAM credential to the broker's public key locally, so
4290
+ * the control plane never sees it either — its only needed permission is
4291
+ * `sts:AssumeRole` on the target role.
4292
+ */
4293
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
4294
+ function parseTtlSeconds$6(input) {
4295
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
4296
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
4297
+ return Number(m[1]) * ({
4298
+ s: 1,
4299
+ m: 60,
4300
+ h: 3600,
4301
+ d: 86400
4302
+ }[m[2] || "s"] ?? 1);
4303
+ }
4304
+ /**
4305
+ * The base IAM credential the broker assumes the role with. From flags or the
4306
+ * standard AWS env vars; JSON-serialized so the executor can parse it. Never the
4307
+ * plaintext leaves this machine unwrapped — it is wrapped to the broker key.
4308
+ */
4309
+ function resolveBaseCredential(opts) {
4310
+ const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
4311
+ const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
4312
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
4313
+ if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
4314
+ return JSON.stringify({
4315
+ accessKeyId,
4316
+ secretAccessKey,
4317
+ ...sessionToken ? { sessionToken } : {}
4318
+ });
4319
+ }
4320
+ function registerAwsCommands(program) {
4321
+ const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
4322
+ const target = aws.command("target").description("manage AWS role targets");
4323
+ target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
4324
+ const ctx = buildContext();
4325
+ const org = await resolveOrg(ctx, options.org);
4326
+ const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
4327
+ const config = {
4328
+ provider: "aws",
4329
+ executor: "in_do",
4330
+ roleArn: options.roleArn,
4331
+ region: options.region,
4332
+ ...options.externalId ? { externalId: options.externalId } : {},
4333
+ ...sessionPolicy ? { sessionPolicy } : {},
4334
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
4335
+ };
4336
+ const baseCredential = resolveBaseCredential(options);
4337
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
4338
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
4339
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
4340
+ name: options.name,
4341
+ config,
4342
+ wrappedAdminSecret
4343
+ });
4344
+ console.error(`registered AWS target ${created.name} (${created.id})`);
4345
+ console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
4346
+ console.log(awsTrustPolicyInstructions(config));
4347
+ });
4348
+ target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
4349
+ const ctx = buildContext();
4350
+ const org = await resolveOrg(ctx, options.org);
4351
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4352
+ for (const t of targets) {
4353
+ const cfg = t.config;
4354
+ if (cfg.provider !== "aws") continue;
4355
+ console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
4356
+ }
4357
+ });
4358
+ target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
4359
+ const ctx = buildContext();
4360
+ const org = await resolveOrg(ctx, options.org);
4361
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4362
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
4363
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
4364
+ const cfg = t.config;
4365
+ if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
4366
+ console.log(awsTrustPolicyInstructions(cfg));
4367
+ });
4368
+ target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
4369
+ const ctx = buildContext();
4370
+ const org = await resolveOrg(ctx, options.org);
4371
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
4372
+ console.error(`deleted ${targetId}`);
4373
+ });
4374
+ aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
4375
+ const ctx = buildContext();
4376
+ const org = await resolveOrg(ctx, options.org);
4377
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4378
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
4379
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
4380
+ const cfg = t.config;
4381
+ if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
4382
+ const ttlSeconds = parseTtlSeconds$6(options.ttl);
4383
+ if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
4384
+ const recipient = await generateAwsRecipientKeyPair();
4385
+ const { aws: leased } = await ctx.client.mintLease(org.id, {
4386
+ provider: "aws",
4387
+ targetId: t.id,
4388
+ recipientPublicKey: recipient.publicKeyJwk,
4389
+ ttlSeconds
4390
+ });
4391
+ const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
4392
+ console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
4393
+ if (options.json) console.log(JSON.stringify({
4394
+ ...cred,
4395
+ roleArn: cfg.roleArn
4396
+ }, null, 2));
4397
+ else {
4398
+ console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
4399
+ console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
4400
+ console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
4401
+ console.log(`export AWS_REGION=${cred.region}`);
4402
+ }
4403
+ });
4404
+ aws.command("leases").description("list AWS leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
4405
+ const ctx = buildContext();
4406
+ const org = await resolveOrg(ctx, options.org);
4407
+ const { leases } = await ctx.client.listLeases(org.id);
4408
+ for (const l of leases) {
4409
+ if (l.provider !== "aws") continue;
4410
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
4411
+ }
4412
+ });
4413
+ aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
4414
+ const ctx = buildContext();
4415
+ const org = await resolveOrg(ctx, options.org);
4416
+ await ctx.client.revokeLease(org.id, leaseId);
4417
+ console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
4418
+ });
4419
+ }
4420
+ //#endregion
4421
+ //#region src/billing.ts
4422
+ /** `included: null` means unlimited; an unavailable meter is unknown, not zero. */
4423
+ function usageLine(usage) {
4424
+ if (!usage.available) return "unknown";
4425
+ return `${usage.quantity} of ${usage.included ?? "unlimited"}`;
4426
+ }
4427
+ /**
4428
+ * Plan, usage, and self-serve billing.
4429
+ *
4430
+ * Checkout and the billing portal are browser flows the biller hosts, so those
4431
+ * commands print a URL rather than pretending to complete a payment in the
4432
+ * terminal.
4433
+ */
4434
+ function registerBillingCommands(program) {
4435
+ const billing = program.command("billing").description("plan, usage, and subscription");
4436
+ billing.command("show", { isDefault: true }).description("show the org's plan, what it includes, and current usage").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4437
+ const ctx = buildContext();
4438
+ const ref = await resolveOrg(ctx, options.org);
4439
+ const info = await ctx.client.getBilling(ref.id);
4440
+ emit(options, info, () => {
4441
+ printFields([
4442
+ ["plan", `${info.plan.name} (${info.plan.id})`],
4443
+ ["status", info.subscription?.status ?? "no subscription (default plan)"],
4444
+ ["renews", info.subscription?.currentPeriodEnd ?? null],
4445
+ ["cancels", info.subscription?.cancelAt ?? null],
4446
+ ["trial ends", info.subscription?.trialEndsAt ?? null],
4447
+ ["limits enforced", info.enforced ? "yes" : "no (informational only)"],
4448
+ ["self-serve", [info.manage.checkout ? `checkout (${info.manage.checkoutFamilies.join(", ")})` : null, info.manage.portal ? "portal" : null].filter(Boolean).join(", ") || "unavailable"]
4449
+ ]);
4450
+ section("usage");
4451
+ printTable(info.usage, [col("metric", (u) => u.label), col("used", (u) => usageLine(u))], "nothing metered yet");
4452
+ if (info.overrides.length > 0) {
4453
+ section("overrides");
4454
+ printTable(info.overrides, [
4455
+ col("key", (o) => o.key),
4456
+ col("value", (o) => String(o.value)),
4457
+ col("note", (o) => o.note),
4458
+ col("expires", (o) => o.expiresAt)
4459
+ ]);
4460
+ }
4461
+ });
4462
+ });
4463
+ billing.command("entitlements").description("list every entitlement this org resolves to").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4464
+ const ctx = buildContext();
4465
+ const ref = await resolveOrg(ctx, options.org);
4466
+ const info = await ctx.client.getBilling(ref.id);
4467
+ emit(options, { entitlements: info.entitlements }, () => printTable(info.entitlements, [
4468
+ col("key", (e) => e.key),
4469
+ col("value", (e) => e.value === null ? "unlimited" : String(e.value)),
4470
+ col("source", (e) => e.source)
4471
+ ]));
4472
+ });
4473
+ billing.command("checkout <family>").description(`start a self-serve upgrade (${VISIBLE_PLAN_FAMILY_IDS.join(" | ")}) — prints a URL`).option("--org <slug>").action(async (family, options) => {
4474
+ if (!PLAN_FAMILY_IDS.includes(family)) fail(`unknown plan "${family}" — one of: ${PLAN_FAMILY_IDS.join(", ")}`);
4475
+ const ctx = buildContext();
4476
+ const ref = await resolveOrg(ctx, options.org);
4477
+ const { url } = await ctx.client.startCheckout(ref.id, { family });
4478
+ console.error("open this to complete checkout:");
4479
+ console.log(url);
4480
+ });
4481
+ billing.command("portal").description("open the billing portal (prints a URL) to manage payment and invoices").option("--org <slug>").action(async (options) => {
4482
+ const ctx = buildContext();
4483
+ const ref = await resolveOrg(ctx, options.org);
4484
+ const { url } = await ctx.client.openBillingPortal(ref.id);
4485
+ console.error("open this to manage billing:");
4486
+ console.log(url);
4487
+ });
4488
+ billing.command("cancel").description("cancel the subscription and drop back to the Free plan").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4489
+ const ctx = buildContext();
4490
+ const ref = await resolveOrg(ctx, options.org);
4491
+ await confirmDestructive(options.yes, `Cancel ${ref.slug}'s subscription and move it to the Free plan?`);
4492
+ await ctx.client.cancelSubscription(ref.id);
4493
+ console.error(`${ref.slug} moved to the Free plan`);
4494
+ });
4495
+ }
4496
+ //#endregion
4497
+ //#region src/branches.ts
4498
+ /**
4499
+ * Resolve `--from` to the environment being branched.
3608
4500
  *
3609
4501
  * Not just `resolveAppEnv`: branch slugs live in the same namespace but are
3610
4502
  * excluded from the environment list, so naming one lands on "no environment
@@ -3674,17 +4566,21 @@ function registerBranchCommands(program) {
3674
4566
  console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
3675
4567
  if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
3676
4568
  });
3677
- branch.command("list").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").action(async (options) => {
4569
+ branch.command("list").alias("ls").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").option("--json", "print the raw API response").action(async (options) => {
3678
4570
  const ctx = buildContext();
3679
- if (options.env) {
4571
+ const branches = options.env ? await (async () => {
3680
4572
  const parent = await resolveAppEnv(ctx, options);
3681
- const { branches } = await ctx.client.listBranches(parent.orgId, parent.envId);
3682
- for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3683
- return;
3684
- }
3685
- const app = await resolveApp(ctx, options);
3686
- const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3687
- for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
4573
+ return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
4574
+ })() : await (async () => {
4575
+ const app = await resolveApp(ctx, options);
4576
+ return (await ctx.client.listAppBranches(app.orgId, app.id)).branches;
4577
+ })();
4578
+ emit(options, { branches }, () => printTable(branches, [
4579
+ col("slug", (b) => b.slug),
4580
+ col("expires", (b) => b.expiresAt ?? "never"),
4581
+ col("created", (b) => b.createdAt),
4582
+ col("id", (b) => b.id)
4583
+ ], "no branches — create one with `seekrit branch create`"));
3688
4584
  });
3689
4585
  branch.command("delete <slug>").alias("rm").description("tear down a branch and everything it overrode").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").action(async (slug, options) => {
3690
4586
  const ctx = buildContext();
@@ -3887,6 +4783,181 @@ function registerGcpCommands(program) {
3887
4783
  });
3888
4784
  }
3889
4785
  //#endregion
4786
+ //#region src/groups.ts
4787
+ /**
4788
+ * Shared groups: reusable secret bags composed into application environments
4789
+ * by matching slug (`seekrit env groups add`). A group holds one environment
4790
+ * per slug — the "variants" of the shared values.
4791
+ */
4792
+ function registerGroupCommands(program) {
4793
+ const group = program.command("group").description("manage shared groups (reusable secret bags)");
4794
+ group.command("list").alias("ls").description("list shared groups in an organization").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4795
+ const ctx = buildContext();
4796
+ const ref = await resolveOrg(ctx, options.org);
4797
+ const { groups } = await ctx.client.listGroups(ref.id);
4798
+ emit(options, { groups }, () => printTable(groups, [
4799
+ col("slug", (g) => g.slug),
4800
+ col("name", (g) => g.name),
4801
+ col("created", (g) => g.createdAt),
4802
+ col("id", (g) => g.id)
4803
+ ], "no groups — create one with `seekrit group create`"));
4804
+ });
4805
+ group.command("show <slug>").description("show a group and the environments (value sets) it holds").option("--org <slug>").option("--json", "print the raw API response").action(async (slug, options) => {
4806
+ const ctx = buildContext();
4807
+ const ref = await resolveGroup(ctx, {
4808
+ org: options.org,
4809
+ group: slug
4810
+ });
4811
+ const { group: row, environments } = await ctx.client.getGroup(ref.orgId, ref.id);
4812
+ emit(options, {
4813
+ group: row,
4814
+ environments
4815
+ }, () => {
4816
+ printFields([
4817
+ ["slug", row.slug],
4818
+ ["name", row.name],
4819
+ ["id", row.id],
4820
+ ["created", row.createdAt]
4821
+ ]);
4822
+ section("environments");
4823
+ printTable(environments, [
4824
+ col("slug", (e) => e.slug),
4825
+ col("name", (e) => e.name),
4826
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4827
+ col("id", (e) => e.id)
4828
+ ], "no environments — create one with `seekrit group env create`");
4829
+ });
4830
+ });
4831
+ group.command("create").description("create a shared group").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4832
+ const ctx = buildContext();
4833
+ const orgRef = await resolveOrg(ctx, options.org);
4834
+ const created = await ctx.client.createGroup(orgRef.id, {
4835
+ name: options.name,
4836
+ slug: options.slug
4837
+ });
4838
+ console.error(`created group ${created.group.slug} (${created.group.id})`);
4839
+ });
4840
+ group.command("rename <slug>").description("change a group's display name (the slug is permanent)").option("--org <slug>").requiredOption("--name <name>", "new display name").action(async (slug, options) => {
4841
+ const ctx = buildContext();
4842
+ const ref = await resolveGroup(ctx, {
4843
+ org: options.org,
4844
+ group: slug
4845
+ });
4846
+ const { group: row } = await ctx.client.updateGroup(ref.orgId, ref.id, { name: options.name });
4847
+ console.error(`renamed ${row.slug} to "${row.name}"`);
4848
+ });
4849
+ group.command("rm <slug>").alias("delete").description("delete a group, its environments, and their secrets").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (slug, options) => {
4850
+ const ctx = buildContext();
4851
+ const ref = await resolveGroup(ctx, {
4852
+ org: options.org,
4853
+ group: slug
4854
+ });
4855
+ const { environments } = await ctx.client.getGroup(ref.orgId, ref.id);
4856
+ await confirmDestructive(options.yes, `Delete group ${ref.slug} and its ${environments.length} environment(s)? Environments composing it will stop receiving these values.`);
4857
+ await ctx.client.deleteGroup(ref.orgId, ref.id);
4858
+ console.error(`deleted group ${ref.slug}`);
4859
+ });
4860
+ const groupEnv = group.command("env").description("manage a group’s environments (per-slug value sets / variants)");
4861
+ groupEnv.command("list").alias("ls").description("list a group's environments").option("--org <slug>").requiredOption("--group <slug>").option("--json", "print the raw API response").action(async (options) => {
4862
+ const ctx = buildContext();
4863
+ const ref = await resolveGroup(ctx, options);
4864
+ const { environments } = await ctx.client.listGroupEnvs(ref.orgId, ref.id);
4865
+ emit(options, { environments }, () => printTable(environments, [
4866
+ col("slug", (e) => e.slug),
4867
+ col("name", (e) => e.name),
4868
+ col("created", (e) => e.createdAt),
4869
+ col("id", (e) => e.id)
4870
+ ], "no environments — create one with `seekrit group env create`"));
4871
+ });
4872
+ groupEnv.command("create").description("create a group environment (generates its data key locally)").option("--org <slug>").requiredOption("--group <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4873
+ const ctx = buildContext();
4874
+ const groupRef = await resolveGroup(ctx, {
4875
+ org: options.org,
4876
+ group: options.group
4877
+ });
4878
+ const { user } = await ctx.client.me();
4879
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4880
+ const dek = generateDek();
4881
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4882
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
4883
+ const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
4884
+ name: options.name,
4885
+ slug: options.slug,
4886
+ wrappedDek,
4887
+ recoveryWrappedDek
4888
+ });
4889
+ console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
4890
+ });
4891
+ }
4892
+ //#endregion
4893
+ //#region src/logsink.ts
4894
+ /** Collect repeated `--header Name: value` flags into a map. */
4895
+ function collectHeader(value, acc = {}) {
4896
+ const sep = value.indexOf(":");
4897
+ if (sep <= 0) fail(`expected "Name: value", got "${value}"`);
4898
+ acc[value.slice(0, sep).trim()] = value.slice(sep + 1).trim();
4899
+ return acc;
4900
+ }
4901
+ /**
4902
+ * The org's OTLP audit-log export — near-real-time shipping of every audit row
4903
+ * to a customer SIEM. Header *values* are write-only: they are encrypted at
4904
+ * rest and never echoed back, so `show` reports only the header names.
4905
+ */
4906
+ function registerLogSinkCommands(program) {
4907
+ const sink = program.command("log-sink").description("stream the audit trail to your own OTLP collector (SIEM)");
4908
+ sink.command("show", { isDefault: true }).description("show the configured log sink and its delivery health").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4909
+ const ctx = buildContext();
4910
+ const ref = await resolveOrg(ctx, options.org);
4911
+ const { sink: config } = await ctx.client.getLogSink(ref.id);
4912
+ emit(options, { sink: config }, () => {
4913
+ if (!config) {
4914
+ console.error("no log sink configured — set one with `seekrit log-sink set`");
4915
+ return;
4916
+ }
4917
+ printFields([
4918
+ ["endpoint", config.endpoint],
4919
+ ["enabled", config.enabled ? "yes" : "no"],
4920
+ ["headers", config.headerNames.length > 0 ? config.headerNames.join(", ") : "none"],
4921
+ ["last success", config.lastSuccessAt ?? "never"],
4922
+ ["last attempt", config.lastAttemptAt ?? "never"],
4923
+ ["last error", config.lastError ?? "none"]
4924
+ ]);
4925
+ });
4926
+ });
4927
+ sink.command("set <endpoint>").description("point the audit export at an OTLP/HTTP logs endpoint").option("--org <slug>").option("--header <name: value>", "auth header to send (repeatable; values are write-only)", collectHeader).option("--clear-headers", "send no headers at all (drops the stored ones)").option("--disabled", "save the config but stop shipping").action(async (endpoint, options) => {
4928
+ if (options.header && options.clearHeaders) fail("pass either --header or --clear-headers, not both");
4929
+ const ctx = buildContext();
4930
+ const ref = await resolveOrg(ctx, options.org);
4931
+ const headers = options.clearHeaders ? {} : options.header;
4932
+ const { sink: config } = await ctx.client.setLogSink(ref.id, {
4933
+ endpoint,
4934
+ headers,
4935
+ enabled: !options.disabled
4936
+ });
4937
+ console.error(`log sink → ${config.endpoint} (${config.enabled ? "enabled" : "disabled"}) — test it with \`seekrit log-sink test\``);
4938
+ });
4939
+ sink.command("test").description("send a probe to the configured endpoint and report the result").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4940
+ const ctx = buildContext();
4941
+ const ref = await resolveOrg(ctx, options.org);
4942
+ const result = await ctx.client.testLogSink(ref.id);
4943
+ emit(options, result, () => {
4944
+ printFields([
4945
+ ["result", result.ok ? "ok" : "failed"],
4946
+ ["status", result.status],
4947
+ ["error", result.error]
4948
+ ]);
4949
+ });
4950
+ if (!result.ok) process.exitCode = 1;
4951
+ });
4952
+ sink.command("rm").alias("delete").description("stop exporting the audit trail and forget the endpoint").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4953
+ const ctx = buildContext();
4954
+ const ref = await resolveOrg(ctx, options.org);
4955
+ await confirmDestructive(options.yes, `Remove ${ref.slug}'s audit log export?`);
4956
+ await ctx.client.deleteLogSink(ref.id);
4957
+ console.error("log sink removed");
4958
+ });
4959
+ }
4960
+ //#endregion
3890
4961
  //#region src/m2m.ts
3891
4962
  /**
3892
4963
  * Resolve M2M client credentials from (in order) the process environment, a
@@ -4278,6 +5349,164 @@ function collect$3(value, acc) {
4278
5349
  return acc;
4279
5350
  }
4280
5351
  //#endregion
5352
+ //#region src/orgs.ts
5353
+ /**
5354
+ * Counts for `org show`. Each list is admin-gated to a different degree, so a
5355
+ * member who can't read one still gets the rest: a failed call reports as
5356
+ * `null`, which `printFields` drops and JSON preserves as "not visible to you".
5357
+ */
5358
+ async function countOrNull(load) {
5359
+ try {
5360
+ return await load();
5361
+ } catch {
5362
+ return null;
5363
+ }
5364
+ }
5365
+ async function orgOverview(ctx, orgId) {
5366
+ const [apps, groups, members, tokens] = await Promise.all([
5367
+ countOrNull(async () => (await ctx.client.listApps(orgId)).apps.length),
5368
+ countOrNull(async () => (await ctx.client.listGroups(orgId)).groups.length),
5369
+ countOrNull(async () => (await ctx.client.listMembers(orgId)).members.length),
5370
+ countOrNull(async () => (await ctx.client.listTokens(orgId)).tokens.length)
5371
+ ]);
5372
+ return {
5373
+ apps,
5374
+ groups,
5375
+ members,
5376
+ tokens
5377
+ };
5378
+ }
5379
+ function registerOrgCommands(program) {
5380
+ const org = program.command("org").description("manage organizations");
5381
+ org.command("list").alias("ls").description("list the organizations you can access").option("--json", "print the raw API response").action(async (options) => {
5382
+ const { orgs } = await buildContext().client.listOrgs();
5383
+ emit(options, { orgs }, () => printTable(orgs, [
5384
+ col("slug", (o) => o.slug),
5385
+ col("name", (o) => o.name),
5386
+ col("role", (o) => o.role),
5387
+ col("id", (o) => o.id)
5388
+ ], "no organizations — create one with `seekrit org create`"));
5389
+ });
5390
+ 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) => {
5391
+ const ctx = buildContext();
5392
+ const ref = await resolveOrg(ctx, slug ?? options.org);
5393
+ const { org: row } = await ctx.client.getOrg(ref.id);
5394
+ const counts = await orgOverview(ctx, ref.id);
5395
+ const shown = (n) => n === null ? "—" : n;
5396
+ emit(options, {
5397
+ org: row,
5398
+ counts
5399
+ }, () => {
5400
+ printFields([
5401
+ ["slug", row.slug],
5402
+ ["name", row.name],
5403
+ ["id", row.id],
5404
+ ["your role", row.role],
5405
+ ["created", row.createdAt],
5406
+ ["applications", shown(counts.apps)],
5407
+ ["groups", shown(counts.groups)],
5408
+ ["members", shown(counts.members)],
5409
+ ["service tokens", shown(counts.tokens)]
5410
+ ]);
5411
+ });
5412
+ });
5413
+ org.command("create").description("create an organization (you become its owner)").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5414
+ const created = await buildContext().client.createOrg({
5415
+ name: options.name,
5416
+ slug: options.slug
5417
+ });
5418
+ console.error(`created org ${created.org.slug} (${created.org.id})`);
5419
+ });
5420
+ org.command("rename").description("change an organization's display name (the slug is permanent)").option("--org <slug>").requiredOption("--name <name>", "new display name").action(async (options) => {
5421
+ const ctx = buildContext();
5422
+ const ref = await resolveOrg(ctx, options.org);
5423
+ const { org: row } = await ctx.client.updateOrg(ref.id, { name: options.name });
5424
+ console.error(`renamed ${row.slug} to "${row.name}"`);
5425
+ });
5426
+ org.command("member").description("view organization members").command("list").alias("ls").description("list members, and whether each has finished key setup").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5427
+ const ctx = buildContext();
5428
+ const ref = await resolveOrg(ctx, options.org);
5429
+ const { members } = await ctx.client.listMembers(ref.id);
5430
+ emit(options, { members }, () => printTable(members, [
5431
+ col("email", (m) => m.email),
5432
+ col("role", (m) => m.role),
5433
+ col("name", (m) => m.name),
5434
+ col("keys", (m) => m.publicKeyJwk ? "ready" : "pending"),
5435
+ col("id", (m) => m.userId)
5436
+ ], "no members"));
5437
+ });
5438
+ const invite = org.command("invite").description("manage pending invitations");
5439
+ invite.command("list").alias("ls").description("list outstanding invitations").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5440
+ const ctx = buildContext();
5441
+ const ref = await resolveOrg(ctx, options.org);
5442
+ const { invites } = await ctx.client.listInvites(ref.id);
5443
+ emit(options, { invites }, () => printTable(invites, [
5444
+ col("email", (i) => i.email),
5445
+ col("role", (i) => i.role),
5446
+ col("invited", (i) => i.createdAt),
5447
+ col("id", (i) => i.id)
5448
+ ], "no pending invitations"));
5449
+ });
5450
+ invite.command("add <email>").description("invite someone to the organization").option("--org <slug>").option("--role <role>", "admin | member", "member").action(async (email, options) => {
5451
+ if (options.role !== "admin" && options.role !== "member") fail("--role must be admin or member");
5452
+ const ctx = buildContext();
5453
+ const ref = await resolveOrg(ctx, options.org);
5454
+ const { invite: row } = await ctx.client.createInvite(ref.id, {
5455
+ email,
5456
+ role: options.role
5457
+ });
5458
+ console.error(`invited ${row.email} as ${row.role} (${row.id}) — they join when they first sign in`);
5459
+ });
5460
+ invite.command("rm <inviteId>").description("rescind an invitation").option("--org <slug>").action(async (inviteId, options) => {
5461
+ const ctx = buildContext();
5462
+ const ref = await resolveOrg(ctx, options.org);
5463
+ await ctx.client.revokeInvite(ref.id, inviteId);
5464
+ console.error(`${inviteId} revoked`);
5465
+ });
5466
+ org.command("mfa").description("show or set the org-wide second-factor requirement").option("--org <slug>").option("--set <policy>", "required | optional").option("--json", "print the raw API response").action(async (options) => {
5467
+ const ctx = buildContext();
5468
+ const ref = await resolveOrg(ctx, options.org);
5469
+ if (options.set !== void 0 && options.set !== "required" && options.set !== "optional") fail("--set must be required or optional");
5470
+ const policy = options.set === void 0 ? await ctx.client.getMfaPolicy(ref.id) : await ctx.client.setMfaPolicy(ref.id, { required: options.set === "required" });
5471
+ emit(options, policy, () => {
5472
+ if (!policy.configured) {
5473
+ console.error("no identity provider configured — MFA cannot be enforced here");
5474
+ return;
5475
+ }
5476
+ console.log(policy.required ? "required for all members" : "optional");
5477
+ });
5478
+ });
5479
+ org.command("tree").description("print the org's applications, environments, and groups as a tree").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5480
+ const ctx = buildContext();
5481
+ const ref = await resolveOrg(ctx, options.org);
5482
+ const [{ apps }, { groups }] = await Promise.all([ctx.client.listApps(ref.id), ctx.client.listGroups(ref.id)]);
5483
+ const appTree = await Promise.all(apps.map(async (a) => ({
5484
+ ...a,
5485
+ environments: (await ctx.client.listEnvs(ref.id, a.id)).environments
5486
+ })));
5487
+ const groupTree = await Promise.all(groups.map(async (g) => ({
5488
+ ...g,
5489
+ environments: (await ctx.client.listGroupEnvs(ref.id, g.id)).environments
5490
+ })));
5491
+ emit(options, {
5492
+ org: ref.slug,
5493
+ apps: appTree,
5494
+ groups: groupTree
5495
+ }, () => {
5496
+ console.log(ref.slug);
5497
+ for (const a of appTree) {
5498
+ console.log(` app ${a.slug}`);
5499
+ for (const e of a.environments) console.log(` ${e.slug}`);
5500
+ }
5501
+ for (const g of groupTree) {
5502
+ console.log(` group ${g.slug}`);
5503
+ for (const e of g.environments) console.log(` ${e.slug}`);
5504
+ }
5505
+ if (appTree.length === 0 && groupTree.length === 0) console.error("no applications or groups yet");
5506
+ });
5507
+ });
5508
+ }
5509
+ //#endregion
4281
5510
  //#region src/pg.ts
4282
5511
  /**
4283
5512
  * `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
@@ -4856,6 +6085,208 @@ function collect(value, acc) {
4856
6085
  return acc;
4857
6086
  }
4858
6087
  //#endregion
6088
+ //#region src/sync.ts
6089
+ function assertProvider(value) {
6090
+ if (!SYNC_PROVIDER_KINDS.includes(value)) fail(`unknown provider "${value}" — one of: ${SYNC_PROVIDER_KINDS.join(", ")}`);
6091
+ return value;
6092
+ }
6093
+ /** Account-scope config for a connection (never the credential itself). */
6094
+ function buildConfig(provider, options) {
6095
+ switch (provider) {
6096
+ case "vercel": return {
6097
+ provider: "vercel",
6098
+ ...options.teamId ? { teamId: options.teamId } : {}
6099
+ };
6100
+ }
6101
+ }
6102
+ /** Where inside the platform a binding writes. */
6103
+ function buildDestination(provider, options) {
6104
+ switch (provider) {
6105
+ case "vercel": {
6106
+ if (!options.project) fail("--project is required for vercel (a prj_… id or project name)");
6107
+ const targets = (options.target ?? "production").split(",").map((t) => t.trim());
6108
+ const unknown = targets.filter((t) => !VERCEL_TARGETS.includes(t));
6109
+ if (unknown.length > 0) fail(`unknown --target ${unknown.join(", ")} — one of: ${VERCEL_TARGETS.join(", ")}`);
6110
+ return {
6111
+ provider: "vercel",
6112
+ projectId: options.project,
6113
+ targets,
6114
+ ...options.gitBranch ? { gitBranch: options.gitBranch } : {}
6115
+ };
6116
+ }
6117
+ }
6118
+ }
6119
+ /** One-line description of a destination, for list output. */
6120
+ function describeDestination(destination) {
6121
+ switch (destination.provider) {
6122
+ case "vercel": return `${destination.projectId} (${destination.targets.join(", ")}${destination.gitBranch ? ` @${destination.gitBranch}` : ""})`;
6123
+ }
6124
+ }
6125
+ /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
6126
+ async function resolveConnection(ctx, orgId, ref) {
6127
+ const { connections } = await ctx.client.listSyncConnections(orgId);
6128
+ const matches = connections.filter((c) => c.id === ref || c.name === ref);
6129
+ if (matches.length === 0) fail(`no sync connection "${ref}"`);
6130
+ if (matches.length > 1) fail(`"${ref}" matches ${matches.length} connections — use the id`);
6131
+ return matches[0];
6132
+ }
6133
+ function registerSyncCommands(program) {
6134
+ const sync = program.command("sync").description("push environments to a third-party platform (Vercel, …)");
6135
+ sync.command("connections").alias("conns").description("list destination accounts seekrit can push to").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
6136
+ const ctx = buildContext();
6137
+ const ref = await resolveOrg(ctx, options.org);
6138
+ const { connections } = await ctx.client.listSyncConnections(ref.id);
6139
+ emit(options, { connections }, () => printTable(connections, [
6140
+ col("name", (c) => c.name),
6141
+ col("provider", (c) => c.provider),
6142
+ col("status", (c) => c.status),
6143
+ col("error", (c) => c.lastError),
6144
+ col("id", (c) => c.id)
6145
+ ], "no connections — add one with `seekrit sync connect`"));
6146
+ });
6147
+ sync.command("connect").description("register a destination account (reads its API token from stdin)").option("--org <slug>").requiredOption("--name <name>", "what to call this account, e.g. acme-vercel").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--team-id <id>", "vercel: Team id (omit for a personal account)").action(async (options) => {
6148
+ const provider = assertProvider(options.provider);
6149
+ const ctx = buildContext();
6150
+ const ref = await resolveOrg(ctx, options.org);
6151
+ const credential = (process.stdin.isTTY ? await promptHidden(`${provider} API token: `) : await readStdin()).trim();
6152
+ if (!credential) fail("no API token given");
6153
+ const id = randomId("syc");
6154
+ const { publicKeyJwk } = await ctx.client.getSyncConnectionKey(ref.id, id);
6155
+ const created = await ctx.client.createSyncConnection(ref.id, {
6156
+ id,
6157
+ name: options.name,
6158
+ config: buildConfig(provider, options),
6159
+ wrappedCredential: await wrapDek(new TextEncoder().encode(credential), publicKeyJwk)
6160
+ });
6161
+ console.error(`connected ${created.connection.name} (${created.connection.id})`);
6162
+ });
6163
+ sync.command("verify <connection>").description("check a stored credential against a destination").option("--org <slug>").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--project <id>", "vercel: project id or name").option("--target <list>", "vercel: comma-separated targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--json", "print the raw API response").action(async (connection, options) => {
6164
+ const provider = assertProvider(options.provider);
6165
+ const ctx = buildContext();
6166
+ const ref = await resolveOrg(ctx, options.org);
6167
+ const conn = await resolveConnection(ctx, ref.id, connection);
6168
+ const result = await ctx.client.verifySyncConnection(ref.id, conn.id, buildDestination(provider, options));
6169
+ emit(options, result, () => printFields([["result", result.ok ? "ok" : "failed"], ["error", result.error ?? null]]));
6170
+ if (!result.ok) process.exitCode = 1;
6171
+ });
6172
+ sync.command("disconnect <connection>").description("delete a destination account, its bindings, and its keypair").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (connection, options) => {
6173
+ const ctx = buildContext();
6174
+ const ref = await resolveOrg(ctx, options.org);
6175
+ const conn = await resolveConnection(ctx, ref.id, connection);
6176
+ const { bindings } = await ctx.client.listSyncBindings(ref.id);
6177
+ const mine = bindings.filter((b) => b.connectionId === conn.id);
6178
+ await confirmDestructive(options.yes, `Delete connection ${conn.name} and its ${mine.length} binding(s)? Values already pushed stay on the destination.`);
6179
+ await ctx.client.deleteSyncConnection(ref.id, conn.id);
6180
+ console.error(`disconnected ${conn.name}`);
6181
+ });
6182
+ sync.command("bindings").description("list which environments are syncing where").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
6183
+ const ctx = buildContext();
6184
+ const ref = await resolveOrg(ctx, options.org);
6185
+ const [{ bindings }, { connections }] = await Promise.all([ctx.client.listSyncBindings(ref.id), ctx.client.listSyncConnections(ref.id)]);
6186
+ const names = new Map(connections.map((c) => [c.id, c.name]));
6187
+ emit(options, { bindings }, () => printTable(bindings, [
6188
+ col("connection", (b) => names.get(b.connectionId) ?? b.connectionId),
6189
+ col("destination", (b) => describeDestination(b.destination)),
6190
+ col("env", (b) => b.environmentId),
6191
+ col("mode", (b) => b.enabled ? b.mode : "disabled"),
6192
+ col("last run", (b) => b.lastRunAt),
6193
+ col("error", (b) => b.lastError),
6194
+ col("id", (b) => b.id)
6195
+ ], "nothing is syncing — enable it with `seekrit sync enable`"));
6196
+ });
6197
+ sync.command("enable").description("start syncing one environment to a destination (lets seekrit decrypt it)").option("--org <slug>").requiredOption("--connection <name>", "destination account, by name or id").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--env <slug>", "the environment to push").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--project <id>", "vercel: project id or name").option("--target <list>", "vercel: comma-separated targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--prefix <prefix>", "prepend this to every destination key name").option("--include <globs>", "comma-separated name globs to push (default: all)").option("--exclude <globs>", "comma-separated name globs to skip").option("--on-delete <action>", "delete | retain — what happens when a secret is removed", "delete").option("--mode <mode>", "auto (push on write) | manual", "auto").option("--acknowledge-decryption", "confirm that seekrit's servers may decrypt this environment to push it").action(async (options) => {
6198
+ const provider = assertProvider(options.provider);
6199
+ if (options.onDelete !== "delete" && options.onDelete !== "retain") fail("--on-delete must be delete or retain");
6200
+ if (options.mode !== "auto" && options.mode !== "manual") fail("--mode must be auto or manual");
6201
+ const ctx = buildContext();
6202
+ const target = await resolveAppEnv(ctx, options);
6203
+ const conn = await resolveConnection(ctx, target.orgId, options.connection);
6204
+ const destination = buildDestination(provider, options);
6205
+ await confirmDestructive(options.acknowledgeDecryption, `Enabling sync lets seekrit's servers decrypt ${target.appSlug}/${target.envSlug} in memory to push it to ${conn.name}. Continue?`, "--acknowledge-decryption");
6206
+ const resolved = await ctx.client.resolve({ env: target.envId });
6207
+ const privateKey = await getPrivateKey(ctx);
6208
+ const wrappedDeks = await Promise.all(resolved.layers.map(async (layer) => {
6209
+ let dek;
6210
+ try {
6211
+ dek = await unwrapDek(layer.wrappedDek, privateKey);
6212
+ } catch {
6213
+ fail(`you hold no key for ${layer.groupSlug ? `group "${layer.groupSlug}"` : "this environment"}, so sync cannot be granted access to it`);
6214
+ }
6215
+ return {
6216
+ environmentId: layer.environmentId,
6217
+ wrappedDek: await wrapDek(dek, conn.publicKeyJwk)
6218
+ };
6219
+ }));
6220
+ const globs = (raw) => raw ? raw.split(",").map((g) => g.trim()).filter(Boolean) : void 0;
6221
+ const { binding } = await ctx.client.createSyncBinding(target.orgId, {
6222
+ connectionId: conn.id,
6223
+ environmentId: target.envId,
6224
+ destination,
6225
+ ...options.prefix ? { nameTransform: { prefix: options.prefix } } : {},
6226
+ ...globs(options.include) ? { include: globs(options.include) } : {},
6227
+ ...globs(options.exclude) ? { exclude: globs(options.exclude) } : {},
6228
+ onDelete: options.onDelete,
6229
+ mode: options.mode,
6230
+ wrappedDeks,
6231
+ acknowledgedDecryption: true
6232
+ });
6233
+ console.error(`syncing ${target.appSlug}/${target.envSlug} → ${conn.name} ${describeDestination(destination)} (${binding.id})`);
6234
+ if (binding.mode === "manual") console.error("mode is manual — push with `seekrit sync run`");
6235
+ });
6236
+ sync.command("pause <bindingId>").description("stop pushing on this binding without deleting it").option("--org <slug>").action(async (bindingId, options) => {
6237
+ const ctx = buildContext();
6238
+ const ref = await resolveOrg(ctx, options.org);
6239
+ await ctx.client.updateSyncBinding(ref.id, bindingId, { enabled: false });
6240
+ console.error(`${bindingId} paused`);
6241
+ });
6242
+ sync.command("resume <bindingId>").description("start pushing on a paused binding again").option("--org <slug>").action(async (bindingId, options) => {
6243
+ const ctx = buildContext();
6244
+ const ref = await resolveOrg(ctx, options.org);
6245
+ await ctx.client.updateSyncBinding(ref.id, bindingId, { enabled: true });
6246
+ console.error(`${bindingId} resumed`);
6247
+ });
6248
+ sync.command("disable <bindingId>").alias("rm").description("stop syncing an environment and revoke seekrit's key for it").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (bindingId, options) => {
6249
+ const ctx = buildContext();
6250
+ const ref = await resolveOrg(ctx, options.org);
6251
+ await confirmDestructive(options.yes, `Delete binding ${bindingId}? Values already pushed stay on the destination.`);
6252
+ await ctx.client.deleteSyncBinding(ref.id, bindingId);
6253
+ console.error(`${bindingId} deleted`);
6254
+ });
6255
+ sync.command("run <bindingId>").description("push now, synchronously, and report what landed").option("--org <slug>").option("--json", "print the raw API response").action(async (bindingId, options) => {
6256
+ const ctx = buildContext();
6257
+ const ref = await resolveOrg(ctx, options.org);
6258
+ const { run } = await ctx.client.runSyncBinding(ref.id, bindingId);
6259
+ emit(options, { run }, () => {
6260
+ printFields([
6261
+ ["status", run.status],
6262
+ ["pushed", run.pushed.length],
6263
+ ["deleted", run.deleted.length],
6264
+ ["failed", run.failures.length],
6265
+ ["error", run.error]
6266
+ ]);
6267
+ if (run.failures.length > 0) {
6268
+ section("failures");
6269
+ printTable(run.failures, [col("name", (f) => f.name), col("error", (f) => f.error)]);
6270
+ }
6271
+ });
6272
+ if (run.status !== "succeeded") process.exitCode = 1;
6273
+ });
6274
+ sync.command("runs").description("show the sync run history").option("--org <slug>").option("--binding <id>", "only runs of this binding").option("--json", "print the raw API response").action(async (options) => {
6275
+ const ctx = buildContext();
6276
+ const ref = await resolveOrg(ctx, options.org);
6277
+ const { runs } = await ctx.client.listSyncRuns(ref.id, options.binding);
6278
+ emit(options, { runs }, () => printTable(runs, [
6279
+ col("started", (r) => r.startedAt),
6280
+ col("status", (r) => r.status),
6281
+ col("trigger", (r) => r.trigger),
6282
+ col("pushed", (r) => r.pushedCount),
6283
+ col("deleted", (r) => r.deletedCount),
6284
+ col("error", (r) => r.error),
6285
+ col("binding", (r) => r.bindingId)
6286
+ ], "no sync runs yet"));
6287
+ });
6288
+ }
6289
+ //#endregion
4859
6290
  //#region src/web-login.ts
4860
6291
  /**
4861
6292
  * `seekrit login` — sign in through the browser.
@@ -5121,101 +6552,9 @@ program.command("init").description("link this directory to an org/app (environm
5121
6552
  });
5122
6553
  console.error(`linked ${org.slug}/${app.slug} → ${path} (environment is selected by the service token at runtime)`);
5123
6554
  });
5124
- program.command("org").description("manage organizations").command("create").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5125
- const created = await buildContext().client.createOrg({
5126
- name: options.name,
5127
- slug: options.slug
5128
- });
5129
- console.error(`created org ${created.org.slug} (${created.org.id})`);
5130
- });
5131
- program.command("app").description("manage applications").command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5132
- const ctx = buildContext();
5133
- const orgRef = await resolveOrg(ctx, options.org);
5134
- const created = await ctx.client.createApp(orgRef.id, {
5135
- name: options.name,
5136
- slug: options.slug
5137
- });
5138
- console.error(`created app ${created.app.slug} (${created.app.id})`);
5139
- });
5140
- const env = program.command("env").description("manage environments");
5141
- env.command("create").description("create an application environment (generates its data key locally)").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5142
- const ctx = buildContext();
5143
- const orgRef = await resolveOrg(ctx, options.org);
5144
- const { apps } = await ctx.client.listApps(orgRef.id);
5145
- const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
5146
- if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
5147
- const { user } = await ctx.client.me();
5148
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
5149
- const dek = generateDek();
5150
- const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
5151
- const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
5152
- const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
5153
- name: options.name,
5154
- slug: options.slug,
5155
- wrappedDek,
5156
- recoveryWrappedDek
5157
- });
5158
- console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
5159
- });
5160
- const envGroups = env.command("groups").description("compose shared groups into an application environment");
5161
- envGroups.command("add").description("compose a group into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").option("--position <n>", "precedence among groups (higher wins)").action(async (options) => {
5162
- const ctx = buildContext();
5163
- const target = await resolveAppEnv(ctx, options);
5164
- const group = await resolveGroup(ctx, {
5165
- org: options.org,
5166
- group: options.group
5167
- });
5168
- await ctx.client.linkEnvGroup(target.orgId, target.envId, {
5169
- groupId: group.id,
5170
- position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
5171
- });
5172
- console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
5173
- });
5174
- envGroups.command("list").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").action(async (options) => {
5175
- const ctx = buildContext();
5176
- const target = await resolveAppEnv(ctx, options);
5177
- const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
5178
- for (const g of groups) console.log(`${g.position}\t${g.slug}\t${g.name}`);
5179
- });
5180
- envGroups.command("rm").description("remove a group from an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").action(async (options) => {
5181
- const ctx = buildContext();
5182
- const target = await resolveAppEnv(ctx, options);
5183
- const group = await resolveGroup(ctx, {
5184
- org: options.org,
5185
- group: options.group
5186
- });
5187
- await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
5188
- console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
5189
- });
5190
- const group = program.command("group").description("manage shared groups (reusable secret bags)");
5191
- group.command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5192
- const ctx = buildContext();
5193
- const orgRef = await resolveOrg(ctx, options.org);
5194
- const created = await ctx.client.createGroup(orgRef.id, {
5195
- name: options.name,
5196
- slug: options.slug
5197
- });
5198
- console.error(`created group ${created.group.slug} (${created.group.id})`);
5199
- });
5200
- group.command("env").description("manage a group’s environments (per-slug value sets / variants)").command("create").description("create a group environment (generates its data key locally)").option("--org <slug>").requiredOption("--group <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5201
- const ctx = buildContext();
5202
- const groupRef = await resolveGroup(ctx, {
5203
- org: options.org,
5204
- group: options.group
5205
- });
5206
- const { user } = await ctx.client.me();
5207
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
5208
- const dek = generateDek();
5209
- const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
5210
- const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
5211
- const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
5212
- name: options.name,
5213
- slug: options.slug,
5214
- wrappedDek,
5215
- recoveryWrappedDek
5216
- });
5217
- console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
5218
- });
6555
+ registerOrgCommands(program);
6556
+ registerAppCommands(program);
6557
+ registerGroupCommands(program);
5219
6558
  /** Parse a positive integer flag/argument (version numbers, page sizes). */
5220
6559
  function parseVersion(raw) {
5221
6560
  const n = Number(raw);
@@ -5227,11 +6566,21 @@ function withTarget(cmd) {
5227
6566
  return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").option("--branch <slug>", "operate on a branch of --env").requiredOption("--env <slug>", "environment slug");
5228
6567
  }
5229
6568
  const secrets = program.command("secrets").description("manage secrets in an application or group environment");
5230
- withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
6569
+ withTarget(secrets.command("list").alias("ls").description("list secret names (no values)").option("--json", "print the listing as JSON (metadata only — never values)")).action(async (options) => {
5231
6570
  const ctx = buildContext();
5232
- const { orgId, envId } = await resolveEnvTarget(ctx, options);
6571
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
5233
6572
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
5234
- for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
6573
+ emit(options, { secrets: rows.map(({ id, name, version, createdAt, updatedAt }) => ({
6574
+ id,
6575
+ name,
6576
+ version,
6577
+ createdAt,
6578
+ updatedAt
6579
+ })) }, () => printTable(rows, [
6580
+ col("name", (s) => s.name),
6581
+ col("version", (s) => `v${s.version}`),
6582
+ col("updated", (s) => s.updatedAt)
6583
+ ], `no secrets in ${label}`));
5235
6584
  });
5236
6585
  withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
5237
6586
  const ctx = buildContext();
@@ -5274,15 +6623,19 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
5274
6623
  const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
5275
6624
  console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
5276
6625
  });
5277
- withTarget(secrets.command("history <name>").description("list a secret's versions (metadata only — no values)").option("--limit <n>", `how many versions to show (max 200)`, "20")).action(async (name, options) => {
6626
+ withTarget(secrets.command("history <name>").description("list a secret's versions (metadata only — no values)").option("--limit <n>", `how many versions to show (max 200)`, "20").option("--json", "print the raw API response")).action(async (name, options) => {
5278
6627
  const ctx = buildContext();
5279
6628
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
5280
6629
  const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, name, { limit: parseVersion(options.limit) });
5281
- for (const v of versions) {
5282
- const marks = [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean);
5283
- const note = marks.length > 0 ? `\t${marks.join(", ")}` : "";
5284
- console.log(`v${v.version}\t${v.createdAt}\t${v.createdByType}:${v.createdById}${note}`);
5285
- }
6630
+ emit(options, {
6631
+ versions,
6632
+ currentVersion
6633
+ }, () => printTable(versions, [
6634
+ col("version", (v) => `v${v.version}`),
6635
+ col("when", (v) => v.createdAt),
6636
+ col("by", (v) => `${v.createdByType}:${v.createdById}`),
6637
+ col("note", (v) => [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean).join(", "))
6638
+ ], `no versions of ${name}`));
5286
6639
  });
5287
6640
  withTarget(secrets.command("restore <name> <version>").description("roll a secret back to an earlier version (stored as a new version)")).action(async (name, version, options) => {
5288
6641
  const ctx = buildContext();
@@ -5515,42 +6868,7 @@ program.command("export").description("print decrypted secrets (dotenv, json, or
5515
6868
  });
5516
6869
  console.log(formatSecrets(values, options.format));
5517
6870
  });
5518
- program.command("grant").description("give a member or service token access to an environment's key").option("--org <slug>").option("--app <slug>").option("--group <slug>", "grant a group environment instead of an app").requiredOption("--env <slug>").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)").action(async (options) => {
5519
- if (!options.user === !options.token) fail("pass exactly one of --user or --token");
5520
- const ctx = buildContext();
5521
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
5522
- const dek = await getDek(ctx, orgId, envId);
5523
- let principalType;
5524
- let principalId;
5525
- let publicKeyJwk;
5526
- if (options.user) {
5527
- const { members } = await ctx.client.listMembers(orgId);
5528
- const member = members.find((m) => m.email === options.user);
5529
- if (!member) fail(`no member ${options.user}`);
5530
- if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
5531
- [principalType, principalId, publicKeyJwk] = [
5532
- "user",
5533
- member.userId,
5534
- member.publicKeyJwk
5535
- ];
5536
- } else {
5537
- const { tokens } = await ctx.client.listTokens(orgId);
5538
- const token = tokens.find((t) => t.id === options.token);
5539
- if (!token) fail(`no service token ${options.token}`);
5540
- [principalType, principalId, publicKeyJwk] = [
5541
- "service_token",
5542
- token.id,
5543
- token.publicKeyJwk
5544
- ];
5545
- }
5546
- const wrappedDek = await wrapDek(dek, publicKeyJwk);
5547
- await ctx.client.grantEnvKey(orgId, envId, {
5548
- principalType,
5549
- principalId,
5550
- wrappedDek
5551
- });
5552
- console.error(`granted ${label} access to ${principalId}`);
5553
- });
6871
+ registerAccessCommands(program);
5554
6872
  const token = program.command("token").description("manage service tokens (CI, docker, agents)");
5555
6873
  token.command("create").description("create a service token (runtime, or --admin for provisioning); prints it once").requiredOption("--name <name>", "display name, e.g. ci-deploy").option("--org <slug>").option("--app <slug>", "application to bind the token to (runtime tokens)").option("--env <slug>", "environment to bind the token to (runtime tokens)").option("--admin", "mint an org-scoped admin token that can provision structure (no env binding required)").option("--allow <group=env>", "also grant an alternate group slice (for `run --with`)", collectKv).option("--no-grant", "skip auto-granting the env + composed group keys").action(async (options) => {
5556
6874
  const ctx = buildContext();
@@ -5601,14 +6919,19 @@ token.command("create").description("create a service token (runtime, or --admin
5601
6919
  console.error(`${role} token created${granted ? " and granted" : ""} for ${scope} — save it now, it is not stored:`);
5602
6920
  console.log(created.token);
5603
6921
  });
5604
- token.command("list").description("list service tokens").option("--org <slug>").action(async (options) => {
6922
+ token.command("list").alias("ls").description("list service tokens").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5605
6923
  const ctx = buildContext();
5606
6924
  const orgRef = await resolveOrg(ctx, options.org);
5607
6925
  const { tokens } = await ctx.client.listTokens(orgRef.id);
5608
- for (const t of tokens) {
5609
- const status = t.revokedAt ? "revoked" : t.expiresAt && Date.parse(t.expiresAt) < Date.now() ? "expired" : "active";
5610
- console.log(`${t.id}\t${t.name}\t${status}\tlast used: ${t.lastUsedAt ?? "never"}`);
5611
- }
6926
+ const status = (t) => t.revokedAt ? "revoked" : t.expiresAt && Date.parse(t.expiresAt) < Date.now() ? "expired" : "active";
6927
+ emit(options, { tokens }, () => printTable(tokens, [
6928
+ col("name", (t) => t.name),
6929
+ col("role", (t) => t.role),
6930
+ col("status", (t) => status(t)),
6931
+ col("env", (t) => t.environmentId ?? "org-scoped"),
6932
+ col("last used", (t) => t.lastUsedAt ?? "never"),
6933
+ col("id", (t) => t.id)
6934
+ ], "no service tokens — create one with `seekrit token create`"));
5612
6935
  });
5613
6936
  token.command("revoke <tokenId>").description("revoke a service token").option("--org <slug>").action(async (tokenId, options) => {
5614
6937
  const ctx = buildContext();
@@ -5637,12 +6960,11 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
5637
6960
  const { runMcpServer } = await import("./mcp-DLplPOvz.js");
5638
6961
  await runMcpServer();
5639
6962
  });
5640
- program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
5641
- const ctx = buildContext();
5642
- const orgRef = await resolveOrg(ctx, options.org);
5643
- const { entries } = await ctx.client.listAudit(orgRef.id, { limit: Number.parseInt(options.limit, 10) || 50 });
5644
- for (const entry of entries) console.log(`${entry.createdAt}\t${entry.action}\t${entry.actorType}:${entry.actorId}\t${entry.resourceType}${entry.resourceId ? `:${entry.resourceId}` : ""}`);
5645
- });
6963
+ registerAuditCommands(program);
6964
+ registerAccountCommands(program);
6965
+ registerLogSinkCommands(program);
6966
+ registerSyncCommands(program);
6967
+ registerBillingCommands(program);
5646
6968
  const argv = process.argv.map((arg) => arg === "-v" ? "--version" : arg);
5647
6969
  program.parseAsync(argv).catch((err) => {
5648
6970
  fail(err instanceof Error ? err.message : String(err));