@samebase/alchemy-convex 0.2.0 → 0.4.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/NOTICE +24 -0
- package/README.md +135 -164
- package/lib/index.d.ts +175 -185
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +800 -502
- package/lib/index.js.map +1 -1
- package/package.json +4 -3
- package/src/Credentials.ts +85 -27
- package/src/Deployment.ts +748 -0
- package/src/ManagementApi.ts +84 -64
- package/src/Project.ts +133 -16
- package/src/Providers.ts +6 -34
- package/src/Push.ts +646 -0
- package/src/index.ts +22 -35
- package/src/DefaultEnvironmentVariable.ts +0 -263
- package/src/Deploy.ts +0 -193
- package/src/DeployKey.ts +0 -332
- package/src/EnvironmentVariable.ts +0 -262
- package/src/PreviewDeployKey.ts +0 -153
package/src/Push.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
// The push of a Convex.Deployment: a short-lived deploy key, the `env`
|
|
2
|
+
// variables, and `convex deploy` with the Convex CLI that the app installed.
|
|
3
|
+
//
|
|
4
|
+
// - The key is created for one push with the Management API credential and
|
|
5
|
+
// deleted after it, also after a failure. It can only deploy and read and
|
|
6
|
+
// write variables (with a push command: every action), and it expires
|
|
7
|
+
// after PUSH_KEY_LIFETIME_MS, so a run that stops before the delete leaves
|
|
8
|
+
// no key that works for long. No key
|
|
9
|
+
// goes into state, into an output, or to another resource.
|
|
10
|
+
// - With an OAuth token as the credential, Convex creates no new key: it
|
|
11
|
+
// returns the OAuth token with a new prefix. The push uses it, and the
|
|
12
|
+
// delete is skipped, because it would revoke the OAuth token.
|
|
13
|
+
// - The key reaches the CLI only through an env file (`--env-file`) with
|
|
14
|
+
// mode 0600 in a new temporary directory. The directory is removed after
|
|
15
|
+
// the run, on success, on failure, and on interruption. It is also the home
|
|
16
|
+
// directory of the CLI, so the CLI cannot read the Convex login of this
|
|
17
|
+
// machine. Variables named `CONVEX_*` are not passed on to the CLI.
|
|
18
|
+
// - The CLI output stays out of the logs. A failure shows its last lines with
|
|
19
|
+
// the key removed.
|
|
20
|
+
//
|
|
21
|
+
// The CLI runner follows Confect `packages/alchemy/src/internal/CommandRunner.ts`
|
|
22
|
+
// (https://github.com/rjdellecese/confect, ISC license, see NOTICE).
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
28
|
+
import { stripVTControlCharacters } from "node:util";
|
|
29
|
+
import { createDeploymentClient } from "@convex-dev/platform";
|
|
30
|
+
import { hashDirectory } from "alchemy/Command/Memo";
|
|
31
|
+
import * as Clock from "effect/Clock";
|
|
32
|
+
import * as Effect from "effect/Effect";
|
|
33
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
34
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
35
|
+
import * as Redacted from "effect/Redacted";
|
|
36
|
+
import * as Schema from "effect/Schema";
|
|
37
|
+
import * as Stream from "effect/Stream";
|
|
38
|
+
import {
|
|
39
|
+
absentAsUndefined,
|
|
40
|
+
type ManagementApiService,
|
|
41
|
+
retryIdempotentWrite,
|
|
42
|
+
settle,
|
|
43
|
+
settleVoid,
|
|
44
|
+
} from "./ManagementApi.ts";
|
|
45
|
+
|
|
46
|
+
/** A value of the `env` prop: a string, or a secret such as `Config.Redacted("AUTH_SECRET")`. */
|
|
47
|
+
export type EnvValue = string | Redacted.Redacted<string>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `source` prop: the Convex functions directory, such as `./convex`, or
|
|
51
|
+
* that directory with a push command and more files for the content identity.
|
|
52
|
+
*/
|
|
53
|
+
export type Source =
|
|
54
|
+
| string
|
|
55
|
+
| {
|
|
56
|
+
/** The Convex functions directory, such as `./convex`. */
|
|
57
|
+
readonly path: string;
|
|
58
|
+
/**
|
|
59
|
+
* Replaces `convex deploy`: a program and its arguments, run in the app
|
|
60
|
+
* directory with CONVEX_DEPLOY_KEY in its environment, such as
|
|
61
|
+
* `["pnpm", "exec", "convex-hosting", "deploy", "--build", "pnpm run build", "--dist", "dist"]`.
|
|
62
|
+
* It must push with `convex deploy`, such as through `--cmd`, so that
|
|
63
|
+
* its output has the CLI's "Deployed Convex functions to" line.
|
|
64
|
+
*/
|
|
65
|
+
readonly command?: readonly [string, ...string[]];
|
|
66
|
+
/**
|
|
67
|
+
* The files of the content identity, as globs relative to the app
|
|
68
|
+
* directory, next to `path`, package.json, convex.json, and the
|
|
69
|
+
* lockfile, such as the build inputs `["src/**", "vite.config.ts"]`.
|
|
70
|
+
* By default, every file of the app that git does not ignore.
|
|
71
|
+
*/
|
|
72
|
+
readonly include?: readonly string[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const sourceOf = (source: Source) =>
|
|
76
|
+
typeof source === "string"
|
|
77
|
+
? { path: source, command: undefined, include: undefined }
|
|
78
|
+
: { path: source.path, command: source.command, include: source.include };
|
|
79
|
+
|
|
80
|
+
/** Lifetime of a push key. Long enough for a slow push with index backfills. */
|
|
81
|
+
export const PUSH_KEY_LIFETIME_MS = 2 * 60 * 60 * 1000;
|
|
82
|
+
|
|
83
|
+
/** The only actions a push key gets. */
|
|
84
|
+
const PUSH_KEY_ACTIONS = [
|
|
85
|
+
"deployment:deploy",
|
|
86
|
+
"deployment:env:view",
|
|
87
|
+
"deployment:env:write",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
/** POST /deployments/{deployment_name}/create_deploy_key */
|
|
91
|
+
const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
|
|
92
|
+
|
|
93
|
+
/** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
|
|
94
|
+
const EnvironmentVariableList = Schema.Struct({
|
|
95
|
+
environmentVariables: Schema.Record(Schema.String, Schema.String),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** No `convex` package resolves from the app directory. */
|
|
99
|
+
export class ConvexCliNotFound extends Schema.TaggedError<ConvexCliNotFound>()(
|
|
100
|
+
"ConvexCliNotFound",
|
|
101
|
+
{ cwd: Schema.String },
|
|
102
|
+
) {
|
|
103
|
+
override get message() {
|
|
104
|
+
return `Convex.Deployment found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), or set source to the functions directory of the app.`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** No directory at or above the parent of `source` has a package.json. */
|
|
109
|
+
export class ConvexAppNotFound extends Schema.TaggedError<ConvexAppNotFound>()(
|
|
110
|
+
"ConvexAppNotFound",
|
|
111
|
+
{ source: Schema.String },
|
|
112
|
+
) {
|
|
113
|
+
override get message() {
|
|
114
|
+
return `Convex.Deployment found no package.json at or above the parent of ${this.source}. Set source to the functions directory of a Convex app, such as "./convex".`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
|
|
119
|
+
export class CodePushFailed extends Schema.TaggedError<CodePushFailed>()("CodePushFailed", {
|
|
120
|
+
/** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
|
|
121
|
+
exitCode: Schema.UndefinedOr(Schema.Number),
|
|
122
|
+
/** The last lines of the CLI output, with the deploy key removed. */
|
|
123
|
+
outputTail: Schema.String,
|
|
124
|
+
}) {
|
|
125
|
+
override get message() {
|
|
126
|
+
return this.exitCode === 0
|
|
127
|
+
? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output, with the deploy key removed:\n${this.outputTail}`
|
|
128
|
+
: `convex deploy failed${this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The CLI pushed to another deployment than the one of the resource. Checked after the push. */
|
|
133
|
+
export class PushTargetMismatch extends Schema.TaggedError<PushTargetMismatch>()(
|
|
134
|
+
"PushTargetMismatch",
|
|
135
|
+
{ expected: Schema.String, actual: Schema.String },
|
|
136
|
+
) {
|
|
137
|
+
override get message() {
|
|
138
|
+
return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Deployment expected ${this.expected}. Check that convex.json does not select another deployment.`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Variables in `env` exist on the deployment with other values, and adoption is off. */
|
|
143
|
+
export class VariablesOwnedBySomeoneElse extends Schema.TaggedError<VariablesOwnedBySomeoneElse>()(
|
|
144
|
+
"VariablesOwnedBySomeoneElse",
|
|
145
|
+
{ deployment: Schema.String, names: Schema.Array(Schema.String) },
|
|
146
|
+
) {
|
|
147
|
+
override get message() {
|
|
148
|
+
return `Environment variables ${this.names.join(", ")} already exist on deployment ${this.deployment} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A preview deploy key cannot set variables: the deployment API takes only a deploy key of the deployment. */
|
|
153
|
+
export class EnvironmentNeedsDeployKey extends Schema.TaggedError<EnvironmentNeedsDeployKey>()(
|
|
154
|
+
"EnvironmentNeedsDeployKey",
|
|
155
|
+
{ deployment: Schema.String },
|
|
156
|
+
) {
|
|
157
|
+
override get message() {
|
|
158
|
+
return `Convex.Deployment cannot set or remove env variables on preview deployment ${this.deployment} with a preview deploy key: Convex lets that key create, push to, and delete preview deployments, but not change their variables. Set CONVEX_ACCESS_TOKEN for this stage, or set the values as project default environment variables for preview deployments in the Convex dashboard.`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
|
|
164
|
+
* (or `<deployment>|<secret>` in old keys) for one deployment, and
|
|
165
|
+
* `preview:<team>:<project>|<secret>` for the preview deployments of a
|
|
166
|
+
* project. Same rules as convex 1.46 `cli/lib/deployment.js`.
|
|
167
|
+
*/
|
|
168
|
+
export const keyTargetOf = (
|
|
169
|
+
key: string,
|
|
170
|
+
):
|
|
171
|
+
| { readonly kind: "deployment"; readonly deployment: string }
|
|
172
|
+
| { readonly kind: "preview"; readonly team: string; readonly project: string }
|
|
173
|
+
| { readonly kind: "other" } => {
|
|
174
|
+
const bar = key.indexOf("|");
|
|
175
|
+
// A key in the env file must be one unquoted dotenv value.
|
|
176
|
+
if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "other" };
|
|
177
|
+
const [first, second, third, ...rest] = key.slice(0, bar).split(":");
|
|
178
|
+
if (rest.length > 0) return { kind: "other" };
|
|
179
|
+
if (first === "preview" && second && third)
|
|
180
|
+
return { kind: "preview", team: second, project: third };
|
|
181
|
+
if (third !== undefined) return { kind: "other" };
|
|
182
|
+
if (second) return { kind: "deployment", deployment: second };
|
|
183
|
+
if (first && second === undefined) return { kind: "deployment", deployment: first };
|
|
184
|
+
return { kind: "other" };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The CLI prints this line with `logFinishedStep` after the push succeeds, to
|
|
189
|
+
* stderr (convex 1.46 `cli/lib/deploy2.js`). The host is
|
|
190
|
+
* `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
|
|
191
|
+
*/
|
|
192
|
+
const DEPLOYED_LINE =
|
|
193
|
+
/Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
|
|
194
|
+
|
|
195
|
+
/** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
|
|
196
|
+
export const parseDeployOutput = (
|
|
197
|
+
text: string,
|
|
198
|
+
): { url: string; deploymentName: string } | undefined => {
|
|
199
|
+
const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
|
|
200
|
+
const url = match?.[1];
|
|
201
|
+
const deploymentName = match?.[2];
|
|
202
|
+
return url === undefined || deploymentName === undefined ? undefined : { url, deploymentName };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Number of output lines that a failure quotes. */
|
|
206
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
207
|
+
|
|
208
|
+
/** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
|
|
209
|
+
const outputTail = (text: string, secrets: readonly string[]) =>
|
|
210
|
+
secrets
|
|
211
|
+
.reduce(
|
|
212
|
+
(redacted, secret) => redacted.split(secret).join("[REDACTED]"),
|
|
213
|
+
stripVTControlCharacters(text),
|
|
214
|
+
)
|
|
215
|
+
.split(/\r?\n|\r/)
|
|
216
|
+
.filter((line) => line.trim() !== "")
|
|
217
|
+
.slice(-OUTPUT_TAIL_LINES)
|
|
218
|
+
.join("\n");
|
|
219
|
+
|
|
220
|
+
const valueOf = (value: EnvValue) => (Redacted.isRedacted(value) ? Redacted.value(value) : value);
|
|
221
|
+
|
|
222
|
+
/** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
|
|
223
|
+
const appDirectoryOf = (source: string) =>
|
|
224
|
+
Effect.tryPromise({
|
|
225
|
+
try: async () => {
|
|
226
|
+
let directory = dirname(resolve(source));
|
|
227
|
+
while (true) {
|
|
228
|
+
try {
|
|
229
|
+
await access(join(directory, "package.json"));
|
|
230
|
+
return directory;
|
|
231
|
+
} catch {
|
|
232
|
+
const parent = dirname(directory);
|
|
233
|
+
if (parent === directory) throw new Error("no package.json");
|
|
234
|
+
directory = parent;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
catch: () => new ConvexAppNotFound({ source }),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/** `source` is not the functions directory that the Convex CLI pushes from this app. */
|
|
242
|
+
export class SourceMismatch extends Schema.TaggedError<SourceMismatch>()("SourceMismatch", {
|
|
243
|
+
source: Schema.String,
|
|
244
|
+
functions: Schema.String,
|
|
245
|
+
}) {
|
|
246
|
+
override get message() {
|
|
247
|
+
return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} in this app (the "functions" field of convex.json, or convex/ by default). Set source to ${this.functions}.`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ConvexJson = Schema.Struct({ functions: Schema.optionalKey(Schema.String) });
|
|
252
|
+
|
|
253
|
+
/** Fails with {@link SourceMismatch} when `path` is not the functions directory of the app in `cwd`. */
|
|
254
|
+
const checkFunctionsDirectory = (cwd: string, path: string) =>
|
|
255
|
+
Effect.gen(function* () {
|
|
256
|
+
const text = yield* Effect.promise(() =>
|
|
257
|
+
readFile(join(cwd, "convex.json"), "utf8").catch(() => undefined),
|
|
258
|
+
);
|
|
259
|
+
const configured =
|
|
260
|
+
text === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: Schema.decodeUnknownSync(Schema.fromJsonString(ConvexJson))(text).functions;
|
|
263
|
+
const functions = resolve(cwd, configured ?? "convex");
|
|
264
|
+
if (functions !== resolve(path)) {
|
|
265
|
+
return yield* new SourceMismatch({ source: resolve(path), functions });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const CliManifest = Schema.Struct({
|
|
270
|
+
bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
|
|
274
|
+
const installedCli = (cwd: string) =>
|
|
275
|
+
Effect.tryPromise({
|
|
276
|
+
try: async (): Promise<readonly [string, ...string[]]> => {
|
|
277
|
+
const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
|
|
278
|
+
const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(
|
|
279
|
+
await readFile(manifestPath, "utf8"),
|
|
280
|
+
);
|
|
281
|
+
const entry = typeof bin === "string" ? bin : bin["convex"];
|
|
282
|
+
if (entry === undefined) throw new Error("the convex package has no convex bin");
|
|
283
|
+
return [process.execPath, resolve(dirname(manifestPath), entry)];
|
|
284
|
+
},
|
|
285
|
+
catch: () => new ConvexCliNotFound({ cwd }),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The content identity of a push: the files of the app directory that git
|
|
290
|
+
* does not ignore (or `path`, the manifests, and the `include` globs), the
|
|
291
|
+
* nearest lockfile, the target deployment, and the `env` names and values.
|
|
292
|
+
* A change in a workspace package outside the app directory is not seen.
|
|
293
|
+
*/
|
|
294
|
+
export const pushHash = (options: {
|
|
295
|
+
readonly source: Source | undefined;
|
|
296
|
+
readonly deployment: string;
|
|
297
|
+
readonly env: Readonly<Record<string, EnvValue>>;
|
|
298
|
+
}) =>
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
const source = options.source === undefined ? undefined : sourceOf(options.source);
|
|
301
|
+
const files =
|
|
302
|
+
source === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: yield* appDirectoryOf(source.path).pipe(
|
|
305
|
+
Effect.flatMap((cwd) =>
|
|
306
|
+
hashDirectory({
|
|
307
|
+
cwd,
|
|
308
|
+
// By default every file of the app that git does not ignore,
|
|
309
|
+
// such as a module outside `source` that a function imports.
|
|
310
|
+
// `include` narrows it to the functions, the manifests, and
|
|
311
|
+
// the listed globs.
|
|
312
|
+
memo:
|
|
313
|
+
source.include === undefined
|
|
314
|
+
? {}
|
|
315
|
+
: {
|
|
316
|
+
include: [
|
|
317
|
+
`${relative(cwd, resolve(source.path)).split("\\").join("/")}/**`,
|
|
318
|
+
"package.json",
|
|
319
|
+
"convex.json",
|
|
320
|
+
...source.include,
|
|
321
|
+
],
|
|
322
|
+
lockfile: true,
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
const env = Object.entries(options.env)
|
|
328
|
+
.map(([name, value]): readonly [string, string] => [name, valueOf(value)])
|
|
329
|
+
.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
330
|
+
return createHash("sha256")
|
|
331
|
+
.update(JSON.stringify({ files, deployment: options.deployment, env }))
|
|
332
|
+
.digest("hex");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Runs `use` with an env file that holds the key, in a new temporary
|
|
337
|
+
* directory, and removes the directory afterwards: on success, on failure,
|
|
338
|
+
* and on interruption.
|
|
339
|
+
*/
|
|
340
|
+
const withKeyFile = <A, E, R>(
|
|
341
|
+
key: string,
|
|
342
|
+
use: (paths: { readonly envFile: string; readonly home: string }) => Effect.Effect<A, E, R>,
|
|
343
|
+
) =>
|
|
344
|
+
Effect.acquireUseRelease(
|
|
345
|
+
Effect.promise(() => mkdtemp(join(tmpdir(), "alchemy-convex-"))),
|
|
346
|
+
(home) =>
|
|
347
|
+
Effect.promise(() =>
|
|
348
|
+
writeFile(join(home, "deploy.env"), `CONVEX_DEPLOY_KEY=${key}\n`, { mode: 0o600 }),
|
|
349
|
+
).pipe(Effect.andThen(use({ envFile: join(home, "deploy.env"), home }))),
|
|
350
|
+
(home) => Effect.promise(() => rm(home, { recursive: true, force: true })),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
/** This process's variables without `CONVEX_*`, with `home` as the home directory. */
|
|
354
|
+
const cliEnvironment = (home: string): Record<string, string | undefined> => ({
|
|
355
|
+
...Object.fromEntries(
|
|
356
|
+
Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_")),
|
|
357
|
+
),
|
|
358
|
+
HOME: home,
|
|
359
|
+
USERPROFILE: home,
|
|
360
|
+
XDG_CONFIG_HOME: home,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Creates a deploy key for one push, runs `use` with it, and deletes it
|
|
365
|
+
* afterwards. Without a push command, the key can only deploy and read and
|
|
366
|
+
* write variables. The delete is skipped for a key that is the OAuth credential.
|
|
367
|
+
* A failed delete is logged: the key expires anyway.
|
|
368
|
+
*/
|
|
369
|
+
export const withPushKey = <A, E, R>(
|
|
370
|
+
api: ManagementApiService,
|
|
371
|
+
deployment: string,
|
|
372
|
+
source: Source | undefined,
|
|
373
|
+
use: (key: Redacted.Redacted<string>) => Effect.Effect<A, E, R>,
|
|
374
|
+
) =>
|
|
375
|
+
Effect.acquireUseRelease(
|
|
376
|
+
Effect.gen(function* () {
|
|
377
|
+
const now = yield* Clock.currentTimeMillis;
|
|
378
|
+
const created = yield* api.request("create deploy key", (client) =>
|
|
379
|
+
client.POST("/deployments/{deployment_name}/create_deploy_key", {
|
|
380
|
+
params: { path: { deployment_name: deployment } },
|
|
381
|
+
body: {
|
|
382
|
+
name: "alchemy-push",
|
|
383
|
+
// A push command can do more than a push, such as the uploads of
|
|
384
|
+
// convex-hosting through `convex run`, so its key has every action.
|
|
385
|
+
...(source === undefined || sourceOf(source).command === undefined
|
|
386
|
+
? { allowedActions: [...PUSH_KEY_ACTIONS] }
|
|
387
|
+
: {}),
|
|
388
|
+
expiresAt: now + PUSH_KEY_LIFETIME_MS,
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
|
|
393
|
+
}),
|
|
394
|
+
use,
|
|
395
|
+
(key) =>
|
|
396
|
+
Effect.gen(function* () {
|
|
397
|
+
if (yield* api.isCredentialToken(Redacted.value(key))) return;
|
|
398
|
+
yield* absentAsUndefined(
|
|
399
|
+
retryIdempotentWrite(
|
|
400
|
+
api.requestVoid("delete deploy key", (client) =>
|
|
401
|
+
client.POST("/deployments/{deployment_name}/delete_deploy_key", {
|
|
402
|
+
params: { path: { deployment_name: deployment } },
|
|
403
|
+
body: { id: Redacted.value(key) },
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}).pipe(
|
|
409
|
+
Effect.catch((error) =>
|
|
410
|
+
Effect.logWarning(
|
|
411
|
+
`Convex.Deployment could not delete its push key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 60_000} minutes.`,
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
/** Every variable of the deployment with its value. */
|
|
418
|
+
const listVariables = (url: string, key: Redacted.Redacted<string>) =>
|
|
419
|
+
settle("list environment variables", () =>
|
|
420
|
+
createDeploymentClient(url, Redacted.value(key)).GET("/list_environment_variables"),
|
|
421
|
+
).pipe(
|
|
422
|
+
Effect.map(
|
|
423
|
+
(body) => Schema.decodeUnknownSync(EnvironmentVariableList)(body).environmentVariables,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
/** One call that sets variables, or removes those whose value is null. */
|
|
428
|
+
const writeVariables = (
|
|
429
|
+
url: string,
|
|
430
|
+
key: Redacted.Redacted<string>,
|
|
431
|
+
changes: ReadonlyArray<{ readonly name: string; readonly value: string | null }>,
|
|
432
|
+
) =>
|
|
433
|
+
settleVoid("update environment variables", () =>
|
|
434
|
+
createDeploymentClient(url, Redacted.value(key)).POST("/update_environment_variables", {
|
|
435
|
+
body: { changes: changes.map(({ name, value }) => ({ name, value })) },
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
export interface PushOptions {
|
|
440
|
+
/** Deployment name, such as `happy-animal-123`. */
|
|
441
|
+
readonly deployment: string;
|
|
442
|
+
/** Client URL of the deployment. */
|
|
443
|
+
readonly url: string;
|
|
444
|
+
/** The functions directory, or it with a push command. Without it, only the variables change. */
|
|
445
|
+
readonly source: Source | undefined;
|
|
446
|
+
readonly env: Readonly<Record<string, EnvValue>>;
|
|
447
|
+
/** Names that an earlier push set on this deployment. Names that left `env` are removed. */
|
|
448
|
+
readonly previousEnvNames: readonly string[];
|
|
449
|
+
/** True when the resource may overwrite variables that it did not set. */
|
|
450
|
+
readonly adopt: boolean;
|
|
451
|
+
/**
|
|
452
|
+
* The preview name for `convex deploy --preview-name`, when `key` is a
|
|
453
|
+
* preview deploy key. Such a key cannot set variables.
|
|
454
|
+
*/
|
|
455
|
+
readonly previewName: string | undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The app directory and the program of a push. Fails before any change when
|
|
460
|
+
* `source` is not the functions directory of the app, or when the app has no
|
|
461
|
+
* installed Convex CLI and no push command.
|
|
462
|
+
*/
|
|
463
|
+
export const checkSource = (input: Source) =>
|
|
464
|
+
Effect.gen(function* () {
|
|
465
|
+
const source = sourceOf(input);
|
|
466
|
+
const cwd = yield* appDirectoryOf(source.path);
|
|
467
|
+
yield* checkFunctionsDirectory(cwd, source.path);
|
|
468
|
+
const program = source.command ?? (yield* installedCli(cwd));
|
|
469
|
+
return { cwd, program };
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Sets the `env` variables and pushes the functions of `source` to one
|
|
474
|
+
* deployment with `key`: a deploy key of the deployment, or a preview deploy
|
|
475
|
+
* key of its project with `previewName`. Returns the URL that the CLI
|
|
476
|
+
* printed, or undefined without `source`.
|
|
477
|
+
*/
|
|
478
|
+
export const push = (
|
|
479
|
+
spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
|
|
480
|
+
key: Redacted.Redacted<string>,
|
|
481
|
+
options: PushOptions,
|
|
482
|
+
) =>
|
|
483
|
+
Effect.gen(function* () {
|
|
484
|
+
const source = options.source === undefined ? undefined : sourceOf(options.source);
|
|
485
|
+
const resolved = options.source === undefined ? undefined : yield* checkSource(options.source);
|
|
486
|
+
const cwd = resolved?.cwd;
|
|
487
|
+
const program = resolved?.program;
|
|
488
|
+
const env = Object.entries(options.env).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
489
|
+
const removed = options.previousEnvNames.filter((name) => !Object.hasOwn(options.env, name));
|
|
490
|
+
|
|
491
|
+
// 1. Variables before the push, so the push sees them. Each attempt
|
|
492
|
+
// reads the current values first, so a retry after a write conflict never
|
|
493
|
+
// overwrites a value that another writer set in the meantime. Only
|
|
494
|
+
// changed values are written, and the earlier ones are kept for a
|
|
495
|
+
// rollback.
|
|
496
|
+
const written =
|
|
497
|
+
env.length === 0 && removed.length === 0
|
|
498
|
+
? { current: {}, changes: [] }
|
|
499
|
+
: yield* retryIdempotentWrite(
|
|
500
|
+
Effect.gen(function* () {
|
|
501
|
+
const current = yield* listVariables(options.url, key);
|
|
502
|
+
if (!options.adopt) {
|
|
503
|
+
// The same value loses nothing, such as a value that an
|
|
504
|
+
// earlier run set before its push failed.
|
|
505
|
+
const taken = env
|
|
506
|
+
.filter(
|
|
507
|
+
([name, value]) =>
|
|
508
|
+
!options.previousEnvNames.includes(name) &&
|
|
509
|
+
Object.hasOwn(current, name) &&
|
|
510
|
+
current[name] !== valueOf(value),
|
|
511
|
+
)
|
|
512
|
+
.map(([name]) => name);
|
|
513
|
+
if (taken.length > 0) {
|
|
514
|
+
return yield* new VariablesOwnedBySomeoneElse({
|
|
515
|
+
deployment: options.deployment,
|
|
516
|
+
names: taken,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const changes = [
|
|
521
|
+
...env
|
|
522
|
+
.filter(([name, value]) => current[name] !== valueOf(value))
|
|
523
|
+
.map(([name, value]) => ({ name, value: valueOf(value) })),
|
|
524
|
+
...removed
|
|
525
|
+
.filter((name) => Object.hasOwn(current, name))
|
|
526
|
+
.map((name) => ({ name, value: null })),
|
|
527
|
+
];
|
|
528
|
+
if (changes.length > 0) yield* writeVariables(options.url, key, changes);
|
|
529
|
+
return { current, changes };
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* After a failed push, the variables go back to their earlier values, so
|
|
535
|
+
* that the deployment matches the hash in state. A failed rollback is
|
|
536
|
+
* logged: the push error is the one to report.
|
|
537
|
+
*/
|
|
538
|
+
const rollback =
|
|
539
|
+
written.changes.length === 0
|
|
540
|
+
? Effect.void
|
|
541
|
+
: retryIdempotentWrite(
|
|
542
|
+
writeVariables(
|
|
543
|
+
options.url,
|
|
544
|
+
key,
|
|
545
|
+
written.changes.map(({ name }) => ({
|
|
546
|
+
name,
|
|
547
|
+
value: Object.hasOwn(written.current, name)
|
|
548
|
+
? (written.current[name] ?? null)
|
|
549
|
+
: null,
|
|
550
|
+
})),
|
|
551
|
+
),
|
|
552
|
+
).pipe(
|
|
553
|
+
Effect.catch((error) =>
|
|
554
|
+
Effect.logWarning(
|
|
555
|
+
`Convex.Deployment could not set the env variables of ${options.deployment} back after the failed push (${error.message}). Run the deploy again.`,
|
|
556
|
+
),
|
|
557
|
+
),
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// 2. The push, with the key only in the env file.
|
|
561
|
+
if (source === undefined || cwd === undefined || program === undefined) return undefined;
|
|
562
|
+
return yield* runPush(spawner, key, { ...options, cwd, program, command: source.command }).pipe(
|
|
563
|
+
Effect.onError(() => rollback),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
/** Runs the installed CLI or the push command once, and checks the deployment that it names. */
|
|
568
|
+
const runPush = (
|
|
569
|
+
spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
|
|
570
|
+
key: Redacted.Redacted<string>,
|
|
571
|
+
options: PushOptions & {
|
|
572
|
+
readonly cwd: string;
|
|
573
|
+
readonly program: readonly [string, ...string[]];
|
|
574
|
+
readonly command: readonly [string, ...string[]] | undefined;
|
|
575
|
+
},
|
|
576
|
+
) =>
|
|
577
|
+
Effect.gen(function* () {
|
|
578
|
+
const { cwd, program } = options;
|
|
579
|
+
const secret = Redacted.value(key);
|
|
580
|
+
const secrets = [secret, secret.slice(secret.indexOf("|") + 1)].filter(
|
|
581
|
+
(value) => value.length >= 8,
|
|
582
|
+
);
|
|
583
|
+
const result = yield* withKeyFile(secret, ({ envFile, home }) =>
|
|
584
|
+
Effect.gen(function* () {
|
|
585
|
+
const [command, ...programArgs] = program;
|
|
586
|
+
// The installed CLI reads the key from the env file. A push command
|
|
587
|
+
// gets it as CONVEX_DEPLOY_KEY, which the CLI that it runs reads.
|
|
588
|
+
const args =
|
|
589
|
+
options.command === undefined
|
|
590
|
+
? [
|
|
591
|
+
...programArgs,
|
|
592
|
+
"deploy",
|
|
593
|
+
...(options.previewName === undefined
|
|
594
|
+
? []
|
|
595
|
+
: ["--preview-name", options.previewName]),
|
|
596
|
+
"--env-file",
|
|
597
|
+
envFile,
|
|
598
|
+
]
|
|
599
|
+
: programArgs;
|
|
600
|
+
const child = yield* spawner.spawn(
|
|
601
|
+
ChildProcess.make(command, args, {
|
|
602
|
+
cwd,
|
|
603
|
+
env: {
|
|
604
|
+
...cliEnvironment(home),
|
|
605
|
+
...(options.command === undefined ? {} : { CONVEX_DEPLOY_KEY: secret }),
|
|
606
|
+
},
|
|
607
|
+
extendEnv: false,
|
|
608
|
+
stdin: "ignore",
|
|
609
|
+
stdout: "pipe",
|
|
610
|
+
stderr: "pipe",
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
const [exitCode, stdout, stderr] = yield* Effect.all(
|
|
614
|
+
[
|
|
615
|
+
child.exitCode,
|
|
616
|
+
Stream.mkString(Stream.decodeText(child.stdout)),
|
|
617
|
+
Stream.mkString(Stream.decodeText(child.stderr)),
|
|
618
|
+
],
|
|
619
|
+
{ concurrency: "unbounded" },
|
|
620
|
+
);
|
|
621
|
+
return { exitCode: Number(exitCode), output: `${stdout}\n${stderr}` };
|
|
622
|
+
}).pipe(Effect.scoped),
|
|
623
|
+
).pipe(
|
|
624
|
+
Effect.mapError(
|
|
625
|
+
(error) =>
|
|
626
|
+
new CodePushFailed({
|
|
627
|
+
exitCode: undefined,
|
|
628
|
+
outputTail: outputTail(error.message, secrets),
|
|
629
|
+
}),
|
|
630
|
+
),
|
|
631
|
+
);
|
|
632
|
+
const deployed = parseDeployOutput(result.output);
|
|
633
|
+
if (result.exitCode !== 0 || deployed === undefined) {
|
|
634
|
+
return yield* new CodePushFailed({
|
|
635
|
+
exitCode: result.exitCode,
|
|
636
|
+
outputTail: outputTail(result.output, secrets),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (deployed.deploymentName !== options.deployment) {
|
|
640
|
+
return yield* new PushTargetMismatch({
|
|
641
|
+
expected: options.deployment,
|
|
642
|
+
actual: deployed.deploymentName,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return deployed.url;
|
|
646
|
+
});
|