@samebase/alchemy-convex 0.25.1 → 0.26.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.
@@ -8,6 +8,11 @@
8
8
  // later deploy reads the client on the hub with that token (RFC 7592). The hub keeps only hashes, so
9
9
  // the state is the only record of the secret and the token.
10
10
  //
11
+ // The answer to a production registration also holds the app's initial access token (RFC 7591
12
+ // section 1.2) and the repository id. The state of stage `prod` keeps them. On a laptop,
13
+ // `alchemy-convex hub-client --shared` reads that row with the Convex login of the team and asks the
14
+ // hub for a development client with the token: see `registerSharedDevelopmentClient`.
15
+ //
11
16
  // A push removes a variable that a deploy stops passing, so a failure here must not drop working
12
17
  // variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that
13
18
  // cannot work asks for a new one: it is gone, or it has another return address. If that request
@@ -16,18 +21,37 @@
16
21
  // and the app hides the button for this client ID.
17
22
  //
18
23
  // No text in this file quotes an answer or a fetch error, so no log line can hold a secret.
24
+ import { execFileSync } from "node:child_process";
25
+ import { readFile } from "node:fs/promises";
26
+ import { dirname, join } from "node:path";
19
27
  import { Resource } from "alchemy";
20
28
  import { isResolved } from "alchemy/Diff";
21
29
  import * as Provider from "alchemy/Provider";
30
+ import { reviveState } from "alchemy/State";
31
+ import * as Cause from "effect/Cause";
22
32
  import * as Effect from "effect/Effect";
23
33
  import * as Option from "effect/Option";
24
34
  import * as Redacted from "effect/Redacted";
25
35
  import * as Schema from "effect/Schema";
36
+ import { DeploymentRow } from "./Deployment.ts";
37
+ import { ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
38
+ import { findProjectByName, getProjectById, prodDeploymentOf } from "./Project.ts";
26
39
  import type { Providers } from "./Providers.ts";
40
+ import { withPushKey } from "./Push.ts";
41
+ import { makeFunctionApi, STORE_PROJECT_NAME } from "./State.ts";
27
42
 
28
43
  /** A hung service must not hold a deploy: the time limit of one request. */
29
44
  const REQUEST_TIMEOUT_MS = 30_000;
30
45
 
46
+ /** The variables of a deployment without a hub client, as the starter writes them. */
47
+ const OFF_VALUES = { issuer: "http://127.0.0.1:1", clientId: "no-hub", clientSecret: "no-hub" };
48
+
49
+ /** The hub that a run asks, which is also its issuer. */
50
+ const hubUrl = () => process.env["HUB_URL"] ?? "https://auth.samebase.com";
51
+
52
+ /** Where a run asks for a new client. */
53
+ const hubClientsUrl = () => `${hubUrl()}/hub-clients`;
54
+
31
55
  export interface SharedHubClientProps {
32
56
  /** The stable name of the app, set once in the stack. */
33
57
  readonly app: string;
@@ -56,6 +80,14 @@ export interface SharedHubClientAttributes {
56
80
  readonly deploymentType: "production" | "preview";
57
81
  /** The return address that the client serves. Empty for the off values. */
58
82
  readonly redirectUri: string;
83
+ /**
84
+ * Returned only at a production registration (RFC 7591 section 1.2). It registers development
85
+ * clients of this app, such as `alchemy-convex hub-client --shared` on a laptop. Empty for a
86
+ * preview and for the off values.
87
+ */
88
+ readonly initialAccessToken: Redacted.Redacted<string>;
89
+ /** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
90
+ readonly repository: string;
59
91
  }
60
92
 
61
93
  export type SharedHubClient = Resource<
@@ -85,6 +117,9 @@ const NewClient = Schema.Struct({
85
117
  redirect_uri: Schema.NonEmptyString,
86
118
  /** The terms of the shared sign-in service, which the log shows once, for the new client. */
87
119
  terms: Schema.NonEmptyString,
120
+ /** Only in the answer to a production registration with the GitHub token. */
121
+ initial_access_token: Schema.optionalKey(Schema.NonEmptyString),
122
+ repository: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^\d+$/))),
88
123
  });
89
124
 
90
125
  /** The other answers of `POST /hub-clients`, by status. */
@@ -113,6 +148,7 @@ const NOT_ALLOWED = new Map([
113
148
  ],
114
149
  ["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
115
150
  ["repository_mismatch", "/samebase-verification.json on the host names another repository"],
151
+ ["app_mismatch", "the initial access token belongs to another app on the identity hub"],
116
152
  ]);
117
153
 
118
154
  /** The windows of a 429, by the name that the answer gives. */
@@ -181,19 +217,14 @@ const readOidcToken = (requestUrl: string, requestToken: string) =>
181
217
  const requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>
182
218
  Effect.gen(function* () {
183
219
  const token = yield* readOidcToken(requestUrl, requestToken);
184
- const { body, status } = yield* send(
185
- "The identity hub",
186
- `${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`,
187
- token,
188
- {
189
- method: "POST",
190
- body: JSON.stringify({
191
- app: props.app,
192
- redirectUri: props.redirectUri,
193
- softwareVersion: props.softwareVersion ?? "unknown",
194
- }),
195
- },
196
- );
220
+ const { body, status } = yield* send("The identity hub", hubClientsUrl(), token, {
221
+ method: "POST",
222
+ body: JSON.stringify({
223
+ app: props.app,
224
+ redirectUri: props.redirectUri,
225
+ softwareVersion: props.softwareVersion ?? "unknown",
226
+ }),
227
+ });
197
228
  if (status !== 201) {
198
229
  return yield* new SharedHubClientFailed({
199
230
  reason: `${refusalOf(status, body)} (HTTP ${status})`,
@@ -212,6 +243,8 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
212
243
  registrationClientUri: client.registration_client_uri,
213
244
  deploymentType: props.deploymentType,
214
245
  redirectUri: client.redirect_uri,
246
+ initialAccessToken: Redacted.make(client.initial_access_token ?? ""),
247
+ repository: client.repository ?? "",
215
248
  };
216
249
  });
217
250
 
@@ -220,13 +253,15 @@ const newClient = (props: SharedHubClientProps) => {
220
253
  const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
221
254
  const off: SharedHubClientAttributes = {
222
255
  status: "off",
223
- issuer: "http://127.0.0.1:1",
224
- clientId: "no-hub",
225
- clientSecret: Redacted.make("no-hub"),
256
+ issuer: OFF_VALUES.issuer,
257
+ clientId: OFF_VALUES.clientId,
258
+ clientSecret: Redacted.make(OFF_VALUES.clientSecret),
226
259
  registrationAccessToken: Redacted.make(""),
227
260
  registrationClientUri: "",
228
261
  deploymentType: props.deploymentType,
229
262
  redirectUri: "",
263
+ initialAccessToken: Redacted.make(""),
264
+ repository: "",
230
265
  };
231
266
  const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
232
267
  const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
@@ -248,9 +283,21 @@ const newClient = (props: SharedHubClientProps) => {
248
283
  );
249
284
  };
250
285
 
286
+ /**
287
+ * The token fields of saved attributes. A row of 0.25 and earlier has neither, and they stay empty
288
+ * until the next production registration.
289
+ */
290
+ const StoredToken = Schema.Struct({
291
+ initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
292
+ repository: Schema.optionalKey(Schema.String),
293
+ });
294
+
251
295
  /** The client after a read on the hub, or undefined when the hub says that the client is gone. */
252
- const renew = (saved: SharedHubClientAttributes) =>
253
- Effect.gen(function* () {
296
+ const renew = (stored: SharedHubClientAttributes) => {
297
+ const { initialAccessToken = Redacted.make(""), repository = "" } =
298
+ Schema.decodeUnknownSync(StoredToken)(stored);
299
+ const saved = { ...stored, initialAccessToken, repository };
300
+ return Effect.gen(function* () {
254
301
  const { status } = yield* send(
255
302
  "The identity hub",
256
303
  saved.registrationClientUri,
@@ -279,6 +326,7 @@ const renew = (saved: SharedHubClientAttributes) =>
279
326
  ).pipe(Effect.as(saved)),
280
327
  ),
281
328
  );
329
+ };
282
330
 
283
331
  export const SharedHubClientProvider = () =>
284
332
  Provider.succeed(SharedHubClient, {
@@ -321,3 +369,277 @@ export const SharedHubClientProvider = () =>
321
369
  ),
322
370
  ),
323
371
  });
372
+
373
+ export interface SharedDevelopmentClientOptions {
374
+ /** The stack of the app, the repository `owner/name`. Its stage `prod` holds the token. */
375
+ readonly stack: string;
376
+ readonly app: string;
377
+ /** The path of the hub's callback after the site URL of the deployment. */
378
+ readonly callbackPath: string;
379
+ /** Prefix of the five deployment variables. */
380
+ readonly envPrefix?: string;
381
+ /** The Convex team, a slug or an id, whose state store holds the stack. */
382
+ readonly team?: string;
383
+ /** The directory of the app. */
384
+ readonly cwd: string;
385
+ /** Runs the Convex CLI on the deployment of `cwd`, and answers its output. */
386
+ readonly convex: (args: readonly string[], input?: string) => string;
387
+ }
388
+
389
+ /** The logical id of the client in the stack of the starter. */
390
+ const CLIENT_FQN = "HubClient";
391
+
392
+ /** The one action that `state:get` needs. */
393
+ const READ_KEY_ACTIONS = ["deployment:functions:runInternalQueries"] as const;
394
+
395
+ /** `CONVEX_DEPLOYMENT` of `.env.local` for a cloud development deployment, such as `dev:happy-otter-123`. */
396
+ const CLOUD_DEPLOYMENT = /^CONVEX_DEPLOYMENT=(?:dev:)?([a-z0-9]+(?:-[a-z0-9]+)*)[ \t]*(?:#.*)?$/m;
397
+
398
+ /** The client row of stage `prod` with a token. A row without one fails to decode. */
399
+ const SavedToken = Schema.Struct({
400
+ resourceType: Schema.Literal("IdentityHub.SharedClient"),
401
+ attr: Schema.Struct({
402
+ initialAccessToken: Schema.Redacted(Schema.NonEmptyString),
403
+ repository: Schema.NonEmptyString,
404
+ }),
405
+ });
406
+
407
+ /** The part of a client's own record (RFC 7592) that a renewal compares. */
408
+ const ClientRecord = Schema.Struct({ redirect_uris: Schema.Array(Schema.String) });
409
+
410
+ /**
411
+ * The directory whose `.env.local` selects the cloud development deployment of `cwd`. A linked git
412
+ * worktree runs a local backend, so the main checkout, the parent of the common git directory,
413
+ * selects it.
414
+ */
415
+ const selectingDirectory = (cwd: string) =>
416
+ Effect.try({
417
+ try: () =>
418
+ // The prefix is the path of `cwd` in the checkout, and an empty line at its root.
419
+ execFileSync(
420
+ "git",
421
+ ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-prefix"],
422
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
423
+ ).split(/\r?\n/),
424
+ catch: () => undefined,
425
+ }).pipe(
426
+ Effect.map(([gitDir, commonDir, prefix = ""]) =>
427
+ gitDir === undefined || commonDir === undefined || gitDir === commonDir
428
+ ? cwd
429
+ : join(dirname(commonDir), prefix),
430
+ ),
431
+ Effect.orElseSucceed(() => cwd),
432
+ );
433
+
434
+ /** The team of the cloud development deployment that `.env.local` selects, or undefined. */
435
+ const teamOfSelectedDeployment = (api: ManagementApiService, cwd: string) =>
436
+ Effect.gen(function* () {
437
+ const directory = yield* selectingDirectory(cwd);
438
+ const text = yield* Effect.tryPromise(() =>
439
+ readFile(join(directory, ".env.local"), "utf8"),
440
+ ).pipe(Effect.orElseSucceed(() => ""));
441
+ const name = CLOUD_DEPLOYMENT.exec(text)?.[1];
442
+ if (name === undefined) return undefined;
443
+ const { projectId } = yield* api
444
+ .request("get deployment", (client) =>
445
+ client.GET("/deployments/{deployment_name}", {
446
+ params: { path: { deployment_name: name } },
447
+ }),
448
+ )
449
+ .pipe(Effect.flatMap(Schema.decodeUnknownEffect(DeploymentRow)));
450
+ return (yield* getProjectById(api, projectId))?.teamId;
451
+ });
452
+
453
+ /**
454
+ * The initial access token and the repository id in the client row of stage `prod` of `stack`, or
455
+ * undefined. It reads the row with the store's own `state:get` and a key that runs internal queries
456
+ * only. It never builds `Convex.state()`, which can create or upgrade the store.
457
+ */
458
+ const readSavedToken = (api: ManagementApiService, team: string | number, stack: string) =>
459
+ Effect.gen(function* () {
460
+ const project = yield* findProjectByName(
461
+ api,
462
+ yield* api.resolveTeamId(team),
463
+ STORE_PROJECT_NAME,
464
+ );
465
+ const deployment = project === undefined ? undefined : yield* prodDeploymentOf(api, project.id);
466
+ if (deployment === undefined) return undefined;
467
+ const reply = yield* withPushKey(api, deployment.name, READ_KEY_ACTIONS, (key) =>
468
+ makeFunctionApi(deployment.deploymentUrl, key).call("state:get", {
469
+ stack,
470
+ stage: "prod",
471
+ fqn: CLIENT_FQN,
472
+ }),
473
+ );
474
+ if (reply.status === "error") {
475
+ return yield* new SharedHubClientFailed({ reason: "the state store refused the read" });
476
+ }
477
+ const text = yield* decode(Schema.NullOr(Schema.String), "The state store")(reply.value);
478
+ if (text === null) return undefined;
479
+ const row = yield* Effect.try({
480
+ try: (): unknown => JSON.parse(text, reviveState),
481
+ catch: () => new SharedHubClientFailed({ reason: "the state store gave a bad row" }),
482
+ });
483
+ return Option.getOrUndefined(Schema.decodeUnknownOption(SavedToken)(row))?.attr;
484
+ });
485
+
486
+ /** Replaces any failure or defect of a step with a fixed text, which quotes nothing. */
487
+ const failsWith =
488
+ (reason: string) =>
489
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
490
+ effect.pipe(Effect.catchCause(() => Effect.fail(new SharedHubClientFailed({ reason }))));
491
+
492
+ /** What the hub says about the client that the variables name. */
493
+ const readClient = (uri: string, token: string, callbackUrl: string) =>
494
+ Effect.gen(function* () {
495
+ const { body, status } = yield* send("The identity hub", uri, token, { method: "GET" });
496
+ switch (status) {
497
+ case 200: {
498
+ const record = yield* decode(ClientRecord, "The identity hub")(body);
499
+ const [only, ...more] = record.redirect_uris;
500
+ return only === callbackUrl && more.length === 0 ? "renewed" : "moved";
501
+ }
502
+ case 401:
503
+ return "gone";
504
+ case 403:
505
+ return "stopped";
506
+ default:
507
+ return yield* new SharedHubClientFailed({
508
+ reason: `the identity hub answered HTTP ${status}`,
509
+ });
510
+ }
511
+ });
512
+
513
+ /**
514
+ * `alchemy-convex hub-client --shared`: a development client of an app on the central identity hub,
515
+ * for `pnpm run dev` on a laptop, with the initial access token that the stack saved at the
516
+ * production registration (RFC 133). Run it after the first push of each dev start. Answers the one
517
+ * line to print. It fails only for a defect: a refusal, a failed request, or no token keeps the
518
+ * variables and answers why.
519
+ *
520
+ * 1. The team: `team`, else `CONVEX_TEAM_ID`, else the team of the selected cloud development
521
+ * deployment.
522
+ * 2. The five variables name a client of this hub (`<prefix>_ISSUER` is `HUB_URL` or
523
+ * `https://auth.samebase.com`) with a real client ID and secret: the hub renews it on a read with
524
+ * its own token (RFC 7592) when its `redirect_uris` are exactly the current callback. A client on
525
+ * another address, or one that the hub no longer has, gets a new client. A client that the hub
526
+ * stopped gets the off values.
527
+ * 3. The token from the client row of stage `prod`, the repository id on an https site, and a new
528
+ * client, in one `env set --force`.
529
+ *
530
+ * The secret and both tokens reach the Convex CLI on standard input, never in its arguments, and no
531
+ * line holds them.
532
+ */
533
+ export const registerSharedDevelopmentClient = (options: SharedDevelopmentClientOptions) =>
534
+ Effect.gen(function* () {
535
+ const api = yield* ManagementApi;
536
+ /** One call of the Convex CLI. Its error can quote the deployment, so it gives a fixed text. */
537
+ const convex = (args: readonly string[], input?: string) =>
538
+ Effect.try({
539
+ try: () => options.convex(args, input),
540
+ catch: () =>
541
+ new SharedHubClientFailed({
542
+ reason: `\`convex ${args.slice(0, 2).join(" ")}\` failed`,
543
+ }),
544
+ });
545
+ const named = options.team ?? process.env["CONVEX_TEAM_ID"];
546
+ const team =
547
+ named !== undefined && named !== ""
548
+ ? named
549
+ : yield* teamOfSelectedDeployment(api, options.cwd).pipe(
550
+ failsWith("the Convex API could not read the selected development deployment"),
551
+ );
552
+ if (team === undefined) {
553
+ return "No Convex team for Google sign-in on this laptop: set CONVEX_TEAM_ID, or select a cloud development deployment.";
554
+ }
555
+
556
+ const prefix = options.envPrefix ?? "HUB";
557
+ const siteUrl = yield* decode(
558
+ Schema.fromJsonString(Schema.String),
559
+ "The deployment",
560
+ )(yield* convex(["run", "--inline-query", "process.env.CONVEX_SITE_URL"]));
561
+ const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
562
+
563
+ const names = (yield* convex(["env", "list", "--names-only"])).split(/\r?\n/);
564
+ const variable = (name: string) => `${prefix}_${name}`;
565
+ const tokenVariable = variable("REGISTRATION_TOKEN");
566
+ const complete = [
567
+ "ISSUER",
568
+ "CLIENT_ID",
569
+ "CLIENT_SECRET",
570
+ "REGISTRATION_URI",
571
+ "REGISTRATION_TOKEN",
572
+ ].every((name) => names.includes(variable(name)));
573
+ // Only a client of the hub that this run asks, with real values, is renewed: the off values,
574
+ // and a client of another hub, get a new client.
575
+ const renewable =
576
+ complete &&
577
+ (yield* convex(["env", "get", variable("ISSUER")])) === hubUrl() &&
578
+ (yield* convex(["env", "get", variable("CLIENT_ID")])) !== OFF_VALUES.clientId &&
579
+ ![OFF_VALUES.clientSecret, ""].includes(
580
+ yield* convex(["env", "get", variable("CLIENT_SECRET")]),
581
+ );
582
+ if (renewable) {
583
+ const client = yield* readClient(
584
+ yield* convex(["env", "get", variable("REGISTRATION_URI")]),
585
+ yield* convex(["env", "get", tokenVariable]),
586
+ callbackUrl,
587
+ );
588
+ if (client === "renewed") return "Hub client renewed.";
589
+ if (client === "stopped") {
590
+ yield* convex(
591
+ ["env", "set", "--force"],
592
+ `${prefix}_ISSUER='${OFF_VALUES.issuer}'\n${prefix}_CLIENT_ID='${OFF_VALUES.clientId}'\n${prefix}_CLIENT_SECRET='${OFF_VALUES.clientSecret}'\n`,
593
+ );
594
+ yield* convex(["env", "remove", variable("REGISTRATION_URI")]);
595
+ yield* convex(["env", "remove", tokenVariable]);
596
+ return "The identity hub stopped the hub client of this laptop (HTTP 403), so the laptop has guest sign-in.";
597
+ }
598
+ }
599
+
600
+ const saved = yield* readSavedToken(api, team, options.stack).pipe(
601
+ failsWith("the state store of the team could not be read"),
602
+ );
603
+ if (saved === undefined) return "Deploy to main once for Google sign-in on this laptop.";
604
+ if (new URL(siteUrl).protocol === "https:") {
605
+ // The hub reads /samebase-verification.json of an https site, which serves this variable.
606
+ yield* convex(["env", "set", "SAMEBASE_REPOSITORY_ID", saved.repository]);
607
+ }
608
+ const { body, status } = yield* send(
609
+ "The identity hub",
610
+ hubClientsUrl(),
611
+ Redacted.value(saved.initialAccessToken),
612
+ {
613
+ method: "POST",
614
+ body: JSON.stringify({
615
+ app: options.app,
616
+ redirectUri: callbackUrl,
617
+ softwareVersion: new URL(siteUrl).host,
618
+ }),
619
+ },
620
+ );
621
+ if (status !== 201) {
622
+ const reason =
623
+ status === 401
624
+ ? "the identity hub refused the initial access token of the app"
625
+ : refusalOf(status, body);
626
+ return `The hub client keeps its values: ${reason} (HTTP ${status}).`;
627
+ }
628
+ const client = yield* decode(NewClient, "The identity hub")(body);
629
+ // One request, so that an interrupted run cannot leave half a client. `--force` replaces the
630
+ // values of an earlier one.
631
+ yield* convex(
632
+ ["env", "set", "--force"],
633
+ `${prefix}_ISSUER='${client.issuer}'\n${prefix}_CLIENT_ID='${client.client_id}'\n${prefix}_CLIENT_SECRET='${client.client_secret}'\n${prefix}_REGISTRATION_URI='${client.registration_client_uri}'\n${tokenVariable}='${client.registration_access_token}'\n`,
634
+ );
635
+ return `Hub client registered for ${callbackUrl}.`;
636
+ }).pipe(
637
+ // A dev start goes on after any failure. Each step gives a fixed text, and the text of any
638
+ // other failure is never shown, because it can quote an answer or a secret.
639
+ Effect.catchCause((cause) => {
640
+ const error = Cause.squash(cause);
641
+ const reason =
642
+ error instanceof SharedHubClientFailed ? error.reason : "an unexpected failure";
643
+ return Effect.succeed(`The hub client keeps its values: ${reason}.`);
644
+ }),
645
+ );
package/src/cli.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  // The commands of the package:
3
3
  //
4
- // alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
4
+ // alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
5
+ // alchemy-convex hub-client --shared --stack <owner/name> --app <app> --callback-path <path>
6
+ // [--env-prefix HUB] [--team <team>]
5
7
  // alchemy-convex stack-comment [--team <team>]
6
8
  //
7
9
  // hub-client runs in the directory of a Convex app at each dev start, and renews the client of the
8
10
  // identity hub of the team that the development deployment of that directory has, or gives it a new
9
- // one: see registerDevelopmentClient. stack-comment runs as a step of a pull request workflow after
10
- // the deploys, and keeps one comment with the stacks of the repository: see src/StackComment.ts. It
11
- // needs GITHUB_TOKEN and, with a team access token, no --team. Both use CONVEX_ACCESS_TOKEN or the
12
- // Convex CLI login.
11
+ // one: see registerDevelopmentClient. With --shared, the client is on the central identity hub, and
12
+ // the initial access token of the stack's production client is the proof: see
13
+ // registerSharedDevelopmentClient. It never stops a dev start. stack-comment runs as a step of a pull
14
+ // request workflow after the deploys, and keeps one comment with the stacks of the repository: see
15
+ // src/StackComment.ts. It needs GITHUB_TOKEN and, with a team access token, no --team. All use
16
+ // CONVEX_ACCESS_TOKEN or the Convex CLI login.
13
17
  import { execFileSync } from "node:child_process";
14
18
  import { parseArgs } from "node:util";
15
19
  import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -19,6 +23,7 @@ import { fromConfig } from "./Credentials.ts";
19
23
  import { registerDevelopmentClient } from "./HubClient.ts";
20
24
  import { ManagementApiLive } from "./ManagementApi.ts";
21
25
  import { installedCli } from "./Push.ts";
26
+ import { registerSharedDevelopmentClient } from "./SharedHubClient.ts";
22
27
  import { updateStackComment } from "./StackComment.ts";
23
28
  import { state } from "./State.ts";
24
29
 
@@ -29,31 +34,60 @@ const { positionals, values } = parseArgs({
29
34
  app: { type: "string" },
30
35
  "callback-path": { type: "string" },
31
36
  "env-prefix": { type: "string" },
37
+ shared: { type: "boolean" },
38
+ stack: { type: "string" },
32
39
  },
33
40
  });
34
- const { team, app, "callback-path": callbackPath, "env-prefix": envPrefix } = values;
41
+ const { team, app, "callback-path": callbackPath, "env-prefix": envPrefix, shared, stack } = values;
35
42
  const USAGE = `Usage:
36
43
  alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
44
+ alchemy-convex hub-client --shared --stack <owner/name> --app <app> --callback-path <path> [--env-prefix HUB] [--team <team>]
37
45
  alchemy-convex stack-comment [--team <team>]`;
38
46
 
39
- const hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>
40
- Effect.gen(function* () {
41
- const [command, ...prefix] = yield* installedCli(process.cwd());
42
- const callbackUrl = yield* registerDevelopmentClient({
43
- ...hub,
44
- convex: (args, input) =>
47
+ /** The installed Convex CLI on the deployment of this directory. Secrets go on standard input. */
48
+ const convexOfThisDirectory = installedCli(process.cwd()).pipe(
49
+ Effect.map(
50
+ ([command, ...prefix]) =>
51
+ (args: readonly string[], input?: string) =>
45
52
  execFileSync(command, [...prefix, ...args], {
46
53
  encoding: "utf8",
47
54
  input,
48
55
  stdio: ["pipe", "pipe", "inherit"],
49
56
  }).trim(),
57
+ ),
58
+ );
59
+
60
+ const managementApi = ManagementApiLive().pipe(Layer.provide(fromConfig()));
61
+
62
+ const hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>
63
+ Effect.gen(function* () {
64
+ const callbackUrl = yield* registerDevelopmentClient({
65
+ ...hub,
66
+ convex: yield* convexOfThisDirectory,
50
67
  });
51
68
  console.log(
52
69
  callbackUrl === undefined
53
70
  ? "Hub client renewed."
54
71
  : `Hub client registered for ${callbackUrl}.`,
55
72
  );
56
- }).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
73
+ }).pipe(Effect.provide(managementApi));
74
+
75
+ const sharedHubClient = (hub: {
76
+ stack: string;
77
+ app: string;
78
+ callbackPath: string;
79
+ envPrefix?: string;
80
+ team?: string;
81
+ }) =>
82
+ convexOfThisDirectory.pipe(
83
+ Effect.flatMap((convex) =>
84
+ registerSharedDevelopmentClient({ ...hub, cwd: process.cwd(), convex }),
85
+ ),
86
+ // Without the Convex CLI, the dev start goes on too.
87
+ Effect.catchTag("ConvexCliNotFound", (error) => Effect.succeed(error.message)),
88
+ Effect.tap((line) => Effect.sync(() => console.log(line))),
89
+ Effect.provide(managementApi),
90
+ );
57
91
 
58
92
  const stackComment = (options: { team?: string }) =>
59
93
  updateStackComment.pipe(
@@ -61,16 +95,26 @@ const stackComment = (options: { team?: string }) =>
61
95
  Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),
62
96
  );
63
97
 
98
+ const prefix = envPrefix === undefined ? {} : { envPrefix };
64
99
  const subcommand = positionals.join(" ");
65
100
  const program: Effect.Effect<unknown, unknown> | undefined =
66
101
  subcommand === "stack-comment"
67
102
  ? stackComment(team === undefined ? {} : { team })
68
- : subcommand === "hub-client" &&
69
- team !== undefined &&
70
- app !== undefined &&
71
- callbackPath !== undefined
72
- ? hubClient({ team, app, callbackPath, ...(envPrefix === undefined ? {} : { envPrefix }) })
73
- : undefined;
103
+ : subcommand !== "hub-client" || app === undefined || callbackPath === undefined
104
+ ? undefined
105
+ : shared === true
106
+ ? stack === undefined
107
+ ? undefined
108
+ : sharedHubClient({
109
+ stack,
110
+ app,
111
+ callbackPath,
112
+ ...prefix,
113
+ ...(team === undefined ? {} : { team }),
114
+ })
115
+ : team === undefined
116
+ ? undefined
117
+ : hubClient({ team, app, callbackPath, ...prefix });
74
118
  if (program === undefined) {
75
119
  console.error(USAGE);
76
120
  process.exit(1);