alchemy 0.79.0 → 0.80.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 (71) hide show
  1. package/bin/alchemy.js +2 -1
  2. package/lib/cloudflare/bindings.d.ts +2 -2
  3. package/lib/cloudflare/bindings.d.ts.map +1 -1
  4. package/lib/cloudflare/bindings.js.map +1 -1
  5. package/lib/cloudflare/bound.d.ts +1 -1
  6. package/lib/cloudflare/bound.d.ts.map +1 -1
  7. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  8. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  9. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  10. package/lib/cloudflare/miniflare/build-worker-options.js +10 -0
  11. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  12. package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -1
  13. package/lib/cloudflare/miniflare/miniflare-controller.js +2 -2
  14. package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -1
  15. package/lib/cloudflare/website.d.ts +4 -0
  16. package/lib/cloudflare/website.d.ts.map +1 -1
  17. package/lib/cloudflare/website.js +36 -2
  18. package/lib/cloudflare/website.js.map +1 -1
  19. package/lib/cloudflare/worker-metadata.d.ts +1 -1
  20. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  21. package/lib/cloudflare/worker-metadata.js +16 -1
  22. package/lib/cloudflare/worker-metadata.js.map +1 -1
  23. package/lib/cloudflare/worker-subdomain.d.ts +1 -0
  24. package/lib/cloudflare/worker-subdomain.d.ts.map +1 -1
  25. package/lib/cloudflare/worker-subdomain.js +10 -2
  26. package/lib/cloudflare/worker-subdomain.js.map +1 -1
  27. package/lib/cloudflare/worker.d.ts +10 -0
  28. package/lib/cloudflare/worker.d.ts.map +1 -1
  29. package/lib/cloudflare/worker.js +8 -0
  30. package/lib/cloudflare/worker.js.map +1 -1
  31. package/lib/cloudflare/wrangler.json.d.ts +1 -1
  32. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  33. package/lib/cloudflare/wrangler.json.js +16 -3
  34. package/lib/cloudflare/wrangler.json.js.map +1 -1
  35. package/lib/docker/api.d.ts +14 -0
  36. package/lib/docker/api.d.ts.map +1 -1
  37. package/lib/docker/api.js +16 -1
  38. package/lib/docker/api.js.map +1 -1
  39. package/lib/docker/container.d.ts +5 -0
  40. package/lib/docker/container.d.ts.map +1 -1
  41. package/lib/docker/container.js +69 -49
  42. package/lib/docker/container.js.map +1 -1
  43. package/lib/docker/volume.d.ts +5 -0
  44. package/lib/docker/volume.d.ts.map +1 -1
  45. package/lib/docker/volume.js +43 -19
  46. package/lib/docker/volume.js.map +1 -1
  47. package/lib/neon/project.d.ts +30 -16
  48. package/lib/neon/project.d.ts.map +1 -1
  49. package/lib/neon/project.js +123 -1
  50. package/lib/neon/project.js.map +1 -1
  51. package/lib/util/find-open-port.d.ts +1 -0
  52. package/lib/util/find-open-port.d.ts.map +1 -1
  53. package/lib/util/find-open-port.js +15 -0
  54. package/lib/util/find-open-port.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cloudflare/bindings.ts +3 -1
  57. package/src/cloudflare/bound.ts +19 -13
  58. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  59. package/src/cloudflare/miniflare/build-worker-options.ts +10 -0
  60. package/src/cloudflare/miniflare/miniflare-controller.ts +3 -2
  61. package/src/cloudflare/website.ts +43 -2
  62. package/src/cloudflare/worker-metadata.ts +19 -1
  63. package/src/cloudflare/worker-subdomain.ts +14 -2
  64. package/src/cloudflare/worker.ts +11 -0
  65. package/src/cloudflare/wrangler.json.ts +14 -1
  66. package/src/docker/api.ts +22 -1
  67. package/src/docker/container.ts +83 -52
  68. package/src/docker/volume.ts +64 -27
  69. package/src/neon/project.ts +157 -2
  70. package/src/util/find-open-port.ts +18 -0
  71. package/workers/tunnel-proxy.js +1 -1
@@ -1,6 +1,7 @@
1
1
  import * as miniflare from "miniflare";
2
2
  import path from "pathe";
3
3
  import { assertNever } from "../../util/assert-never.ts";
4
+ import { reservePort } from "../../util/find-open-port.ts";
4
5
  import type { HTTPServer } from "../../util/http.ts";
5
6
  import { logger } from "../../util/logger.ts";
6
7
  import type { CloudflareApi } from "../api.ts";
@@ -83,6 +84,7 @@ export const buildWorkerOptions = async (
83
84
  // This exposes the worker as a route that can be accessed by setting the MF-Route-Override header.
84
85
  routes: [input.name],
85
86
  };
87
+ const port = input.port ?? (await reservePort(input.name));
86
88
  for (const [key, binding] of Object.entries(input.bindings ?? {})) {
87
89
  if (typeof binding === "string") {
88
90
  (options.bindings ??= {})[key] = binding;
@@ -92,6 +94,14 @@ export const buildWorkerOptions = async (
92
94
  (options.serviceBindings ??= {})[key] = miniflare.kCurrentWorker;
93
95
  continue;
94
96
  }
97
+ if (binding.type === "cloudflare::Worker::DevDomain") {
98
+ (options.bindings ??= {})[key] = `localhost:${port}`;
99
+ continue;
100
+ }
101
+ if (binding.type === "cloudflare::Worker::DevUrl") {
102
+ (options.bindings ??= {})[key] = `http://localhost:${port}`;
103
+ continue;
104
+ }
95
105
  switch (binding.type) {
96
106
  case "ai": {
97
107
  const existing = remoteBindings.find((b) => b.type === "ai");
@@ -2,7 +2,7 @@ import * as miniflare from "miniflare";
2
2
  import assert from "node:assert";
3
3
  import path from "pathe";
4
4
  import { Scope } from "../../scope.ts";
5
- import { findOpenPort } from "../../util/find-open-port.ts";
5
+ import { reservePort } from "../../util/find-open-port.ts";
6
6
  import type { HTTPServer } from "../../util/http.ts";
7
7
  import { logger } from "../../util/logger.ts";
8
8
  import { AsyncMutex } from "../../util/mutex.ts";
@@ -37,6 +37,7 @@ export class MiniflareController {
37
37
 
38
38
  async add(input: MiniflareWorkerInput) {
39
39
  const { watch, remoteProxy } = await buildWorkerOptions(input);
40
+
40
41
  if (remoteProxy) {
41
42
  this.remoteProxies.set(input.name, remoteProxy);
42
43
  }
@@ -54,7 +55,7 @@ export class MiniflareController {
54
55
  });
55
56
  } else {
56
57
  const proxy = await createMiniflareWorkerProxy({
57
- port: input.port ?? (await findOpenPort()),
58
+ port: input.port ?? (await reservePort(input.name)),
58
59
  getWorkerName: () => input.name,
59
60
  miniflare,
60
61
  mode: "local",
@@ -5,6 +5,7 @@ import { Exec } from "../os/index.ts";
5
5
  import { Scope } from "../scope.ts";
6
6
  import { dedent } from "../util/dedent.ts";
7
7
  import { logger } from "../util/logger.ts";
8
+ import { createCloudflareApi } from "./api.ts";
8
9
  import { Assets } from "./assets.ts";
9
10
  import type { Bindings } from "./bindings.ts";
10
11
  import { DEFAULT_COMPATIBILITY_DATE } from "./compatibility-date.gen.ts";
@@ -13,6 +14,7 @@ import {
13
14
  extractStringAndSecretBindings,
14
15
  unencryptSecrets,
15
16
  } from "./util/filter-env-bindings.ts";
17
+ import { computeWorkerDevDomain } from "./worker-subdomain.ts";
16
18
  import { type AssetsConfig, Worker, type WorkerProps } from "./worker.ts";
17
19
  import { WranglerJson, type WranglerJsonSpec } from "./wrangler.json.ts";
18
20
 
@@ -70,6 +72,10 @@ export interface WebsiteProps<B extends Bindings>
70
72
  * The command to run to start the dev server
71
73
  */
72
74
  command?: string;
75
+ /**
76
+ * The local domain to use for the dev server
77
+ */
78
+ domain?: string;
73
79
  /**
74
80
  * Additional environment variables to set when running the dev command
75
81
  */
@@ -225,11 +231,46 @@ export async function Website<B extends Bindings>(
225
231
  };
226
232
  })();
227
233
  const secrets = props.wrangler?.secrets ?? !props.wrangler?.path;
234
+
228
235
  const env = {
229
- ...(process.env ?? {}),
230
- ...(props.env ?? {}),
236
+ ...process.env,
237
+ ...props.env,
231
238
  ...extractStringAndSecretBindings(props.bindings ?? {}, secrets),
232
239
  };
240
+ if (props.bindings) {
241
+ for (const [key, value] of Object.entries(props.bindings)) {
242
+ if (typeof value !== "object") continue;
243
+ if (
244
+ value.type === "cloudflare::Worker::DevDomain" ||
245
+ value.type === "cloudflare::Worker::DevUrl"
246
+ ) {
247
+ if (Scope.current.local) {
248
+ const domain = typeof dev === "object" ? dev.domain : undefined;
249
+ if (!domain) {
250
+ // we can't know which port the local web server (e.g. vite dev) will use
251
+ // vite dev # default to 5173 or configured in vite.config.ts
252
+ // vite dev --port XYZ
253
+ // next dev # who fucking knows
254
+ // for now: we require the user tell us the local domain
255
+ throw new Error(
256
+ "You must set `dev.domain` when running in local development mode and a Worker.DevDomain binding",
257
+ );
258
+ }
259
+ env[key] =
260
+ value.type === "cloudflare::Worker::DevDomain"
261
+ ? domain
262
+ : `http://${domain}`;
263
+ } else {
264
+ const api = await createCloudflareApi(props);
265
+ const domain = await computeWorkerDevDomain(api, name);
266
+ env[key] =
267
+ value.type === "cloudflare::Worker::DevDomain"
268
+ ? domain
269
+ : `https://${domain}`;
270
+ }
271
+ }
272
+ }
273
+ }
233
274
  const worker = {
234
275
  ...workerProps,
235
276
  name,
@@ -19,7 +19,13 @@ import type {
19
19
  MultiStepMigration,
20
20
  SingleStepMigration,
21
21
  } from "./worker-migration.ts";
22
- import { isWorker, type AssetsConfig, type WorkerProps } from "./worker.ts";
22
+ import { computeWorkerDevDomain } from "./worker-subdomain.ts";
23
+ import {
24
+ isWorker,
25
+ Worker,
26
+ type AssetsConfig,
27
+ type WorkerProps,
28
+ } from "./worker.ts";
23
29
 
24
30
  /**
25
31
  * Metadata returned by Cloudflare API for a worker script
@@ -427,6 +433,18 @@ export async function prepareWorkerMetadata(
427
433
  service: props.workerName,
428
434
  entrypoint: binding.__entrypoint__,
429
435
  });
436
+ } else if (binding.type === "cloudflare::Worker::DevDomain") {
437
+ meta.bindings.push({
438
+ type: "plain_text",
439
+ name: bindingName,
440
+ text: await computeWorkerDevDomain(api, props.workerName),
441
+ });
442
+ } else if (binding.type === "cloudflare::Worker::DevUrl") {
443
+ meta.bindings.push({
444
+ type: "plain_text",
445
+ name: bindingName,
446
+ text: `https://${await computeWorkerDevDomain(api, props.workerName)}`,
447
+ });
430
448
  } else if (binding.type === "d1") {
431
449
  meta.bindings.push({
432
450
  type: "d1",
@@ -87,7 +87,9 @@ export async function disableWorkerSubdomain(
87
87
  `disable subdomain for "${scriptName}"`,
88
88
  api.post(
89
89
  `/accounts/${api.accountId}/workers/scripts/${scriptName}/subdomain`,
90
- { enabled: false },
90
+ {
91
+ enabled: false,
92
+ },
91
93
  ),
92
94
  ).catch((error) => {
93
95
  if (error.status === 404) {
@@ -107,7 +109,10 @@ export async function enableWorkerSubdomain(
107
109
  `enable subdomain for "${scriptName}"`,
108
110
  api.post(
109
111
  `/accounts/${api.accountId}/workers/scripts/${scriptName}/subdomain`,
110
- { enabled: true, previews_enabled: true },
112
+ {
113
+ enabled: true,
114
+ previews_enabled: true,
115
+ },
111
116
  ),
112
117
  ),
113
118
  (error) => error instanceof CloudflareApiError && error.status === 404,
@@ -116,6 +121,13 @@ export async function enableWorkerSubdomain(
116
121
  );
117
122
  }
118
123
 
124
+ export async function computeWorkerDevDomain(
125
+ api: CloudflareApi,
126
+ scriptName: string,
127
+ ) {
128
+ return `${scriptName}.${await getAccountSubdomain(api)}.workers.dev`;
129
+ }
130
+
119
131
  export async function getWorkerSubdomain(
120
132
  api: CloudflareApi,
121
133
  scriptName: string,
@@ -809,6 +809,17 @@ export function Worker<const B extends Bindings>(
809
809
  return _Worker(id, props as WorkerProps<B>);
810
810
  }
811
811
 
812
+ export namespace Worker {
813
+ export type DevDomain = typeof DevDomain;
814
+ export const DevDomain = {
815
+ type: "cloudflare::Worker::DevDomain" as const,
816
+ };
817
+ export type DevUrl = typeof DevUrl;
818
+ export const DevUrl = {
819
+ type: "cloudflare::Worker::DevUrl" as const,
820
+ };
821
+ }
822
+
812
823
  Worker.experimentalEntrypoint = <RPC extends Rpc.WorkerEntrypointBranded>(
813
824
  worker: Worker | WorkerRef | Self,
814
825
  entrypoint: string,
@@ -14,7 +14,8 @@ import type { EventSource } from "./event-source.ts";
14
14
  import { isQueueEventSource } from "./event-source.ts";
15
15
  import { isQueue } from "./queue.ts";
16
16
  import { unencryptSecrets } from "./util/filter-env-bindings.ts";
17
- import { isWorker, type Worker, type WorkerProps } from "./worker.ts";
17
+ import { getAccountSubdomain } from "./worker-subdomain.ts";
18
+ import { isWorker, Worker, type WorkerProps } from "./worker.ts";
18
19
 
19
20
  /**
20
21
  * Properties for wrangler.json configuration file
@@ -180,6 +181,7 @@ export async function WranglerJson(
180
181
  Scope.current.local && !props.worker.dev?.remote,
181
182
  async () =>
182
183
  worker.accountId ?? (await createCloudflareApi(worker)).accountId,
184
+ async () => await getAccountSubdomain(await createCloudflareApi(worker)),
183
185
  );
184
186
  }
185
187
 
@@ -253,6 +255,7 @@ async function processBindings(
253
255
  writeSecrets: boolean,
254
256
  local: boolean,
255
257
  getAccountId: () => Promise<string>,
258
+ getAccountSubdomain: () => Promise<string>,
256
259
  ): Promise<void> {
257
260
  // Arrays to collect different binding types
258
261
  const kvNamespaces: WranglerJsonConfig["kv_namespaces"] = [];
@@ -322,6 +325,16 @@ async function processBindings(
322
325
  service: workerName,
323
326
  entrypoint: binding.__entrypoint__,
324
327
  });
328
+ } else if (binding.type === "cloudflare::Worker::DevDomain") {
329
+ const subdomain = await getAccountSubdomain();
330
+ // Subdomain binding
331
+ (spec.vars ??= {})[bindingName] =
332
+ `${workerName}.${subdomain}.workers.dev`;
333
+ } else if (binding.type === "cloudflare::Worker::DevUrl") {
334
+ const subdomain = await getAccountSubdomain();
335
+ // Subdomain binding
336
+ (spec.vars ??= {})[bindingName] =
337
+ `https://${workerName}.${subdomain}.workers.dev`;
325
338
  } else if (binding.type === "service") {
326
339
  // Service binding
327
340
  services.push({
package/src/docker/api.ts CHANGED
@@ -69,6 +69,12 @@ type VolumeInfo = {
69
69
  Scope: string;
70
70
  };
71
71
 
72
+ type ContainerInfo = {
73
+ Id: string;
74
+ State: { Status: "created" | "running" | "paused" | "stopped" | "exited" };
75
+ Created: string;
76
+ };
77
+
72
78
  /**
73
79
  * Docker API client that wraps Docker CLI commands
74
80
  */
@@ -351,6 +357,21 @@ export class DockerApi {
351
357
  return stdout;
352
358
  }
353
359
 
360
+ /**
361
+ * Get Docker container information
362
+ *
363
+ * @param containerId Container ID or name
364
+ * @returns Container details in JSON format
365
+ */
366
+ async inspectContainer(containerId: string): Promise<ContainerInfo[]> {
367
+ const { stdout } = await this.exec(["container", "inspect", containerId]);
368
+ try {
369
+ return JSON.parse(stdout.trim()) as ContainerInfo[];
370
+ } catch (_error) {
371
+ return [];
372
+ }
373
+ }
374
+
354
375
  /**
355
376
  * Check if a container exists
356
377
  *
@@ -359,7 +380,7 @@ export class DockerApi {
359
380
  */
360
381
  async containerExists(containerId: string): Promise<boolean> {
361
382
  try {
362
- await this.exec(["inspect", containerId]);
383
+ await this.inspectContainer(containerId);
363
384
  return true;
364
385
  } catch (_error) {
365
386
  return false;
@@ -176,6 +176,12 @@ export interface ContainerProps {
176
176
  * Healthcheck configuration
177
177
  */
178
178
  healthcheck?: HealthcheckConfig;
179
+
180
+ /**
181
+ * Whether to adopt the container if it already exists
182
+ * @default false
183
+ */
184
+ adopt?: boolean;
179
185
  }
180
186
 
181
187
  /**
@@ -303,71 +309,96 @@ export const Container = Resource(
303
309
 
304
310
  // Return destroyed state
305
311
  return this.destroy();
306
- } else {
307
- let containerState: NonNullable<Container["state"]> = "created";
312
+ }
308
313
 
309
- if (this.phase === "update") {
310
- // Check if container already exists (for update)
311
- const containerExists = await api.containerExists(containerName);
314
+ let containerState: NonNullable<Container["state"]> = "created";
312
315
 
313
- if (containerExists) {
314
- // Remove existing container for update
315
- await api.removeContainer(containerName, true);
316
+ // Check if container already exists
317
+ const containerExists = await api.containerExists(containerName);
318
+
319
+ if (containerExists) {
320
+ if (this.phase === "update") {
321
+ // Remove existing container for update
322
+ await api.removeContainer(containerName, true);
323
+ } else {
324
+ // Create phase - check for adoption
325
+ if (!props.adopt) {
326
+ throw new Error(
327
+ `Container "${containerName}" already exists. Use adopt: true to adopt it.`,
328
+ );
316
329
  }
317
- }
318
330
 
319
- // Prepare port mappings
320
- const portMappings: Record<string, string> = {};
321
- if (props.ports) {
322
- for (const port of props.ports) {
323
- const protocol = port.protocol || "tcp";
324
- portMappings[`${port.external}`] = `${port.internal}/${protocol}`;
331
+ // Adopt existing container
332
+ const containerInfos = await api.inspectContainer(containerName);
333
+ const containerInfo = containerInfos[0];
334
+ let adoptedState = containerInfo.State.Status;
335
+
336
+ // Optionally start the container if requested
337
+ if (props.start && containerInfo.State.Status !== "running") {
338
+ await api.startContainer(containerName);
339
+ adoptedState = "running";
325
340
  }
341
+
342
+ return {
343
+ ...props,
344
+ id: containerInfo.Id,
345
+ name: containerName,
346
+ state: adoptedState,
347
+ createdAt: new Date(containerInfo.Created).getTime(),
348
+ };
326
349
  }
350
+ }
327
351
 
328
- // Prepare volume mappings
329
- const volumeMappings: Record<string, string> = {};
330
- if (props.volumes) {
331
- for (const volume of props.volumes) {
332
- const readOnlyFlag = volume.readOnly ? ":ro" : "";
333
- volumeMappings[volume.hostPath] =
334
- `${volume.containerPath}${readOnlyFlag}`;
335
- }
352
+ // Prepare port mappings
353
+ const portMappings: Record<string, string> = {};
354
+ if (props.ports) {
355
+ for (const port of props.ports) {
356
+ const protocol = port.protocol || "tcp";
357
+ portMappings[`${port.external}`] = `${port.internal}/${protocol}`;
336
358
  }
359
+ }
337
360
 
338
- // Create new container
339
- const containerId = await api.createContainer(imageRef, containerName, {
340
- ports: portMappings,
341
- env: props.environment,
342
- volumes: volumeMappings,
343
- cmd: props.command,
344
- healthcheck: props.healthcheck,
345
- });
346
-
347
- // Connect to networks if specified
348
- if (props.networks) {
349
- for (const network of props.networks) {
350
- const networkId =
351
- typeof network === "string" ? network : network.name;
352
- await api.connectNetwork(containerId, networkId, {
353
- aliases: network.aliases,
354
- });
355
- }
361
+ // Prepare volume mappings
362
+ const volumeMappings: Record<string, string> = {};
363
+ if (props.volumes) {
364
+ for (const volume of props.volumes) {
365
+ const readOnlyFlag = volume.readOnly ? ":ro" : "";
366
+ volumeMappings[volume.hostPath] =
367
+ `${volume.containerPath}${readOnlyFlag}`;
356
368
  }
369
+ }
357
370
 
358
- // Start container if requested
359
- if (props.start) {
360
- await api.startContainer(containerId);
361
- containerState = "running";
371
+ // Create new container
372
+ const containerId = await api.createContainer(imageRef, containerName, {
373
+ ports: portMappings,
374
+ env: props.environment,
375
+ volumes: volumeMappings,
376
+ cmd: props.command,
377
+ healthcheck: props.healthcheck,
378
+ });
379
+
380
+ // Connect to networks if specified
381
+ if (props.networks) {
382
+ for (const network of props.networks) {
383
+ const networkId = typeof network === "string" ? network : network.name;
384
+ await api.connectNetwork(containerId, networkId, {
385
+ aliases: network.aliases,
386
+ });
362
387
  }
388
+ }
363
389
 
364
- return {
365
- ...props,
366
- id: containerId,
367
- name: containerName,
368
- state: containerState,
369
- createdAt: Date.now(),
370
- };
390
+ // Start container if requested
391
+ if (props.start) {
392
+ await api.startContainer(containerId);
393
+ containerState = "running";
371
394
  }
395
+
396
+ return {
397
+ ...props,
398
+ id: containerId,
399
+ name: containerName,
400
+ state: containerState,
401
+ createdAt: Date.now(),
402
+ };
372
403
  },
373
404
  );
@@ -43,6 +43,12 @@ export interface VolumeProps {
43
43
  * Custom metadata labels for the volume
44
44
  */
45
45
  labels?: VolumeLabel[] | Record<string, string>;
46
+
47
+ /**
48
+ * Whether to adopt the volume if it already exists
49
+ * @default false
50
+ */
51
+ adopt?: boolean;
46
52
  }
47
53
 
48
54
  /**
@@ -133,33 +139,64 @@ export const Volume = Resource(
133
139
 
134
140
  // Return destroyed state
135
141
  return this.destroy();
136
- } else {
137
- // Set default driver if not provided
138
- const driver = props.driver || "local";
139
- const driverOpts = props.driverOpts || {};
140
-
141
- // Create the volume
142
- const volumeId = await api.createVolume(
143
- volumeName,
144
- driver,
145
- driverOpts,
146
- processedLabels,
147
- );
148
-
149
- // Get volume details to retrieve mountpoint
150
- const volumeInfos = await api.inspectVolume(volumeId);
151
- const mountpoint = volumeInfos[0].Mountpoint;
152
-
153
- return {
154
- ...props,
155
- driver: driver,
156
- id: volumeId,
157
- name: volumeName,
158
- mountpoint,
159
- createdAt: Date.now(),
160
- labels: Array.isArray(props.labels) ? props.labels : undefined,
161
- driverOpts: props.driverOpts,
162
- };
163
142
  }
143
+
144
+ // Check if volume already exists
145
+ const volumeExists = await api.volumeExists(volumeName);
146
+
147
+ if (volumeExists) {
148
+ if (this.phase === "update") {
149
+ this.replace();
150
+ } else {
151
+ // Create phase - check for adoption
152
+ if (!props.adopt) {
153
+ throw new Error(
154
+ `Volume "${volumeName}" already exists. Use adopt: true to adopt it.`,
155
+ );
156
+ }
157
+
158
+ // Adopt existing volume
159
+ const volumeInfos = await api.inspectVolume(volumeName);
160
+ const volumeInfo = volumeInfos[0];
161
+
162
+ return {
163
+ ...props,
164
+ id: volumeInfo.Name,
165
+ name: volumeInfo.Name,
166
+ driver: volumeInfo.Driver,
167
+ mountpoint: volumeInfo.Mountpoint,
168
+ createdAt: new Date(volumeInfo.CreatedAt).getTime(),
169
+ labels: Array.isArray(props.labels) ? props.labels : undefined,
170
+ driverOpts: volumeInfo.Options,
171
+ };
172
+ }
173
+ }
174
+
175
+ // Set default driver if not provided
176
+ const driver = props.driver || "local";
177
+ const driverOpts = props.driverOpts || {};
178
+
179
+ // Create the volume
180
+ const volumeId = await api.createVolume(
181
+ volumeName,
182
+ driver,
183
+ driverOpts,
184
+ processedLabels,
185
+ );
186
+
187
+ // Get volume details to retrieve mountpoint
188
+ const volumeInfos = await api.inspectVolume(volumeId);
189
+ const mountpoint = volumeInfos[0].Mountpoint;
190
+
191
+ return {
192
+ ...props,
193
+ driver: driver,
194
+ id: volumeId,
195
+ name: volumeName,
196
+ mountpoint,
197
+ createdAt: Date.now(),
198
+ labels: Array.isArray(props.labels) ? props.labels : undefined,
199
+ driverOpts: props.driverOpts,
200
+ };
164
201
  },
165
202
  );