alchemy 0.71.0 → 0.71.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 (63) hide show
  1. package/README.md +1 -1
  2. package/bin/alchemy.js +119 -44
  3. package/bin/services/execute-alchemy.ts +12 -3
  4. package/bin/trpc.ts +12 -12
  5. package/lib/alchemy.d.ts.map +1 -1
  6. package/lib/alchemy.js +21 -6
  7. package/lib/alchemy.js.map +1 -1
  8. package/lib/apply.js +1 -1
  9. package/lib/apply.js.map +1 -1
  10. package/lib/cloudflare/d1-database.d.ts +1 -1
  11. package/lib/cloudflare/d1-database.d.ts.map +1 -1
  12. package/lib/cloudflare/d1-database.js +1 -1
  13. package/lib/cloudflare/d1-database.js.map +1 -1
  14. package/lib/cloudflare/health-check.d.ts +261 -0
  15. package/lib/cloudflare/health-check.d.ts.map +1 -0
  16. package/lib/cloudflare/health-check.js +277 -0
  17. package/lib/cloudflare/health-check.js.map +1 -0
  18. package/lib/cloudflare/index.d.ts +1 -0
  19. package/lib/cloudflare/index.d.ts.map +1 -1
  20. package/lib/cloudflare/index.js +1 -0
  21. package/lib/cloudflare/index.js.map +1 -1
  22. package/lib/cloudflare/wrangler.json.d.ts +8 -8
  23. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  24. package/lib/cloudflare/wrangler.json.js +9 -11
  25. package/lib/cloudflare/wrangler.json.js.map +1 -1
  26. package/lib/cloudflare/zone.js +1 -1
  27. package/lib/destroy.d.ts.map +1 -1
  28. package/lib/destroy.js +27 -0
  29. package/lib/destroy.js.map +1 -1
  30. package/lib/docker/image.js +1 -1
  31. package/lib/docker/image.js.map +1 -1
  32. package/lib/resource.js +1 -1
  33. package/lib/resource.js.map +1 -1
  34. package/lib/scope.d.ts +6 -1
  35. package/lib/scope.d.ts.map +1 -1
  36. package/lib/scope.js +16 -15
  37. package/lib/scope.js.map +1 -1
  38. package/lib/state/instrumented-state-store.js +1 -1
  39. package/lib/state/instrumented-state-store.js.map +1 -1
  40. package/lib/util/cli-args.d.ts +3 -0
  41. package/lib/util/cli-args.d.ts.map +1 -0
  42. package/lib/util/cli-args.js +6 -0
  43. package/lib/util/cli-args.js.map +1 -0
  44. package/lib/util/telemetry.d.ts +1 -0
  45. package/lib/util/telemetry.d.ts.map +1 -1
  46. package/lib/util/telemetry.js +47 -2
  47. package/lib/util/telemetry.js.map +1 -1
  48. package/package.json +10 -8
  49. package/src/alchemy.ts +25 -11
  50. package/src/apply.ts +1 -1
  51. package/src/cloudflare/d1-database.ts +2 -2
  52. package/src/cloudflare/health-check.ts +617 -0
  53. package/src/cloudflare/index.ts +1 -0
  54. package/src/cloudflare/wrangler.json.ts +21 -23
  55. package/src/cloudflare/zone.ts +1 -1
  56. package/src/destroy.ts +32 -0
  57. package/src/docker/image.ts +1 -1
  58. package/src/resource.ts +1 -1
  59. package/src/scope.ts +25 -15
  60. package/src/state/instrumented-state-store.ts +1 -1
  61. package/src/util/cli-args.ts +11 -0
  62. package/src/util/telemetry.ts +68 -2
  63. package/workers/tunnel-proxy.js +1 -1
@@ -4,7 +4,6 @@ import type { Context } from "../context.ts";
4
4
  import { Resource } from "../resource.ts";
5
5
  import { Scope } from "../scope.ts";
6
6
  import { isSecret } from "../secret.ts";
7
- import { unencryptSecrets } from "./util/filter-env-bindings.ts";
8
7
  import { assertNever } from "../util/assert-never.ts";
9
8
  import type { Bindings, WorkerBindingRateLimit } from "./bindings.ts";
10
9
  import type { R2BucketJurisdiction } from "./bucket.ts";
@@ -12,6 +11,7 @@ import type { DurableObjectNamespace } from "./durable-object-namespace.ts";
12
11
  import type { EventSource } from "./event-source.ts";
13
12
  import { isQueueEventSource } from "./event-source.ts";
14
13
  import { isQueue } from "./queue.ts";
14
+ import { unencryptSecrets } from "./util/filter-env-bindings.ts";
15
15
  import { isWorker, type Worker, type WorkerProps } from "./worker.ts";
16
16
 
17
17
  type WranglerJsonRateLimit = Omit<WorkerBindingRateLimit, "type"> & {
@@ -301,7 +301,7 @@ export interface WranglerJsonSpec {
301
301
  */
302
302
  ai?: {
303
303
  binding: string;
304
- experimental_remote?: boolean;
304
+ remote?: boolean;
305
305
  };
306
306
 
307
307
  /**
@@ -309,7 +309,7 @@ export interface WranglerJsonSpec {
309
309
  */
310
310
  browser?: {
311
311
  binding: string;
312
- experimental_remote?: boolean;
312
+ remote?: boolean;
313
313
  };
314
314
 
315
315
  /**
@@ -317,7 +317,7 @@ export interface WranglerJsonSpec {
317
317
  */
318
318
  images?: {
319
319
  binding: string;
320
- experimental_remote?: boolean;
320
+ remote?: boolean;
321
321
  };
322
322
 
323
323
  /**
@@ -330,7 +330,7 @@ export interface WranglerJsonSpec {
330
330
  * The ID of the KV namespace used during `wrangler dev`
331
331
  */
332
332
  preview_id?: string;
333
- experimental_remote?: boolean;
333
+ remote?: boolean;
334
334
  }[];
335
335
 
336
336
  /**
@@ -355,7 +355,7 @@ export interface WranglerJsonSpec {
355
355
  * The preview name of this R2 bucket at the edge.
356
356
  */
357
357
  preview_bucket_name?: string;
358
- experimental_remote?: boolean;
358
+ remote?: boolean;
359
359
  }[];
360
360
 
361
361
  /**
@@ -396,7 +396,7 @@ export interface WranglerJsonSpec {
396
396
  vectorize?: {
397
397
  binding: string;
398
398
  index_name: string;
399
- experimental_remote?: boolean;
399
+ remote?: boolean;
400
400
  }[];
401
401
 
402
402
  /**
@@ -416,7 +416,7 @@ export interface WranglerJsonSpec {
416
416
  * The ID of the D1 database used during `wrangler dev`
417
417
  */
418
418
  preview_database_id?: string;
419
- experimental_remote?: boolean;
419
+ remote?: boolean;
420
420
  }[];
421
421
 
422
422
  /**
@@ -499,7 +499,7 @@ export interface WranglerJsonSpec {
499
499
  dispatch_namespaces?: {
500
500
  binding: string;
501
501
  namespace: string;
502
- experimental_remote?: boolean;
502
+ remote?: boolean;
503
503
  }[];
504
504
 
505
505
  /**
@@ -559,7 +559,7 @@ function processBindings(
559
559
  binding: string;
560
560
  id: string;
561
561
  preview_id: string;
562
- experimental_remote?: boolean;
562
+ remote?: boolean;
563
563
  }[] = [];
564
564
  const durableObjects: {
565
565
  name: string;
@@ -592,7 +592,7 @@ function processBindings(
592
592
  database_name: string;
593
593
  migrations_dir?: string;
594
594
  preview_database_id: string;
595
- experimental_remote?: boolean;
595
+ remote?: boolean;
596
596
  }[] = [];
597
597
  const queues: {
598
598
  producers: { queue: string; binding: string }[];
@@ -608,7 +608,7 @@ function processBindings(
608
608
  const vectorizeIndexes: {
609
609
  binding: string;
610
610
  index_name: string;
611
- experimental_remote?: boolean;
611
+ remote?: boolean;
612
612
  }[] = [];
613
613
  const analyticsEngineDatasets: { binding: string; dataset: string }[] = [];
614
614
  const hyperdrive: {
@@ -625,7 +625,7 @@ function processBindings(
625
625
  const dispatchNamespaces: {
626
626
  binding: string;
627
627
  namespace: string;
628
- experimental_remote?: boolean;
628
+ remote?: boolean;
629
629
  }[] = [];
630
630
  const unsafeBindings: WranglerJsonRateLimit[] = [];
631
631
  const containers: {
@@ -693,9 +693,7 @@ function processBindings(
693
693
  binding: bindingName,
694
694
  id: id,
695
695
  preview_id: id,
696
- ...("dev" in binding && binding.dev?.remote
697
- ? { experimental_remote: true }
698
- : {}),
696
+ ...("dev" in binding && binding.dev?.remote ? { remote: true } : {}),
699
697
  });
700
698
  } else if (
701
699
  typeof binding === "object" &&
@@ -725,7 +723,7 @@ function processBindings(
725
723
  preview_bucket_name: name,
726
724
  jurisdiction:
727
725
  binding.jurisdiction === "default" ? undefined : binding.jurisdiction,
728
- ...(binding.dev?.remote ? { experimental_remote: true } : {}),
726
+ ...(binding.dev?.remote ? { remote: true } : {}),
729
727
  });
730
728
  } else if (binding.type === "secret") {
731
729
  // Secret binding
@@ -753,7 +751,7 @@ function processBindings(
753
751
  database_name: binding.name,
754
752
  migrations_dir: binding.migrationsDir,
755
753
  preview_database_id: id,
756
- ...(binding.dev?.remote ? { experimental_remote: true } : {}),
754
+ ...(binding.dev?.remote ? { remote: true } : {}),
757
755
  });
758
756
  } else if (binding.type === "queue") {
759
757
  const id =
@@ -769,7 +767,7 @@ function processBindings(
769
767
  binding: bindingName,
770
768
  index_name: binding.name,
771
769
  // https://developers.cloudflare.com/workers/development-testing/#recommended-remote-bindings
772
- experimental_remote: true,
770
+ remote: true,
773
771
  });
774
772
  } else if (binding.type === "browser") {
775
773
  if (spec.browser) {
@@ -778,7 +776,7 @@ function processBindings(
778
776
  spec.browser = {
779
777
  binding: bindingName,
780
778
  // https://developers.cloudflare.com/workers/development-testing/#recommended-remote-bindings
781
- experimental_remote: true,
779
+ remote: true,
782
780
  };
783
781
  } else if (binding.type === "ai") {
784
782
  if (spec.ai) {
@@ -787,7 +785,7 @@ function processBindings(
787
785
  spec.ai = {
788
786
  binding: bindingName,
789
787
  // https://developers.cloudflare.com/workers/development-testing/#recommended-remote-bindings
790
- experimental_remote: true,
788
+ remote: true,
791
789
  };
792
790
  } else if (binding.type === "images") {
793
791
  if (spec.images) {
@@ -796,7 +794,7 @@ function processBindings(
796
794
  spec.images = {
797
795
  binding: bindingName,
798
796
  // https://developers.cloudflare.com/workers/development-testing/#recommended-remote-bindings
799
- experimental_remote: true,
797
+ remote: true,
800
798
  };
801
799
  } else if (binding.type === "analytics_engine") {
802
800
  analyticsEngineDatasets.push({
@@ -837,7 +835,7 @@ function processBindings(
837
835
  dispatchNamespaces.push({
838
836
  binding: bindingName,
839
837
  namespace: binding.namespaceName,
840
- experimental_remote: true,
838
+ remote: true,
841
839
  });
842
840
  } else if (binding.type === "ratelimit") {
843
841
  unsafeBindings.push({
@@ -414,7 +414,7 @@ export const Zone = Resource(
414
414
 
415
415
  // Add a small delay to ensure settings are propagated
416
416
  // TODO(michael): do we need this?
417
- // https://github.com/sam-goodwin/alchemy/issues/681
417
+ // https://github.com/alchemy-run/alchemy/issues/681
418
418
  await new Promise((resolve) => setTimeout(resolve, 2000));
419
419
 
420
420
  // Update Bot Management configuration if provided
package/src/destroy.ts CHANGED
@@ -14,6 +14,7 @@ import { isScope, type PendingDeletions, Scope } from "./scope.ts";
14
14
  import type { State } from "./state.ts";
15
15
  import { formatFQN } from "./util/cli.ts";
16
16
  import { logger } from "./util/logger.ts";
17
+ import { createAndSendEvent } from "./util/telemetry.ts";
17
18
 
18
19
  export function isDestroyedSignal(error: any): error is DestroyedSignal {
19
20
  return error instanceof Error && (error as any).kind === "DestroyedSignal";
@@ -109,6 +110,7 @@ export async function destroy(
109
110
  logger.warn(`Resource "${instance[ResourceFQN]}" has no scope`);
110
111
  }
111
112
  const quiet = options?.quiet ?? scope.quiet;
113
+ const start = performance.now();
112
114
 
113
115
  try {
114
116
  if (!quiet && !options?.noop) {
@@ -122,6 +124,15 @@ export async function destroy(
122
124
  });
123
125
  }
124
126
 
127
+ await createAndSendEvent({
128
+ event: "resource.start",
129
+ resource: instance[ResourceKind],
130
+ status: "deleting",
131
+ phase: "destroy",
132
+ duration: performance.now() - start,
133
+ replaced: !!options?.replace,
134
+ });
135
+
125
136
  let state: State;
126
137
  let props: ResourceProps | undefined;
127
138
  if (options?.replace) {
@@ -224,7 +235,28 @@ export async function destroy(
224
235
  status: "success",
225
236
  });
226
237
  }
238
+
239
+ await createAndSendEvent({
240
+ event: "resource.success",
241
+ resource: instance[ResourceKind],
242
+ status: "deleted",
243
+ phase: "destroy",
244
+ duration: performance.now() - start,
245
+ replaced: !!options?.replace,
246
+ });
227
247
  } catch (error) {
248
+ let errorToSend = error instanceof Error ? error : new Error(String(error));
249
+ await createAndSendEvent(
250
+ {
251
+ event: "resource.error",
252
+ resource: instance[ResourceKind],
253
+ duration: performance.now() - start,
254
+ phase: "destroy",
255
+ status: "deleting",
256
+ replaced: !!options?.replace,
257
+ },
258
+ errorToSend,
259
+ );
228
260
  logger.error(error);
229
261
  throw error;
230
262
  }
@@ -190,7 +190,7 @@ export const Image = Resource(
190
190
 
191
191
  // Add build arguments
192
192
  for (const [key, value] of Object.entries(buildOptions)) {
193
- buildArgs.push("--build-arg", `${key}="${value}"`);
193
+ buildArgs.push("--build-arg", `${key}=${value}`);
194
194
  }
195
195
 
196
196
  buildArgs.push("-f", dockerfile);
package/src/resource.ts CHANGED
@@ -179,7 +179,7 @@ export function Resource<
179
179
  const error = new Error(
180
180
  `Resource ${resourceID} already exists in the stack and is of a different type: '${otherResource?.[ResourceKind]}' !== '${type}'`,
181
181
  );
182
- createAndSendEvent(
182
+ await createAndSendEvent(
183
183
  {
184
184
  event: "resource.error",
185
185
  resource: type,
package/src/scope.ts CHANGED
@@ -116,6 +116,11 @@ export interface ScopeOptions extends ProviderCredentials {
116
116
  * `undefined` if the program was not run with `--app`
117
117
  */
118
118
  isSelected?: boolean;
119
+ /**
120
+ * @internal
121
+ * The timestamp when the scope was started.
122
+ */
123
+ startedAt?: DOMHighResTimeStamp;
119
124
  }
120
125
 
121
126
  /**
@@ -209,6 +214,7 @@ export class Scope {
209
214
  public readonly dotAlchemy: string;
210
215
  public readonly isSelected: boolean | undefined;
211
216
  public readonly profile: string | undefined;
217
+ public readonly startedAt: DOMHighResTimeStamp;
212
218
 
213
219
  // Provider credentials for scope-level credential overrides
214
220
  public readonly providerCredentials: ProviderCredentials;
@@ -216,7 +222,6 @@ export class Scope {
216
222
  private isErrored = false;
217
223
  private isSkipped = false;
218
224
  private finalized = false;
219
- private startedAt = performance.now();
220
225
  private deferred: (() => Promise<any>)[] = [];
221
226
  private cleanups: (() => Promise<void>)[] = [];
222
227
 
@@ -249,6 +254,7 @@ export class Scope {
249
254
  isSelected,
250
255
  noTrack,
251
256
  profile,
257
+ startedAt,
252
258
  ...providerCredentials
253
259
  } = options;
254
260
 
@@ -257,7 +263,7 @@ export class Scope {
257
263
  this.parent = parent ?? Scope.getScope();
258
264
  this.rootDir = rootDir ?? this.parent?.rootDir ?? ALCHEMY_ROOT;
259
265
  this.isSelected = isSelected ?? this.parent?.isSelected;
260
-
266
+ this.startedAt = startedAt ?? this.parent?.startedAt ?? performance.now();
261
267
  this.dotAlchemy =
262
268
  dotAlchemy ??
263
269
  this.parent?.dotAlchemy ??
@@ -305,7 +311,7 @@ export class Scope {
305
311
  destroyStrategy ?? this.parent?.destroyStrategy ?? "sequential";
306
312
  if (this.local) {
307
313
  this.logger.warnOnce(
308
- "Development mode is in beta. Please report any issues to https://github.com/sam-goodwin/alchemy/issues.",
314
+ "Development mode is in beta. Please report any issues to https://github.com/alchemy-run/alchemy/issues.",
309
315
  );
310
316
  }
311
317
 
@@ -515,10 +521,15 @@ export class Scope {
515
521
  this.parent === undefined ||
516
522
  this?.parent?.scopeName === this.root.scopeName;
517
523
  if (this.phase === "read") {
518
- createAndSendEvent({
519
- event: "alchemy.success",
520
- duration: performance.now() - this.startedAt,
521
- });
524
+ if (this.parent == null) {
525
+ await createAndSendEvent(
526
+ {
527
+ event: this.isErrored ? "alchemy.error" : "alchemy.success",
528
+ duration: performance.now() - this.startedAt,
529
+ },
530
+ this.isErrored ? new Error("Scope failed") : undefined,
531
+ );
532
+ }
522
533
  return;
523
534
  }
524
535
  if (this.finalized && !shouldForce) {
@@ -566,18 +577,17 @@ export class Scope {
566
577
  force: shouldForce,
567
578
  noop: options?.noop,
568
579
  });
569
- createAndSendEvent({
570
- event: "alchemy.success",
571
- duration: performance.now() - this.startedAt,
572
- });
573
580
  } else if (this.isErrored) {
574
581
  this.logger.warn("Scope is in error, skipping finalize");
575
- createAndSendEvent(
582
+ }
583
+
584
+ if (this.parent == null) {
585
+ await createAndSendEvent(
576
586
  {
577
- event: "alchemy.error",
587
+ event: this.isErrored ? "alchemy.error" : "alchemy.success",
578
588
  duration: performance.now() - this.startedAt,
579
589
  },
580
- new Error("Scope failed"),
590
+ this.isErrored ? new Error("Scope failed") : undefined,
581
591
  );
582
592
  }
583
593
 
@@ -596,7 +606,7 @@ export class Scope {
596
606
  throw e;
597
607
  })) ?? [];
598
608
 
599
- //todo(michael): remove once we deprecate doss; see: https://github.com/sam-goodwin/alchemy/issues/585
609
+ //todo(michael): remove once we deprecate doss; see: https://github.com/alchemy-run/alchemy/issues/585
600
610
  let hasCorruptedResources = false;
601
611
  if (pendingDeletions) {
602
612
  for (const { resource, oldProps } of pendingDeletions) {
@@ -30,7 +30,7 @@ export class InstrumentedStateStore<T extends StateStore>
30
30
  error = err;
31
31
  throw err;
32
32
  } finally {
33
- createAndSendEvent(
33
+ await createAndSendEvent(
34
34
  {
35
35
  event,
36
36
  stateStore: this.stateStoreClass,
@@ -0,0 +1,11 @@
1
+ export const cliArgs = process.argv.slice(2);
2
+
3
+ export function parseOption<D extends string | undefined>(
4
+ option: string,
5
+ defaultValue?: D,
6
+ ): D {
7
+ const i = cliArgs.indexOf(option);
8
+ return (
9
+ i !== -1 && i + 1 < cliArgs.length ? cliArgs[i + 1] : defaultValue
10
+ ) as D;
11
+ }
@@ -1,11 +1,15 @@
1
1
  import envPaths from "env-paths";
2
2
  import { exec } from "node:child_process";
3
3
  import fs from "node:fs/promises";
4
+ import http from "node:http";
5
+ import https from "node:https";
4
6
  import os from "node:os";
7
+ import { URL } from "node:url";
5
8
  import path from "pathe";
6
9
  import pkg from "../../package.json" with { type: "json" };
7
10
  import type { Phase } from "../alchemy.ts";
8
11
  import { Scope } from "../scope.ts";
12
+ import { parseOption } from "./cli-args.ts";
9
13
  import { logger } from "./logger.ts";
10
14
  import { memoize } from "./memoize.ts";
11
15
 
@@ -170,9 +174,25 @@ export const collectData = memoize(async (): Promise<GenericTelemetryData> => {
170
174
  getRuntime(),
171
175
  getEnvironment(),
172
176
  ]);
177
+
178
+ const sessionId =
179
+ parseOption("--telemetry-session-id") ??
180
+ process.env.ALCHEMY_TELEMETRY_SESSION_ID ??
181
+ //@ts-expect-error
182
+ globalThis.ALCHEMY_TELEMETRY_SESSION_ID ??
183
+ crypto.randomUUID();
184
+
185
+ const referrer =
186
+ parseOption("--telemetry-ref") ??
187
+ process.env.ALCHEMY_TELEMETRY_REF ??
188
+ //@ts-expect-error
189
+ globalThis.ALCHEMY_TELEMETRY_REF ??
190
+ "";
191
+
173
192
  return {
174
193
  userId: userId ?? "",
175
- sessionId: crypto.randomUUID(),
194
+ sessionId,
195
+ referrer,
176
196
  platform: os.platform(),
177
197
  osVersion: os.release(),
178
198
  arch: os.arch(),
@@ -192,6 +212,7 @@ export const collectData = memoize(async (): Promise<GenericTelemetryData> => {
192
212
  export type GenericTelemetryData = {
193
213
  userId: string;
194
214
  sessionId: string;
215
+ referrer: string;
195
216
  platform: string;
196
217
  osVersion: string;
197
218
  arch: string;
@@ -287,7 +308,7 @@ export async function createAndSendEvent(
287
308
  ...(await collectData()),
288
309
  ...serializeError(error),
289
310
  };
290
- await fetch(TELEMETRY_API_URL, {
311
+ await fetchNoResponse(TELEMETRY_API_URL, {
291
312
  method: "POST",
292
313
  headers: {
293
314
  "Content-Type": "application/json",
@@ -326,3 +347,48 @@ function serializeError(error: Error | undefined) {
326
347
  errorStack: "",
327
348
  };
328
349
  }
350
+
351
+ async function fetchNoResponse(
352
+ url: string,
353
+ options: {
354
+ body?: string;
355
+ headers?: Record<string, string>;
356
+ method?: string;
357
+ } = {},
358
+ ) {
359
+ const parsedUrl = new URL(url);
360
+ const isHttps = parsedUrl.protocol === "https:";
361
+ const client = isHttps ? https : http;
362
+
363
+ const body = options.body || "";
364
+ const headers = {
365
+ ...options.headers,
366
+ "Content-Length": Buffer.byteLength(body),
367
+ };
368
+
369
+ const requestOptions = {
370
+ hostname: parsedUrl.hostname,
371
+ port: parsedUrl.port || (isHttps ? 443 : 80),
372
+ path: parsedUrl.pathname + parsedUrl.search,
373
+ method: options.method || "GET",
374
+ headers,
375
+ };
376
+
377
+ await new Promise<void>((resolve, reject) => {
378
+ const req = client.request(requestOptions);
379
+
380
+ req.on("error", (error) => {
381
+ reject(error);
382
+ });
383
+
384
+ if (body) {
385
+ req.write(body);
386
+ }
387
+
388
+ req.once("finish", () => {
389
+ resolve(void 0);
390
+ });
391
+
392
+ req.end();
393
+ });
394
+ }
@@ -34,7 +34,7 @@ var h={async fetch(e,r){let n=new URL(e.url);n.host=r.TUNNEL_HOST;let l=new Head
34
34
  Alchemy</a>.</p>
35
35
  </div>
36
36
  <div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
37
- <p class="text-sm text-slate-500">Alchemy 0.71.0</p>
37
+ <p class="text-sm text-slate-500">Alchemy 0.71.1</p>
38
38
  </div>
39
39
  </div>
40
40
  </body>