alchemy 0.76.1 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/bin/alchemy.js +109 -29
  2. package/bin/services/execute-alchemy.ts +10 -1
  3. package/lib/aws/ec2/index.d.ts +1 -0
  4. package/lib/aws/ec2/index.d.ts.map +1 -1
  5. package/lib/aws/ec2/index.js +1 -0
  6. package/lib/aws/ec2/index.js.map +1 -1
  7. package/lib/aws/index.d.ts +1 -7
  8. package/lib/aws/index.d.ts.map +1 -1
  9. package/lib/aws/index.js +1 -8
  10. package/lib/aws/index.js.map +1 -1
  11. package/lib/cloudflare/bucket-custom-domain.d.ts +90 -0
  12. package/lib/cloudflare/bucket-custom-domain.d.ts.map +1 -0
  13. package/lib/cloudflare/bucket-custom-domain.js +81 -0
  14. package/lib/cloudflare/bucket-custom-domain.js.map +1 -0
  15. package/lib/cloudflare/bucket.d.ts +28 -4
  16. package/lib/cloudflare/bucket.d.ts.map +1 -1
  17. package/lib/cloudflare/bucket.js +41 -14
  18. package/lib/cloudflare/bucket.js.map +1 -1
  19. package/lib/cloudflare/cloudflare-env-proxy.d.ts.map +1 -1
  20. package/lib/cloudflare/cloudflare-env-proxy.js +0 -3
  21. package/lib/cloudflare/cloudflare-env-proxy.js.map +1 -1
  22. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  23. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  24. package/lib/cloudflare/container.d.ts.map +1 -1
  25. package/lib/cloudflare/container.js +8 -5
  26. package/lib/cloudflare/container.js.map +1 -1
  27. package/lib/cloudflare/d1-database.d.ts +13 -3
  28. package/lib/cloudflare/d1-database.d.ts.map +1 -1
  29. package/lib/cloudflare/d1-database.js +31 -15
  30. package/lib/cloudflare/d1-database.js.map +1 -1
  31. package/lib/cloudflare/index.d.ts +1 -0
  32. package/lib/cloudflare/index.d.ts.map +1 -1
  33. package/lib/cloudflare/index.js +1 -0
  34. package/lib/cloudflare/index.js.map +1 -1
  35. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  36. package/lib/cloudflare/miniflare/remote-binding-proxy.js +73 -9
  37. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  38. package/lib/cloudflare/react-router/plugin.d.ts +2 -1
  39. package/lib/cloudflare/react-router/plugin.d.ts.map +1 -1
  40. package/lib/cloudflare/react-router/plugin.js +6 -14
  41. package/lib/cloudflare/react-router/plugin.js.map +1 -1
  42. package/lib/cloudflare/vite/plugin.d.ts.map +1 -1
  43. package/lib/cloudflare/vite/plugin.js +0 -3
  44. package/lib/cloudflare/vite/plugin.js.map +1 -1
  45. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  46. package/lib/cloudflare/worker-bundle.js +17 -0
  47. package/lib/cloudflare/worker-bundle.js.map +1 -1
  48. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  49. package/lib/cloudflare/worker-metadata.js +6 -4
  50. package/lib/cloudflare/worker-metadata.js.map +1 -1
  51. package/lib/cloudflare/worker-subdomain.d.ts.map +1 -1
  52. package/lib/cloudflare/worker-subdomain.js +0 -1
  53. package/lib/cloudflare/worker-subdomain.js.map +1 -1
  54. package/lib/cloudflare/worker.d.ts.map +1 -1
  55. package/lib/cloudflare/worker.js +28 -38
  56. package/lib/cloudflare/worker.js.map +1 -1
  57. package/lib/cloudflare/wrangler.json.d.ts +2 -281
  58. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  59. package/lib/cloudflare/wrangler.json.js +23 -11
  60. package/lib/cloudflare/wrangler.json.js.map +1 -1
  61. package/lib/docker/api.d.ts +4 -0
  62. package/lib/docker/api.d.ts.map +1 -1
  63. package/lib/docker/api.js +3 -0
  64. package/lib/docker/api.js.map +1 -1
  65. package/lib/docker/image.d.ts +50 -12
  66. package/lib/docker/image.d.ts.map +1 -1
  67. package/lib/docker/image.js +86 -58
  68. package/lib/docker/image.js.map +1 -1
  69. package/lib/docker/remote-image.d.ts +1 -0
  70. package/lib/docker/remote-image.d.ts.map +1 -1
  71. package/lib/docker/remote-image.js +2 -0
  72. package/lib/docker/remote-image.js.map +1 -1
  73. package/lib/encrypt.d.ts +28 -3
  74. package/lib/encrypt.d.ts.map +1 -1
  75. package/lib/encrypt.js +77 -3
  76. package/lib/encrypt.js.map +1 -1
  77. package/lib/planetscale/role.d.ts +9 -0
  78. package/lib/planetscale/role.d.ts.map +1 -1
  79. package/lib/planetscale/role.js +18 -3
  80. package/lib/planetscale/role.js.map +1 -1
  81. package/lib/prisma-postgres/api.d.ts.map +1 -1
  82. package/lib/prisma-postgres/api.js +1 -0
  83. package/lib/prisma-postgres/api.js.map +1 -1
  84. package/lib/scope.d.ts +5 -0
  85. package/lib/scope.d.ts.map +1 -1
  86. package/lib/scope.js +23 -0
  87. package/lib/scope.js.map +1 -1
  88. package/lib/serde.js +1 -1
  89. package/lib/serde.js.map +1 -1
  90. package/lib/util/telemetry.js +4 -0
  91. package/lib/util/telemetry.js.map +1 -1
  92. package/package.json +12 -10
  93. package/src/aws/ec2/index.ts +1 -0
  94. package/src/aws/index.ts +1 -9
  95. package/src/cloudflare/bucket-custom-domain.ts +281 -0
  96. package/src/cloudflare/bucket.ts +85 -18
  97. package/src/cloudflare/cloudflare-env-proxy.ts +0 -3
  98. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  99. package/src/cloudflare/container.ts +10 -7
  100. package/src/cloudflare/d1-database.ts +44 -6
  101. package/src/cloudflare/index.ts +1 -0
  102. package/src/cloudflare/miniflare/remote-binding-proxy.ts +80 -9
  103. package/src/cloudflare/react-router/plugin.ts +9 -21
  104. package/src/cloudflare/vite/plugin.ts +0 -3
  105. package/src/cloudflare/worker-bundle.ts +28 -1
  106. package/src/cloudflare/worker-metadata.ts +8 -4
  107. package/src/cloudflare/worker-subdomain.ts +0 -1
  108. package/src/cloudflare/worker.ts +32 -45
  109. package/src/cloudflare/wrangler.json.ts +62 -397
  110. package/src/docker/api.ts +7 -0
  111. package/src/docker/image.ts +164 -89
  112. package/src/docker/remote-image.ts +3 -0
  113. package/src/encrypt.ts +115 -3
  114. package/src/planetscale/role.ts +30 -3
  115. package/src/prisma-postgres/api.ts +1 -0
  116. package/src/scope.ts +27 -0
  117. package/src/serde.ts +1 -1
  118. package/src/util/telemetry.ts +5 -0
  119. package/workers/cloudflare-state-store.js +4802 -8
  120. package/workers/dofs-state-store.js +632 -2
  121. package/workers/remote-binding-proxy.js +1929 -3
  122. package/workers/tunnel-proxy.js +58 -6
  123. package/workers/tunnel-proxy.ts +1 -1
@@ -5,6 +5,7 @@ import type { Context } from "../context.ts";
5
5
  import { Resource } from "../resource.ts";
6
6
  import type { Secret } from "../secret.ts";
7
7
  import { DockerApi } from "./api.ts";
8
+ import type { RemoteImage } from "./remote-image.ts";
8
9
 
9
10
  /**
10
11
  * Options for building a Docker image
@@ -40,9 +41,28 @@ export interface DockerBuildOptions {
40
41
  target?: string;
41
42
 
42
43
  /**
43
- * List of images to use for cache
44
+ * Use an external cache source for a build
45
+ *
46
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-from
47
+ *
44
48
  */
45
49
  cacheFrom?: string[];
50
+
51
+ /**
52
+ * Export build cache to an external cache destination
53
+ *
54
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-to
55
+ *
56
+ */
57
+ cacheTo?: string[];
58
+
59
+ /**
60
+ * Additional options to pass to the Docker build command. This serves as an escape hatch for any additional options that are not supported by the other properties.
61
+ *
62
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#options
63
+ *
64
+ */
65
+ options?: string[];
46
66
  }
47
67
 
48
68
  export interface ImageRegistry {
@@ -54,22 +74,12 @@ export interface ImageRegistry {
54
74
  /**
55
75
  * Properties for creating a Docker image
56
76
  */
57
- export interface ImageProps {
58
- /**
59
- * Repository name for the image (e.g., "username/image")
60
- */
61
- name?: string;
62
-
77
+ export type ImageProps = {
63
78
  /**
64
79
  * Tag for the image (e.g., "latest")
65
80
  */
66
81
  tag?: string;
67
82
 
68
- /**
69
- * Build configuration
70
- */
71
- build?: DockerBuildOptions;
72
-
73
83
  /**
74
84
  * Registry credentials
75
85
  */
@@ -79,12 +89,36 @@ export interface ImageProps {
79
89
  * Whether to skip pushing the image to registry
80
90
  */
81
91
  skipPush?: boolean;
82
- }
92
+ } & (
93
+ | {
94
+ /**
95
+ * Image name or reference (e.g., "nginx:alpine")
96
+ */
97
+ image: string | Image | RemoteImage;
98
+ build?: never;
99
+ name?: never;
100
+ }
101
+ | {
102
+ /**
103
+ * Repository name for the image (e.g., "username/image")
104
+ *
105
+ * @default - the id
106
+ */
107
+ name?: string;
108
+ /**
109
+ * Build configuration
110
+ */
111
+ build: DockerBuildOptions;
112
+
113
+ image?: never;
114
+ }
115
+ );
83
116
 
84
117
  /**
85
118
  * Docker Image resource
86
119
  */
87
- export interface Image extends ImageProps {
120
+ export interface Image {
121
+ kind: "Image";
88
122
  /**
89
123
  * Image name
90
124
  */
@@ -109,6 +143,15 @@ export interface Image extends ImageProps {
109
143
  * Time when the image was built
110
144
  */
111
145
  builtAt: number;
146
+ /**
147
+ * Tag for the image
148
+ */
149
+ tag: string;
150
+
151
+ /**
152
+ * Build configuration
153
+ */
154
+ build: DockerBuildOptions | undefined;
112
155
  }
113
156
 
114
157
  /**
@@ -142,12 +185,32 @@ export const Image = Resource(
142
185
  // No action needed for delete as Docker images aren't automatically removed
143
186
  // This is intentional as other resources might depend on the same image
144
187
  return this.destroy();
145
- } else {
146
- // Normalize properties
147
- const tag = props.tag || "latest";
148
- const name = props.name || id;
149
- const imageRef = `${name}:${tag}`;
188
+ }
150
189
 
190
+ const tag = props.tag || "latest";
191
+ const name =
192
+ props.name ||
193
+ (typeof props.image === "string"
194
+ ? props.image
195
+ : props.image?.name
196
+ )?.split(":")[0] ||
197
+ id;
198
+ const imageRef = `${name}:${tag}`;
199
+ let imageId: string | undefined;
200
+ if (props.image) {
201
+ const image =
202
+ typeof props.image === "string" ? props.image : props.image.imageRef;
203
+
204
+ const kind =
205
+ typeof props.image === "object" && props.image.kind === "Image"
206
+ ? "local"
207
+ : "remote";
208
+ if (kind === "remote") {
209
+ await api.pullImage(image);
210
+ }
211
+ await api.tagImage(image, imageRef);
212
+ // TODO: Extract image ID from pull output if available
213
+ } else {
151
214
  let context: string;
152
215
  let dockerfile: string;
153
216
  if (props.build?.dockerfile && props.build?.context) {
@@ -170,7 +233,7 @@ export const Image = Resource(
170
233
  const buildOptions: Record<string, string> = props.build?.args || {};
171
234
 
172
235
  // Add platform if specified
173
- let buildArgs = ["build", "-t", imageRef];
236
+ const buildArgs = ["build", "-t", imageRef];
174
237
 
175
238
  if (props.build?.platform) {
176
239
  buildArgs.push("--platform", props.build.platform);
@@ -188,11 +251,23 @@ export const Image = Resource(
188
251
  }
189
252
  }
190
253
 
254
+ // Add cache destinations if specified
255
+ if (props.build?.cacheTo && props.build.cacheTo.length > 0) {
256
+ for (const cacheTarget of props.build.cacheTo) {
257
+ buildArgs.push("--cache-to", cacheTarget);
258
+ }
259
+ }
260
+
191
261
  // Add build arguments
192
262
  for (const [key, value] of Object.entries(buildOptions)) {
193
263
  buildArgs.push("--build-arg", `${key}=${value}`);
194
264
  }
195
265
 
266
+ // Add build options if specified
267
+ if (props.build?.options && props.build.options.length > 0) {
268
+ buildArgs.push(...props.build.options);
269
+ }
270
+
196
271
  buildArgs.push("-f", dockerfile);
197
272
 
198
273
  // Add context path
@@ -203,77 +278,77 @@ export const Image = Resource(
203
278
 
204
279
  // Extract image ID from build output if available
205
280
  const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout);
206
- const imageId = imageIdMatch ? imageIdMatch[1] : undefined;
207
-
208
- // Handle push if required
209
- let repoDigest: string | undefined;
210
- let finalImageRef = imageRef;
211
- if (props.registry && !props.skipPush) {
212
- const { server, username, password } = props.registry;
213
-
214
- // Ensure the registry server does not have trailing slash
215
- const registryHost = server.replace(/\/$/, "");
216
-
217
- // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
218
- const firstSegment = imageRef.split("/")[0];
219
- const hasRegistryPrefix = firstSegment.includes(".");
220
-
221
- // Compose the target image reference that will be pushed
222
- const targetImage = hasRegistryPrefix
223
- ? imageRef // already fully-qualified
224
- : `${registryHost}/${imageRef}`;
225
-
226
- try {
227
- // Create a temporary directory that will act as an isolated Docker config
228
- // (credentials) directory. This prevents race-conditions when multiple
229
- // concurrent tests perform `docker login` / `logout` by ensuring each
230
- // Image operation has its own credential store.
231
- const tempConfigDir = await fs.mkdtemp(
232
- path.join(os.tmpdir(), "docker-config-"),
233
- );
234
- const api = new DockerApi({ configDir: tempConfigDir });
235
-
236
- // Authenticate to registry using the isolated config directory
237
- await api.login(registryHost, username, password.unencrypted);
238
-
239
- // Tag local image with fully qualified name if necessary
240
- if (targetImage !== imageRef) {
241
- await api.exec(["tag", imageRef, targetImage]);
242
- }
243
-
244
- // Push the image
245
- const { stdout: pushOut } = await api.exec(["push", targetImage]);
246
-
247
- // Attempt to extract the repo digest from push output
248
- const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(
249
- pushOut,
250
- );
251
- if (digestMatch) {
252
- const digestHash = digestMatch[1];
253
- // Strip tag (anything after last :) to build image@digest reference
254
- const [repoWithoutTag] =
255
- targetImage.split(":").length > 2
256
- ? [targetImage] // unlikely but safety
257
- : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
258
- repoDigest = `${repoWithoutTag}@${digestHash}`;
259
- }
260
-
261
- // Update the final image reference to point at the pushed image
262
- finalImageRef = targetImage;
263
- } finally {
264
- // Clean up credentials from the isolated config
265
- await api.logout(registryHost);
281
+ imageId = imageIdMatch ? imageIdMatch[1] : undefined;
282
+ }
283
+
284
+ // Handle push if required
285
+ let repoDigest: string | undefined;
286
+ let finalImageRef = imageRef;
287
+ if (props.registry && !props.skipPush) {
288
+ const { server, username, password } = props.registry;
289
+
290
+ // Ensure the registry server does not have trailing slash
291
+ const registryHost = server.replace(/\/$/, "");
292
+
293
+ // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
294
+ const firstSegment = imageRef.split("/")[0];
295
+ const hasRegistryPrefix = firstSegment.includes(".");
296
+
297
+ // Compose the target image reference that will be pushed
298
+ const targetImage = hasRegistryPrefix
299
+ ? imageRef // already fully-qualified
300
+ : `${registryHost}/${imageRef}`;
301
+
302
+ try {
303
+ // Create a temporary directory that will act as an isolated Docker config
304
+ // (credentials) directory. This prevents race-conditions when multiple
305
+ // concurrent tests perform `docker login` / `logout` by ensuring each
306
+ // Image operation has its own credential store.
307
+ const tempConfigDir = await fs.mkdtemp(
308
+ path.join(os.tmpdir(), "docker-config-"),
309
+ );
310
+ const api = new DockerApi({ configDir: tempConfigDir });
311
+
312
+ // Authenticate to registry using the isolated config directory
313
+ await api.login(registryHost, username, password.unencrypted);
314
+
315
+ // Tag local image with fully qualified name if necessary
316
+ if (targetImage !== imageRef) {
317
+ await api.exec(["tag", imageRef, targetImage]);
266
318
  }
267
- }
268
319
 
269
- return {
270
- ...props,
271
- name,
272
- imageRef: finalImageRef,
273
- imageId,
274
- repoDigest,
275
- builtAt: Date.now(),
276
- };
320
+ // Push the image
321
+ const { stdout: pushOut } = await api.exec(["push", targetImage]);
322
+
323
+ // Attempt to extract the repo digest from push output
324
+ const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(pushOut);
325
+ if (digestMatch) {
326
+ const digestHash = digestMatch[1];
327
+ // Strip tag (anything after last :) to build image@digest reference
328
+ const [repoWithoutTag] =
329
+ targetImage.split(":").length > 2
330
+ ? [targetImage] // unlikely but safety
331
+ : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
332
+ repoDigest = `${repoWithoutTag}@${digestHash}`;
333
+ }
334
+
335
+ // Update the final image reference to point at the pushed image
336
+ finalImageRef = targetImage;
337
+ } finally {
338
+ // Clean up credentials from the isolated config
339
+ await api.logout(registryHost);
340
+ }
277
341
  }
342
+ return {
343
+ kind: "Image",
344
+ ...props,
345
+ tag,
346
+ name,
347
+ imageRef: finalImageRef,
348
+ imageId,
349
+ repoDigest,
350
+ builtAt: Date.now(),
351
+ build: props.build,
352
+ };
278
353
  },
279
354
  );
@@ -26,6 +26,7 @@ export interface RemoteImageProps {
26
26
  * Docker Remote Image resource
27
27
  */
28
28
  export interface RemoteImage extends RemoteImageProps {
29
+ kind: "RemoteImage";
29
30
  /**
30
31
  * Full image reference (name:tag)
31
32
  */
@@ -71,8 +72,10 @@ export const RemoteImage = Resource(
71
72
  await api.pullImage(imageRef);
72
73
 
73
74
  return {
75
+ kind: "RemoteImage",
74
76
  ...props,
75
77
  imageRef,
78
+ tag,
76
79
  createdAt: Date.now(),
77
80
  };
78
81
  }
package/src/encrypt.ts CHANGED
@@ -1,11 +1,122 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const KEY_LEN = 32;
4
+ const SCRYPT_N = 16384;
5
+ const SCRYPT_R = 8;
6
+ const SCRYPT_P = 1;
7
+ const SALT_LEN = 16;
8
+ const IV_LEN = 12;
9
+ const ALGO = "aes-256-gcm";
10
+
11
+ interface Encrypted {
12
+ version: "v1";
13
+ ciphertext: string; // base64
14
+ iv: string; // base64
15
+ salt: string; // base64
16
+ tag: string; // base64
17
+ }
18
+
19
+ /**
20
+ * Decrypts a value encrypted with a symmetric key.
21
+ * For backwards compatibility, uses the legacy libsodium implementation if the value is a string.
22
+ * Otherwise, uses the AES-256-GCM implementation with scrypt key derivation.
23
+ */
24
+ export function decryptWithKey(
25
+ value: string | Encrypted,
26
+ key: string,
27
+ ): Promise<string> {
28
+ if (typeof value === "string") {
29
+ return libsodiumDecrypt(value, key);
30
+ }
31
+ return aes256Decrypt(value, key);
32
+ }
33
+
34
+ /**
35
+ * Encrypt a value with a symmetric key using AES-256-GCM with scrypt key derivation.
36
+ */
37
+ export async function encrypt(
38
+ value: string,
39
+ passphrase: string,
40
+ ): Promise<Encrypted> {
41
+ const salt = crypto.randomBytes(SALT_LEN);
42
+ const key = await deriveScryptKey(passphrase, salt);
43
+ const iv = crypto.randomBytes(IV_LEN);
44
+
45
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
46
+ const ciphertext = Buffer.concat([
47
+ cipher.update(value, "utf8"),
48
+ cipher.final(),
49
+ ]);
50
+ const tag = cipher.getAuthTag();
51
+
52
+ return {
53
+ version: "v1",
54
+ ciphertext: ciphertext.toString("base64"),
55
+ iv: iv.toString("base64"),
56
+ salt: salt.toString("base64"),
57
+ tag: tag.toString("base64"),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Decrypt a value encrypted with a symmetric key using AES-256-GCM with scrypt key derivation.
63
+ * @internal - Exposed for testing. Use `decryptWithKey` instead.
64
+ */
65
+ export async function aes256Decrypt(
66
+ parts: Encrypted,
67
+ passphrase: string,
68
+ ): Promise<string> {
69
+ const salt = Buffer.from(parts.salt, "base64");
70
+ const iv = Buffer.from(parts.iv, "base64");
71
+ const ciphertext = Buffer.from(parts.ciphertext, "base64");
72
+ const tag = Buffer.from(parts.tag, "base64");
73
+
74
+ const key = await deriveScryptKey(passphrase, salt);
75
+
76
+ const decipher = crypto.createDecipheriv(ALGO, key, iv);
77
+ decipher.setAuthTag(tag);
78
+
79
+ const plaintext = Buffer.concat([
80
+ decipher.update(ciphertext),
81
+ decipher.final(),
82
+ ]);
83
+ return plaintext.toString("utf8");
84
+ }
85
+
86
+ async function deriveScryptKey(
87
+ passphrase: string,
88
+ salt: Buffer,
89
+ ): Promise<Buffer> {
90
+ return new Promise((resolve, reject) => {
91
+ crypto.scrypt(
92
+ passphrase,
93
+ salt,
94
+ KEY_LEN,
95
+ {
96
+ N: SCRYPT_N,
97
+ r: SCRYPT_R,
98
+ p: SCRYPT_P,
99
+ },
100
+ (err, derivedKey) => {
101
+ if (err) reject(err);
102
+ else resolve(derivedKey);
103
+ },
104
+ );
105
+ });
106
+ }
107
+
1
108
  /**
2
109
  * Encrypt a value with a symmetric key using libsodium
110
+ * @internal - Exposed to test backwards compatibility. Use `encrypt` instead.
3
111
  *
4
112
  * @param value - The value to encrypt
5
113
  * @param key - The encryption key
6
114
  * @returns The base64-encoded encrypted value with nonce
7
115
  */
8
- export async function encrypt(value: string, key: string): Promise<string> {
116
+ export async function libsodiumEncrypt(
117
+ value: string,
118
+ key: string,
119
+ ): Promise<string> {
9
120
  const sodium = (await import("libsodium-wrappers")).default;
10
121
  // Initialize libsodium
11
122
  await sodium.ready;
@@ -35,13 +146,14 @@ export async function encrypt(value: string, key: string): Promise<string> {
35
146
  }
36
147
 
37
148
  /**
38
- * Decrypt a value encrypted with a symmetric key
149
+ * Decrypt a value encrypted with a symmetric key using libsodium.
150
+ * @internal - Exposed to test backwards compatibility. Use `decryptWithKey` instead.
39
151
  *
40
152
  * @param encryptedValue - The base64-encoded encrypted value with nonce
41
153
  * @param key - The decryption key
42
154
  * @returns The decrypted string
43
155
  */
44
- export async function decryptWithKey(
156
+ export async function libsodiumDecrypt(
45
157
  encryptedValue: string,
46
158
  key: string,
47
159
  ): Promise<string> {
@@ -51,6 +51,12 @@ export interface RoleProps extends PlanetScaleProps {
51
51
  * @default true
52
52
  */
53
53
  delete?: boolean;
54
+
55
+ /**
56
+ * successor role
57
+ * @default postgres
58
+ */
59
+ successor?: string | Role;
54
60
  }
55
61
 
56
62
  /**
@@ -123,6 +129,11 @@ export interface Role extends Omit<RoleProps, "inheritedRoles"> {
123
129
  * The roles that this role inherits from.
124
130
  */
125
131
  inheritedRoles: InheritedRole[];
132
+
133
+ /**
134
+ * The successor role
135
+ */
136
+ successor: string;
126
137
  }
127
138
 
128
139
  /**
@@ -198,10 +209,18 @@ export const Role = Resource(
198
209
  : props.inheritedRoles.inheritedRoles;
199
210
  const shouldDelete = props.delete ?? false;
200
211
 
212
+ const successorRole =
213
+ typeof props.successor === "string"
214
+ ? props.successor
215
+ : (props.successor?.name ?? "postgres");
216
+
201
217
  switch (this.phase) {
202
218
  case "delete": {
203
219
  if (shouldDelete && this.output?.id) {
204
220
  const res = await api.deleteRole({
221
+ body: {
222
+ successor: successorRole,
223
+ },
205
224
  path: {
206
225
  organization,
207
226
  database,
@@ -275,6 +294,7 @@ export const Role = Resource(
275
294
  expiresAt: data.expires_at,
276
295
  databaseName: data.database_name,
277
296
  inheritedRoles,
297
+ successor: successorRole,
278
298
  connectionUrl: alchemy.secret(
279
299
  `postgresql://${data.username}:${data.password}@${data.access_host_url}:5432/${data.database_name}?sslmode=verify-full`,
280
300
  ),
@@ -284,9 +304,16 @@ export const Role = Resource(
284
304
  };
285
305
  }
286
306
  case "update": {
287
- // According to the types, the only property that can be updated is the name.
288
- // However, I was getting 500 errors when trying to update the name, so we'll just replace.
289
- return this.replace();
307
+ if (successorRole !== this.output.successor) {
308
+ // According to the types, the only property that can be updated is the name.
309
+ // However, I was getting 500 errors when trying to update the name, so we'll just replace.
310
+ return this.replace();
311
+ } else {
312
+ return {
313
+ ...this.output,
314
+ successor: successorRole,
315
+ };
316
+ }
290
317
  }
291
318
  }
292
319
  },
@@ -56,6 +56,7 @@ export function createPrismaApi(
56
56
  throwOnError: true,
57
57
  headers: {
58
58
  Authorization: `Bearer ${serviceToken}`,
59
+ "User-Agent": "alchemy/1.0",
59
60
  },
60
61
  }),
61
62
  );
package/src/scope.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import fs from "node:fs/promises";
2
5
  import util from "node:util";
3
6
  import path from "pathe";
4
7
  import pc from "picocolors";
@@ -357,6 +360,30 @@ export class Scope {
357
360
  return result.extracted as any;
358
361
  }
359
362
 
363
+ public async exec(
364
+ id: string,
365
+ command: string,
366
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
367
+ const logFilePath = path.join(this.dotAlchemy, "logs", `${id}.log`);
368
+ const [cmd, ...args] = command.split(/\s+/);
369
+ const child = spawn(cmd, args, { stdio: "pipe" });
370
+ await once(child, "spawn"); // throws if command not found
371
+ await fs.mkdir(path.dirname(logFilePath), { recursive: true });
372
+ const logFile = await fs.open(logFilePath, "w");
373
+ let stdout = "";
374
+ let stderr = "";
375
+ child.stdout.on("data", (chunk) => {
376
+ stdout += chunk.toString();
377
+ logFile.write(chunk);
378
+ });
379
+ child.stderr.on("data", (chunk) => {
380
+ stderr += chunk.toString();
381
+ logFile.write(chunk);
382
+ });
383
+ const [exitCode] = (await once(child, "exit")) as [number];
384
+ return { exitCode, stdout, stderr };
385
+ }
386
+
360
387
  /**
361
388
  * @internal
362
389
  */
package/src/serde.ts CHANGED
@@ -183,7 +183,7 @@ export async function deserialize(
183
183
  "See: https://alchemy.run/concepts/secret/#encryption-password",
184
184
  );
185
185
  }
186
- if (typeof value["@secret"] === "object") {
186
+ if (typeof value["@secret"] === "object" && "data" in value["@secret"]) {
187
187
  return new Secret(
188
188
  JSON.parse(
189
189
  await decryptWithKey(value["@secret"].data, scope.password),
@@ -354,6 +354,7 @@ async function fetchNoResponse(
354
354
  body?: string;
355
355
  headers?: Record<string, string>;
356
356
  method?: string;
357
+ timeout?: number;
357
358
  } = {},
358
359
  ) {
359
360
  const parsedUrl = new URL(url);
@@ -372,12 +373,16 @@ async function fetchNoResponse(
372
373
  path: parsedUrl.pathname + parsedUrl.search,
373
374
  method: options.method || "GET",
374
375
  headers,
376
+ signal: AbortSignal.timeout(options?.timeout ?? 3000),
375
377
  };
376
378
 
377
379
  await new Promise<void>((resolve, reject) => {
378
380
  const req = client.request(requestOptions);
379
381
 
380
382
  req.on("error", (error) => {
383
+ if (error.name === "AbortError" && !SUPPRESS_TELEMETRY_ERRORS) {
384
+ logger.warn("Telemetry request timed out");
385
+ }
381
386
  reject(error);
382
387
  });
383
388