alchemy 0.78.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 (81) 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 +23 -3
  30. package/lib/cloudflare/worker.js.map +1 -1
  31. package/lib/cloudflare/workflow.d.ts +1 -0
  32. package/lib/cloudflare/workflow.d.ts.map +1 -1
  33. package/lib/cloudflare/workflow.js +6 -0
  34. package/lib/cloudflare/workflow.js.map +1 -1
  35. package/lib/cloudflare/wrangler.json.d.ts +1 -1
  36. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  37. package/lib/cloudflare/wrangler.json.js +16 -3
  38. package/lib/cloudflare/wrangler.json.js.map +1 -1
  39. package/lib/docker/api.d.ts +14 -0
  40. package/lib/docker/api.d.ts.map +1 -1
  41. package/lib/docker/api.js +18 -2
  42. package/lib/docker/api.js.map +1 -1
  43. package/lib/docker/container.d.ts +5 -0
  44. package/lib/docker/container.d.ts.map +1 -1
  45. package/lib/docker/container.js +69 -49
  46. package/lib/docker/container.js.map +1 -1
  47. package/lib/docker/volume.d.ts +5 -0
  48. package/lib/docker/volume.d.ts.map +1 -1
  49. package/lib/docker/volume.js +43 -19
  50. package/lib/docker/volume.js.map +1 -1
  51. package/lib/neon/project.d.ts +30 -16
  52. package/lib/neon/project.d.ts.map +1 -1
  53. package/lib/neon/project.js +123 -1
  54. package/lib/neon/project.js.map +1 -1
  55. package/lib/util/camel-to-snake.d.ts +1 -1
  56. package/lib/util/camel-to-snake.d.ts.map +1 -1
  57. package/lib/util/camel-to-snake.js +26 -13
  58. package/lib/util/camel-to-snake.js.map +1 -1
  59. package/lib/util/find-open-port.d.ts +1 -0
  60. package/lib/util/find-open-port.d.ts.map +1 -1
  61. package/lib/util/find-open-port.js +15 -0
  62. package/lib/util/find-open-port.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cloudflare/bindings.ts +3 -1
  65. package/src/cloudflare/bound.ts +19 -13
  66. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  67. package/src/cloudflare/miniflare/build-worker-options.ts +10 -0
  68. package/src/cloudflare/miniflare/miniflare-controller.ts +3 -2
  69. package/src/cloudflare/website.ts +43 -2
  70. package/src/cloudflare/worker-metadata.ts +19 -1
  71. package/src/cloudflare/worker-subdomain.ts +14 -2
  72. package/src/cloudflare/worker.ts +36 -2
  73. package/src/cloudflare/workflow.ts +9 -0
  74. package/src/cloudflare/wrangler.json.ts +14 -1
  75. package/src/docker/api.ts +24 -2
  76. package/src/docker/container.ts +83 -52
  77. package/src/docker/volume.ts +64 -27
  78. package/src/neon/project.ts +157 -2
  79. package/src/util/camel-to-snake.ts +34 -19
  80. package/src/util/find-open-port.ts +18 -0
  81. package/workers/tunnel-proxy.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Sam Goodwin <sam@alchemy.run>",
6
6
  "homepage": "https://alchemy.run",
@@ -26,10 +26,10 @@ import type { SecretRef as CloudflareSecretRef } from "./secret-ref.ts";
26
26
  import type { Secret as CloudflareSecret } from "./secret.ts";
27
27
  import type { VectorizeIndex } from "./vectorize-index.ts";
28
28
  import type { VersionMetadata } from "./version-metadata.ts";
29
+ import type { WorkerLoader } from "./worker-loader.ts";
29
30
  import type { WorkerRef } from "./worker-ref.ts";
30
31
  import type { WorkerStub } from "./worker-stub.ts";
31
32
  import type { Worker } from "./worker.ts";
32
- import type { WorkerLoader } from "./worker-loader.ts";
33
33
  import type { Workflow } from "./workflow.ts";
34
34
 
35
35
  export type Bindings = {
@@ -72,6 +72,8 @@ export type Binding =
72
72
  | string
73
73
  | VectorizeIndex
74
74
  | Worker
75
+ | Worker.DevDomain
76
+ | Worker.DevUrl
75
77
  | WorkerStub
76
78
  | WorkerRef
77
79
  | WorkerEntrypoint
@@ -88,17 +88,23 @@ export type Bound<T extends Binding> =
88
88
  ? ImagesBinding
89
89
  : T extends _VersionMetadata
90
90
  ? WorkerVersionMetadata
91
- : T extends Self
92
- ? Service
93
- : T extends Json<infer T>
94
- ? T
95
- : T extends _Container<
96
- infer Obj
91
+ : T extends
92
+ | _Worker.DevDomain
93
+ | _Worker.DevUrl
94
+ ? string
95
+ : T extends Self
96
+ ? Service
97
+ : T extends Json<
98
+ infer T
97
99
  >
98
- ? DurableObjectNamespace<
99
- Obj &
100
- Rpc.DurableObjectBranded
101
- >
102
- : T extends undefined
103
- ? undefined
104
- : Service;
100
+ ? T
101
+ : T extends _Container<
102
+ infer Obj
103
+ >
104
+ ? DurableObjectNamespace<
105
+ Obj &
106
+ Rpc.DurableObjectBranded
107
+ >
108
+ : T extends undefined
109
+ ? undefined
110
+ : Service;
@@ -4,4 +4,4 @@
4
4
  /**
5
5
  * The default Cloudflare Workers compatibility date, set based on the latest workerd release at the time of build.
6
6
  */
7
- export const DEFAULT_COMPATIBILITY_DATE = "2025-11-19";
7
+ export const DEFAULT_COMPATIBILITY_DATE = "2025-12-02";
@@ -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,
@@ -51,11 +51,17 @@ import {
51
51
  import {
52
52
  type WorkerScriptMetadata,
53
53
  bumpMigrationTagVersion,
54
+ getWorkerSettings,
54
55
  prepareWorkerMetadata,
55
56
  } from "./worker-metadata.ts";
56
57
  import { WorkerSubdomain, disableWorkerSubdomain } from "./worker-subdomain.ts";
57
58
  import { createTail } from "./worker-tail.ts";
58
- import { Workflow, isWorkflow, upsertWorkflow } from "./workflow.ts";
59
+ import {
60
+ Workflow,
61
+ deleteWorkflow,
62
+ isWorkflow,
63
+ upsertWorkflow,
64
+ } from "./workflow.ts";
59
65
 
60
66
  // Previous versions of `Worker` used the `Bundle` resource.
61
67
  // This import is here to avoid errors when destroying the `Bundle` resource.
@@ -803,6 +809,17 @@ export function Worker<const B extends Bindings>(
803
809
  return _Worker(id, props as WorkerProps<B>);
804
810
  }
805
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
+
806
823
  Worker.experimentalEntrypoint = <RPC extends Rpc.WorkerEntrypointBranded>(
807
824
  worker: Worker | WorkerRef | Self,
808
825
  entrypoint: string,
@@ -951,7 +968,10 @@ const _Worker = Resource(
951
968
  await createEmptyWorker(api, options.name, props.version);
952
969
  }
953
970
  } else {
954
- await deleteQueueConsumers(api, options.name);
971
+ await Promise.all([
972
+ deleteQueueConsumers(api, options.name),
973
+ deleteWorkflows(api, options.name),
974
+ ]);
955
975
  await deleteWorker(api, {
956
976
  scriptName: options.name,
957
977
  dispatchNamespace: options.dispatchNamespace,
@@ -1708,6 +1728,20 @@ async function deleteQueueConsumers(api: CloudflareApi, scriptName: string) {
1708
1728
  );
1709
1729
  }
1710
1730
 
1731
+ async function deleteWorkflows(api: CloudflareApi, scriptName: string) {
1732
+ const settings = await getWorkerSettings(api, scriptName);
1733
+ await Promise.all(
1734
+ (settings?.bindings ?? []).map(async (binding) => {
1735
+ if (
1736
+ binding.type === "workflow" &&
1737
+ (binding.script_name === scriptName || !binding.script_name)
1738
+ ) {
1739
+ await deleteWorkflow(api, binding.workflow_name);
1740
+ }
1741
+ }),
1742
+ );
1743
+ }
1744
+
1711
1745
  export async function deleteWorker(
1712
1746
  api: CloudflareApi,
1713
1747
  props: {
@@ -116,3 +116,12 @@ export async function upsertWorkflow(
116
116
 
117
117
  return body.result;
118
118
  }
119
+
120
+ export async function deleteWorkflow(api: CloudflareApi, name: string) {
121
+ const response = await api.delete(
122
+ `/accounts/${api.accountId}/workflows/${name}`,
123
+ );
124
+ if (!response.ok && response.status !== 404) {
125
+ await handleApiError(response, "delete", "workflow", name);
126
+ }
127
+ }
@@ -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
  */
@@ -134,7 +140,8 @@ export class DockerApi {
134
140
  } catch (error: any) {
135
141
  const message = stderr || error.message || "Command failed";
136
142
  if (
137
- message.includes("unexpected status from HEAD request") &&
143
+ (message.includes("unexpected status from HEAD request") ||
144
+ message.includes("502 Bad Gateway")) &&
138
145
  remainingAttempts > 0
139
146
  ) {
140
147
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -350,6 +357,21 @@ export class DockerApi {
350
357
  return stdout;
351
358
  }
352
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
+
353
375
  /**
354
376
  * Check if a container exists
355
377
  *
@@ -358,7 +380,7 @@ export class DockerApi {
358
380
  */
359
381
  async containerExists(containerId: string): Promise<boolean> {
360
382
  try {
361
- await this.exec(["inspect", containerId]);
383
+ await this.inspectContainer(containerId);
362
384
  return true;
363
385
  } catch (_error) {
364
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
  );