@seekrit/cli 0.26.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.
- package/dist/index.js +1928 -293
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
7
7
|
import { dirname, join, parse } from "node:path";
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
@@ -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
|
-
|
|
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`, … */
|
|
@@ -1064,6 +1222,16 @@ z.object({
|
|
|
1064
1222
|
expiresAt: z.iso.datetime().nullish()
|
|
1065
1223
|
});
|
|
1066
1224
|
z.object({ family: planFamilySchema });
|
|
1225
|
+
z.object({
|
|
1226
|
+
sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
|
|
1227
|
+
/** SHA-256 hash (base64url) of the full session token string. */
|
|
1228
|
+
tokenHash: z.string().min(1).max(128),
|
|
1229
|
+
/** Display-only, e.g. `miles@studio.local`. */
|
|
1230
|
+
deviceLabel: z.string().trim().min(1).max(120),
|
|
1231
|
+
/** Display-only, e.g. `cli/0.4.2`. */
|
|
1232
|
+
client: z.string().trim().max(60).optional()
|
|
1233
|
+
});
|
|
1234
|
+
z.object({ code: z.string().trim().min(1).max(32) });
|
|
1067
1235
|
z.object({
|
|
1068
1236
|
cursor: z.string().optional(),
|
|
1069
1237
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
@@ -1071,6 +1239,128 @@ z.object({
|
|
|
1071
1239
|
resourceType: z.string().optional()
|
|
1072
1240
|
});
|
|
1073
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
|
|
1074
1364
|
//#region ../../packages/crypto/src/encoding.ts
|
|
1075
1365
|
const CHUNK = 32768;
|
|
1076
1366
|
/** Base64url (no padding) — portable across browsers, Workers, and Node. */
|
|
@@ -2139,9 +2429,12 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
|
|
|
2139
2429
|
* Format: `skt_<token id>_<private key pkcs8, base64url>`
|
|
2140
2430
|
*/
|
|
2141
2431
|
const TOKEN_PREFIX = "skt";
|
|
2432
|
+
const CLI_SESSION_PREFIX = "skc";
|
|
2142
2433
|
const TOKEN_ID_LENGTH = 22;
|
|
2143
2434
|
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2144
|
-
|
|
2435
|
+
/** 32 bytes of entropy for the CLI session secret. */
|
|
2436
|
+
const CLI_SESSION_SECRET_BYTES = 32;
|
|
2437
|
+
function randomTokenId(prefix = TOKEN_PREFIX) {
|
|
2145
2438
|
let out = "";
|
|
2146
2439
|
while (out.length < TOKEN_ID_LENGTH) {
|
|
2147
2440
|
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
|
|
@@ -2150,7 +2443,7 @@ function randomTokenId() {
|
|
|
2150
2443
|
if (out.length === TOKEN_ID_LENGTH) break;
|
|
2151
2444
|
}
|
|
2152
2445
|
}
|
|
2153
|
-
return `${
|
|
2446
|
+
return `${prefix}_${out}`;
|
|
2154
2447
|
}
|
|
2155
2448
|
async function hashToken(token) {
|
|
2156
2449
|
const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
|
|
@@ -2184,9 +2477,27 @@ async function parseServiceToken(token) {
|
|
|
2184
2477
|
function isServiceToken(value) {
|
|
2185
2478
|
return value.startsWith(`${TOKEN_PREFIX}_`);
|
|
2186
2479
|
}
|
|
2480
|
+
async function createCliSessionToken() {
|
|
2481
|
+
const sessionId = randomTokenId(CLI_SESSION_PREFIX);
|
|
2482
|
+
const token = `${sessionId}_${toBase64Url(crypto.getRandomValues(new Uint8Array(CLI_SESSION_SECRET_BYTES)))}`;
|
|
2483
|
+
return {
|
|
2484
|
+
token,
|
|
2485
|
+
sessionId,
|
|
2486
|
+
tokenHash: await hashToken(token)
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
/** The public `skc_…` id embedded in a CLI session token. */
|
|
2490
|
+
function parseCliSessionToken(token) {
|
|
2491
|
+
const match = /^(skc_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
|
|
2492
|
+
if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit CLI session token");
|
|
2493
|
+
return { sessionId: match[1] };
|
|
2494
|
+
}
|
|
2495
|
+
function isCliSessionToken(value) {
|
|
2496
|
+
return value.startsWith(`${CLI_SESSION_PREFIX}_`);
|
|
2497
|
+
}
|
|
2187
2498
|
//#endregion
|
|
2188
2499
|
//#region package.json
|
|
2189
|
-
var version = "0.
|
|
2500
|
+
var version = "0.28.0";
|
|
2190
2501
|
//#endregion
|
|
2191
2502
|
//#region ../../packages/api-client/src/index.ts
|
|
2192
2503
|
var SeekritApiError = class extends Error {
|
|
@@ -2248,6 +2559,32 @@ var SeekritClient = class {
|
|
|
2248
2559
|
getMyNotificationPrefs() {
|
|
2249
2560
|
return this.request("GET", "/v1/me/notifications");
|
|
2250
2561
|
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Devices this user has authorized. `currentSessionId` is set when the caller
|
|
2564
|
+
* *is* a CLI session, so it can label (or revoke) itself.
|
|
2565
|
+
*/
|
|
2566
|
+
listCliSessions() {
|
|
2567
|
+
return this.request("GET", "/v1/me/cli-sessions");
|
|
2568
|
+
}
|
|
2569
|
+
/** Sign a device out. Its token stops authenticating immediately. */
|
|
2570
|
+
revokeCliSession(sessionId) {
|
|
2571
|
+
return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
|
|
2572
|
+
}
|
|
2573
|
+
/** What a pending login request is asking for — for the approval screen. */
|
|
2574
|
+
getCliLoginRequest(code) {
|
|
2575
|
+
return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Authorize a device. Requires a browser session; members with a second
|
|
2579
|
+
* factor must have re-entered it just now, else this rejects with
|
|
2580
|
+
* `mfa_required` (recoverable — prompt for a code and retry).
|
|
2581
|
+
*/
|
|
2582
|
+
approveCliLogin(code) {
|
|
2583
|
+
return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/approve`);
|
|
2584
|
+
}
|
|
2585
|
+
denyCliLogin(code) {
|
|
2586
|
+
return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/deny`);
|
|
2587
|
+
}
|
|
2251
2588
|
setMyNotificationPrefs(input) {
|
|
2252
2589
|
return this.request("PUT", "/v1/me/notifications", input);
|
|
2253
2590
|
}
|
|
@@ -2525,6 +2862,54 @@ var SeekritClient = class {
|
|
|
2525
2862
|
deleteLeaseTarget(orgId, targetId) {
|
|
2526
2863
|
return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
|
|
2527
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
|
+
}
|
|
2528
2913
|
listLeases(orgId) {
|
|
2529
2914
|
return this.request("GET", `/v1/orgs/${orgId}/leases`);
|
|
2530
2915
|
}
|
|
@@ -2591,6 +2976,39 @@ var SeekritClient = class {
|
|
|
2591
2976
|
return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
|
|
2592
2977
|
}
|
|
2593
2978
|
};
|
|
2979
|
+
async function unauthenticatedPost(baseUrl, path, body, fetchImpl, client) {
|
|
2980
|
+
const headers = {
|
|
2981
|
+
accept: "application/json",
|
|
2982
|
+
"content-type": "application/json"
|
|
2983
|
+
};
|
|
2984
|
+
if (client) headers["x-seekrit-client"] = client;
|
|
2985
|
+
const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}${path}`, {
|
|
2986
|
+
method: "POST",
|
|
2987
|
+
headers,
|
|
2988
|
+
body: JSON.stringify(body)
|
|
2989
|
+
});
|
|
2990
|
+
if (!res.ok) {
|
|
2991
|
+
const fallback = { error: {
|
|
2992
|
+
code: "internal",
|
|
2993
|
+
message: `HTTP ${res.status}`
|
|
2994
|
+
} };
|
|
2995
|
+
const payload = await res.json().catch(() => fallback);
|
|
2996
|
+
throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
|
|
2997
|
+
}
|
|
2998
|
+
return await res.json();
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Open a browser-approved login request. `input.tokenHash` is the SHA-256 of a
|
|
3002
|
+
* session token the caller minted locally and keeps — never send the token.
|
|
3003
|
+
*/
|
|
3004
|
+
function startCliLogin(baseUrl, input, options = {}) {
|
|
3005
|
+
return unauthenticatedPost(baseUrl, "/v1/cli-login", input, options.fetch ?? ((...args) => fetch(...args)), options.client);
|
|
3006
|
+
}
|
|
3007
|
+
/** Ask whether a human has authorized the request yet. */
|
|
3008
|
+
function pollCliLogin(baseUrl, code, options = {}) {
|
|
3009
|
+
const fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
3010
|
+
return unauthenticatedPost(baseUrl, "/v1/cli-login/poll", { code }, fetchImpl, options.client);
|
|
3011
|
+
}
|
|
2594
3012
|
const PROJECT_FILE = "seekrit.json";
|
|
2595
3013
|
function globalConfigPath() {
|
|
2596
3014
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -2600,6 +3018,10 @@ function readGlobalConfig() {
|
|
|
2600
3018
|
if (!existsSync(path)) return {};
|
|
2601
3019
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
2602
3020
|
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Merge into the saved config. A key set to `undefined` is *removed* (JSON
|
|
3023
|
+
* drops it), which is how the login paths clear a credential they replace.
|
|
3024
|
+
*/
|
|
2603
3025
|
function writeGlobalConfig(update) {
|
|
2604
3026
|
const path = globalConfigPath();
|
|
2605
3027
|
const merged = {
|
|
@@ -2671,6 +3093,54 @@ function promptHidden(question) {
|
|
|
2671
3093
|
});
|
|
2672
3094
|
});
|
|
2673
3095
|
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Wait for the user to press Enter (or Ctrl-C). Resolves immediately when stdin
|
|
3098
|
+
* isn't a TTY — a piped or CI invocation has nobody to press a key, and blocking
|
|
3099
|
+
* there would hang `seekrit login` forever.
|
|
3100
|
+
*/
|
|
3101
|
+
function promptEnter(question) {
|
|
3102
|
+
if (!process.stdin.isTTY) {
|
|
3103
|
+
process.stderr.write("\n");
|
|
3104
|
+
return Promise.resolve();
|
|
3105
|
+
}
|
|
3106
|
+
process.stderr.write(question);
|
|
3107
|
+
const rl = createInterface({
|
|
3108
|
+
input: process.stdin,
|
|
3109
|
+
output: process.stderr,
|
|
3110
|
+
terminal: true
|
|
3111
|
+
});
|
|
3112
|
+
return new Promise((resolve) => {
|
|
3113
|
+
rl.question("", () => {
|
|
3114
|
+
rl.close();
|
|
3115
|
+
resolve();
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
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
|
+
}
|
|
2674
3144
|
/** Read all of stdin (for `seekrit secrets set NAME -` piping). */
|
|
2675
3145
|
async function readStdin() {
|
|
2676
3146
|
const chunks = [];
|
|
@@ -2696,7 +3166,7 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
2696
3166
|
const config = readGlobalConfig();
|
|
2697
3167
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
2698
3168
|
const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
|
|
2699
|
-
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
|
|
3169
|
+
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
|
|
2700
3170
|
const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
|
|
2701
3171
|
let auth;
|
|
2702
3172
|
if (token) auth = {
|
|
@@ -2719,7 +3189,7 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
2719
3189
|
}
|
|
2720
3190
|
function buildContext() {
|
|
2721
3191
|
const ctx = tryBuildContext();
|
|
2722
|
-
if (!ctx) fail("no credentials found — run `seekrit login --token skt_…`
|
|
3192
|
+
if (!ctx) fail("no credentials found — run `seekrit login` to sign in through your browser (or `seekrit login --token skt_…` / `--client-id … --client-secret …`), or set SEEKRIT_TOKEN / SEEKRIT_CLIENT_ID + SEEKRIT_CLIENT_SECRET / SEEKRIT_DEV_USER");
|
|
2723
3193
|
return ctx;
|
|
2724
3194
|
}
|
|
2725
3195
|
function isTokenAuth(ctx) {
|
|
@@ -2744,6 +3214,69 @@ async function getDek(ctx, orgId, envId) {
|
|
|
2744
3214
|
return unwrapDek(wrappedDek, privateKey);
|
|
2745
3215
|
}
|
|
2746
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
|
|
2747
3280
|
//#region src/target.ts
|
|
2748
3281
|
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
2749
3282
|
async function resolveOrg(ctx, orgSlug) {
|
|
@@ -2859,144 +3392,149 @@ async function resolveGroup(ctx, opts) {
|
|
|
2859
3392
|
};
|
|
2860
3393
|
}
|
|
2861
3394
|
//#endregion
|
|
2862
|
-
//#region src/
|
|
3395
|
+
//#region src/access.ts
|
|
2863
3396
|
/**
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
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.
|
|
2866
3400
|
*
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
2869
|
-
* credential and the broker returns it wrapped to that key, so the control plane
|
|
2870
|
-
* only ever relays ciphertext and only this machine can unwrap it. Registering a
|
|
2871
|
-
* target wraps the base IAM credential to the broker's public key locally, so
|
|
2872
|
-
* the control plane never sees it either — its only needed permission is
|
|
2873
|
-
* `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.
|
|
2874
3403
|
*/
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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>");
|
|
2885
3437
|
}
|
|
2886
3438
|
/**
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2889
|
-
*
|
|
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.
|
|
2890
3444
|
*/
|
|
2891
|
-
function
|
|
2892
|
-
const
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
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)");
|
|
2896
|
-
return JSON.stringify({
|
|
2897
|
-
accessKeyId,
|
|
2898
|
-
secretAccessKey,
|
|
2899
|
-
...sessionToken ? { sessionToken } : {}
|
|
2900
|
-
});
|
|
2901
|
-
}
|
|
2902
|
-
function registerAwsCommands(program) {
|
|
2903
|
-
const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
|
|
2904
|
-
const target = aws.command("target").description("manage AWS role targets");
|
|
2905
|
-
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");
|
|
2906
3449
|
const ctx = buildContext();
|
|
2907
|
-
const
|
|
2908
|
-
const
|
|
2909
|
-
const
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
...options.externalId ? { externalId: options.externalId } : {},
|
|
2915
|
-
...sessionPolicy ? { sessionPolicy } : {},
|
|
2916
|
-
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
|
|
2917
|
-
};
|
|
2918
|
-
const baseCredential = resolveBaseCredential(options);
|
|
2919
|
-
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
2920
|
-
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
|
|
2921
|
-
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
2922
|
-
name: options.name,
|
|
2923
|
-
config,
|
|
2924
|
-
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)
|
|
2925
3457
|
});
|
|
2926
|
-
console.error(`
|
|
2927
|
-
console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
|
|
2928
|
-
console.log(awsTrustPolicyInstructions(config));
|
|
3458
|
+
console.error(`granted ${label} access to ${principal.id}`);
|
|
2929
3459
|
});
|
|
2930
|
-
|
|
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) => {
|
|
2931
3461
|
const ctx = buildContext();
|
|
2932
|
-
const
|
|
2933
|
-
const {
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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"));
|
|
2939
3470
|
});
|
|
2940
|
-
|
|
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");
|
|
2941
3473
|
const ctx = buildContext();
|
|
2942
|
-
const
|
|
2943
|
-
const {
|
|
2944
|
-
|
|
2945
|
-
if (
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
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}`);
|
|
2949
3487
|
});
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
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)"));
|
|
2955
3516
|
});
|
|
2956
|
-
|
|
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) => {
|
|
2957
3518
|
const ctx = buildContext();
|
|
2958
|
-
const
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
const cfg = t.config;
|
|
2963
|
-
if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
|
|
2964
|
-
const ttlSeconds = parseTtlSeconds$6(options.ttl);
|
|
2965
|
-
if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
|
|
2966
|
-
const recipient = await generateAwsRecipientKeyPair();
|
|
2967
|
-
const { aws: leased } = await ctx.client.mintLease(org.id, {
|
|
2968
|
-
provider: "aws",
|
|
2969
|
-
targetId: t.id,
|
|
2970
|
-
recipientPublicKey: recipient.publicKeyJwk,
|
|
2971
|
-
ttlSeconds
|
|
2972
|
-
});
|
|
2973
|
-
const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
|
|
2974
|
-
console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
|
|
2975
|
-
if (options.json) console.log(JSON.stringify({
|
|
2976
|
-
...cred,
|
|
2977
|
-
roleArn: cfg.roleArn
|
|
2978
|
-
}, null, 2));
|
|
2979
|
-
else {
|
|
2980
|
-
console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
|
|
2981
|
-
console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
|
|
2982
|
-
console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
|
|
2983
|
-
console.log(`export AWS_REGION=${cred.region}`);
|
|
2984
|
-
}
|
|
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" : ""}`);
|
|
2985
3523
|
});
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
const
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
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
|
+
]));
|
|
2994
3532
|
});
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
await
|
|
2999
|
-
console.error(
|
|
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}`);
|
|
3000
3538
|
});
|
|
3001
3539
|
}
|
|
3002
3540
|
//#endregion
|
|
@@ -3485,6 +4023,477 @@ function registerRecoveryCommands(program) {
|
|
|
3485
4023
|
});
|
|
3486
4024
|
}
|
|
3487
4025
|
//#endregion
|
|
4026
|
+
//#region src/apps.ts
|
|
4027
|
+
/**
|
|
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
|
|
3488
4497
|
//#region src/branches.ts
|
|
3489
4498
|
/**
|
|
3490
4499
|
* Resolve `--from` to the environment being branched.
|
|
@@ -3557,17 +4566,21 @@ function registerBranchCommands(program) {
|
|
|
3557
4566
|
console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
|
|
3558
4567
|
if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
|
|
3559
4568
|
});
|
|
3560
|
-
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) => {
|
|
3561
4570
|
const ctx = buildContext();
|
|
3562
|
-
|
|
4571
|
+
const branches = options.env ? await (async () => {
|
|
3563
4572
|
const parent = await resolveAppEnv(ctx, options);
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
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`"));
|
|
3571
4584
|
});
|
|
3572
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) => {
|
|
3573
4586
|
const ctx = buildContext();
|
|
@@ -3770,6 +4783,181 @@ function registerGcpCommands(program) {
|
|
|
3770
4783
|
});
|
|
3771
4784
|
}
|
|
3772
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
|
|
3773
4961
|
//#region src/m2m.ts
|
|
3774
4962
|
/**
|
|
3775
4963
|
* Resolve M2M client credentials from (in order) the process environment, a
|
|
@@ -3787,12 +4975,16 @@ function readM2mCreds(dotenvVars = {}) {
|
|
|
3787
4975
|
clientSecret
|
|
3788
4976
|
};
|
|
3789
4977
|
}
|
|
3790
|
-
/**
|
|
4978
|
+
/**
|
|
4979
|
+
* True when a service/session/dev credential is already configured explicitly.
|
|
4980
|
+
* A browser-authorized session counts: a human who ran `seekrit login` must not
|
|
4981
|
+
* be silently swapped onto a machine identity.
|
|
4982
|
+
*/
|
|
3791
4983
|
function hasExplicitCredential(dotenvVars) {
|
|
3792
4984
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
3793
4985
|
if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
|
|
3794
4986
|
const config = readGlobalConfig();
|
|
3795
|
-
return Boolean(config.token || config.devUser);
|
|
4987
|
+
return Boolean(config.token || config.sessionToken || config.devUser);
|
|
3796
4988
|
}
|
|
3797
4989
|
/** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
|
|
3798
4990
|
async function mintAdminToken(apiUrl, creds) {
|
|
@@ -4157,6 +5349,164 @@ function collect$3(value, acc) {
|
|
|
4157
5349
|
return acc;
|
|
4158
5350
|
}
|
|
4159
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
|
|
4160
5510
|
//#region src/pg.ts
|
|
4161
5511
|
/**
|
|
4162
5512
|
* `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
|
|
@@ -4735,6 +6085,383 @@ function collect(value, acc) {
|
|
|
4735
6085
|
return acc;
|
|
4736
6086
|
}
|
|
4737
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
|
|
6290
|
+
//#region src/web-login.ts
|
|
6291
|
+
/**
|
|
6292
|
+
* `seekrit login` — sign in through the browser.
|
|
6293
|
+
*
|
|
6294
|
+
* The credential is born here and never leaves: we mint a CLI session token
|
|
6295
|
+
* locally, register only its SHA-256 hash, and wait for a human to authorize
|
|
6296
|
+
* that hash in the dashboard. When they do, we already hold the token — the
|
|
6297
|
+
* approval round-trip carries nothing secret, so there is no window in which the
|
|
6298
|
+
* API (or anything watching it) could learn our credential.
|
|
6299
|
+
*
|
|
6300
|
+
* The saved session authenticates as *you*, which is why it needs no org, app,
|
|
6301
|
+
* or environment selection: commands see every org you're a member of, and
|
|
6302
|
+
* decryption still runs through your own passphrase-unlocked key. Machines want
|
|
6303
|
+
* the opposite trade — a scoped, key-carrying credential — and keep using
|
|
6304
|
+
* `seekrit login --token skt_…`.
|
|
6305
|
+
*/
|
|
6306
|
+
/** Open a URL in the platform's default browser. Best-effort and silent. */
|
|
6307
|
+
function openBrowser(url) {
|
|
6308
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
|
|
6309
|
+
"/c",
|
|
6310
|
+
"start",
|
|
6311
|
+
"",
|
|
6312
|
+
url
|
|
6313
|
+
]] : ["xdg-open", [url]];
|
|
6314
|
+
try {
|
|
6315
|
+
const child = spawn(command, args, {
|
|
6316
|
+
stdio: "ignore",
|
|
6317
|
+
detached: true
|
|
6318
|
+
});
|
|
6319
|
+
child.on("error", () => void 0);
|
|
6320
|
+
child.unref();
|
|
6321
|
+
} catch {}
|
|
6322
|
+
}
|
|
6323
|
+
/** A frame of the waiting spinner, or a static line on a non-TTY. */
|
|
6324
|
+
const SPINNER = [
|
|
6325
|
+
"⠋",
|
|
6326
|
+
"⠙",
|
|
6327
|
+
"⠹",
|
|
6328
|
+
"⠸",
|
|
6329
|
+
"⠼",
|
|
6330
|
+
"⠴",
|
|
6331
|
+
"⠦",
|
|
6332
|
+
"⠧",
|
|
6333
|
+
"⠇",
|
|
6334
|
+
"⠏"
|
|
6335
|
+
];
|
|
6336
|
+
function startWaitingIndicator(message) {
|
|
6337
|
+
if (!process.stderr.isTTY) {
|
|
6338
|
+
process.stderr.write(`${message}\n`);
|
|
6339
|
+
return () => void 0;
|
|
6340
|
+
}
|
|
6341
|
+
let frame = 0;
|
|
6342
|
+
const timer = setInterval(() => {
|
|
6343
|
+
process.stderr.write(`\r${SPINNER[frame % SPINNER.length]} ${message}`);
|
|
6344
|
+
frame++;
|
|
6345
|
+
}, 80);
|
|
6346
|
+
return () => {
|
|
6347
|
+
clearInterval(timer);
|
|
6348
|
+
process.stderr.write(`\r${" ".repeat(message.length + 2)}\r`);
|
|
6349
|
+
};
|
|
6350
|
+
}
|
|
6351
|
+
function sleep(ms) {
|
|
6352
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6353
|
+
}
|
|
6354
|
+
async function runWebLogin(options) {
|
|
6355
|
+
const config = readGlobalConfig();
|
|
6356
|
+
const apiUrl = options.apiUrl ?? process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev";
|
|
6357
|
+
const client = `cli/${version}`;
|
|
6358
|
+
const session = await createCliSessionToken();
|
|
6359
|
+
const deviceLabel = `${userInfo().username}@${hostname()}`;
|
|
6360
|
+
const started = await startCliLogin(apiUrl, {
|
|
6361
|
+
sessionId: session.sessionId,
|
|
6362
|
+
tokenHash: session.tokenHash,
|
|
6363
|
+
deviceLabel,
|
|
6364
|
+
client
|
|
6365
|
+
}, { client }).catch((err) => {
|
|
6366
|
+
fail(`couldn't start sign-in: ${err instanceof Error ? err.message : String(err)}`);
|
|
6367
|
+
});
|
|
6368
|
+
console.error(`Sign in to seekrit to authorize this device (${deviceLabel}).\n`);
|
|
6369
|
+
console.error(` ${started.verifyUrl}\n`);
|
|
6370
|
+
console.error(` code: ${started.code} — check it matches the one in your browser\n`);
|
|
6371
|
+
if (options.browser === false) console.error("Open that URL to continue.\n");
|
|
6372
|
+
else {
|
|
6373
|
+
await promptEnter("Press [Enter] to open it in your browser (Ctrl-C to cancel)… ");
|
|
6374
|
+
openBrowser(started.verifyUrl);
|
|
6375
|
+
}
|
|
6376
|
+
const stopWaiting = startWaitingIndicator("Waiting for you to authorize…");
|
|
6377
|
+
try {
|
|
6378
|
+
const deadline = Date.parse(started.requestExpiresAt);
|
|
6379
|
+
while (true) {
|
|
6380
|
+
const result = await pollCliLogin(apiUrl, started.code, { client }).catch(() => null);
|
|
6381
|
+
if (result?.status === "approved") {
|
|
6382
|
+
writeGlobalConfig({
|
|
6383
|
+
sessionToken: session.token,
|
|
6384
|
+
token: void 0,
|
|
6385
|
+
...options.apiUrl ? { apiUrl: options.apiUrl } : {}
|
|
6386
|
+
});
|
|
6387
|
+
stopWaiting();
|
|
6388
|
+
const who = result.email ?? "your account";
|
|
6389
|
+
console.error(`Signed in as ${who} — this device is authorized for 90 days.`);
|
|
6390
|
+
if (config.token) console.error("(the service token saved here was replaced; SEEKRIT_TOKEN still wins)");
|
|
6391
|
+
await reportOrgs(apiUrl, session.token, client);
|
|
6392
|
+
return;
|
|
6393
|
+
}
|
|
6394
|
+
if (result?.status === "denied") {
|
|
6395
|
+
stopWaiting();
|
|
6396
|
+
fail("sign-in was declined in the browser");
|
|
6397
|
+
}
|
|
6398
|
+
if (result?.status === "expired" || Date.now() > deadline) {
|
|
6399
|
+
stopWaiting();
|
|
6400
|
+
fail("sign-in request expired — run `seekrit login` again");
|
|
6401
|
+
}
|
|
6402
|
+
await sleep(started.pollIntervalSeconds * 1e3);
|
|
6403
|
+
}
|
|
6404
|
+
} finally {
|
|
6405
|
+
stopWaiting();
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
/**
|
|
6409
|
+
* Print what the new session can reach, so a successful login ends with proof it
|
|
6410
|
+
* works rather than a bare "ok". Best-effort: a hiccup here doesn't undo a
|
|
6411
|
+
* login that already succeeded.
|
|
6412
|
+
*/
|
|
6413
|
+
async function reportOrgs(apiUrl, token, client) {
|
|
6414
|
+
try {
|
|
6415
|
+
const { user, orgs } = await new SeekritClient({
|
|
6416
|
+
baseUrl: apiUrl,
|
|
6417
|
+
auth: {
|
|
6418
|
+
type: "bearer",
|
|
6419
|
+
token
|
|
6420
|
+
},
|
|
6421
|
+
client
|
|
6422
|
+
}).me();
|
|
6423
|
+
for (const org of orgs) console.error(` ${org.slug} (${org.role})`);
|
|
6424
|
+
if (!user.hasKeys) console.error("\nNext: run `seekrit keys setup` to create your encryption keys.");
|
|
6425
|
+
} catch {}
|
|
6426
|
+
}
|
|
6427
|
+
/**
|
|
6428
|
+
* `seekrit logout` — drop the saved credential, and revoke it server-side when
|
|
6429
|
+
* it's a CLI session (the one credential this machine owns outright). A service
|
|
6430
|
+
* token is shared infrastructure that other machines may hold, so it is only
|
|
6431
|
+
* removed locally, never revoked out from under them.
|
|
6432
|
+
*/
|
|
6433
|
+
async function runLogout() {
|
|
6434
|
+
const config = readGlobalConfig();
|
|
6435
|
+
const sessionToken = config.sessionToken;
|
|
6436
|
+
if (!sessionToken && !config.token && !config.devUser && !config.clientId) {
|
|
6437
|
+
console.error("not signed in");
|
|
6438
|
+
return;
|
|
6439
|
+
}
|
|
6440
|
+
if (sessionToken) {
|
|
6441
|
+
const api = new SeekritClient({
|
|
6442
|
+
baseUrl: process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev",
|
|
6443
|
+
auth: {
|
|
6444
|
+
type: "bearer",
|
|
6445
|
+
token: sessionToken
|
|
6446
|
+
},
|
|
6447
|
+
client: `cli/${version}`
|
|
6448
|
+
});
|
|
6449
|
+
try {
|
|
6450
|
+
const { currentSessionId } = await api.listCliSessions();
|
|
6451
|
+
if (currentSessionId) await api.revokeCliSession(currentSessionId);
|
|
6452
|
+
console.error("signed out — this device is no longer authorized");
|
|
6453
|
+
} catch (err) {
|
|
6454
|
+
console.error(`signed out locally, but couldn't revoke the session: ${err instanceof Error ? err.message : String(err)}`);
|
|
6455
|
+
}
|
|
6456
|
+
}
|
|
6457
|
+
writeGlobalConfig({
|
|
6458
|
+
sessionToken: void 0,
|
|
6459
|
+
token: void 0,
|
|
6460
|
+
devUser: void 0
|
|
6461
|
+
});
|
|
6462
|
+
if (config.clientId) console.error("(machine client credentials are kept — remove them with `seekrit login`)");
|
|
6463
|
+
}
|
|
6464
|
+
//#endregion
|
|
4738
6465
|
//#region src/index.ts
|
|
4739
6466
|
/** Collect repeated `--with group=env` flags into a map. */
|
|
4740
6467
|
function collectKv(value, acc = {}) {
|
|
@@ -4752,11 +6479,21 @@ const program = new Command("seekrit").description("End-to-end encrypted secrets
|
|
|
4752
6479
|
program.hook("preAction", async () => {
|
|
4753
6480
|
await ensureM2mAdminToken();
|
|
4754
6481
|
});
|
|
4755
|
-
program.command("login").description("
|
|
6482
|
+
program.command("login").description("sign in through the browser (or pass a credential to store one directly)").option("--token <token>", "service token (skt_…) — skips the browser").option("--client-id <id>", "machine (M2M) client id — auto-mints an admin token").option("--client-secret <secret>", "machine (M2M) client secret").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").option("--no-browser", "print the sign-in URL instead of opening it").action(async (options) => {
|
|
4756
6483
|
if (options.token && !isServiceToken(options.token)) fail("token must start with skt_");
|
|
4757
6484
|
if (Boolean(options.clientId) !== Boolean(options.clientSecret)) fail("--client-id and --client-secret must be given together");
|
|
6485
|
+
if (!(options.token || options.clientId || options.devUser)) {
|
|
6486
|
+
await runWebLogin({
|
|
6487
|
+
apiUrl: options.apiUrl,
|
|
6488
|
+
browser: options.browser
|
|
6489
|
+
});
|
|
6490
|
+
return;
|
|
6491
|
+
}
|
|
4758
6492
|
writeGlobalConfig({
|
|
4759
|
-
...options.token ? {
|
|
6493
|
+
...options.token ? {
|
|
6494
|
+
token: options.token,
|
|
6495
|
+
sessionToken: void 0
|
|
6496
|
+
} : {},
|
|
4760
6497
|
...options.clientId ? { clientId: options.clientId } : {},
|
|
4761
6498
|
...options.clientSecret ? { clientSecret: options.clientSecret } : {},
|
|
4762
6499
|
...options.devUser ? { devUser: options.devUser } : {},
|
|
@@ -4764,6 +6501,9 @@ program.command("login").description("store credentials for the API").option("--
|
|
|
4764
6501
|
});
|
|
4765
6502
|
console.error("credentials saved");
|
|
4766
6503
|
});
|
|
6504
|
+
program.command("logout").description("forget the saved credentials (revokes a browser-authorized session)").action(async () => {
|
|
6505
|
+
await runLogout();
|
|
6506
|
+
});
|
|
4767
6507
|
program.command("whoami").description("show the authenticated identity").action(async () => {
|
|
4768
6508
|
const ctx = buildContext();
|
|
4769
6509
|
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
@@ -4780,6 +6520,10 @@ program.command("whoami").description("show the authenticated identity").action(
|
|
|
4780
6520
|
const { user, orgs } = await ctx.client.me();
|
|
4781
6521
|
console.log(`${user.email}${user.hasKeys ? "" : " (key setup pending — run `seekrit keys setup`)"}`);
|
|
4782
6522
|
for (const org of orgs) console.log(` ${org.slug} (${org.role})`);
|
|
6523
|
+
if (ctx.auth.type === "bearer" && isCliSessionToken(ctx.auth.token)) {
|
|
6524
|
+
const { sessionId } = parseCliSessionToken(ctx.auth.token);
|
|
6525
|
+
console.log(` via CLI session ${sessionId} (revoke it with \`seekrit logout\`)`);
|
|
6526
|
+
}
|
|
4783
6527
|
});
|
|
4784
6528
|
program.command("keys").description("manage your encryption keys").command("setup").description("generate your keypair and protect it with a passphrase").action(async () => {
|
|
4785
6529
|
const ctx = buildContext();
|
|
@@ -4808,101 +6552,9 @@ program.command("init").description("link this directory to an org/app (environm
|
|
|
4808
6552
|
});
|
|
4809
6553
|
console.error(`linked ${org.slug}/${app.slug} → ${path} (environment is selected by the service token at runtime)`);
|
|
4810
6554
|
});
|
|
4811
|
-
program
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
slug: options.slug
|
|
4815
|
-
});
|
|
4816
|
-
console.error(`created org ${created.org.slug} (${created.org.id})`);
|
|
4817
|
-
});
|
|
4818
|
-
program.command("app").description("manage applications").command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
4819
|
-
const ctx = buildContext();
|
|
4820
|
-
const orgRef = await resolveOrg(ctx, options.org);
|
|
4821
|
-
const created = await ctx.client.createApp(orgRef.id, {
|
|
4822
|
-
name: options.name,
|
|
4823
|
-
slug: options.slug
|
|
4824
|
-
});
|
|
4825
|
-
console.error(`created app ${created.app.slug} (${created.app.id})`);
|
|
4826
|
-
});
|
|
4827
|
-
const env = program.command("env").description("manage environments");
|
|
4828
|
-
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) => {
|
|
4829
|
-
const ctx = buildContext();
|
|
4830
|
-
const orgRef = await resolveOrg(ctx, options.org);
|
|
4831
|
-
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
4832
|
-
const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
|
|
4833
|
-
if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
|
|
4834
|
-
const { user } = await ctx.client.me();
|
|
4835
|
-
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
4836
|
-
const dek = generateDek();
|
|
4837
|
-
const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
|
|
4838
|
-
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
|
|
4839
|
-
const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
4840
|
-
name: options.name,
|
|
4841
|
-
slug: options.slug,
|
|
4842
|
-
wrappedDek,
|
|
4843
|
-
recoveryWrappedDek
|
|
4844
|
-
});
|
|
4845
|
-
console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
|
|
4846
|
-
});
|
|
4847
|
-
const envGroups = env.command("groups").description("compose shared groups into an application environment");
|
|
4848
|
-
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) => {
|
|
4849
|
-
const ctx = buildContext();
|
|
4850
|
-
const target = await resolveAppEnv(ctx, options);
|
|
4851
|
-
const group = await resolveGroup(ctx, {
|
|
4852
|
-
org: options.org,
|
|
4853
|
-
group: options.group
|
|
4854
|
-
});
|
|
4855
|
-
await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
4856
|
-
groupId: group.id,
|
|
4857
|
-
position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
|
|
4858
|
-
});
|
|
4859
|
-
console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
|
|
4860
|
-
});
|
|
4861
|
-
envGroups.command("list").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").action(async (options) => {
|
|
4862
|
-
const ctx = buildContext();
|
|
4863
|
-
const target = await resolveAppEnv(ctx, options);
|
|
4864
|
-
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
4865
|
-
for (const g of groups) console.log(`${g.position}\t${g.slug}\t${g.name}`);
|
|
4866
|
-
});
|
|
4867
|
-
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) => {
|
|
4868
|
-
const ctx = buildContext();
|
|
4869
|
-
const target = await resolveAppEnv(ctx, options);
|
|
4870
|
-
const group = await resolveGroup(ctx, {
|
|
4871
|
-
org: options.org,
|
|
4872
|
-
group: options.group
|
|
4873
|
-
});
|
|
4874
|
-
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
|
|
4875
|
-
console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
|
|
4876
|
-
});
|
|
4877
|
-
const group = program.command("group").description("manage shared groups (reusable secret bags)");
|
|
4878
|
-
group.command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
|
|
4879
|
-
const ctx = buildContext();
|
|
4880
|
-
const orgRef = await resolveOrg(ctx, options.org);
|
|
4881
|
-
const created = await ctx.client.createGroup(orgRef.id, {
|
|
4882
|
-
name: options.name,
|
|
4883
|
-
slug: options.slug
|
|
4884
|
-
});
|
|
4885
|
-
console.error(`created group ${created.group.slug} (${created.group.id})`);
|
|
4886
|
-
});
|
|
4887
|
-
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) => {
|
|
4888
|
-
const ctx = buildContext();
|
|
4889
|
-
const groupRef = await resolveGroup(ctx, {
|
|
4890
|
-
org: options.org,
|
|
4891
|
-
group: options.group
|
|
4892
|
-
});
|
|
4893
|
-
const { user } = await ctx.client.me();
|
|
4894
|
-
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
4895
|
-
const dek = generateDek();
|
|
4896
|
-
const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
|
|
4897
|
-
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
|
|
4898
|
-
const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
|
|
4899
|
-
name: options.name,
|
|
4900
|
-
slug: options.slug,
|
|
4901
|
-
wrappedDek,
|
|
4902
|
-
recoveryWrappedDek
|
|
4903
|
-
});
|
|
4904
|
-
console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
|
|
4905
|
-
});
|
|
6555
|
+
registerOrgCommands(program);
|
|
6556
|
+
registerAppCommands(program);
|
|
6557
|
+
registerGroupCommands(program);
|
|
4906
6558
|
/** Parse a positive integer flag/argument (version numbers, page sizes). */
|
|
4907
6559
|
function parseVersion(raw) {
|
|
4908
6560
|
const n = Number(raw);
|
|
@@ -4914,11 +6566,21 @@ function withTarget(cmd) {
|
|
|
4914
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");
|
|
4915
6567
|
}
|
|
4916
6568
|
const secrets = program.command("secrets").description("manage secrets in an application or group environment");
|
|
4917
|
-
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) => {
|
|
4918
6570
|
const ctx = buildContext();
|
|
4919
|
-
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
6571
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
4920
6572
|
const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
|
|
4921
|
-
|
|
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}`));
|
|
4922
6584
|
});
|
|
4923
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) => {
|
|
4924
6586
|
const ctx = buildContext();
|
|
@@ -4961,15 +6623,19 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
|
|
|
4961
6623
|
const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
|
|
4962
6624
|
console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
|
|
4963
6625
|
});
|
|
4964
|
-
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) => {
|
|
4965
6627
|
const ctx = buildContext();
|
|
4966
6628
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
4967
6629
|
const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, name, { limit: parseVersion(options.limit) });
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
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}`));
|
|
4973
6639
|
});
|
|
4974
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) => {
|
|
4975
6641
|
const ctx = buildContext();
|
|
@@ -5202,42 +6868,7 @@ program.command("export").description("print decrypted secrets (dotenv, json, or
|
|
|
5202
6868
|
});
|
|
5203
6869
|
console.log(formatSecrets(values, options.format));
|
|
5204
6870
|
});
|
|
5205
|
-
program
|
|
5206
|
-
if (!options.user === !options.token) fail("pass exactly one of --user or --token");
|
|
5207
|
-
const ctx = buildContext();
|
|
5208
|
-
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
5209
|
-
const dek = await getDek(ctx, orgId, envId);
|
|
5210
|
-
let principalType;
|
|
5211
|
-
let principalId;
|
|
5212
|
-
let publicKeyJwk;
|
|
5213
|
-
if (options.user) {
|
|
5214
|
-
const { members } = await ctx.client.listMembers(orgId);
|
|
5215
|
-
const member = members.find((m) => m.email === options.user);
|
|
5216
|
-
if (!member) fail(`no member ${options.user}`);
|
|
5217
|
-
if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
|
|
5218
|
-
[principalType, principalId, publicKeyJwk] = [
|
|
5219
|
-
"user",
|
|
5220
|
-
member.userId,
|
|
5221
|
-
member.publicKeyJwk
|
|
5222
|
-
];
|
|
5223
|
-
} else {
|
|
5224
|
-
const { tokens } = await ctx.client.listTokens(orgId);
|
|
5225
|
-
const token = tokens.find((t) => t.id === options.token);
|
|
5226
|
-
if (!token) fail(`no service token ${options.token}`);
|
|
5227
|
-
[principalType, principalId, publicKeyJwk] = [
|
|
5228
|
-
"service_token",
|
|
5229
|
-
token.id,
|
|
5230
|
-
token.publicKeyJwk
|
|
5231
|
-
];
|
|
5232
|
-
}
|
|
5233
|
-
const wrappedDek = await wrapDek(dek, publicKeyJwk);
|
|
5234
|
-
await ctx.client.grantEnvKey(orgId, envId, {
|
|
5235
|
-
principalType,
|
|
5236
|
-
principalId,
|
|
5237
|
-
wrappedDek
|
|
5238
|
-
});
|
|
5239
|
-
console.error(`granted ${label} access to ${principalId}`);
|
|
5240
|
-
});
|
|
6871
|
+
registerAccessCommands(program);
|
|
5241
6872
|
const token = program.command("token").description("manage service tokens (CI, docker, agents)");
|
|
5242
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) => {
|
|
5243
6874
|
const ctx = buildContext();
|
|
@@ -5288,14 +6919,19 @@ token.command("create").description("create a service token (runtime, or --admin
|
|
|
5288
6919
|
console.error(`${role} token created${granted ? " and granted" : ""} for ${scope} — save it now, it is not stored:`);
|
|
5289
6920
|
console.log(created.token);
|
|
5290
6921
|
});
|
|
5291
|
-
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) => {
|
|
5292
6923
|
const ctx = buildContext();
|
|
5293
6924
|
const orgRef = await resolveOrg(ctx, options.org);
|
|
5294
6925
|
const { tokens } = await ctx.client.listTokens(orgRef.id);
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
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`"));
|
|
5299
6935
|
});
|
|
5300
6936
|
token.command("revoke <tokenId>").description("revoke a service token").option("--org <slug>").action(async (tokenId, options) => {
|
|
5301
6937
|
const ctx = buildContext();
|
|
@@ -5324,12 +6960,11 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
|
|
|
5324
6960
|
const { runMcpServer } = await import("./mcp-DLplPOvz.js");
|
|
5325
6961
|
await runMcpServer();
|
|
5326
6962
|
});
|
|
5327
|
-
program
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
});
|
|
6963
|
+
registerAuditCommands(program);
|
|
6964
|
+
registerAccountCommands(program);
|
|
6965
|
+
registerLogSinkCommands(program);
|
|
6966
|
+
registerSyncCommands(program);
|
|
6967
|
+
registerBillingCommands(program);
|
|
5333
6968
|
const argv = process.argv.map((arg) => arg === "-v" ? "--version" : arg);
|
|
5334
6969
|
program.parseAsync(argv).catch((err) => {
|
|
5335
6970
|
fail(err instanceof Error ? err.message : String(err));
|