@samebase/alchemy-convex 0.1.0 → 0.3.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 +246 -55
- package/lib/index.d.ts +510 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1411 -10
- package/lib/index.js.map +1 -1
- package/package.json +8 -5
- package/src/Adoption.ts +30 -0
- package/src/Code.ts +463 -0
- package/src/DefaultEnvironmentVariable.ts +127 -29
- package/src/Deploy.ts +47 -35
- package/src/DeployKey.ts +221 -60
- package/src/Deployment.ts +409 -0
- package/src/EnvironmentVariable.ts +204 -44
- package/src/ManagementApi.ts +57 -1
- package/src/PreviewDeployKey.ts +65 -38
- package/src/Project.ts +236 -52
- package/src/Providers.ts +9 -1
- package/src/index.ts +29 -1
- package/lib/Credentials.d.ts +0 -39
- package/lib/Credentials.d.ts.map +0 -1
- package/lib/Credentials.js +0 -49
- package/lib/Credentials.js.map +0 -1
- package/lib/DefaultEnvironmentVariable.d.ts +0 -58
- package/lib/DefaultEnvironmentVariable.d.ts.map +0 -1
- package/lib/DefaultEnvironmentVariable.js +0 -93
- package/lib/DefaultEnvironmentVariable.js.map +0 -1
- package/lib/Deploy.d.ts +0 -62
- package/lib/Deploy.d.ts.map +0 -1
- package/lib/Deploy.js +0 -92
- package/lib/Deploy.js.map +0 -1
- package/lib/DeployKey.d.ts +0 -48
- package/lib/DeployKey.d.ts.map +0 -1
- package/lib/DeployKey.js +0 -104
- package/lib/DeployKey.js.map +0 -1
- package/lib/EnvironmentVariable.d.ts +0 -37
- package/lib/EnvironmentVariable.d.ts.map +0 -1
- package/lib/EnvironmentVariable.js +0 -65
- package/lib/EnvironmentVariable.js.map +0 -1
- package/lib/ManagementApi.d.ts +0 -50
- package/lib/ManagementApi.d.ts.map +0 -1
- package/lib/ManagementApi.js +0 -103
- package/lib/ManagementApi.js.map +0 -1
- package/lib/PreviewDeployKey.d.ts +0 -34
- package/lib/PreviewDeployKey.d.ts.map +0 -1
- package/lib/PreviewDeployKey.js +0 -78
- package/lib/PreviewDeployKey.js.map +0 -1
- package/lib/Project.d.ts +0 -29
- package/lib/Project.d.ts.map +0 -1
- package/lib/Project.js +0 -114
- package/lib/Project.js.map +0 -1
- package/lib/Providers.d.ts +0 -13
- package/lib/Providers.d.ts.map +0 -1
- package/lib/Providers.js +0 -30
- package/lib/Providers.js.map +0 -1
package/src/Code.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// Convex.Code: pushes the functions of a Convex app to one deployment with
|
|
2
|
+
// the Convex CLI that the app has installed, and exposes the URLs that a
|
|
3
|
+
// frontend or a Worker needs.
|
|
4
|
+
//
|
|
5
|
+
// - Every apply pushes. The resource does not hash the sources: a preview
|
|
6
|
+
// deployment can expire, a push from outside Alchemy can change the
|
|
7
|
+
// deployment, and a hash of the app directory misses changes in workspace
|
|
8
|
+
// packages. The Convex CLI uploads only the modules that changed.
|
|
9
|
+
// - The CLI is the `convex` package that `cwd` resolves, run with the Node.js
|
|
10
|
+
// that runs Alchemy. Nothing is downloaded. The `command` prop replaces it.
|
|
11
|
+
// - The deploy key reaches the CLI only through an env file (`--env-file`)
|
|
12
|
+
// with mode 0600 in a new temporary directory. The directory is removed
|
|
13
|
+
// after the run, on success, on failure, and on interruption. It is also the
|
|
14
|
+
// home directory of the CLI, so the CLI cannot read the Convex login of this
|
|
15
|
+
// machine and pushes with the deploy key only. Variables named `CONVEX_*`
|
|
16
|
+
// are not passed on to the CLI.
|
|
17
|
+
// - The CLI output stays out of the logs. A failure shows its last lines with
|
|
18
|
+
// the deploy key removed.
|
|
19
|
+
// - Before the push, the key must fit the deployment: a deploy key of that
|
|
20
|
+
// deployment, or a preview deploy key for a Convex.Deployment preview. After
|
|
21
|
+
// the push, the deployment that the CLI names must be that deployment.
|
|
22
|
+
// - `env` variables are set on the deployment before the push with the deploy
|
|
23
|
+
// key, through the deployment API like Convex.EnvironmentVariable. A
|
|
24
|
+
// variable that exists with another value and that this resource did not
|
|
25
|
+
// set is someone else's: it is overwritten only with --adopt. A name that
|
|
26
|
+
// leaves `env` is removed from the deployment.
|
|
27
|
+
// - Delete does nothing. The functions and the variables stay on the
|
|
28
|
+
// deployment: the deployed code can need them.
|
|
29
|
+
//
|
|
30
|
+
// Patterns from Confect `packages/alchemy/src/Code.ts`,
|
|
31
|
+
// `internal/CodeDeployment.ts`, and `internal/CommandRunner.ts`
|
|
32
|
+
// (https://github.com/rjdellecese/confect, ISC license).
|
|
33
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
34
|
+
import { createRequire } from "node:module";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { dirname, join, resolve } from "node:path";
|
|
37
|
+
import { stripVTControlCharacters } from "node:util";
|
|
38
|
+
import { Resource } from "alchemy";
|
|
39
|
+
import { OwnedBySomeoneElse } from "alchemy/AdoptPolicy";
|
|
40
|
+
import * as Provider from "alchemy/Provider";
|
|
41
|
+
import * as Clock from "effect/Clock";
|
|
42
|
+
import * as Effect from "effect/Effect";
|
|
43
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
44
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
45
|
+
import * as Redacted from "effect/Redacted";
|
|
46
|
+
import * as Schema from "effect/Schema";
|
|
47
|
+
import * as Stream from "effect/Stream";
|
|
48
|
+
import { shouldAdopt } from "./Adoption.ts";
|
|
49
|
+
import { type DeploymentAttributes, siteUrlOf } from "./Deployment.ts";
|
|
50
|
+
import { listVariables, writeVariables } from "./EnvironmentVariable.ts";
|
|
51
|
+
import { retryIdempotentWrite } from "./ManagementApi.ts";
|
|
52
|
+
import type { Providers } from "./Providers.ts";
|
|
53
|
+
|
|
54
|
+
/** The attributes of a Convex.Deployment that a push needs. Pass the resource itself. */
|
|
55
|
+
export type CodeDeployment = Pick<DeploymentAttributes, "name" | "type" | "url" | "previewName">;
|
|
56
|
+
|
|
57
|
+
export interface CodeProps {
|
|
58
|
+
/**
|
|
59
|
+
* The deployment that receives the push: a Convex.Deployment resource, or a
|
|
60
|
+
* deployment name such as `happy-animal-123`. A preview deploy key needs the
|
|
61
|
+
* resource. For `env` on a deployment outside US East, pass the resource.
|
|
62
|
+
*/
|
|
63
|
+
readonly deployment: string | CodeDeployment;
|
|
64
|
+
/**
|
|
65
|
+
* A deploy key of `deployment` (Convex.DeployKey), or, for a preview
|
|
66
|
+
* deployment, a preview deploy key of its project (Convex.PreviewDeployKey).
|
|
67
|
+
*/
|
|
68
|
+
readonly deployKey: Redacted.Redacted<string>;
|
|
69
|
+
/** The Convex app directory: the one with `convex/` and the `convex` package installed. */
|
|
70
|
+
readonly cwd: string;
|
|
71
|
+
/**
|
|
72
|
+
* The program and first arguments that run the Convex CLI, such as
|
|
73
|
+
* `["pnpm", "exec", "convex"]`. Code adds `deploy` and its flags. By
|
|
74
|
+
* default, the `convex` package that `cwd` resolves, run with this Node.js.
|
|
75
|
+
*/
|
|
76
|
+
readonly command?: readonly [string, ...string[]];
|
|
77
|
+
/** Environment variables to set on the deployment before the push. Needs a deploy key of the deployment. */
|
|
78
|
+
readonly env?: Readonly<Record<string, string | Redacted.Redacted<string>>>;
|
|
79
|
+
/** `convex deploy --typecheck`. The CLI default is `try`. */
|
|
80
|
+
readonly typecheck?: "enable" | "try" | "disable";
|
|
81
|
+
/** `convex deploy --codegen`. The CLI default is `enable`. */
|
|
82
|
+
readonly codegen?: "enable" | "disable";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CodeAttributes {
|
|
86
|
+
/** The deployment that received the push, such as `happy-animal-123`. */
|
|
87
|
+
readonly deploymentName: string;
|
|
88
|
+
/** Client URL, such as `https://happy-animal-123.convex.cloud`. */
|
|
89
|
+
readonly url: string;
|
|
90
|
+
/** HTTP actions URL, such as `https://happy-animal-123.convex.site`. */
|
|
91
|
+
readonly siteUrl: string;
|
|
92
|
+
/** When the push finished, in milliseconds since the Unix epoch. */
|
|
93
|
+
readonly deployedAt: number;
|
|
94
|
+
/** The names of the `env` variables that this resource set on the deployment, sorted. */
|
|
95
|
+
readonly envNames: readonly string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type Code = Resource<"Convex.Code", CodeProps, CodeAttributes, never, Providers>;
|
|
99
|
+
export const Code = Resource<Code>("Convex.Code");
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The CLI prints this line with `logFinishedStep` after the push succeeds, to
|
|
103
|
+
* stderr (convex 1.46 `cli/lib/deploy2.js` for existing deployments,
|
|
104
|
+
* `cli/deploy.js` for preview deployments). The host is
|
|
105
|
+
* `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
|
|
106
|
+
*/
|
|
107
|
+
const DEPLOYED_LINE =
|
|
108
|
+
/Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
|
|
109
|
+
|
|
110
|
+
/** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
|
|
111
|
+
export const parseDeployOutput = (
|
|
112
|
+
text: string,
|
|
113
|
+
): { url: string; deploymentName: string } | undefined => {
|
|
114
|
+
const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
|
|
115
|
+
const url = match?.[1];
|
|
116
|
+
const deploymentName = match?.[2];
|
|
117
|
+
return url === undefined || deploymentName === undefined ? undefined : { url, deploymentName };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
|
|
122
|
+
* (or `<deployment>|<secret>` in old keys) for one deployment,
|
|
123
|
+
* `preview:<team>:<project>|<secret>` for preview deployments of a project,
|
|
124
|
+
* `project:<team>:<project>|<secret>` for the project's production
|
|
125
|
+
* deployment. Same rules as convex 1.46 `cli/lib/deployment.js`.
|
|
126
|
+
*/
|
|
127
|
+
export const keyTargetOf = (
|
|
128
|
+
key: string,
|
|
129
|
+
):
|
|
130
|
+
| { readonly kind: "deployment"; readonly deployment: string }
|
|
131
|
+
| { readonly kind: "preview" | "project" | "unknown" } => {
|
|
132
|
+
const bar = key.indexOf("|");
|
|
133
|
+
// A key in the env file must be one unquoted dotenv value.
|
|
134
|
+
if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "unknown" };
|
|
135
|
+
const parts = key.slice(0, bar).split(":");
|
|
136
|
+
const [first, second] = parts;
|
|
137
|
+
if (parts.length === 3 && first === "preview") return { kind: "preview" };
|
|
138
|
+
if (parts.length === 3 && first === "project") return { kind: "project" };
|
|
139
|
+
if (parts.length === 2 && second) return { kind: "deployment", deployment: second };
|
|
140
|
+
if (parts.length === 1 && first) return { kind: "deployment", deployment: first };
|
|
141
|
+
return { kind: "unknown" };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The deploy key does not fit the deployment. Checked before the push. */
|
|
145
|
+
export class DeployKeyMismatch extends Schema.TaggedError<DeployKeyMismatch>()(
|
|
146
|
+
"DeployKeyMismatch",
|
|
147
|
+
{
|
|
148
|
+
/** The deployment that should receive the push. */
|
|
149
|
+
deployment: Schema.String,
|
|
150
|
+
/** What the key targets. */
|
|
151
|
+
key: Schema.Literals(["deployment", "preview", "project", "unknown"]),
|
|
152
|
+
/** For a deployment key: the deployment in the key prefix. Never the secret. */
|
|
153
|
+
keyDeployment: Schema.UndefinedOr(Schema.String),
|
|
154
|
+
},
|
|
155
|
+
) {
|
|
156
|
+
override get message() {
|
|
157
|
+
switch (this.key) {
|
|
158
|
+
case "deployment":
|
|
159
|
+
return `The deploy key is for deployment ${this.keyDeployment}, not for ${this.deployment}. Pass a deploy key of ${this.deployment}, such as Convex.DeployKey with deployment ${this.deployment}.`;
|
|
160
|
+
case "preview":
|
|
161
|
+
return `A preview deploy key pushes to a preview deployment by its preview name, and ${this.deployment} is not a Convex.Deployment of type "preview". Pass the Convex.Deployment preview resource as deployment, or a deploy key of ${this.deployment}.`;
|
|
162
|
+
case "project":
|
|
163
|
+
return `Convex.Code does not take a project deploy key: it cannot check before the push that the key targets ${this.deployment}. Pass a deploy key of ${this.deployment} (Convex.DeployKey).`;
|
|
164
|
+
case "unknown":
|
|
165
|
+
return `deployKey is not a Convex deploy key ("<type>:<deployment>|<secret>" or "preview:<team>:<project>|<secret>"). Pass Convex.DeployKey(...).deployKey or Convex.PreviewDeployKey(...).previewDeployKey.`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `env` needs the deployment API, which takes only a deploy key of the deployment. */
|
|
171
|
+
export class EnvironmentNeedsDeployKey extends Schema.TaggedError<EnvironmentNeedsDeployKey>()(
|
|
172
|
+
"EnvironmentNeedsDeployKey",
|
|
173
|
+
{ deployment: Schema.String },
|
|
174
|
+
) {
|
|
175
|
+
override get message() {
|
|
176
|
+
return `Convex.Code cannot set or remove env variables on ${this.deployment} with a preview deploy key: the deployment API takes only a deploy key of the deployment. Pass a Convex.DeployKey of ${this.deployment}, or use Convex.DefaultEnvironmentVariable with deploymentType "preview" for values that every new preview deployment gets.`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** No `convex` package resolves from `cwd`. */
|
|
181
|
+
export class ConvexCliNotFound extends Schema.TaggedError<ConvexCliNotFound>()(
|
|
182
|
+
"ConvexCliNotFound",
|
|
183
|
+
{ cwd: Schema.String },
|
|
184
|
+
) {
|
|
185
|
+
override get message() {
|
|
186
|
+
return `Convex.Code found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), set cwd to the app directory, or set the command prop.`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
|
|
191
|
+
export class CodePushFailed extends Schema.TaggedError<CodePushFailed>()("CodePushFailed", {
|
|
192
|
+
/** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
|
|
193
|
+
exitCode: Schema.UndefinedOr(Schema.Number),
|
|
194
|
+
/** The last lines of the CLI output, with the deploy key removed. */
|
|
195
|
+
outputTail: Schema.String,
|
|
196
|
+
}) {
|
|
197
|
+
override get message() {
|
|
198
|
+
return this.exitCode === 0
|
|
199
|
+
? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line, so Convex.Code has no deployment URL. Last output, with the deploy key removed:\n${this.outputTail}`
|
|
200
|
+
: `convex deploy failed${this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The CLI pushed to another deployment than the one in the props. Checked after the push. */
|
|
205
|
+
export class PushTargetMismatch extends Schema.TaggedError<PushTargetMismatch>()(
|
|
206
|
+
"PushTargetMismatch",
|
|
207
|
+
{ expected: Schema.String, actual: Schema.String },
|
|
208
|
+
) {
|
|
209
|
+
override get message() {
|
|
210
|
+
return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Code expected ${this.expected}. With a preview deploy key, this happens when the key belongs to another project, or when the preview deployment ${this.expected} expired during the deploy and the CLI created ${this.actual} for the same preview name. Check the key. After an expiry, run the deploy again with --adopt, so that Convex.Deployment takes over ${this.actual}.`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Checks that the key fits the deployment, and returns the preview name for
|
|
216
|
+
* `--preview-name` when the key is a preview deploy key.
|
|
217
|
+
*/
|
|
218
|
+
const previewNameFor = (
|
|
219
|
+
target: {
|
|
220
|
+
readonly name: string;
|
|
221
|
+
readonly type: CodeDeployment["type"] | undefined;
|
|
222
|
+
readonly previewName: string | undefined;
|
|
223
|
+
},
|
|
224
|
+
keyTarget: ReturnType<typeof keyTargetOf>,
|
|
225
|
+
changesEnvironment: boolean,
|
|
226
|
+
): Effect.Effect<string | undefined, DeployKeyMismatch | EnvironmentNeedsDeployKey> => {
|
|
227
|
+
const mismatch = new DeployKeyMismatch({
|
|
228
|
+
deployment: target.name,
|
|
229
|
+
key: keyTarget.kind,
|
|
230
|
+
keyDeployment: keyTarget.kind === "deployment" ? keyTarget.deployment : undefined,
|
|
231
|
+
});
|
|
232
|
+
switch (keyTarget.kind) {
|
|
233
|
+
case "deployment":
|
|
234
|
+
return keyTarget.deployment === target.name
|
|
235
|
+
? Effect.succeed(undefined)
|
|
236
|
+
: Effect.fail(mismatch);
|
|
237
|
+
case "preview":
|
|
238
|
+
if (target.type !== "preview" || target.previewName === undefined) {
|
|
239
|
+
return Effect.fail(mismatch);
|
|
240
|
+
}
|
|
241
|
+
return changesEnvironment
|
|
242
|
+
? Effect.fail(new EnvironmentNeedsDeployKey({ deployment: target.name }))
|
|
243
|
+
: Effect.succeed(target.previewName);
|
|
244
|
+
case "project":
|
|
245
|
+
case "unknown":
|
|
246
|
+
return Effect.fail(mismatch);
|
|
247
|
+
default:
|
|
248
|
+
return keyTarget satisfies never;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Number of output lines that a failure quotes. */
|
|
253
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
254
|
+
|
|
255
|
+
/** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
|
|
256
|
+
const outputTail = (text: string, secrets: readonly string[]) =>
|
|
257
|
+
secrets
|
|
258
|
+
.reduce(
|
|
259
|
+
(redacted, secret) => redacted.split(secret).join("[REDACTED]"),
|
|
260
|
+
stripVTControlCharacters(text),
|
|
261
|
+
)
|
|
262
|
+
.split(/\r?\n|\r/)
|
|
263
|
+
.filter((line) => line.trim() !== "")
|
|
264
|
+
.slice(-OUTPUT_TAIL_LINES)
|
|
265
|
+
.join("\n");
|
|
266
|
+
|
|
267
|
+
const CliManifest = Schema.Struct({
|
|
268
|
+
bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
|
|
272
|
+
const installedCli = (cwd: string) =>
|
|
273
|
+
Effect.tryPromise({
|
|
274
|
+
try: async (): Promise<readonly [string, ...string[]]> => {
|
|
275
|
+
const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
|
|
276
|
+
const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(
|
|
277
|
+
await readFile(manifestPath, "utf8"),
|
|
278
|
+
);
|
|
279
|
+
const entry = typeof bin === "string" ? bin : bin["convex"];
|
|
280
|
+
if (entry === undefined) throw new Error("the convex package has no convex bin");
|
|
281
|
+
return [process.execPath, resolve(dirname(manifestPath), entry)];
|
|
282
|
+
},
|
|
283
|
+
catch: () => new ConvexCliNotFound({ cwd }),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Runs `use` with an env file that holds the key, in a new temporary
|
|
288
|
+
* directory, and removes the directory afterwards: on success, on failure,
|
|
289
|
+
* and on interruption.
|
|
290
|
+
*/
|
|
291
|
+
const withKeyFile = <A, E, R>(
|
|
292
|
+
key: string,
|
|
293
|
+
use: (paths: { readonly envFile: string; readonly home: string }) => Effect.Effect<A, E, R>,
|
|
294
|
+
) =>
|
|
295
|
+
Effect.acquireUseRelease(
|
|
296
|
+
Effect.promise(() => mkdtemp(join(tmpdir(), "alchemy-convex-"))),
|
|
297
|
+
(home) =>
|
|
298
|
+
Effect.promise(() =>
|
|
299
|
+
writeFile(join(home, "deploy.env"), `CONVEX_DEPLOY_KEY=${key}\n`, { mode: 0o600 }),
|
|
300
|
+
).pipe(Effect.andThen(use({ envFile: join(home, "deploy.env"), home }))),
|
|
301
|
+
(home) => Effect.promise(() => rm(home, { recursive: true, force: true })),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The environment of the CLI: this process's variables without `CONVEX_*`,
|
|
306
|
+
* with `home` as the home directory.
|
|
307
|
+
*/
|
|
308
|
+
const cliEnvironment = (home: string): Record<string, string | undefined> => ({
|
|
309
|
+
...Object.fromEntries(
|
|
310
|
+
Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_")),
|
|
311
|
+
),
|
|
312
|
+
HOME: home,
|
|
313
|
+
USERPROFILE: home,
|
|
314
|
+
XDG_CONFIG_HOME: home,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export const CodeProvider = () =>
|
|
318
|
+
Provider.effect(
|
|
319
|
+
Code,
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
322
|
+
|
|
323
|
+
/** Runs the CLI with piped output. Interruption stops its process group. */
|
|
324
|
+
const run = (argv: readonly [string, ...string[]], cwd: string, home: string) =>
|
|
325
|
+
Effect.gen(function* () {
|
|
326
|
+
const [command, ...args] = argv;
|
|
327
|
+
const child = yield* spawner.spawn(
|
|
328
|
+
ChildProcess.make(command, args, {
|
|
329
|
+
cwd,
|
|
330
|
+
env: cliEnvironment(home),
|
|
331
|
+
extendEnv: false,
|
|
332
|
+
stdin: "ignore",
|
|
333
|
+
stdout: "pipe",
|
|
334
|
+
stderr: "pipe",
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
const [exitCode, stdout, stderr] = yield* Effect.all(
|
|
338
|
+
[
|
|
339
|
+
child.exitCode,
|
|
340
|
+
Stream.mkString(Stream.decodeText(child.stdout)),
|
|
341
|
+
Stream.mkString(Stream.decodeText(child.stderr)),
|
|
342
|
+
],
|
|
343
|
+
{ concurrency: "unbounded" },
|
|
344
|
+
);
|
|
345
|
+
return { exitCode: Number(exitCode), output: `${stdout}\n${stderr}` };
|
|
346
|
+
}).pipe(Effect.scoped);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
// Every apply pushes: see the top of this file.
|
|
350
|
+
diff: () => Effect.succeed({ action: "update" } as const),
|
|
351
|
+
|
|
352
|
+
reconcile: Effect.fn(function* ({ fqn, news, output }) {
|
|
353
|
+
const target =
|
|
354
|
+
typeof news.deployment === "string"
|
|
355
|
+
? {
|
|
356
|
+
name: news.deployment,
|
|
357
|
+
address: news.deployment,
|
|
358
|
+
type: undefined,
|
|
359
|
+
previewName: undefined,
|
|
360
|
+
}
|
|
361
|
+
: { ...news.deployment, address: news.deployment.url };
|
|
362
|
+
const key = Redacted.value(news.deployKey);
|
|
363
|
+
const secrets = [key, key.slice(key.indexOf("|") + 1)].filter(
|
|
364
|
+
(secret) => secret.length >= 8,
|
|
365
|
+
);
|
|
366
|
+
const env = Object.entries(news.env ?? {}).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
367
|
+
// Names set on another deployment stay there: this push does not own them.
|
|
368
|
+
const previous =
|
|
369
|
+
output !== undefined && output.deploymentName === target.name ? output.envNames : [];
|
|
370
|
+
const removed = previous.filter((name) => !env.some(([envName]) => envName === name));
|
|
371
|
+
|
|
372
|
+
// 1. The key must fit the deployment. No side effect before this.
|
|
373
|
+
const previewName = yield* previewNameFor(
|
|
374
|
+
target,
|
|
375
|
+
keyTargetOf(key),
|
|
376
|
+
env.length > 0 || removed.length > 0,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// 2. The CLI must exist before any variable changes.
|
|
380
|
+
const cwd = resolve(news.cwd);
|
|
381
|
+
const program = news.command ?? (yield* installedCli(cwd));
|
|
382
|
+
|
|
383
|
+
// 3. Variables before the push, so the push sees them. Each
|
|
384
|
+
// attempt checks first, so a retry after a write conflict never
|
|
385
|
+
// overwrites a value that another writer set in the meantime.
|
|
386
|
+
if (env.length > 0 || removed.length > 0) {
|
|
387
|
+
const adopt = yield* shouldAdopt(fqn);
|
|
388
|
+
const fresh = env.filter(([name]) => !previous.includes(name));
|
|
389
|
+
yield* retryIdempotentWrite(
|
|
390
|
+
Effect.gen(function* () {
|
|
391
|
+
if (fresh.length > 0 && !adopt) {
|
|
392
|
+
const current = yield* listVariables(target.address, news.deployKey);
|
|
393
|
+
// The same value loses nothing, such as a value that an
|
|
394
|
+
// earlier run set before its push failed.
|
|
395
|
+
const taken = fresh
|
|
396
|
+
.filter(
|
|
397
|
+
([name, value]) =>
|
|
398
|
+
Object.hasOwn(current, name) &&
|
|
399
|
+
current[name] !==
|
|
400
|
+
(Redacted.isRedacted(value) ? Redacted.value(value) : value),
|
|
401
|
+
)
|
|
402
|
+
.map(([name]) => name);
|
|
403
|
+
if (taken.length > 0) {
|
|
404
|
+
return yield* new OwnedBySomeoneElse({
|
|
405
|
+
message: `Environment variables ${taken.join(", ")} already exist on deployment ${target.name} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`,
|
|
406
|
+
resourceType: Code.Type,
|
|
407
|
+
physicalName: target.name,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
yield* writeVariables(target.address, news.deployKey, [
|
|
412
|
+
...env.map(([name, value]) => ({ name, value })),
|
|
413
|
+
...removed.map((name) => ({ name, value: null })),
|
|
414
|
+
]);
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 4. The push.
|
|
420
|
+
const args = [
|
|
421
|
+
"deploy",
|
|
422
|
+
...(previewName === undefined ? [] : ["--preview-name", previewName]),
|
|
423
|
+
...(news.typecheck === undefined ? [] : [`--typecheck=${news.typecheck}`]),
|
|
424
|
+
...(news.codegen === undefined ? [] : [`--codegen=${news.codegen}`]),
|
|
425
|
+
];
|
|
426
|
+
const result = yield* withKeyFile(key, ({ envFile, home }) =>
|
|
427
|
+
run([...program, ...args, "--env-file", envFile], cwd, home),
|
|
428
|
+
).pipe(
|
|
429
|
+
Effect.mapError(
|
|
430
|
+
(error) =>
|
|
431
|
+
new CodePushFailed({
|
|
432
|
+
exitCode: undefined,
|
|
433
|
+
outputTail: outputTail(error.message, secrets),
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
const deployed = parseDeployOutput(result.output);
|
|
438
|
+
if (result.exitCode !== 0 || deployed === undefined) {
|
|
439
|
+
return yield* new CodePushFailed({
|
|
440
|
+
exitCode: result.exitCode,
|
|
441
|
+
outputTail: outputTail(result.output, secrets),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (deployed.deploymentName !== target.name) {
|
|
445
|
+
return yield* new PushTargetMismatch({
|
|
446
|
+
expected: target.name,
|
|
447
|
+
actual: deployed.deploymentName,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
deploymentName: deployed.deploymentName,
|
|
452
|
+
url: deployed.url,
|
|
453
|
+
siteUrl: siteUrlOf(deployed.url),
|
|
454
|
+
deployedAt: yield* Clock.currentTimeMillis,
|
|
455
|
+
envNames: env.map(([name]) => name),
|
|
456
|
+
};
|
|
457
|
+
}),
|
|
458
|
+
|
|
459
|
+
// The functions and the variables stay: the deployment runs them.
|
|
460
|
+
delete: () => Effect.void,
|
|
461
|
+
};
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
@@ -6,14 +6,31 @@
|
|
|
6
6
|
// credential, not with a deploy key. Setting a default and removing it are the
|
|
7
7
|
// same call: update_default_environment_variables with `value: null` removes
|
|
8
8
|
// the default, and removing an absent default also answers 200.
|
|
9
|
+
//
|
|
10
|
+
// A default that exists but is not in state belongs to someone else: its old
|
|
11
|
+
// value is lost when this resource writes it. Alchemy takes it over only with
|
|
12
|
+
// --adopt.
|
|
13
|
+
//
|
|
14
|
+
// The default's project, name, and deployment type cannot change. A change
|
|
15
|
+
// fails with VariableIdentityChange: use a new resource with a new logical
|
|
16
|
+
// id. Read and delete use the identity in the attributes: after a refused
|
|
17
|
+
// change, the props name the refused one.
|
|
9
18
|
import { Resource } from "alchemy";
|
|
19
|
+
import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
|
|
10
20
|
import { isResolved } from "alchemy/Diff";
|
|
11
21
|
import * as Provider from "alchemy/Provider";
|
|
12
22
|
import * as Effect from "effect/Effect";
|
|
13
23
|
import * as Redacted from "effect/Redacted";
|
|
14
24
|
import * as Schema from "effect/Schema";
|
|
15
25
|
import type { components } from "@convex-dev/platform/managementApi";
|
|
16
|
-
import {
|
|
26
|
+
import { shouldAdopt } from "./Adoption.ts";
|
|
27
|
+
import { VariableIdentityChange } from "./EnvironmentVariable.ts";
|
|
28
|
+
import {
|
|
29
|
+
absentAsUndefined,
|
|
30
|
+
ManagementApi,
|
|
31
|
+
type ManagementApiService,
|
|
32
|
+
retryIdempotentWrite,
|
|
33
|
+
} from "./ManagementApi.ts";
|
|
17
34
|
import type { Providers } from "./Providers.ts";
|
|
18
35
|
|
|
19
36
|
/** Deployment types a default can target, from the generated schema. */
|
|
@@ -52,11 +69,8 @@ export const listDefaults = (
|
|
|
52
69
|
)
|
|
53
70
|
.pipe(Effect.map((body) => Schema.decodeUnknownSync(DefaultEnvironmentVariableList)(body)));
|
|
54
71
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
* with an empty body, so this reads no response data.
|
|
58
|
-
*/
|
|
59
|
-
export const updateDefault = (
|
|
72
|
+
/** One attempt to set one default, or to remove it when `value` is null. */
|
|
73
|
+
const writeDefault = (
|
|
60
74
|
api: ManagementApiService,
|
|
61
75
|
projectId: number,
|
|
62
76
|
name: string,
|
|
@@ -78,6 +92,34 @@ export const updateDefault = (
|
|
|
78
92
|
}),
|
|
79
93
|
);
|
|
80
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Sets one default, or removes it when `value` is null. Convex answers 200
|
|
97
|
+
* with an empty body, so this reads no response data. The call sets a value,
|
|
98
|
+
* so it runs again after a write conflict or a 5xx answer, for example when
|
|
99
|
+
* another resource writes a default of the same project at the same time.
|
|
100
|
+
*/
|
|
101
|
+
export const updateDefault = (
|
|
102
|
+
api: ManagementApiService,
|
|
103
|
+
projectId: number,
|
|
104
|
+
name: string,
|
|
105
|
+
deploymentType: DeploymentType,
|
|
106
|
+
value: string | Redacted.Redacted<string> | null,
|
|
107
|
+
) => retryIdempotentWrite(writeDefault(api, projectId, name, deploymentType, value));
|
|
108
|
+
|
|
109
|
+
/** The default's identity, or undefined when the default or the project is absent. */
|
|
110
|
+
const readDefault = (
|
|
111
|
+
api: ManagementApiService,
|
|
112
|
+
projectId: number,
|
|
113
|
+
name: string,
|
|
114
|
+
deploymentType: DeploymentType,
|
|
115
|
+
) =>
|
|
116
|
+
// 404 ProjectNotFound: the project no longer exists, so neither does the default.
|
|
117
|
+
absentAsUndefined(listDefaults(api, projectId, name, deploymentType)).pipe(
|
|
118
|
+
Effect.map((list) =>
|
|
119
|
+
list === undefined ? undefined : findDefault(list, projectId, name, deploymentType),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
81
123
|
/**
|
|
82
124
|
* GET /projects/{project_id}/list_default_environment_variables. Only the
|
|
83
125
|
* fields the provider uses: the row's `value` is the secret in plain text.
|
|
@@ -114,6 +156,34 @@ export const DefaultEnvironmentVariable = Resource<DefaultEnvironmentVariable>(
|
|
|
114
156
|
"Convex.DefaultEnvironmentVariable",
|
|
115
157
|
);
|
|
116
158
|
|
|
159
|
+
/** Fails with VariableIdentityChange when the props name another default than the one in state. */
|
|
160
|
+
const checkIdentity = (
|
|
161
|
+
output: DefaultEnvironmentVariableAttributes,
|
|
162
|
+
news: DefaultEnvironmentVariableProps,
|
|
163
|
+
) => {
|
|
164
|
+
const changed =
|
|
165
|
+
output.projectId !== news.projectId
|
|
166
|
+
? ({
|
|
167
|
+
field: "projectId",
|
|
168
|
+
current: `${output.projectId}`,
|
|
169
|
+
requested: `${news.projectId}`,
|
|
170
|
+
} as const)
|
|
171
|
+
: output.name !== news.name
|
|
172
|
+
? ({ field: "name", current: output.name, requested: news.name } as const)
|
|
173
|
+
: output.deploymentType !== news.deploymentType
|
|
174
|
+
? ({
|
|
175
|
+
field: "deploymentType",
|
|
176
|
+
current: output.deploymentType,
|
|
177
|
+
requested: news.deploymentType,
|
|
178
|
+
} as const)
|
|
179
|
+
: undefined;
|
|
180
|
+
return changed === undefined
|
|
181
|
+
? Effect.void
|
|
182
|
+
: Effect.fail(
|
|
183
|
+
new VariableIdentityChange({ resourceType: DefaultEnvironmentVariable.Type, ...changed }),
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
117
187
|
export const DefaultEnvironmentVariableProvider = () =>
|
|
118
188
|
Provider.effect(
|
|
119
189
|
DefaultEnvironmentVariable,
|
|
@@ -121,32 +191,60 @@ export const DefaultEnvironmentVariableProvider = () =>
|
|
|
121
191
|
const api = yield* ManagementApi;
|
|
122
192
|
|
|
123
193
|
return {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
olds.name !== news.name ||
|
|
133
|
-
olds.deploymentType !== news.deploymentType
|
|
134
|
-
? ({ action: "replace" } as const)
|
|
135
|
-
: undefined,
|
|
136
|
-
);
|
|
137
|
-
},
|
|
194
|
+
// Compares with the attributes: after a refused change, `olds` are
|
|
195
|
+
// the refused props. Otherwise undefined: the engine updates when any
|
|
196
|
+
// prop changed and compares a Redacted value by content.
|
|
197
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
198
|
+
if (!isResolved(news) || output === undefined) return undefined;
|
|
199
|
+
yield* checkIdentity(output, news);
|
|
200
|
+
return undefined;
|
|
201
|
+
}),
|
|
138
202
|
|
|
139
|
-
read: Effect.fn(function* ({ olds }) {
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return findDefault(list, olds.projectId, olds.name, olds.deploymentType);
|
|
203
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
204
|
+
const at = output ?? olds;
|
|
205
|
+
const found = yield* readDefault(api, at.projectId, at.name, at.deploymentType);
|
|
206
|
+
if (found === undefined) return undefined;
|
|
207
|
+
// No state: the default belongs to someone else until the user passes --adopt.
|
|
208
|
+
return output ?? Unowned(found);
|
|
146
209
|
}),
|
|
147
210
|
|
|
148
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
149
|
-
|
|
211
|
+
reconcile: Effect.fn(function* ({ fqn, news, output }) {
|
|
212
|
+
// Also here: with an unresolved prop in plan, diff could not check.
|
|
213
|
+
if (output !== undefined) yield* checkIdentity(output, news);
|
|
214
|
+
const write = writeDefault(
|
|
215
|
+
api,
|
|
216
|
+
news.projectId,
|
|
217
|
+
news.name,
|
|
218
|
+
news.deploymentType,
|
|
219
|
+
news.value,
|
|
220
|
+
);
|
|
221
|
+
if (output !== undefined || (yield* shouldAdopt(fqn))) {
|
|
222
|
+
// In state, or adopted: the default is ours to set.
|
|
223
|
+
yield* retryIdempotentWrite(write);
|
|
224
|
+
} else {
|
|
225
|
+
// Not in state: overwriting a default that exists loses its
|
|
226
|
+
// value, so that needs adoption. Each attempt checks first, so a
|
|
227
|
+
// retry after a write conflict never overwrites a default that
|
|
228
|
+
// another writer created in the meantime.
|
|
229
|
+
yield* retryIdempotentWrite(
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
const existing = yield* readDefault(
|
|
232
|
+
api,
|
|
233
|
+
news.projectId,
|
|
234
|
+
news.name,
|
|
235
|
+
news.deploymentType,
|
|
236
|
+
);
|
|
237
|
+
if (existing !== undefined) {
|
|
238
|
+
return yield* new OwnedBySomeoneElse({
|
|
239
|
+
message: `Project ${news.projectId} already has a ${news.deploymentType} default environment variable ${news.name}. Re-run with --adopt to take it over and overwrite its value, or use a different name.`,
|
|
240
|
+
resourceType: DefaultEnvironmentVariable.Type,
|
|
241
|
+
physicalName: news.name,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
yield* write;
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
150
248
|
return {
|
|
151
249
|
projectId: news.projectId,
|
|
152
250
|
name: news.name,
|