alchemy 0.80.1 → 0.81.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.
Files changed (48) hide show
  1. package/bin/alchemy.js +11 -5
  2. package/bin/services/execute-alchemy.ts +18 -6
  3. package/lib/alchemy.js +1 -1
  4. package/lib/alchemy.js.map +1 -1
  5. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  6. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  7. package/lib/cloudflare/hyperdrive.d.ts +23 -5
  8. package/lib/cloudflare/hyperdrive.d.ts.map +1 -1
  9. package/lib/cloudflare/hyperdrive.js.map +1 -1
  10. package/lib/cloudflare/index.d.ts +3 -0
  11. package/lib/cloudflare/index.d.ts.map +1 -1
  12. package/lib/cloudflare/index.js +3 -0
  13. package/lib/cloudflare/index.js.map +1 -1
  14. package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts.map +1 -1
  15. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js +7 -0
  16. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js.map +1 -1
  17. package/lib/cloudflare/queue.d.ts +0 -3
  18. package/lib/cloudflare/queue.d.ts.map +1 -1
  19. package/lib/cloudflare/queue.js +53 -31
  20. package/lib/cloudflare/queue.js.map +1 -1
  21. package/lib/cloudflare/tunnel-route.d.ts +172 -0
  22. package/lib/cloudflare/tunnel-route.d.ts.map +1 -0
  23. package/lib/cloudflare/tunnel-route.js +251 -0
  24. package/lib/cloudflare/tunnel-route.js.map +1 -0
  25. package/lib/cloudflare/warp-default-profile.d.ts +133 -0
  26. package/lib/cloudflare/warp-default-profile.d.ts.map +1 -0
  27. package/lib/cloudflare/warp-default-profile.js +138 -0
  28. package/lib/cloudflare/warp-default-profile.js.map +1 -0
  29. package/lib/cloudflare/warp-device-profile.d.ts +272 -0
  30. package/lib/cloudflare/warp-device-profile.d.ts.map +1 -0
  31. package/lib/cloudflare/warp-device-profile.js +291 -0
  32. package/lib/cloudflare/warp-device-profile.js.map +1 -0
  33. package/lib/scope.d.ts +1 -1
  34. package/lib/scope.d.ts.map +1 -1
  35. package/lib/scope.js +1 -1
  36. package/lib/scope.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/alchemy.ts +1 -1
  39. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  40. package/src/cloudflare/hyperdrive.ts +27 -5
  41. package/src/cloudflare/index.ts +3 -0
  42. package/src/cloudflare/miniflare/miniflare-worker-proxy.ts +11 -0
  43. package/src/cloudflare/queue.ts +97 -56
  44. package/src/cloudflare/tunnel-route.ts +495 -0
  45. package/src/cloudflare/warp-default-profile.ts +320 -0
  46. package/src/cloudflare/warp-device-profile.ts +616 -0
  47. package/src/scope.ts +2 -2
  48. package/workers/tunnel-proxy.js +1 -1
@@ -0,0 +1,616 @@
1
+ import type { Context } from "../context.ts";
2
+ import { Resource, ResourceKind } from "../resource.ts";
3
+ import { logger } from "../util/logger.ts";
4
+ import { CloudflareApiError, handleApiError } from "./api-error.ts";
5
+ import { extractCloudflareResult } from "./api-response.ts";
6
+ import {
7
+ createCloudflareApi,
8
+ type CloudflareApi,
9
+ type CloudflareApiOptions,
10
+ } from "./api.ts";
11
+
12
+ /**
13
+ * Service mode configuration for WARP client
14
+ */
15
+ export interface ServiceModeV2 {
16
+ /**
17
+ * WARP client operational mode
18
+ */
19
+ mode: "warp" | "proxy" | "doh_only" | "warp_tunnel_only";
20
+
21
+ /**
22
+ * Port number (only used for proxy mode)
23
+ */
24
+ port?: number;
25
+ }
26
+
27
+ /**
28
+ * Split tunnel route entry
29
+ */
30
+ export interface SplitTunnelEntry {
31
+ /**
32
+ * IP address or CIDR block (e.g., "10.0.0.0/8" or "192.168.1.1")
33
+ * or domain name (e.g., "example.com"). Use either address or host.
34
+ */
35
+ address?: string;
36
+
37
+ /**
38
+ * Domain host for split tunnel (alternative to address)
39
+ */
40
+ host?: string;
41
+
42
+ /**
43
+ * Optional description for this route
44
+ */
45
+ description?: string;
46
+ }
47
+
48
+ /**
49
+ * Split tunnel configuration
50
+ */
51
+ export interface SplitTunnelConfig {
52
+ /**
53
+ * Split tunnel mode
54
+ * - "include": Only specified routes go through WARP
55
+ * - "exclude": All routes except specified ones go through WARP
56
+ */
57
+ mode: "include" | "exclude";
58
+
59
+ /**
60
+ * List of routes to include or exclude
61
+ */
62
+ entries: SplitTunnelEntry[];
63
+ }
64
+
65
+ /**
66
+ * Properties for creating or updating a WARP Device Profile
67
+ */
68
+ export interface WarpDeviceProfileProps extends CloudflareApiOptions {
69
+ /**
70
+ * Name of the device profile
71
+ *
72
+ * @default ${app}-${stage}-${id}
73
+ */
74
+ name?: string;
75
+
76
+ /**
77
+ * Description of the device profile
78
+ */
79
+ description?: string;
80
+
81
+ /**
82
+ * Wirefilter expression for device matching
83
+ * Determines which devices this profile applies to
84
+ *
85
+ * @example 'identity.groups.name == "Engineering"'
86
+ * @example 'identity.email == "admin@example.com"'
87
+ */
88
+ match?: string;
89
+
90
+ /**
91
+ * Precedence order (lower number = higher priority)
92
+ * Profiles with lower precedence values are evaluated first
93
+ */
94
+ precedence?: number;
95
+
96
+ /**
97
+ * Whether the profile is enabled
98
+ *
99
+ * @default true
100
+ */
101
+ enabled?: boolean;
102
+
103
+ /**
104
+ * Service mode configuration for WARP client
105
+ */
106
+ serviceModeV2?: ServiceModeV2;
107
+
108
+ /**
109
+ * Disable automatic fallback to direct connection if tunnel fails
110
+ */
111
+ disableAutoFallback?: boolean;
112
+
113
+ /**
114
+ * Allow users to manually switch WARP modes
115
+ */
116
+ allowModeSwitch?: boolean;
117
+
118
+ /**
119
+ * Lock the WARP toggle switch (users cannot change it)
120
+ */
121
+ switchLocked?: boolean;
122
+
123
+ /**
124
+ * Tunnel protocol to use
125
+ */
126
+ tunnelProtocol?: "wireguard" | "masque";
127
+
128
+ /**
129
+ * Auto-connect timeout in seconds
130
+ * Set to 0 to disable auto-connect
131
+ */
132
+ autoConnect?: number;
133
+
134
+ /**
135
+ * Allow users to disconnect from WARP
136
+ */
137
+ allowedToLeave?: boolean;
138
+
139
+ /**
140
+ * Captive portal timeout in seconds
141
+ * Time before showing captive portal
142
+ */
143
+ captivePortal?: number;
144
+
145
+ /**
146
+ * Support URL for feedback button in WARP client
147
+ */
148
+ supportUrl?: string;
149
+
150
+ /**
151
+ * Exclude office IPs from WARP tunnel
152
+ */
153
+ excludeOfficeIps?: boolean;
154
+
155
+ /**
156
+ * LAN allow duration in minutes
157
+ */
158
+ lanAllowMinutes?: number;
159
+
160
+ /**
161
+ * LAN subnet size for local network access
162
+ */
163
+ lanAllowSubnetSize?: number;
164
+
165
+ /**
166
+ * Split tunnel configuration
167
+ * Controls which routes bypass or use the WARP tunnel
168
+ */
169
+ splitTunnel?: SplitTunnelConfig;
170
+
171
+ /**
172
+ * Whether to adopt an existing profile with the same name if it exists
173
+ * If true and a profile with the same name exists, it will be adopted rather than creating a new one
174
+ *
175
+ * @default false
176
+ */
177
+ adopt?: boolean;
178
+
179
+ /**
180
+ * Whether to delete the profile when removed from Alchemy
181
+ * If set to false, the profile will remain but the resource will be removed from state
182
+ *
183
+ * @default true
184
+ */
185
+ delete?: boolean;
186
+ }
187
+
188
+ export function isWarpDeviceProfile(
189
+ resource: any,
190
+ ): resource is WarpDeviceProfile {
191
+ return resource?.[ResourceKind] === "cloudflare::WarpDeviceProfile";
192
+ }
193
+
194
+ /**
195
+ * Output returned after WARP Device Profile creation/update
196
+ */
197
+ export type WarpDeviceProfile = Omit<
198
+ WarpDeviceProfileProps,
199
+ "delete" | "adopt"
200
+ > & {
201
+ /**
202
+ * The policy ID assigned by Cloudflare
203
+ */
204
+ policyId: string;
205
+
206
+ /**
207
+ * Name of the profile (required in output)
208
+ */
209
+ name: string;
210
+
211
+ /**
212
+ * Time at which the profile was created
213
+ */
214
+ createdAt: number;
215
+
216
+ /**
217
+ * Time at which the profile was last modified
218
+ */
219
+ modifiedAt: number;
220
+ };
221
+
222
+ /**
223
+ * Creates and manages a Cloudflare WARP Device Profile, which defines WARP client
224
+ * settings for specific sets of devices based on matching rules.
225
+ *
226
+ * Device profiles allow you to apply different WARP configurations to different
227
+ * groups of devices based on user identity, groups, operating system, or other criteria.
228
+ *
229
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/device-profiles/
230
+ *
231
+ * @example
232
+ * ## Basic device profile for a user group
233
+ *
234
+ * Create a profile that applies to all devices belonging to the Engineering group
235
+ *
236
+ * const engProfile = await WarpDeviceProfile("engineering", {
237
+ * name: "Engineering Team",
238
+ * match: 'identity.groups.name == "Engineering"',
239
+ * precedence: 100,
240
+ * serviceModeV2: { mode: "warp" },
241
+ * allowedToLeave: false,
242
+ * switchLocked: true
243
+ * });
244
+ *
245
+ * @example
246
+ * ## Profile with split tunnel configuration
247
+ *
248
+ * Create a profile that excludes internal network routes from the WARP tunnel
249
+ *
250
+ * const internalProfile = await WarpDeviceProfile("internal-network", {
251
+ * name: "Internal Network Access",
252
+ * match: 'identity.email.ends_with("@company.com")',
253
+ * precedence: 50,
254
+ * serviceModeV2: { mode: "warp" },
255
+ * splitTunnel: {
256
+ * mode: "exclude",
257
+ * entries: [
258
+ * { address: "10.0.0.0/8", description: "Internal network" },
259
+ * { address: "192.168.0.0/16", description: "Local network" }
260
+ * ]
261
+ * }
262
+ * });
263
+ *
264
+ * @example
265
+ * ## Profile with include mode split tunnel
266
+ *
267
+ * Only route specific networks through WARP
268
+ *
269
+ * const selectiveProfile = await WarpDeviceProfile("selective", {
270
+ * name: "Selective Routing",
271
+ * match: 'identity.groups.name == "Remote Workers"',
272
+ * precedence: 200,
273
+ * serviceModeV2: { mode: "warp" },
274
+ * splitTunnel: {
275
+ * mode: "include",
276
+ * entries: [
277
+ * { address: "10.0.0.0/8", description: "Company network" },
278
+ * { address: "company.com", description: "Company domain" }
279
+ * ]
280
+ * }
281
+ * });
282
+ *
283
+ * @example
284
+ * ## Adopt an existing profile
285
+ *
286
+ * Take over management of an existing device profile
287
+ *
288
+ * const existingProfile = await WarpDeviceProfile("existing", {
289
+ * name: "Existing Profile",
290
+ * adopt: true,
291
+ * match: 'identity.groups.name == "IT"',
292
+ * precedence: 10
293
+ * });
294
+ *
295
+ * @example
296
+ * ## Profile with all WARP settings
297
+ *
298
+ * Configure comprehensive WARP client behavior
299
+ *
300
+ * const fullProfile = await WarpDeviceProfile("comprehensive", {
301
+ * name: "Full Configuration",
302
+ * match: 'identity.email == "admin@example.com"',
303
+ * precedence: 1,
304
+ * enabled: true,
305
+ * serviceModeV2: { mode: "warp" },
306
+ * disableAutoFallback: false,
307
+ * allowModeSwitch: false,
308
+ * switchLocked: true,
309
+ * tunnelProtocol: "wireguard",
310
+ * autoConnect: 0,
311
+ * allowedToLeave: false,
312
+ * captivePortal: 180,
313
+ * supportUrl: "https://support.example.com",
314
+ * excludeOfficeIps: true,
315
+ * lanAllowMinutes: 5,
316
+ * lanAllowSubnetSize: 24
317
+ * });
318
+ */
319
+ export const WarpDeviceProfile = Resource(
320
+ "cloudflare::WarpDeviceProfile",
321
+ async function (
322
+ this: Context<WarpDeviceProfile>,
323
+ id: string,
324
+ props: WarpDeviceProfileProps = {},
325
+ ): Promise<WarpDeviceProfile> {
326
+ const api = await createCloudflareApi(props);
327
+
328
+ const name =
329
+ props.name ?? this.output?.name ?? this.scope.createPhysicalName(id);
330
+ const adopt = props.adopt ?? this.scope.adopt;
331
+
332
+ if (this.phase === "delete") {
333
+ if (this.output?.policyId && props.delete !== false) {
334
+ await deletePolicy(api, this.output.policyId);
335
+ }
336
+ return this.destroy();
337
+ }
338
+
339
+ // Handle replacement for immutable properties
340
+ if (this.phase === "update" && this.output?.name !== name) {
341
+ this.replace();
342
+ }
343
+
344
+ let policyId: string;
345
+ let createdAt = this.output?.createdAt ?? Date.now();
346
+
347
+ if (this.phase === "update" && this.output?.policyId) {
348
+ // Update existing policy
349
+ await updatePolicy(api, this.output.policyId, { ...props, name });
350
+ policyId = this.output.policyId;
351
+ } else {
352
+ // Create new policy
353
+ try {
354
+ const result = await createPolicy(api, { ...props, name });
355
+ policyId = result.policy_id ?? result.id;
356
+ createdAt = Date.now();
357
+ } catch (error) {
358
+ if (
359
+ adopt &&
360
+ error instanceof CloudflareApiError &&
361
+ (error.status === 400 || error.status === 409) &&
362
+ (error.message.includes("already exists") ||
363
+ error.message.includes("duplicate") ||
364
+ error.message.includes("precedence must be unique"))
365
+ ) {
366
+ logger.log(
367
+ `WARP device profile '${name}' already exists, adopting it`,
368
+ );
369
+ const existing = await findPolicyByName(api, name);
370
+ if (!existing) {
371
+ throw new Error(
372
+ `Failed to find existing WARP device profile '${name}' for adoption`,
373
+ );
374
+ }
375
+ policyId = existing.policy_id;
376
+ } else {
377
+ throw error;
378
+ }
379
+ }
380
+ }
381
+
382
+ // Update split tunnel configuration if provided
383
+ if (props.splitTunnel) {
384
+ await updateSplitTunnel(api, policyId, props.splitTunnel);
385
+ }
386
+
387
+ return {
388
+ policyId,
389
+ name,
390
+ description: props.description,
391
+ match: props.match,
392
+ precedence: props.precedence,
393
+ enabled: props.enabled ?? true,
394
+ serviceModeV2: props.serviceModeV2,
395
+ disableAutoFallback: props.disableAutoFallback,
396
+ allowModeSwitch: props.allowModeSwitch,
397
+ switchLocked: props.switchLocked,
398
+ tunnelProtocol: props.tunnelProtocol,
399
+ autoConnect: props.autoConnect,
400
+ allowedToLeave: props.allowedToLeave,
401
+ captivePortal: props.captivePortal,
402
+ supportUrl: props.supportUrl,
403
+ excludeOfficeIps: props.excludeOfficeIps,
404
+ lanAllowMinutes: props.lanAllowMinutes,
405
+ lanAllowSubnetSize: props.lanAllowSubnetSize,
406
+ splitTunnel: props.splitTunnel,
407
+ createdAt,
408
+ modifiedAt: Date.now(),
409
+ };
410
+ },
411
+ );
412
+
413
+ /**
414
+ * Internal API response type for policy creation
415
+ * @internal
416
+ */
417
+ interface CloudflarePolicyResponse {
418
+ id: string;
419
+ policy_id?: string;
420
+ name?: string;
421
+ description?: string;
422
+ match?: string;
423
+ precedence?: number;
424
+ enabled?: boolean;
425
+ created_at?: string;
426
+ updated_at?: string;
427
+ }
428
+
429
+ /**
430
+ * Internal API response type for policy list
431
+ * @internal
432
+ */
433
+ interface CloudflarePolicyListItem {
434
+ id: string;
435
+ policy_id?: string;
436
+ name: string;
437
+ description?: string;
438
+ match?: string;
439
+ precedence?: number;
440
+ enabled?: boolean;
441
+ }
442
+
443
+ async function createPolicy(
444
+ api: CloudflareApi,
445
+ props: WarpDeviceProfileProps & { name: string },
446
+ ): Promise<CloudflarePolicyResponse> {
447
+ const requestBody = buildRequestBody(props);
448
+
449
+ const response = await api.post(
450
+ `/accounts/${api.accountId}/devices/policy`,
451
+ requestBody,
452
+ );
453
+
454
+ if (!response.ok) {
455
+ await handleApiError(response, "create", "warp_device_profile", props.name);
456
+ }
457
+
458
+ return await extractCloudflareResult<CloudflarePolicyResponse>(
459
+ `create WARP device profile "${props.name}"`,
460
+ Promise.resolve(response),
461
+ );
462
+ }
463
+
464
+ async function updatePolicy(
465
+ api: CloudflareApi,
466
+ policyId: string,
467
+ props: WarpDeviceProfileProps & { name: string },
468
+ ): Promise<void> {
469
+ const requestBody = buildRequestBody(props);
470
+
471
+ const response = await api.patch(
472
+ `/accounts/${api.accountId}/devices/policy/${policyId}`,
473
+ requestBody,
474
+ );
475
+
476
+ if (!response.ok) {
477
+ await handleApiError(response, "update", "warp_device_profile", policyId);
478
+ }
479
+ }
480
+
481
+ async function deletePolicy(
482
+ api: CloudflareApi,
483
+ policyId: string,
484
+ ): Promise<void> {
485
+ const response = await api.delete(
486
+ `/accounts/${api.accountId}/devices/policy/${policyId}`,
487
+ );
488
+
489
+ if (!response.ok && response.status !== 404) {
490
+ await handleApiError(response, "delete", "warp_device_profile", policyId);
491
+ }
492
+ }
493
+
494
+ async function findPolicyByName(
495
+ api: CloudflareApi,
496
+ name: string,
497
+ ): Promise<{ policy_id: string } | null> {
498
+ const response = await api.get(`/accounts/${api.accountId}/devices/policies`);
499
+
500
+ if (!response.ok) {
501
+ await handleApiError(response, "list", "warp_device_profile", "all");
502
+ }
503
+
504
+ const data = (await response.json()) as {
505
+ result: CloudflarePolicyListItem[];
506
+ };
507
+
508
+ const policy = data.result?.find((p) => p.name === name);
509
+ if (!policy) return null;
510
+ return {
511
+ policy_id: policy.policy_id ?? policy.id,
512
+ };
513
+ }
514
+
515
+ async function updateSplitTunnel(
516
+ api: CloudflareApi,
517
+ policyId: string,
518
+ config: SplitTunnelConfig,
519
+ ): Promise<void> {
520
+ const routes = config.entries.map((entry) => ({
521
+ ...(entry.address && { address: entry.address }),
522
+ ...(entry.host && { host: entry.host }),
523
+ ...(entry.description && { description: entry.description }),
524
+ }));
525
+
526
+ if (config.mode === "include") {
527
+ const response = await api.put(
528
+ `/accounts/${api.accountId}/devices/policy/${policyId}/include`,
529
+ routes,
530
+ );
531
+ if (!response.ok) {
532
+ await handleApiError(
533
+ response,
534
+ "update split tunnel includes",
535
+ "warp_device_profile",
536
+ policyId,
537
+ );
538
+ }
539
+ } else {
540
+ const response = await api.put(
541
+ `/accounts/${api.accountId}/devices/policy/${policyId}/exclude`,
542
+ routes,
543
+ );
544
+ if (!response.ok) {
545
+ await handleApiError(
546
+ response,
547
+ "update split tunnel excludes",
548
+ "warp_device_profile",
549
+ policyId,
550
+ );
551
+ }
552
+ }
553
+ }
554
+
555
+ function buildRequestBody(
556
+ props: WarpDeviceProfileProps & { name: string },
557
+ ): Record<string, unknown> {
558
+ const requestBody: Record<string, unknown> = {
559
+ name: props.name,
560
+ };
561
+
562
+ if (props.description !== undefined) {
563
+ requestBody.description = props.description;
564
+ }
565
+ if (props.match !== undefined) {
566
+ requestBody.match = props.match;
567
+ }
568
+ if (props.precedence !== undefined) {
569
+ requestBody.precedence = props.precedence;
570
+ }
571
+ if (props.enabled !== undefined) {
572
+ requestBody.enabled = props.enabled;
573
+ }
574
+
575
+ if (props.serviceModeV2) {
576
+ requestBody.service_mode_v2 = {
577
+ mode: props.serviceModeV2.mode,
578
+ ...(props.serviceModeV2.port && { port: props.serviceModeV2.port }),
579
+ };
580
+ }
581
+ if (props.disableAutoFallback !== undefined) {
582
+ requestBody.disable_auto_fallback = props.disableAutoFallback;
583
+ }
584
+ if (props.allowModeSwitch !== undefined) {
585
+ requestBody.allow_mode_switch = props.allowModeSwitch;
586
+ }
587
+ if (props.switchLocked !== undefined) {
588
+ requestBody.switch_locked = props.switchLocked;
589
+ }
590
+ if (props.tunnelProtocol !== undefined) {
591
+ requestBody.tunnel_protocol = props.tunnelProtocol;
592
+ }
593
+ if (props.autoConnect !== undefined) {
594
+ requestBody.auto_connect = props.autoConnect;
595
+ }
596
+ if (props.allowedToLeave !== undefined) {
597
+ requestBody.allowed_to_leave = props.allowedToLeave;
598
+ }
599
+ if (props.captivePortal !== undefined) {
600
+ requestBody.captive_portal = props.captivePortal;
601
+ }
602
+ if (props.supportUrl !== undefined) {
603
+ requestBody.support_url = props.supportUrl;
604
+ }
605
+ if (props.excludeOfficeIps !== undefined) {
606
+ requestBody.exclude_office_ips = props.excludeOfficeIps;
607
+ }
608
+ if (props.lanAllowMinutes !== undefined) {
609
+ requestBody.lan_allow_minutes = props.lanAllowMinutes;
610
+ }
611
+ if (props.lanAllowSubnetSize !== undefined) {
612
+ requestBody.lan_allow_subnet_size = props.lanAllowSubnetSize;
613
+ }
614
+
615
+ return requestBody;
616
+ }
package/src/scope.ts CHANGED
@@ -43,7 +43,7 @@ export class RootScopeStateAttemptError extends Error {
43
43
 
44
44
  export interface ScopeOptions extends ProviderCredentials {
45
45
  stage?: string;
46
- parent: Scope | undefined;
46
+ parent: Scope | undefined | null;
47
47
  scopeName: string;
48
48
  password?: string;
49
49
  stateStore?: StateStoreType;
@@ -263,7 +263,7 @@ export class Scope {
263
263
 
264
264
  this.scopeName = scopeName;
265
265
  this.name = this.scopeName;
266
- this.parent = parent ?? Scope.getScope();
266
+ this.parent = parent === null ? undefined : (parent ?? Scope.getScope());
267
267
  this.rootDir = rootDir ?? this.parent?.rootDir ?? ALCHEMY_ROOT;
268
268
  this.isSelected = isSelected ?? this.parent?.isSelected;
269
269
  this.startedAt = startedAt ?? this.parent?.startedAt ?? performance.now();
@@ -83,7 +83,7 @@ const renderErrorHtml = (props) => `
83
83
  Alchemy</a>.</p>
84
84
  </div>
85
85
  <div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
86
- <p class="text-sm text-slate-500">Alchemy 0.80.1</p>
86
+ <p class="text-sm text-slate-500">Alchemy 0.81.0</p>
87
87
  </div>
88
88
  </div>
89
89
  </body>