@samebase/alchemy-convex 0.25.0 → 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.
- package/README.md +25 -1
- package/lib/{HubClient-Bs5_j18U.js → SharedHubClient-D-FJJUCZ.js} +403 -10
- package/lib/SharedHubClient-D-FJJUCZ.js.map +1 -0
- package/lib/cli.js +34 -17
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -156
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedHubClient.ts +340 -18
- package/src/cli.ts +63 -19
- package/src/store/component/runRecord.ts +15 -0
- package/src/store/component/state.ts +32 -9
- package/lib/HubClient-Bs5_j18U.js.map +0 -1
package/src/SharedHubClient.ts
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
224
|
-
clientId:
|
|
225
|
-
clientSecret: Redacted.make(
|
|
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 = (
|
|
253
|
-
|
|
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.
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
|
@@ -12,6 +12,21 @@ export const runRecord = v.object({
|
|
|
12
12
|
recordedAt: v.string(),
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
type RunRecord = Infer<typeof runRecord>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when a write with `next` must replace `current`, the record of the stage. Records that differ
|
|
19
|
+
* only in `recordedAt`, the time of one write, name the same run. Two laptop deploys of one commit
|
|
20
|
+
* have no `runUrl`, so they are the same run too. A write without a record (`null`) clears one.
|
|
21
|
+
*/
|
|
22
|
+
export const runChanged = (current: RunRecord | undefined, next: RunRecord | null) => {
|
|
23
|
+
if (next === null || current === undefined) return next !== null || current !== undefined;
|
|
24
|
+
const other = new Map(Object.entries(next));
|
|
25
|
+
return Object.entries(current).some(
|
|
26
|
+
([field, value]) => field !== "recordedAt" && other.get(field) !== value,
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
15
30
|
/** Whether `value` passes `validator`. Only the kinds of {@link runRecord} pass; another kind fails. */
|
|
16
31
|
export const validate = <V extends GenericValidator>(
|
|
17
32
|
validator: V,
|
|
@@ -10,7 +10,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
|
10
10
|
// oxlint-disable-next-line samebase/no-raw-public-convex-function-builders -- Store entrypoints have no app wrapper; the reader and the deploy key enforce access.
|
|
11
11
|
import { mutation, query } from "./_generated/server.js";
|
|
12
12
|
import { generationOf } from "./keys.ts";
|
|
13
|
-
import { runRecord } from "./runRecord.ts";
|
|
13
|
+
import { runChanged, runRecord } from "./runRecord.ts";
|
|
14
14
|
|
|
15
15
|
const stage = { stack: v.string(), stage: v.string() };
|
|
16
16
|
const resource = { ...stage, fqn: v.string() };
|
|
@@ -43,17 +43,31 @@ const outputOf = (ctx: QueryCtx, args: { stack: string; stage: string }) =>
|
|
|
43
43
|
*/
|
|
44
44
|
const run = v.union(runRecord, v.null());
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* A resource write and its current run become visible in one transaction. The output row of the
|
|
48
|
+
* stage changes only when the run changes (see `runChanged`). Alchemy writes the resources of a
|
|
49
|
+
* deploy at the same time, and a write to the one output row of the stage made all of them conflict
|
|
50
|
+
* until Convex stopped the retries. A write of the same run only reads the row, and reads do not
|
|
51
|
+
* conflict. So `recordedAt` stays the time of the first write of the run, until `setOutput` writes
|
|
52
|
+
* it again.
|
|
53
|
+
*/
|
|
47
54
|
const writeCurrentRun = async (
|
|
48
55
|
ctx: MutationCtx,
|
|
49
56
|
args: { stack: string; stage: string; run: Infer<typeof run> },
|
|
50
57
|
) => {
|
|
51
58
|
const output = await outputOf(ctx, args);
|
|
59
|
+
if (output === null) {
|
|
60
|
+
if (args.run !== null)
|
|
61
|
+
await ctx.db.insert("outputs", {
|
|
62
|
+
stack: args.stack,
|
|
63
|
+
stage: args.stage,
|
|
64
|
+
currentRun: args.run,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
52
68
|
// A write without a run record clears the record of the previous run.
|
|
53
|
-
if (output
|
|
69
|
+
if (runChanged(output.currentRun, args.run))
|
|
54
70
|
await ctx.db.patch("outputs", output._id, { currentRun: args.run ?? undefined });
|
|
55
|
-
else if (args.run !== null)
|
|
56
|
-
await ctx.db.insert("outputs", { stack: args.stack, stage: args.stage, currentRun: args.run });
|
|
57
71
|
};
|
|
58
72
|
|
|
59
73
|
/** The sorted union of two lists of names. */
|
|
@@ -228,10 +242,19 @@ export const setOutput = mutation({
|
|
|
228
242
|
returns: v.null(),
|
|
229
243
|
handler: async (ctx, args) => {
|
|
230
244
|
const row = await outputOf(ctx, args);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
245
|
+
// The output and the record of its run, with the time of this last write of the run.
|
|
246
|
+
if (row !== null)
|
|
247
|
+
await ctx.db.patch("outputs", row._id, {
|
|
248
|
+
value: args.value,
|
|
249
|
+
currentRun: args.run ?? undefined,
|
|
250
|
+
});
|
|
251
|
+
else
|
|
252
|
+
await ctx.db.insert("outputs", {
|
|
253
|
+
stack: args.stack,
|
|
254
|
+
stage: args.stage,
|
|
255
|
+
value: args.value,
|
|
256
|
+
...(args.run === null ? {} : { currentRun: args.run }),
|
|
257
|
+
});
|
|
235
258
|
return null;
|
|
236
259
|
},
|
|
237
260
|
});
|