@seekrit/cli 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +243 -8
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1513,7 +1513,13 @@ z.object({
1513
1513
  * See `docs/third-party-sync.md`.
1514
1514
  */
1515
1515
  /** Platforms seekrit can push to. Append-only — persisted in `sync_connections.provider`. */
1516
- const SYNC_PROVIDER_KINDS = ["vercel"];
1516
+ const SYNC_PROVIDER_KINDS = [
1517
+ "vercel",
1518
+ "cloudflare-workers",
1519
+ "cloudflare-pages",
1520
+ "cloudflare-secrets-store",
1521
+ "railway"
1522
+ ];
1517
1523
  z.enum(SYNC_PROVIDER_KINDS);
1518
1524
  /**
1519
1525
  * Vercel account scope. The API token itself is never here — it is wrapped to
@@ -1528,7 +1534,79 @@ const vercelConnectionConfigSchema = z.object({
1528
1534
  /** Vercel Team id (`team_…`). Omit for a personal account. */
1529
1535
  teamId: z.string().trim().min(1).max(128).optional()
1530
1536
  });
1531
- const syncConnectionConfigSchema = z.discriminatedUnion("provider", [vercelConnectionConfigSchema]);
1537
+ /**
1538
+ * A Cloudflare account id — 32 lowercase hex characters, found in the sidebar
1539
+ * of any account's dashboard. Every Cloudflare endpoint seekrit calls is
1540
+ * account-scoped, so this is the account half of "which account, which thing".
1541
+ *
1542
+ * Validated by shape because the alternative is a bare 400 from Cloudflare
1543
+ * hours later inside an alarm, with nobody watching. It does not catch pasting
1544
+ * a *zone* id, which has the same shape — only the API can tell those apart.
1545
+ */
1546
+ const cloudflareAccountIdSchema = z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Cloudflare account ID (lowercase hex)");
1547
+ /**
1548
+ * Cloudflare account scope, shared by all three Cloudflare providers. The API
1549
+ * token is never here — it is wrapped to the connection's public key and
1550
+ * stored as ciphertext, exactly as Vercel's is.
1551
+ *
1552
+ * The three providers are deliberately separate kinds rather than one
1553
+ * `cloudflare` with a mode field: they target different APIs, take different
1554
+ * destinations, and fail in different ways. Splitting them keeps the
1555
+ * exhaustiveness guard in `connectorFor` meaningful.
1556
+ */
1557
+ const cloudflareWorkersConnectionConfigSchema = z.object({
1558
+ provider: z.literal("cloudflare-workers"),
1559
+ accountId: cloudflareAccountIdSchema
1560
+ });
1561
+ const cloudflarePagesConnectionConfigSchema = z.object({
1562
+ provider: z.literal("cloudflare-pages"),
1563
+ accountId: cloudflareAccountIdSchema
1564
+ });
1565
+ const cloudflareSecretsStoreConnectionConfigSchema = z.object({
1566
+ provider: z.literal("cloudflare-secrets-store"),
1567
+ accountId: cloudflareAccountIdSchema
1568
+ });
1569
+ /**
1570
+ * A Railway id — every project, environment, and service is a UUID. Validated
1571
+ * by shape for the same reason Cloudflare's account id is: the alternative is a
1572
+ * bare GraphQL "Problem processing request" hours later inside an alarm, with
1573
+ * nobody watching.
1574
+ */
1575
+ const railwayIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a Railway UUID");
1576
+ /**
1577
+ * Which kind of Railway API token the connection holds. This is connection
1578
+ * scope rather than a destination detail because it decides the *header* the
1579
+ * request carries, and getting it wrong fails every call identically:
1580
+ *
1581
+ * - `account` — a personal or workspace token, sent as `Authorization: Bearer`.
1582
+ * Reaches every project the token's owner can see.
1583
+ * - `project` — a project token, sent as `Project-Access-Token`. Scoped to one
1584
+ * project and environment by Railway itself, which makes it the
1585
+ * least-privilege choice when a connection serves a single destination.
1586
+ *
1587
+ * Railway rejects a project token sent as a bearer, so this cannot be sniffed
1588
+ * at request time — the operator states it once, when they paste the token.
1589
+ */
1590
+ const RAILWAY_TOKEN_KINDS = ["account", "project"];
1591
+ /**
1592
+ * Railway account scope. The token is never here — it is wrapped to the
1593
+ * connection's public key and stored as ciphertext, exactly as Vercel's is.
1594
+ *
1595
+ * There is no workspace/team id to carry: Railway ids are globally unique and
1596
+ * a destination names its project outright, so the token plus the destination
1597
+ * is the whole address.
1598
+ */
1599
+ const railwayConnectionConfigSchema = z.object({
1600
+ provider: z.literal("railway"),
1601
+ tokenKind: z.enum(RAILWAY_TOKEN_KINDS).default("account")
1602
+ });
1603
+ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
1604
+ vercelConnectionConfigSchema,
1605
+ cloudflareWorkersConnectionConfigSchema,
1606
+ cloudflarePagesConnectionConfigSchema,
1607
+ cloudflareSecretsStoreConnectionConfigSchema,
1608
+ railwayConnectionConfigSchema
1609
+ ]);
1532
1610
  /** Vercel's three deployment targets. A binding writes to one or more. */
1533
1611
  const VERCEL_TARGETS = [
1534
1612
  "production",
@@ -1547,7 +1625,84 @@ const vercelDestinationSchema = z.object({
1547
1625
  */
1548
1626
  gitBranch: z.string().trim().min(1).max(255).optional()
1549
1627
  });
1550
- const syncDestinationSchema = z.discriminatedUnion("provider", [vercelDestinationSchema]);
1628
+ /**
1629
+ * The Worker whose secrets a binding owns. Wrangler *environments* are not a
1630
+ * separate field because they are not a separate concept at the API: deploying
1631
+ * `my-api` with `--env staging` creates a Worker literally named
1632
+ * `my-api-staging`, so pointing at an environment means naming that script.
1633
+ */
1634
+ const cloudflareWorkersDestinationSchema = z.object({
1635
+ provider: z.literal("cloudflare-workers"),
1636
+ /** Worker script name, as shown in the dashboard (`my-api`). */
1637
+ scriptName: z.string().trim().min(1).max(63).regex(/^[A-Za-z0-9_][A-Za-z0-9_-]*$/, "must be a Worker script name")
1638
+ });
1639
+ /** The two deployment configs a Pages project keeps env vars under. */
1640
+ const CLOUDFLARE_PAGES_ENVIRONMENTS = ["production", "preview"];
1641
+ const cloudflarePagesDestinationSchema = z.object({
1642
+ provider: z.literal("cloudflare-pages"),
1643
+ /** Pages project name (`my-site`) — Pages has no separate project id. */
1644
+ projectName: z.string().trim().min(1).max(58).regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/, "must be a Pages project name"),
1645
+ /** Which deployment configs receive these values. At least one. */
1646
+ environments: z.array(z.enum(CLOUDFLARE_PAGES_ENVIRONMENTS)).min(1)
1647
+ });
1648
+ /**
1649
+ * Scopes a Secrets Store secret may be used by. Cloudflare requires at least
1650
+ * one at creation and they cannot be inferred, so a binding states them.
1651
+ */
1652
+ const CLOUDFLARE_SECRETS_STORE_SCOPES = [
1653
+ "workers",
1654
+ "ai_gateway",
1655
+ "dex",
1656
+ "access",
1657
+ "containers",
1658
+ "websearch"
1659
+ ];
1660
+ const cloudflareSecretsStoreDestinationSchema = z.object({
1661
+ provider: z.literal("cloudflare-secrets-store"),
1662
+ /** Store id (32 hex). An account has exactly one store today. */
1663
+ storeId: z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Secrets Store ID (lowercase hex)"),
1664
+ /** Scopes applied to secrets this binding creates. At least one. */
1665
+ scopes: z.array(z.enum(CLOUDFLARE_SECRETS_STORE_SCOPES)).min(1)
1666
+ });
1667
+ /**
1668
+ * Where inside Railway a binding writes.
1669
+ *
1670
+ * Railway variables are addressed by (project, environment, service) — the
1671
+ * environment here is *Railway's* (`production`, `pr-42`), not the seekrit
1672
+ * environment the binding reads from; a binding is precisely the mapping
1673
+ * between the two.
1674
+ *
1675
+ * Omitting `serviceId` targets the project's **shared** variables for that
1676
+ * environment, which services opt into with `${{shared.NAME}}`. That is a
1677
+ * genuinely different destination from any one service's variables, so it is an
1678
+ * absent field rather than a sentinel.
1679
+ */
1680
+ const railwayDestinationSchema = z.object({
1681
+ provider: z.literal("railway"),
1682
+ /** Railway project id (a UUID, from the project's Settings page or URL). */
1683
+ projectId: railwayIdSchema,
1684
+ /** Railway environment id (a UUID) — the deployment environment to write. */
1685
+ environmentId: railwayIdSchema,
1686
+ /** Service to write. Omit to write the environment's shared variables. */
1687
+ serviceId: railwayIdSchema.optional(),
1688
+ /**
1689
+ * Suppress the redeploy Railway triggers when a variable changes.
1690
+ *
1691
+ * Left off (the default), a sync that changes a value redeploys the service,
1692
+ * which is what makes the new value actually reach the running process —
1693
+ * Railway applies variables at deploy time. Turn it on when deploys are
1694
+ * gated behind a release process and a secrets push must not start one; the
1695
+ * values then sit staged until the next deploy.
1696
+ */
1697
+ skipDeploys: z.boolean().optional()
1698
+ });
1699
+ const syncDestinationSchema = z.discriminatedUnion("provider", [
1700
+ vercelDestinationSchema,
1701
+ cloudflareWorkersDestinationSchema,
1702
+ cloudflarePagesDestinationSchema,
1703
+ cloudflareSecretsStoreDestinationSchema,
1704
+ railwayDestinationSchema
1705
+ ]);
1551
1706
  /**
1552
1707
  * How seekrit secret names become destination key names. Applied in order:
1553
1708
  * explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
@@ -2751,7 +2906,7 @@ function isCliSessionToken(value) {
2751
2906
  }
2752
2907
  //#endregion
2753
2908
  //#region package.json
2754
- var version = "0.33.0";
2909
+ var version = "0.35.0";
2755
2910
  //#endregion
2756
2911
  //#region ../../packages/api-client/src/index.ts
2757
2912
  var SeekritApiError = class extends Error {
@@ -6556,6 +6711,23 @@ function collect(value, acc) {
6556
6711
  }
6557
6712
  //#endregion
6558
6713
  //#region src/sync.ts
6714
+ /** Split a `--flag a,b` list into trimmed, non-empty members. */
6715
+ function list(raw, fallback) {
6716
+ return (raw ?? fallback).split(",").map((item) => item.trim()).filter(Boolean);
6717
+ }
6718
+ /** Reject list members outside a known set, naming both the strays and the choices. */
6719
+ function assertMembers(values, allowed, flag) {
6720
+ const unknown = values.filter((v) => !allowed.includes(v));
6721
+ if (unknown.length > 0) fail(`unknown ${flag} ${unknown.join(", ")} — one of: ${allowed.join(", ")}`);
6722
+ return values;
6723
+ }
6724
+ /** Every Railway id is a UUID; a name or a URL slug in the slot is the common slip. */
6725
+ function assertRailwayId(value, flag) {
6726
+ if (!value) fail(`${flag} is required for railway (a UUID)`);
6727
+ const id = value.trim();
6728
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) fail(`${flag} should be a Railway UUID, not "${id}"`);
6729
+ return id;
6730
+ }
6559
6731
  function assertProvider(value) {
6560
6732
  if (!SYNC_PROVIDER_KINDS.includes(value)) fail(`unknown provider "${value}" — one of: ${SYNC_PROVIDER_KINDS.join(", ")}`);
6561
6733
  return value;
@@ -6567,6 +6739,22 @@ function buildConfig(provider, options) {
6567
6739
  provider: "vercel",
6568
6740
  ...options.teamId ? { teamId: options.teamId } : {}
6569
6741
  };
6742
+ case "cloudflare-workers":
6743
+ case "cloudflare-pages":
6744
+ case "cloudflare-secrets-store":
6745
+ if (!options.accountId) fail(`--account-id is required for ${provider} — 32 hex characters, in the sidebar of any Cloudflare dashboard page`);
6746
+ return {
6747
+ provider,
6748
+ accountId: options.accountId
6749
+ };
6750
+ case "railway": {
6751
+ const tokenKind = options.tokenKind ?? "account";
6752
+ if (!RAILWAY_TOKEN_KINDS.includes(tokenKind)) fail(`unknown --token-kind "${tokenKind}" — one of: ${RAILWAY_TOKEN_KINDS.join(", ")}`);
6753
+ return {
6754
+ provider: "railway",
6755
+ tokenKind
6756
+ };
6757
+ }
6570
6758
  }
6571
6759
  }
6572
6760
  /** Where inside the platform a binding writes. */
@@ -6584,14 +6772,61 @@ function buildDestination(provider, options) {
6584
6772
  ...options.gitBranch ? { gitBranch: options.gitBranch } : {}
6585
6773
  };
6586
6774
  }
6775
+ case "cloudflare-workers":
6776
+ if (!options.script) fail("--script is required for cloudflare-workers (the Worker's name, e.g. my-api)");
6777
+ return {
6778
+ provider: "cloudflare-workers",
6779
+ scriptName: options.script
6780
+ };
6781
+ case "cloudflare-pages": {
6782
+ if (!options.project) fail("--project is required for cloudflare-pages (the project name)");
6783
+ const environments = assertMembers(list(options.target, "production"), CLOUDFLARE_PAGES_ENVIRONMENTS, "--target");
6784
+ return {
6785
+ provider: "cloudflare-pages",
6786
+ projectName: options.project,
6787
+ environments
6788
+ };
6789
+ }
6790
+ case "cloudflare-secrets-store": {
6791
+ if (!options.storeId) fail("--store-id is required for cloudflare-secrets-store (32 hex characters)");
6792
+ const scopes = assertMembers(list(options.scopes, "workers"), CLOUDFLARE_SECRETS_STORE_SCOPES, "--scopes");
6793
+ return {
6794
+ provider: "cloudflare-secrets-store",
6795
+ storeId: options.storeId,
6796
+ scopes
6797
+ };
6798
+ }
6799
+ case "railway": {
6800
+ const projectId = assertRailwayId(options.railwayProject, "--railway-project");
6801
+ const environmentId = assertRailwayId(options.railwayEnvironment, "--railway-environment");
6802
+ const serviceId = options.service ? assertRailwayId(options.service, "--service") : void 0;
6803
+ return {
6804
+ provider: "railway",
6805
+ projectId,
6806
+ environmentId,
6807
+ ...serviceId ? { serviceId } : {},
6808
+ ...options.skipDeploys ? { skipDeploys: true } : {}
6809
+ };
6810
+ }
6587
6811
  }
6588
6812
  }
6589
6813
  /** One-line description of a destination, for list output. */
6590
6814
  function describeDestination(destination) {
6591
6815
  switch (destination.provider) {
6592
6816
  case "vercel": return `${destination.projectId} (${destination.targets.join(", ")}${destination.gitBranch ? ` @${destination.gitBranch}` : ""})`;
6817
+ case "cloudflare-workers": return destination.scriptName;
6818
+ case "cloudflare-pages": return `${destination.projectName} (${destination.environments.join(", ")})`;
6819
+ case "cloudflare-secrets-store": return `store ${destination.storeId} (${destination.scopes.join(", ")})`;
6820
+ case "railway": return `${destination.projectId} / ${destination.environmentId} (${destination.serviceId ? `service ${destination.serviceId}` : "shared"})`;
6593
6821
  }
6594
6822
  }
6823
+ /**
6824
+ * Destination flags, shared by `verify` and `enable` so the two can never drift
6825
+ * into accepting different ways of naming the same destination.
6826
+ */
6827
+ function destinationOptions(command) {
6828
+ return command.option("--project <id>", "vercel: project id or name · cloudflare-pages: project name").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables)").option("--skip-deploys", "railway: stage values without triggering a redeploy");
6829
+ }
6595
6830
  /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
6596
6831
  async function resolveConnection(ctx, orgId, ref) {
6597
6832
  const { connections } = await ctx.client.listSyncConnections(orgId);
@@ -6601,7 +6836,7 @@ async function resolveConnection(ctx, orgId, ref) {
6601
6836
  return matches[0];
6602
6837
  }
6603
6838
  function registerSyncCommands(program) {
6604
- const sync = program.command("sync").description("push environments to a third-party platform (Vercel, …)");
6839
+ const sync = program.command("sync").description("push environments to a third-party platform (Vercel, Cloudflare, …)");
6605
6840
  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) => {
6606
6841
  const ctx = buildContext();
6607
6842
  const ref = await resolveOrg(ctx, options.org);
@@ -6614,7 +6849,7 @@ function registerSyncCommands(program) {
6614
6849
  col("id", (c) => c.id)
6615
6850
  ], "no connections — add one with `seekrit sync connect`"));
6616
6851
  });
6617
- 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) => {
6852
+ 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)").option("--token-kind <kind>", `railway: ${RAILWAY_TOKEN_KINDS.join(" | ")}`, "account").option("--account-id <id>", "cloudflare: account ID (32 hex, from the dashboard sidebar)").action(async (options) => {
6618
6853
  const provider = assertProvider(options.provider);
6619
6854
  const ctx = buildContext();
6620
6855
  const ref = await resolveOrg(ctx, options.org);
@@ -6630,7 +6865,7 @@ function registerSyncCommands(program) {
6630
6865
  });
6631
6866
  console.error(`connected ${created.connection.name} (${created.connection.id})`);
6632
6867
  });
6633
- 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) => {
6868
+ destinationOptions(sync.command("verify <connection>").description("check a stored credential against a destination").option("--org <slug>").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel")).option("--json", "print the raw API response").action(async (connection, options) => {
6634
6869
  const provider = assertProvider(options.provider);
6635
6870
  const ctx = buildContext();
6636
6871
  const ref = await resolveOrg(ctx, options.org);
@@ -6664,7 +6899,7 @@ function registerSyncCommands(program) {
6664
6899
  col("id", (b) => b.id)
6665
6900
  ], "nothing is syncing — enable it with `seekrit sync enable`"));
6666
6901
  });
6667
- 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) => {
6902
+ destinationOptions(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("--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) => {
6668
6903
  const provider = assertProvider(options.provider);
6669
6904
  if (options.onDelete !== "delete" && options.onDelete !== "retain") fail("--on-delete must be delete or retain");
6670
6905
  if (options.mode !== "auto" && options.mode !== "manual") fail("--mode must be auto or manual");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -27,9 +27,9 @@
27
27
  "@types/node": "^26.1.0",
28
28
  "tsdown": "^0.22.3",
29
29
  "vitest": "^4.1.9",
30
+ "@seekrit/core": "0.0.1",
30
31
  "@seekrit/api-client": "0.0.1",
31
- "@seekrit/crypto": "0.0.1",
32
- "@seekrit/core": "0.0.1"
32
+ "@seekrit/crypto": "0.0.1"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsdown",