From a194ab5aadf521e6a35834d17e61748bd3c1b63c Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 20 Jun 2025 01:26:43 -0700 Subject: [PATCH 1/4] fix(core): allow colors in CI environments, only disable for NO_COLOR (#429) --- alchemy/src/cloudflare/bundle/bundle-worker.ts | 2 +- alchemy/src/util/cli.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/alchemy/src/cloudflare/bundle/bundle-worker.ts b/alchemy/src/cloudflare/bundle/bundle-worker.ts index 5ced5053c..d6fd1c245 100644 --- a/alchemy/src/cloudflare/bundle/bundle-worker.ts +++ b/alchemy/src/cloudflare/bundle/bundle-worker.ts @@ -71,7 +71,7 @@ export async function bundleWorkerScript( ).flat(), ), ); - const useColor = !(process.env.CI || process.env.NO_COLOR); + const useColor = !process.env.NO_COLOR; logger.log( `${useColor ? kleur.gray("worker:") : "worker:"} ${useColor ? kleur.blue(props.name) : props.name}`, ); diff --git a/alchemy/src/util/cli.ts b/alchemy/src/util/cli.ts index 5f25eb317..5994ff114 100644 --- a/alchemy/src/util/cli.ts +++ b/alchemy/src/util/cli.ts @@ -17,9 +17,7 @@ type ColorName = keyof typeof colors; // Check if colors should be disabled const shouldDisableColors = (): boolean => { - return Boolean( - process.env.CI || process.env.NO_COLOR || !process.stdout.isTTY, - ); + return Boolean(process.env.NO_COLOR); }; // Apply color if colors are enabled From d0c7ce836f6737d2ec7951036a4e20eb1652c611 Mon Sep 17 00:00:00 2001 From: Nico Baier Date: Fri, 20 Jun 2025 03:29:39 -0500 Subject: [PATCH 2/4] fix(cli): improve package manager handling in create-alchemy (#423) --- alchemy/bin/alchemy.ts | 2 +- alchemy/bin/create-alchemy.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/alchemy/bin/alchemy.ts b/alchemy/bin/alchemy.ts index 0659da664..cda2f967c 100644 --- a/alchemy/bin/alchemy.ts +++ b/alchemy/bin/alchemy.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { parseArgs } from "node:util"; -import { createAlchemy } from "./create-alchemy.ts"; import { bootstrapS3 } from "./bootstrap-s3.ts"; +import { createAlchemy } from "./create-alchemy.ts"; // Parse command-line arguments. We allow unknown flags because different // sub-commands may accept different sets. diff --git a/alchemy/bin/create-alchemy.ts b/alchemy/bin/create-alchemy.ts index ee1d883f9..ce5fa9225 100644 --- a/alchemy/bin/create-alchemy.ts +++ b/alchemy/bin/create-alchemy.ts @@ -375,7 +375,8 @@ async function initAstroProject( projectPath: string, ): Promise { create( - `astro@latest ${projectName} -- --no-git --no-deploy --install ${options.yes ? "--yes" : ""}`, + pm, + `astro@latest ${projectName} ${pm === "npm" ? "--" : ""} --no-git --no-deploy --install ${options.yes ? "--yes" : ""}`, ); await initWebsiteProject(projectPath, { @@ -432,7 +433,8 @@ async function initReactRouterProject( projectPath: string, ): Promise { create( - `cloudflare@2.49.3 ${projectName} -- --framework=react-router --no-git --no-deploy ${options.yes ? "--yes" : ""}`, + pm, + `cloudflare@2.49.3 ${projectName} ${pm === "npm" ? "--" : ""} --framework=react-router --no-git --no-deploy ${options.yes ? "--yes" : ""}`, ); await initWebsiteProject(projectPath, { @@ -1164,7 +1166,7 @@ async function mkdir(...path: string[]): Promise { } function execCommand(command: string, cwd: string = process.cwd()): void { - console.log(command); + console.log(command.replaceAll(/ +/g, " ")); try { execSync(command, { stdio: "inherit", cwd }); } catch { @@ -1206,7 +1208,11 @@ function npx(command: string, cwd: string = process.cwd()): void { ); } -function create(command: string, cwd: string = process.cwd()): void { +function create( + pm: PackageManager, + command: string, + cwd: string = process.cwd(), +): void { execCommand( `${getPackageManagerCommands(pm).create} ${options.yes ? "-y" : ""} ${command}`, cwd, From 6f97398392a947a5c4452cefc582c0457b0b5301 Mon Sep 17 00:00:00 2001 From: Pavitra Golchha Date: Fri, 20 Jun 2025 14:11:45 +0530 Subject: [PATCH 3/4] feat(docker): Docker provider (#189) --- .../docs/providers/docker/container.md | 86 +++++ alchemy-web/docs/providers/docker/image.md | 82 ++++ alchemy-web/docs/providers/docker/index.md | 108 ++++++ alchemy-web/docs/providers/docker/network.md | 120 ++++++ .../docs/providers/docker/remote-image.md | 56 +++ alchemy-web/docs/providers/docker/volume.md | 121 ++++++ alchemy/package.json | 4 + alchemy/src/docker/api.ts | 365 ++++++++++++++++++ alchemy/src/docker/container.ts | 267 +++++++++++++ alchemy/src/docker/image.ts | 200 ++++++++++ alchemy/src/docker/index.ts | 6 + alchemy/src/docker/network.ts | 101 +++++ alchemy/src/docker/remote-image.ts | 83 ++++ alchemy/src/docker/volume.ts | 154 ++++++++ alchemy/src/os/exec.ts | 54 ++- alchemy/test/docker/api.test.ts | 57 +++ alchemy/test/docker/container.test.ts | 28 ++ .../docker/fixtures/build-args/Dockerfile | 11 + .../docker/fixtures/multi-stage/Dockerfile | 18 + .../docker/fixtures/simple-image/Dockerfile | 2 + alchemy/test/docker/image.test.ts | 134 +++++++ alchemy/test/docker/network.test.ts | 26 ++ alchemy/test/docker/remote-image.test.ts | 43 +++ alchemy/test/docker/volume.test.ts | 93 +++++ bun.lock | 6 + examples/docker/.env | 4 + examples/docker/.gitignore | 1 + examples/docker/README.md | 76 ++++ examples/docker/alchemy.run.ts | 95 +++++ examples/docker/package.json | 10 + examples/docker/tsconfig.json | 9 + 31 files changed, 2414 insertions(+), 6 deletions(-) create mode 100644 alchemy-web/docs/providers/docker/container.md create mode 100644 alchemy-web/docs/providers/docker/image.md create mode 100644 alchemy-web/docs/providers/docker/index.md create mode 100644 alchemy-web/docs/providers/docker/network.md create mode 100644 alchemy-web/docs/providers/docker/remote-image.md create mode 100644 alchemy-web/docs/providers/docker/volume.md create mode 100644 alchemy/src/docker/api.ts create mode 100644 alchemy/src/docker/container.ts create mode 100644 alchemy/src/docker/image.ts create mode 100644 alchemy/src/docker/index.ts create mode 100644 alchemy/src/docker/network.ts create mode 100644 alchemy/src/docker/remote-image.ts create mode 100644 alchemy/src/docker/volume.ts create mode 100644 alchemy/test/docker/api.test.ts create mode 100644 alchemy/test/docker/container.test.ts create mode 100644 alchemy/test/docker/fixtures/build-args/Dockerfile create mode 100644 alchemy/test/docker/fixtures/multi-stage/Dockerfile create mode 100644 alchemy/test/docker/fixtures/simple-image/Dockerfile create mode 100644 alchemy/test/docker/image.test.ts create mode 100644 alchemy/test/docker/network.test.ts create mode 100644 alchemy/test/docker/remote-image.test.ts create mode 100644 alchemy/test/docker/volume.test.ts create mode 100644 examples/docker/.env create mode 100644 examples/docker/.gitignore create mode 100644 examples/docker/README.md create mode 100644 examples/docker/alchemy.run.ts create mode 100644 examples/docker/package.json create mode 100644 examples/docker/tsconfig.json diff --git a/alchemy-web/docs/providers/docker/container.md b/alchemy-web/docs/providers/docker/container.md new file mode 100644 index 000000000..68c63caa5 --- /dev/null +++ b/alchemy-web/docs/providers/docker/container.md @@ -0,0 +1,86 @@ +--- +title: Container +description: Deploy and manage Docker containers with Alchemy +--- + +# Container + +The `Container` resource allows you to create and manage Docker containers using Alchemy. + +## Usage + +```typescript +import * as docker from "alchemy/docker"; + +const myContainer = await docker.Container("my-container", { + image: "nginx:latest", + name: "web-server", + ports: [{ external: 80, internal: 80 }], + start: true +}); +``` + +## Properties + +| Name | Type | Required | Description | +|------|------|----------|--------------| +| `image` | `RemoteImage \| string` | Yes | Docker image to use for the container | +| `name` | `string` | No | Name for the container | +| `command` | `string[]` | No | Command to run in the container | +| `environment` | `Record` | No | Environment variables for the container | +| `ports` | `{ external: number \| string, internal: number \| string, protocol?: "tcp" \| "udp" }[]` | No | Port mappings from host to container | +| `volumes` | `{ hostPath: string, containerPath: string, readOnly?: boolean }[]` | No | Volume mappings from host paths to container paths | +| `networks` | `{ name: string, aliases?: string[] }[]` | No | Networks to connect to | +| `restart` | `"no" \| "always" \| "on-failure" \| "unless-stopped"` | No | Restart policy | +| `removeOnExit` | `boolean` | No | Whether to remove the container when it exits | +| `start` | `boolean` | No | Start the container after creation | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `id` | `string` | The ID of the container | +| `state` | `"created" \| "running" \| "paused" \| "stopped" \| "exited"` | The current state of the container | +| `createdAt` | `number` | Time when the container was created | + +## Example + +```typescript +import * as docker from "alchemy/docker"; + +// Create a Docker network +const network = await docker.Network("app-network", { + name: "microservices-network" +}); + +// Pull the Redis image +const redisImage = await docker.RemoteImage("redis-image", { + name: "redis", + tag: "alpine" +}); + +// Run Redis container +const redis = await docker.Container("redis", { + image: redisImage.imageRef, + name: "redis", + networks: [{ name: network.name }], + start: true +}); + +// Run the application container +const app = await docker.Container("app", { + image: "my-node-app:latest", + name: "web-app", + ports: [{ external: 3000, internal: 3000 }], + networks: [{ name: network.name }], + environment: { + REDIS_HOST: "redis", + NODE_ENV: "production" + }, + volumes: [ + { hostPath: "./logs", containerPath: "/app/logs" } + ], + restart: "always", + start: true +}); +``` diff --git a/alchemy-web/docs/providers/docker/image.md b/alchemy-web/docs/providers/docker/image.md new file mode 100644 index 000000000..eaa36f538 --- /dev/null +++ b/alchemy-web/docs/providers/docker/image.md @@ -0,0 +1,82 @@ +--- +title: Image +description: Build and manage Docker images with Alchemy +--- + +# Image + +The `Image` resource allows you to build and manage Docker images from local Dockerfiles using Alchemy. + +## Usage + +```typescript +import * as docker from "alchemy/docker"; + +const myImage = await docker.Image("app-image", { + name: "my-app", + tag: "v1.0", + build: { + context: "./app" + } +}); +``` + +## Properties + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `name` | `string` | Yes | Docker image name | +| `tag` | `string` | No | Tag for the image (defaults to "latest") | +| `build` | `{ context: string, dockerfile?: string, target?: string, buildArgs?: Record }` | Yes | Build configuration | +| `build.context` | `string` | Yes | Path to the build context (directory containing Dockerfile) | +| `build.dockerfile` | `string` | No | Path to the Dockerfile (relative to context, defaults to "Dockerfile") | +| `build.target` | `string` | No | Target stage to build in multi-stage Dockerfiles | +| `build.buildArgs` | `Record` | No | Build arguments to pass to Docker build | +| `skipPush` | `boolean` | No | Skip pushing the image to a registry (default: true) | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `imageRef` | `string` | Full image reference (name:tag) | +| `imageId` | `string` | Docker image ID | +| `createdAt` | `number` | Time when the image was built | + +## Example + +```typescript +import * as docker from "alchemy/docker"; + +// Build a Docker image from a Dockerfile +const appImage = await docker.Image("app-image", { + name: "my-node-app", + tag: "1.0", + build: { + context: "./app", + dockerfile: "Dockerfile.prod", + buildArgs: { + NODE_ENV: "production", + API_VERSION: "v2" + } + } +}); + +// Use the built image in a container +const appContainer = await docker.Container("app", { + image: appImage, + ports: [{ external: 3000, internal: 3000 }], + restart: "always", + start: true +}); + +// For multi-stage builds, you can target a specific stage +const builderImage = await docker.Image("builder", { + name: "app-builder", + tag: "latest", + build: { + context: "./app", + target: "builder" // Target the 'builder' stage in a multi-stage Dockerfile + }, + skipPush: true +}); +``` diff --git a/alchemy-web/docs/providers/docker/index.md b/alchemy-web/docs/providers/docker/index.md new file mode 100644 index 000000000..c8f193122 --- /dev/null +++ b/alchemy-web/docs/providers/docker/index.md @@ -0,0 +1,108 @@ +--- +title: Docker Provider +description: Deploy and manage Docker resources using Alchemy +--- + +# Docker Provider + +The Docker provider allows you to create, manage, and orchestrate Docker resources directly from your Alchemy applications. With this provider, you can pull images, run containers, create networks, and more, all using the familiar Alchemy Resource syntax. + +## Resources + +The Docker provider includes the following resources: + +- [RemoteImage](./remote-image.md) - Pull and manage Docker images +- [Image](./image.md) - Build Docker images from local Dockerfiles +- [Container](./container.md) - Run and manage Docker containers +- [Network](./network.md) - Create and manage Docker networks +- [Volume](./volume.md) - Create and manage persistent Docker volumes + +## Example + +Here's a complete example of using the Docker provider to create a web application with Redis, custom images, and persistent volumes: + +```typescript +import * as docker from "alchemy/docker"; + +// Create a Docker network +const network = await docker.Network("app-network", { + name: "my-application-network" +}); + +// Create a persistent volume for Redis data +const redisVolume = await docker.Volume("redis-data", { + name: "redis-data", + labels: [ + { name: "app", value: "my-application" }, + { name: "service", value: "redis" } + ] +}); + +// Pull Redis image +const redisImage = await docker.RemoteImage("redis-image", { + name: "redis", + tag: "alpine" +}); + +// Run Redis container with persistent volume +const redis = await docker.Container("redis", { + image: redisImage.imageRef, + name: "redis", + networks: [{ name: network.name }], + volumes: [ + { + hostPath: redisVolume.name, + containerPath: "/data" + } + ], + start: true +}); + +// Build a custom application image from local Dockerfile +const appImage = await docker.Image("app-image", { + name: "my-web-app", + tag: "latest", + build: { + context: "./app", + buildArgs: { + NODE_ENV: "production" + } + } +}); + +// Create a volume for application logs +const logsVolume = await docker.Volume("logs-volume", { + name: "app-logs", + labels: { + "com.example.environment": "production", + "com.example.backup": "daily" + } +}); + +// Run the application container +const app = await docker.Container("app", { + image: appImage, // Using the custom built image + name: "web-app", + ports: [{ external: 3000, internal: 3000 }], + networks: [{ name: network.name }], + volumes: [ + { + hostPath: logsVolume.name, + containerPath: "/app/logs" + } + ], + environment: { + REDIS_HOST: "redis", + NODE_ENV: "production" + }, + restart: "always", + start: true +}); + +// Output the URL +export const url = `http://localhost:3000`; +``` + +## Additional Resources + +For more complex examples, see the [Docker Example](https://github.com/sam-goodwin/alchemy/tree/main/examples/docker) in the Alchemy repository. diff --git a/alchemy-web/docs/providers/docker/network.md b/alchemy-web/docs/providers/docker/network.md new file mode 100644 index 000000000..7121c0bee --- /dev/null +++ b/alchemy-web/docs/providers/docker/network.md @@ -0,0 +1,120 @@ +--- +title: Network +description: Create and manage Docker networks with Alchemy +--- + +# Network + +The `Network` resource allows you to create and manage Docker networks using Alchemy, enabling container-to-container communication. + +## Usage + +```typescript +import * as docker from "alchemy/docker"; + +const network = await docker.Network("app-network", { + name: "app-network" +}); +``` + +## Properties + +| Name | Type | Required | Description | +|------|------|----------|--------------| +| `name` | `string` | Yes | Network name | +| `driver` | `"bridge" \| "host" \| "none" \| "overlay" \| "macvlan" \| string` | No | Network driver to use | +| `enableIPv6` | `boolean` | No | Enable IPv6 on the network | +| `labels` | `Record` | No | Network-scoped alias for containers | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `id` | `string` | Network ID | +| `createdAt` | `number` | Time when the network was created | + +## Example + +```typescript +import * as docker from "alchemy/docker"; + +// Create a simple bridge network +const appNetwork = await docker.Network("app-network", { + name: "app-network" +}); + +// Create a custom network with driver +const overlayNetwork = await docker.Network("overlay-network", { + name: "overlay-network", + driver: "overlay", + enableIPv6: true, + labels: { + "com.example.description": "Network for application services" + } +}); + +// Create containers connected to the network +const service1 = await docker.Container("service1", { + image: "service1:latest", + name: "service1", + networks: [{ name: appNetwork.name }], + start: true +}); + +const service2 = await docker.Container("service2", { + image: "service2:latest", + name: "service2", + networks: [{ name: appNetwork.name }], + environment: { + // Service discovery using container names + SERVICE1_URL: `http://service1:8080` + }, + start: true +}); +``` + +## Network Communication + +When containers are connected to the same Docker network, they can communicate with each other using the container names as hostnames. This built-in DNS resolution simplifies service discovery in multi-container applications. + +```typescript +const service1 = await docker.Container("service1", { + image: "service1:latest", + name: "service1", + networks: [{ name: appNetwork.name }], + start: true +}); + +const service2 = await docker.Container("service2", { + image: "service2:latest", + name: "service2", + networks: [{ name: appNetwork.name }], + environment: { + // Service discovery using container names + SERVICE1_URL: `http://service1:8080` + }, + start: true +}); +``` + +Or, you can set aliases for the container to make it accessible by multiple names: + +```typescript +const service1 = await docker.Container("service1", { + image: "service1:latest", + name: "service1", + networks: [{ name: appNetwork.name, aliases: ["api"] }], + start: true +}); + +const service2 = await docker.Container("service2", { + image: "service2:latest", + name: "service2", + networks: [{ name: appNetwork.name }], + environment: { + // Service discovery using container names + SERVICE1_URL: `http://api:8080` + }, + start: true +}); +``` diff --git a/alchemy-web/docs/providers/docker/remote-image.md b/alchemy-web/docs/providers/docker/remote-image.md new file mode 100644 index 000000000..b924ebb41 --- /dev/null +++ b/alchemy-web/docs/providers/docker/remote-image.md @@ -0,0 +1,56 @@ +--- +title: RemoteImage +description: Pull and manage Docker images with Alchemy +--- + +# RemoteImage + +The `RemoteImage` resource allows you to pull and manage Docker images using Alchemy. + +## Usage + +```typescript +import * as docker from "alchemy/docker"; + +const myImage = await docker.RemoteImage("nginx", { + name: "nginx", + tag: "latest", +}); +``` + +## Properties + +| Name | Type | Required | Description | +|------|------|----------|--------------| +| `name` | `string` | Yes | Docker image name (e.g., "nginx") | +| `tag` | `string` | No | Tag for the image (e.g., "latest" or "1.19-alpine") | +| `alwaysPull` | `boolean` | No | Always attempt to pull the image, even if it exists locally | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `imageRef` | `string` | Full image reference (name:tag) | +| `createdAt` | `number` | Time when the image was created or pulled | + +## Example + +```typescript +import * as docker from "alchemy/docker"; + +// Pull the nginx image +const nginxImage = await docker.RemoteImage("nginx", { + name: "nginx", + tag: "latest" +}); + +// Pull a specific version of Node.js +const nodeImage = await docker.RemoteImage("node-app", { + name: "node", + tag: "16-alpine", + alwaysPull: true +}); + +// The full image reference can be used when creating containers +console.log(`Pulled image: ${nginxImage.imageRef}`); +``` diff --git a/alchemy-web/docs/providers/docker/volume.md b/alchemy-web/docs/providers/docker/volume.md new file mode 100644 index 000000000..94e35508c --- /dev/null +++ b/alchemy-web/docs/providers/docker/volume.md @@ -0,0 +1,121 @@ +--- +title: Volume +description: Create and manage Docker volumes with Alchemy +--- + +# Volume + +The `Volume` resource allows you to create and manage persistent Docker volumes using Alchemy. + +## Usage + +```typescript +import * as docker from "alchemy/docker"; + +const myVolume = await docker.Volume("data-volume", { + name: "app-data", + driver: "local" +}); +``` + +## Properties + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `name` | `string` | Yes | Docker volume name | +| `driver` | `string` | No | Volume driver to use (defaults to "local") | +| `driverOpts` | `Record` | No | Driver-specific options | +| `labels` | `VolumeLabel[] \| Record` | No | Custom metadata labels for the volume | + +The `VolumeLabel` interface has the following structure: +```typescript +interface VolumeLabel { + name: string; // Label name + value: string; // Label value +} +``` + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `id` | `string` | Volume ID (same as name for Docker volumes) | +| `mountpoint` | `string` | Volume mountpoint path on the host | +| `createdAt` | `number` | Time when the volume was created | + +## Example + +```typescript +import * as docker from "alchemy/docker"; + +// Create a simple Docker volume for persistent data +const dataVolume = await docker.Volume("data-volume", { + name: "postgres-data" +}); + +// Create a Docker volume with custom driver options +const dbVolume = await docker.Volume("db-data", { + name: "mysql-data", + driver: "local", + driverOpts: { + "type": "nfs", + "o": "addr=10.0.0.1,rw", + "device": ":/path/to/dir" + } +}); + +// Create a volume with labels (array format) +const logsVolume = await docker.Volume("logs-volume", { + name: "app-logs", + labels: [ + { name: "com.example.environment", value: "production" }, + { name: "com.example.created-by", value: "alchemy" } + ] +}); + +// Create a volume with labels (record format) +const configVolume = await docker.Volume("config-volume", { + name: "app-config", + labels: { + "com.example.environment": "staging", + "com.example.created-by": "alchemy" + } +}); + +// Use volumes with a container +const dbContainer = await docker.Container("database", { + image: "postgres:14", + name: "postgres", + volumes: [ + { + hostPath: dataVolume.name, // Reference the volume by name + containerPath: "/var/lib/postgresql/data" + }, + { + hostPath: logsVolume.name, + containerPath: "/var/log/postgresql", + readOnly: false + } + ], + environment: { + POSTGRES_PASSWORD: "secret" + }, + restart: "always", + start: true +}); +``` + +## Using Docker Volumes for Persistence + +Docker volumes are the preferred mechanism for persisting data generated by and used by Docker containers. Their benefits include: + +1. **Data Persistence**: Data stored in volumes persists even when containers are stopped or removed +2. **Performance**: Better performance than bind mounts, especially on Windows and macOS +3. **Portability**: Volumes can be easily backed up, restored, and migrated +4. **Driver Support**: Support for various storage backends through volume drivers + +When using Docker volumes with Alchemy, it's a common pattern to: +1. Create volumes with meaningful names +2. Assign metadata using labels +3. Reference volumes in containers by name +4. Configure volume permissions with the `readOnly` flag when mounting diff --git a/alchemy/package.json b/alchemy/package.json index 1f0c36eca..635821afb 100644 --- a/alchemy/package.json +++ b/alchemy/package.json @@ -55,6 +55,10 @@ "bun": "./src/dns/index.ts", "import": "./lib/dns/index.js" }, + "./docker": { + "bun": "./src/docker/index.ts", + "import": "./lib/docker/index.js" + }, "./esbuild": { "bun": "./src/esbuild/index.ts", "import": "./lib/esbuild/index.js" diff --git a/alchemy/src/docker/api.ts b/alchemy/src/docker/api.ts new file mode 100644 index 000000000..2d558f761 --- /dev/null +++ b/alchemy/src/docker/api.ts @@ -0,0 +1,365 @@ +import { exec } from "../os/exec.ts"; + +/** + * Options for Docker API requests + */ +export interface DockerApiOptions { + /** + * Custom path to Docker binary + */ + dockerPath?: string; +} + +type VolumeInfo = { + CreatedAt: string; + Driver: string; + Labels: Record; + Mountpoint: string; + Name: string; + Options: Record; + Scope: string; +}; + +/** + * Docker API client that wraps Docker CLI commands + */ +export class DockerApi { + /** Path to Docker CLI */ + readonly dockerPath: string; + + /** + * Create a new Docker API client + * + * @param options Docker API options + */ + constructor(options: DockerApiOptions = {}) { + this.dockerPath = options.dockerPath || "docker"; + } + + /** + * Run a Docker CLI command + * + * @param args Command arguments to pass to Docker CLI + * @returns Result of the command + */ + async exec(args: string[]): Promise<{ stdout: string; stderr: string }> { + const command = `${this.dockerPath} ${args.join(" ")}`; + const result = (await exec(command, { + captureOutput: true, + shell: true, + env: process.env, + })) as { stdout: string; stderr: string }; + + return result; + } + + /** + * Check if Docker daemon is running + * + * @returns True if Docker daemon is running + */ + async isRunning(): Promise { + try { + // Use a quick, lightweight command to test if Docker is running + await this.exec(["version", "--format", "{{.Server.Version}}"]); + return true; + } catch (error) { + console.log( + `Docker daemon not running: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + + /** + * Pull Docker image + * + * @param image Image name and tag + * @returns Result of the pull command + */ + async pullImage(image: string): Promise<{ stdout: string; stderr: string }> { + return this.exec(["pull", image]); + } + + /** + * Build Docker image + * + * @param path Path to Dockerfile directory + * @param tag Tag for the image + * @param buildArgs Build arguments + * @returns Result of the build command + */ + async buildImage( + path: string, + tag: string, + buildArgs: Record = {}, + ): Promise<{ stdout: string; stderr: string }> { + const args = ["build", "-t", tag, path]; + + for (const [key, value] of Object.entries(buildArgs)) { + args.push("--build-arg", `${key}=${value}`); + } + + return this.exec(args); + } + + /** + * List Docker images + * + * @returns JSON string containing image list + */ + async listImages(): Promise { + const { stdout } = await this.exec(["images", "--format", "{{json .}}"]); + return stdout; + } + + /** + * Create Docker container + * + * @param image Image name + * @param name Container name + * @param options Container options + * @returns Container ID + */ + async createContainer( + image: string, + name: string, + options: { + ports?: Record; + env?: Record; + volumes?: Record; + cmd?: string[]; + } = {}, + ): Promise { + const args = ["create", "--name", name]; + + // Add port mappings + if (options.ports) { + for (const [hostPort, containerPort] of Object.entries(options.ports)) { + args.push("-p", `${hostPort}:${containerPort}`); + } + } + + // Add environment variables + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + args.push("-e", `${key}=${value}`); + } + } + + // Add volume mappings + if (options.volumes) { + for (const [hostPath, containerPath] of Object.entries(options.volumes)) { + args.push("-v", `${hostPath}:${containerPath}`); + } + } + + args.push(image); + + // Add command if specified + if (options.cmd && options.cmd.length > 0) { + args.push(...options.cmd); + } + + const { stdout } = await this.exec(args); + return stdout.trim(); + } + + /** + * Start Docker container + * + * @param containerId Container ID or name + */ + async startContainer(containerId: string): Promise { + await this.exec(["start", containerId]); + } + + /** + * Stop Docker container + * + * @param containerId Container ID or name + */ + async stopContainer(containerId: string): Promise { + await this.exec(["stop", containerId]); + } + + /** + * Remove Docker container + * + * @param containerId Container ID or name + * @param force Force removal + */ + async removeContainer(containerId: string, force = false): Promise { + const args = ["rm"]; + if (force) { + args.push("-f"); + } + args.push(containerId); + await this.exec(args); + } + + /** + * Get container logs + * + * @param containerId Container ID or name + * @returns Container logs + */ + async getContainerLogs(containerId: string): Promise { + const { stdout } = await this.exec(["logs", containerId]); + return stdout; + } + + /** + * Check if a container exists + * + * @param containerId Container ID or name + * @returns True if container exists + */ + async containerExists(containerId: string): Promise { + try { + await this.exec(["inspect", containerId]); + return true; + } catch (_error) { + return false; + } + } + + /** + * Create Docker network + * + * @param name Network name + * @param driver Network driver + * @returns Network ID + */ + async createNetwork(name: string, driver = "bridge"): Promise { + const { stdout } = await this.exec([ + "network", + "create", + "--driver", + driver, + name, + ]); + return stdout.trim(); + } + + /** + * Remove Docker network + * + * @param networkId Network ID or name + */ + async removeNetwork(networkId: string): Promise { + await this.exec(["network", "rm", networkId]); + } + + /** + * Connect container to network + * + * @param containerId Container ID or name + * @param networkId Network ID or name + */ + async connectNetwork( + containerId: string, + networkId: string, + options: { + aliases?: string[]; + } = {}, + ): Promise { + const args = ["network", "connect"]; + if (options.aliases) { + for (const alias of options.aliases) { + args.push("--alias", alias); + } + } + args.push(networkId, containerId); + await this.exec(args); + } + + /** + * Disconnect container from network + * + * @param containerId Container ID or name + * @param networkId Network ID or name + */ + async disconnectNetwork( + containerId: string, + networkId: string, + ): Promise { + await this.exec(["network", "disconnect", networkId, containerId]); + } + + /** + * Create Docker volume + * + * @param name Volume name + * @param driver Volume driver + * @param driverOpts Driver options + * @param labels Volume labels + * @returns Volume name + */ + async createVolume( + name: string, + driver = "local", + driverOpts: Record = {}, + labels: Record = {}, + ): Promise { + const args = ["volume", "create", "--name", name, "--driver", driver]; + + // Add driver options + for (const [key, value] of Object.entries(driverOpts)) { + args.push("--opt", `${key}=${value}`); + } + + // Add labels + for (const [key, value] of Object.entries(labels)) { + args.push("--label", `${key}=${value}`); + } + + const { stdout } = await this.exec(args); + return stdout.trim(); + } + + /** + * Remove Docker volume + * + * @param volumeName Volume name + * @param force Force removal of the volume + */ + async removeVolume(volumeName: string, force = false): Promise { + const args = ["volume", "rm"]; + if (force) { + args.push("--force"); + } + args.push(volumeName); + await this.exec(args); + } + + /** + * Get Docker volume information + * + * @param volumeName Volume name + * @returns Volume details in JSON format + */ + async inspectVolume(volumeName: string): Promise { + const { stdout } = await this.exec(["volume", "inspect", volumeName]); + try { + return JSON.parse(stdout.trim()) as VolumeInfo[]; + } catch (_error) { + return []; + } + } + + /** + * Check if a volume exists + * + * @param volumeName Volume name + * @returns True if volume exists + */ + async volumeExists(volumeName: string): Promise { + try { + await this.inspectVolume(volumeName); + return true; + } catch (_error) { + return false; + } + } +} diff --git a/alchemy/src/docker/container.ts b/alchemy/src/docker/container.ts new file mode 100644 index 000000000..da948066f --- /dev/null +++ b/alchemy/src/docker/container.ts @@ -0,0 +1,267 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { DockerApi } from "./api.ts"; +import type { Image } from "./image.ts"; +import type { RemoteImage } from "./remote-image.ts"; + +/** + * Port mapping configuration + */ +export interface PortMapping { + /** + * External port on the host + */ + external: number | string; + + /** + * Internal port inside the container + */ + internal: number | string; + + /** + * Protocol (tcp or udp) + */ + protocol?: "tcp" | "udp"; +} + +/** + * Volume mapping configuration + */ +export interface VolumeMapping { + /** + * Host path + */ + hostPath: string; + + /** + * Container path + */ + containerPath: string; + + /** + * Read-only flag + */ + readOnly?: boolean; +} + +/** + * Network mapping configuration + */ +export interface NetworkMapping { + /** + * Network name or ID + */ + name: string; + + /** + * Aliases for the container in the network + */ + aliases?: string[]; +} + +/** + * Properties for creating a Docker container + */ +export interface ContainerProps { + /** + * Image to use for the container + * Can be an Alchemy Image or RemoteImage resource or a string image reference + */ + image: Image | RemoteImage | string; + + /** + * Container name + */ + name?: string; + + /** + * Command to run in the container + */ + command?: string[]; + + /** + * Environment variables + */ + environment?: Record; + + /** + * Port mappings + */ + ports?: PortMapping[]; + + /** + * Volume mappings + */ + volumes?: VolumeMapping[]; + + /** + * Restart policy + */ + restart?: "no" | "always" | "on-failure" | "unless-stopped"; + + /** + * Networks to connect to + */ + networks?: NetworkMapping[]; + + /** + * Whether to remove the container when it exits + */ + removeOnExit?: boolean; + + /** + * Start the container after creation + */ + start?: boolean; +} + +/** + * Docker Container resource + */ +export interface Container + extends Resource<"docker::Container">, + ContainerProps { + /** + * Container ID + */ + id: string; + + /** + * Container state + */ + state?: "created" | "running" | "paused" | "stopped" | "exited"; + + /** + * Time when the container was created + */ + createdAt: number; +} + +/** + * Create and manage a Docker Container + * + * @example + * // Create a simple Nginx container + * const webContainer = await Container("web", { + * image: "nginx:latest", + * ports: [ + * { external: 8080, internal: 80 } + * ], + * start: true + * }); + * + * @example + * // Create a container with environment variables and volume mounts + * const appContainer = await Container("app", { + * image: customImage, // Using an Alchemy RemoteImage resource + * environment: { + * NODE_ENV: "production", + * API_KEY: "secret-key" + * }, + * volumes: [ + * { hostPath: "./data", containerPath: "/app/data" } + * ], + * ports: [ + * { external: 3000, internal: 3000 } + * ], + * restart: "always", + * start: true + * }); + */ +export const Container = Resource( + "docker::Container", + async function ( + this: Context, + id: string, + props: ContainerProps, + ): Promise { + // Initialize Docker API client + const api = new DockerApi(); + + // Get image reference + const imageRef = + typeof props.image === "string" ? props.image : props.image.imageRef; + + // Use provided name or generate one based on resource ID + const containerName = + props.name || `alchemy-${id.replace(/[^a-zA-Z0-9_.-]/g, "-")}`; + + // Handle delete phase + if (this.phase === "delete") { + if (this.output?.id) { + // Stop container if running + await api.stopContainer(this.output.id); + + // Remove container + await api.removeContainer(this.output.id, true); + } + + // Return destroyed state + return this.destroy(); + } else { + let containerState: NonNullable = "created"; + + if (this.phase === "update") { + // Check if container already exists (for update) + const containerExists = await api.containerExists(containerName); + + if (containerExists) { + // Remove existing container for update + await api.removeContainer(containerName, true); + } + } + + // Prepare port mappings + const portMappings: Record = {}; + if (props.ports) { + for (const port of props.ports) { + const protocol = port.protocol || "tcp"; + portMappings[`${port.external}`] = `${port.internal}/${protocol}`; + } + } + + // Prepare volume mappings + const volumeMappings: Record = {}; + if (props.volumes) { + for (const volume of props.volumes) { + const readOnlyFlag = volume.readOnly ? ":ro" : ""; + volumeMappings[volume.hostPath] = + `${volume.containerPath}${readOnlyFlag}`; + } + } + + // Create new container + const containerId = await api.createContainer(imageRef, containerName, { + ports: portMappings, + env: props.environment, + volumes: volumeMappings, + cmd: props.command, + }); + + // Connect to networks if specified + if (props.networks) { + for (const network of props.networks) { + const networkId = + typeof network === "string" ? network : network.name; + await api.connectNetwork(containerId, networkId, { + aliases: network.aliases, + }); + } + } + + // Start container if requested + if (props.start) { + await api.startContainer(containerId); + containerState = "running"; + } + + // Return the resource using this() to construct output + return this({ + ...props, + id: containerId, + state: containerState, + createdAt: Date.now(), + }); + } + }, +); diff --git a/alchemy/src/docker/image.ts b/alchemy/src/docker/image.ts new file mode 100644 index 000000000..0dd7af45d --- /dev/null +++ b/alchemy/src/docker/image.ts @@ -0,0 +1,200 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { DockerApi } from "./api.ts"; + +/** + * Options for building a Docker image + */ +export interface DockerBuildOptions { + /** + * Path to the build context directory + */ + context: string; + + /** + * Path to the Dockerfile, relative to context + */ + dockerfile?: string; + + /** + * Target build platform (e.g., linux/amd64) + */ + platform?: string; + + /** + * Build arguments as key-value pairs + */ + buildArgs?: Record; + + /** + * Target build stage in multi-stage builds + */ + target?: string; + + /** + * List of images to use for cache + */ + cacheFrom?: string[]; +} + +/** + * Properties for creating a Docker image + */ +export interface ImageProps { + /** + * Repository name for the image (e.g., "username/image") + */ + name: string; + + /** + * Tag for the image (e.g., "latest") + */ + tag?: string; + + /** + * Build configuration + */ + build: DockerBuildOptions; + + /** + * Whether to skip pushing the image to registry + */ + skipPush?: boolean; +} + +/** + * Docker Image resource + */ +export interface Image extends Resource<"docker::Image">, ImageProps { + /** + * Full image reference (name:tag) + */ + imageRef: string; + + /** + * Image ID + */ + imageId?: string; + + /** + * Repository digest if pushed + */ + repoDigest?: string; + + /** + * Time when the image was built + */ + builtAt: number; +} + +/** + * Build and manage a Docker image from a Dockerfile + * + * @example + * // Build a Docker image from a Dockerfile + * const appImage = await Image("app-image", { + * name: "myapp", + * tag: "latest", + * build: { + * context: "./app", + * dockerfile: "Dockerfile", + * buildArgs: { + * NODE_ENV: "production" + * } + * } + * }); + */ +export const Image = Resource( + "docker::Image", + async function ( + this: Context, + _id: string, + props: ImageProps, + ): Promise { + // Initialize Docker API client + const api = new DockerApi(); + + if (this.phase === "delete") { + // No action needed for delete as Docker images aren't automatically removed + // This is intentional as other resources might depend on the same image + return this.destroy(); + } else { + // Normalize properties + const tag = props.tag || "latest"; + const imageRef = `${props.name}:${tag}`; + + // Validate build context + const { context } = props.build; + await fs.access(context); + + // Determine Dockerfile path + const dockerfile = props.build.dockerfile || "Dockerfile"; + const dockerfilePath = path.join(context, dockerfile); + await fs.access(dockerfilePath); + + // Prepare build options + const buildOptions: Record = props.build.buildArgs || {}; + + // Add platform if specified + let buildArgs = ["build", "-t", imageRef]; + + if (props.build.platform) { + buildArgs.push("--platform", props.build.platform); + } + + // Add target if specified + if (props.build.target) { + buildArgs.push("--target", props.build.target); + } + + // Add cache sources if specified + if (props.build.cacheFrom && props.build.cacheFrom.length > 0) { + for (const cacheSource of props.build.cacheFrom) { + buildArgs.push("--cache-from", cacheSource); + } + } + + // Add build arguments + for (const [key, value] of Object.entries(buildOptions)) { + buildArgs.push("--build-arg", `${key}="${value}"`); + } + + // Add dockerfile if not the default + if (props.build.dockerfile && props.build.dockerfile !== "Dockerfile") { + buildArgs.push("-f", props.build.dockerfile); + } + + // Add context path + buildArgs.push(props.build.context); + + // Execute build command + console.log(`Building Docker image: ${imageRef}`); + const { stdout } = await api.exec(buildArgs); + + // Extract image ID from build output if available + const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout); + const imageId = imageIdMatch ? imageIdMatch[1] : undefined; + + console.log(`Successfully built Docker image: ${imageRef}`); + + // Handle push if required + let repoDigest: string | undefined; + if (!props.skipPush) { + console.log(`Pushing Docker image: ${imageRef}`); + // TODO: Implement push once API supports it + console.warn("Image pushing is not yet implemented"); + } + + // Return the resource using this() to construct output + return this({ + ...props, + imageRef, + imageId, + repoDigest, + builtAt: Date.now(), + }); + } + }, +); diff --git a/alchemy/src/docker/index.ts b/alchemy/src/docker/index.ts new file mode 100644 index 000000000..25ce51b82 --- /dev/null +++ b/alchemy/src/docker/index.ts @@ -0,0 +1,6 @@ +export * from "./api.ts"; +export * from "./remote-image.ts"; +export * from "./container.ts"; +export * from "./network.ts"; +export * from "./volume.ts"; +export * from "./image.ts"; diff --git a/alchemy/src/docker/network.ts b/alchemy/src/docker/network.ts new file mode 100644 index 000000000..eeeae735c --- /dev/null +++ b/alchemy/src/docker/network.ts @@ -0,0 +1,101 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { DockerApi } from "./api.ts"; + +/** + * Properties for creating a Docker network + */ +export interface NetworkProps { + /** + * Network name + */ + name: string; + + /** + * Network driver to use + * @default "bridge" + */ + driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {}); + + /** + * Enable IPv6 on the network + * @default false + */ + enableIPv6?: boolean; + + /** + * Network-scoped alias for containers + */ + labels?: Record; +} + +/** + * Docker Network resource + */ +export interface Network extends Resource<"docker::Network">, NetworkProps { + /** + * Network ID + */ + id: string; + + /** + * Time when the network was created + */ + createdAt: number; +} + +/** + * Create and manage a Docker Network + * + * @see https://docs.docker.com/engine/network/ + * + * @example + * // Create a simple bridge network + * const appNetwork = await Network("app-network", { + * name: "app-network" + * }); + * + * @example + * // Create a custom network with driver + * const overlayNetwork = await Network("overlay-network", { + * name: "overlay-network", + * driver: "overlay", + * enableIPv6: true, + * labels: { + * "com.example.description": "Network for application services" + * } + * }); + */ +export const Network = Resource( + "docker::Network", + async function ( + this: Context, + _id: string, + props: NetworkProps, + ): Promise { + // Initialize Docker API client + const api = new DockerApi(); + + // Handle delete phase + if (this.phase === "delete") { + if (this.output?.id) { + // Remove network + await api.removeNetwork(this.output.id); + } + + // Return destroyed state + return this.destroy(); + } else { + // Create the network + props.driver = props.driver || "bridge"; + const networkId = await api.createNetwork(props.name, props.driver); + + // Return the resource using this() to construct output + return this({ + ...props, + id: networkId, + createdAt: Date.now(), + }); + } + }, +); diff --git a/alchemy/src/docker/remote-image.ts b/alchemy/src/docker/remote-image.ts new file mode 100644 index 000000000..a548d2b51 --- /dev/null +++ b/alchemy/src/docker/remote-image.ts @@ -0,0 +1,83 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { DockerApi } from "./api.ts"; + +/** + * Properties for creating a Docker image + */ +export interface RemoteImageProps { + /** + * Docker image name (e.g., "nginx") + */ + name: string; + + /** + * Tag for the image (e.g., "latest" or "1.19-alpine") + */ + tag?: string; + + /** + * Always attempt to pull the image, even if it exists locally + */ + alwaysPull?: boolean; +} + +/** + * Docker Remote Image resource + */ +export interface RemoteImage + extends Resource<"docker::RemoteImage">, + RemoteImageProps { + /** + * Full image reference (name:tag) + */ + imageRef: string; + + /** + * Time when the image was created or pulled + */ + createdAt: number; +} + +/** + * Create or reference a Docker Remote Image + * + * @example + * // Pull the nginx image + * const nginxImage = await RemoteImage("nginx", { + * name: "nginx", + * tag: "latest" + * }); + * + */ +export const RemoteImage = Resource( + "docker::RemoteImage", + async function ( + this: Context, + _id: string, + props: RemoteImageProps, + ): Promise { + // Initialize Docker API client + const api = new DockerApi(); + + if (this.phase === "delete") { + // No action needed for delete as Docker images aren't automatically removed + // This is intentional as other resources might depend on the same image + return this.destroy(); + } else { + // Normalize properties + const tag = props.tag || "latest"; + const imageRef = `${props.name}:${tag}`; + + // Pull image + await api.pullImage(imageRef); + + // Return the resource using this() to construct output + return this({ + ...props, + imageRef, + createdAt: Date.now(), + }); + } + }, +); diff --git a/alchemy/src/docker/volume.ts b/alchemy/src/docker/volume.ts new file mode 100644 index 000000000..6271ee429 --- /dev/null +++ b/alchemy/src/docker/volume.ts @@ -0,0 +1,154 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { DockerApi } from "./api.ts"; + +/** + * Interface for volume label + */ +export interface VolumeLabel { + /** + * Label name + */ + name: string; + + /** + * Label value + */ + value: string; +} + +/** + * Properties for creating a Docker volume + */ +export interface VolumeProps { + /** + * Volume name + */ + name: string; + + /** + * Volume driver to use + * @default "local" + */ + driver?: string; + + /** + * Driver-specific options + */ + driverOpts?: Record; + + /** + * Custom metadata labels for the volume + */ + labels?: VolumeLabel[] | Record; +} + +/** + * Docker Volume resource + */ +export interface Volume extends Resource<"docker::Volume">, VolumeProps { + /** + * Volume ID (same as name for Docker volumes) + */ + id: string; + + /** + * Volume mountpoint path on the host + */ + mountpoint?: string; + + /** + * Time when the volume was created + */ + createdAt: number; +} + +/** + * Create and manage a Docker Volume + * + * @see https://docs.docker.com/engine/reference/commandline/volume/ + * + * @example + * // Create a simple Docker volume + * const dataVolume = await Volume("data-volume", { + * name: "data-volume" + * }); + * + * @example + * // Create a Docker volume with custom driver and options + * const dbVolume = await Volume("db-data", { + * name: "db-data", + * driver: "local", + * driverOpts: { + * "type": "nfs", + * "o": "addr=10.0.0.1,rw", + * "device": ":/path/to/dir" + * }, + * labels: [ + * { name: "com.example.usage", value: "database-storage" }, + * { name: "com.example.backup", value: "weekly" } + * ] + * }); + */ +export const Volume = Resource( + "docker::Volume", + async function ( + this: Context, + _id: string, + props: VolumeProps, + ): Promise { + // Initialize Docker API client + const api = new DockerApi(); + + // Process labels to ensure consistent format + const processedLabels: Record = {}; + if (props.labels) { + if (Array.isArray(props.labels)) { + // Convert array of label objects to Record + for (const label of props.labels) { + processedLabels[label.name] = label.value; + } + } else { + // Use Record directly + Object.assign(processedLabels, props.labels); + } + } + + // Handle delete phase + if (this.phase === "delete") { + if (this.output?.name) { + // Remove volume + await api.removeVolume(this.output.name); + } + + // Return destroyed state + return this.destroy(); + } else { + // Set default driver if not provided + props.driver = props.driver || "local"; + const driverOpts = props.driverOpts || {}; + + // Create the volume + const volumeName = await api.createVolume( + props.name, + props.driver, + driverOpts, + processedLabels, + ); + + // Get volume details to retrieve mountpoint + const volumeInfos = await api.inspectVolume(volumeName); + const mountpoint = volumeInfos[0].Mountpoint; + + // Return the resource using this() to construct output + return this({ + ...props, + id: volumeName, + mountpoint, + createdAt: Date.now(), + labels: Array.isArray(props.labels) ? props.labels : undefined, + driverOpts: props.driverOpts, + }); + } + }, +); diff --git a/alchemy/src/os/exec.ts b/alchemy/src/os/exec.ts index 30968a83a..2b64e0041 100644 --- a/alchemy/src/os/exec.ts +++ b/alchemy/src/os/exec.ts @@ -266,30 +266,72 @@ const defaultOptions: SpawnOptions = { shell: true, }; +/** + * Options for exec function + */ +export interface ExecOptions extends Partial { + /** + * Whether to capture stdout and stderr + * @default false + */ + captureOutput?: boolean; +} + /** * Execute a shell command. + * + * @param command The command to execute + * @param options Options for the command execution + * @returns Promise that resolves when the command completes. + * If captureOutput is true, resolves with { stdout, stderr } strings. */ export async function exec( command: string, - options?: Partial, -): Promise { + options?: ExecOptions, +): Promise<{ stdout: string; stderr: string } | undefined> { const [cmd, ...args] = command.split(/\s+/); + const captureOutput = options?.captureOutput === true; return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { + // Set stdio to pipe only if we're capturing output + const spawnOptions = { ...defaultOptions, ...options, env: { ...defaultOptions.env, ...options?.env, }, - }); + stdio: captureOutput ? "pipe" : defaultOptions.stdio, + }; + + const child = spawn(cmd, args, spawnOptions); + + let stdout = ""; + let stderr = ""; + + if (captureOutput) { + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + } child.on("close", (code) => { if (code === 0) { - resolve(); + if (captureOutput) { + resolve({ stdout, stderr }); + } else { + resolve(undefined); + } } else { - reject(new Error(`Command failed with exit code ${code}`)); + reject( + new Error( + `Command failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`, + ), + ); } }); diff --git a/alchemy/test/docker/api.test.ts b/alchemy/test/docker/api.test.ts new file mode 100644 index 000000000..1dd2437a3 --- /dev/null +++ b/alchemy/test/docker/api.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { DockerApi } from "../../src/docker/api.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("DockerApi", () => { + test("should initialize with default docker path", async (scope) => { + try { + const dockerApi = new DockerApi(); + expect(dockerApi.dockerPath).toBe("docker"); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should initialize with custom docker path", async (scope) => { + try { + const dockerApi = new DockerApi({ dockerPath: "/usr/local/bin/docker" }); + expect(dockerApi.dockerPath).toBe("/usr/local/bin/docker"); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should execute docker command", async (scope) => { + try { + const dockerApi = new DockerApi(); + const result = await dockerApi.exec(["--version"]); + + expect(result).toHaveProperty("stdout"); + expect(result).toHaveProperty("stderr"); + // Docker version output should contain the word "Docker" + expect(result.stdout.includes("Docker")).toBe(true); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should check if docker daemon is running", async (scope) => { + try { + const dockerApi = new DockerApi(); + const isRunning = await dockerApi.isRunning(); + + // This might be true or false depending on whether Docker is installed and running + // Just ensure it returns a boolean + expect(typeof isRunning).toBe("boolean"); + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/alchemy/test/docker/container.test.ts b/alchemy/test/docker/container.test.ts new file mode 100644 index 000000000..d16c693b6 --- /dev/null +++ b/alchemy/test/docker/container.test.ts @@ -0,0 +1,28 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { Container } from "../../src/docker/container.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Container", () => { + test("should create a container without starting it", async (scope) => { + try { + // Create a container without starting it to avoid port conflicts + const container = await Container("test-container", { + image: "hello-world:latest", + name: "alchemy-test-container", + start: false, + }); + + expect(container.name).toBe("alchemy-test-container"); + expect(container.state).toBe("created"); + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/alchemy/test/docker/fixtures/build-args/Dockerfile b/alchemy/test/docker/fixtures/build-args/Dockerfile new file mode 100644 index 000000000..529468a95 --- /dev/null +++ b/alchemy/test/docker/fixtures/build-args/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:latest + +# Define build arguments +ARG MESSAGE=default +ARG VERSION=1.0 + +# Use the build arguments +RUN echo "$MESSAGE (version $VERSION)" > /message.txt + +# Display the content when container runs +CMD ["cat", "/message.txt"] diff --git a/alchemy/test/docker/fixtures/multi-stage/Dockerfile b/alchemy/test/docker/fixtures/multi-stage/Dockerfile new file mode 100644 index 000000000..df79b8bcb --- /dev/null +++ b/alchemy/test/docker/fixtures/multi-stage/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: Build stage +FROM node:16-alpine AS builder + +WORKDIR /app + +# Create a simple index.js file +RUN echo 'console.log("Hello from Alchemy multi-stage build");' > index.js + +# Stage 2: Production stage +FROM node:16-alpine AS production + +WORKDIR /app + +# Copy only what we need from the builder stage +COPY --from=builder /app/index.js . + +# Set the command +CMD ["node", "index.js"] diff --git a/alchemy/test/docker/fixtures/simple-image/Dockerfile b/alchemy/test/docker/fixtures/simple-image/Dockerfile new file mode 100644 index 000000000..4cf68f4de --- /dev/null +++ b/alchemy/test/docker/fixtures/simple-image/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +CMD ["echo", "Hello from Alchemy!"] diff --git a/alchemy/test/docker/image.test.ts b/alchemy/test/docker/image.test.ts new file mode 100644 index 000000000..09c9fb3b9 --- /dev/null +++ b/alchemy/test/docker/image.test.ts @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { Image } from "../../src/docker/image.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +// Helper function to get the absolute path to a fixture +function getFixturePath(fixtureName: string): string { + return path.join( + process.cwd(), + "alchemy", + "test", + "docker", + "fixtures", + fixtureName, + ); +} + +describe("Image", () => { + test("should build a simple image", async (scope) => { + try { + const contextPath = getFixturePath("simple-image"); + + // Ensure the context path exists + expect(fs.existsSync(contextPath)).toBe(true); + expect(fs.existsSync(path.join(contextPath, "Dockerfile"))).toBe(true); + + const image = await Image("test-simple-image", { + name: "alchemy-test", + tag: "simple", + build: { + context: contextPath, + }, + skipPush: true, + }); + + expect(image.name).toBe("alchemy-test"); + expect(image.tag).toBe("simple"); + expect(image.imageRef).toBe("alchemy-test:simple"); + expect(image.build.context).toBe(contextPath); + // imageId might not be available in a CI environment where Docker is not running + if (image.imageId) { + expect(image.imageId.length).toBeGreaterThan(0); + } + } finally { + await alchemy.destroy(scope); + } + }); + + test("should handle build arguments", async (scope) => { + try { + const contextPath = getFixturePath("build-args"); + + // Ensure the context path exists + expect(fs.existsSync(contextPath)).toBe(true); + expect(fs.existsSync(path.join(contextPath, "Dockerfile"))).toBe(true); + + const image = await Image("test-build-args", { + name: "alchemy-test", + tag: "args", + build: { + context: contextPath, + buildArgs: { + MESSAGE: "Hello from Alchemy", + VERSION: "2.0", + }, + }, + skipPush: true, + }); + + expect(image.name).toBe("alchemy-test"); + expect(image.tag).toBe("args"); + expect(image.build.buildArgs).toEqual({ + MESSAGE: "Hello from Alchemy", + VERSION: "2.0", + }); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should support multi-stage builds with target", async (scope) => { + try { + const contextPath = getFixturePath("multi-stage"); + + // Ensure the context path exists + expect(fs.existsSync(contextPath)).toBe(true); + expect(fs.existsSync(path.join(contextPath, "Dockerfile"))).toBe(true); + + const image = await Image("test-multi-stage", { + name: "alchemy-test", + tag: "multi", + build: { + context: contextPath, + target: "builder", // Target the builder stage + }, + skipPush: true, + }); + + expect(image.name).toBe("alchemy-test"); + expect(image.tag).toBe("multi"); + expect(image.build.target).toBe("builder"); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should handle invalid context path", async (scope) => { + try { + await Image("test-invalid-context", { + name: "alchemy-test", + tag: "invalid", + build: { + context: "/non/existent/path", + }, + skipPush: true, + }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "ENOENT: no such file or directory, access '/non/existent/path'", + ); + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/alchemy/test/docker/network.test.ts b/alchemy/test/docker/network.test.ts new file mode 100644 index 000000000..00d2a767e --- /dev/null +++ b/alchemy/test/docker/network.test.ts @@ -0,0 +1,26 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { Network } from "../../src/docker/network.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Network", () => { + test("should create a test network with default driver", async (scope) => { + try { + const networkName = `alchemy-test-network-${Date.now()}`; + const network = await Network("test-network", { + name: networkName, + }); + + expect(network.name).toBe(networkName); + expect(network.driver).toBe("bridge"); // default value + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/alchemy/test/docker/remote-image.test.ts b/alchemy/test/docker/remote-image.test.ts new file mode 100644 index 000000000..77d061776 --- /dev/null +++ b/alchemy/test/docker/remote-image.test.ts @@ -0,0 +1,43 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { RemoteImage } from "../../src/docker/remote-image.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("RemoteImage", () => { + test("should pull a small test image", async (scope) => { + try { + // Use a small test image to avoid long download times + const image = await RemoteImage("hello-world-image", { + name: "hello-world", + tag: "latest", + }); + + expect(image.name).toBe("hello-world"); + expect(image.tag).toBe("latest"); + expect(image.imageRef).toBe("hello-world:latest"); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should fail when using a non-existent tag", async (scope) => { + try { + await expect( + RemoteImage("non-existent-image", { + name: "non-existent", + tag: "test-tag-123", + }), + ).rejects.toThrow( + "Command failed with exit code 1: Error response from daemon: pull access denied for non-existent, repository does not exist or may require 'docker login'", + ); + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/alchemy/test/docker/volume.test.ts b/alchemy/test/docker/volume.test.ts new file mode 100644 index 000000000..3f7568ac1 --- /dev/null +++ b/alchemy/test/docker/volume.test.ts @@ -0,0 +1,93 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { Volume } from "../../src/docker/volume.ts"; +import { BRANCH_PREFIX } from "../util.ts"; + +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("Volume", () => { + test("should create a volume with default driver", async (scope) => { + try { + const volumeName = `alchemy-test-volume-${Date.now()}`; + const volume = await Volume("test-volume", { + name: volumeName, + }); + + expect(volume.name).toBe(volumeName); + expect(volume.driver).toBe("local"); // default value + expect(volume.id).toBe(volumeName); // volume ID is same as name + } finally { + await alchemy.destroy(scope); + } + }); + + test("should create a volume with custom driver and options", async (scope) => { + try { + const volumeName = `alchemy-test-volume-custom-${Date.now()}`; + const volume = await Volume("test-volume-custom", { + name: volumeName, + driver: "local", + driverOpts: { + type: "tmpfs", + device: "tmpfs", + o: "size=100m,uid=1000", + }, + }); + + expect(volume.name).toBe(volumeName); + expect(volume.driver).toBe("local"); + expect(volume.driverOpts).toEqual({ + type: "tmpfs", + device: "tmpfs", + o: "size=100m,uid=1000", + }); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should create a volume with labels", async (scope) => { + try { + const volumeName = `alchemy-test-volume-labels-${Date.now()}`; + const volume = await Volume("test-volume-labels", { + name: volumeName, + labels: [ + { name: "com.example.usage", value: "test-volume" }, + { name: "com.example.created-by", value: "alchemy-tests" }, + ], + }); + + expect(volume.name).toBe(volumeName); + expect(volume.labels).toEqual([ + { name: "com.example.usage", value: "test-volume" }, + { name: "com.example.created-by", value: "alchemy-tests" }, + ]); + } finally { + await alchemy.destroy(scope); + } + }); + + test("should create a volume with labels as record", async (scope) => { + try { + const volumeName = `alchemy-test-volume-record-labels-${Date.now()}`; + const volume = await Volume("test-volume-record-labels", { + name: volumeName, + labels: { + "com.example.usage": "test-volume", + "com.example.created-by": "alchemy-tests", + }, + }); + + expect(volume.name).toBe(volumeName); + // When we pass labels as a record, it should be processed correctly internally + // but not reflected in the output since we're preserving the input format + expect(volume.labels).toBeUndefined(); + } finally { + await alchemy.destroy(scope); + } + }); +}); diff --git a/bun.lock b/bun.lock index b26e806a6..331692066 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,10 @@ "alchemy": "workspace:*", }, }, + "examples/docker": { + "name": "docker", + "version": "0.0.0", + }, "stacks": { "name": "alchemy-stacks", "version": "0.0.1", @@ -2034,6 +2038,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "docker": ["docker@workspace:examples/docker"], + "dofs": ["dofs@0.0.1", "", { "peerDependencies": { "hono": "^4.7.10" } }, "sha512-vNfYmLREQNQRE0R+ZiUuoW4VkxLZm/M6PHxAlTE5YDwrDIYdSTA0rfZ3Rgp7g2Tkvv5VI9xTt7pUtdMD46eM1Q=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], diff --git a/examples/docker/.env b/examples/docker/.env new file mode 100644 index 000000000..c8c2d3175 --- /dev/null +++ b/examples/docker/.env @@ -0,0 +1,4 @@ +mongoHost=mongodb://mongo:27017 +database=cart +nodeEnvironment=development +protocol=http:// \ No newline at end of file diff --git a/examples/docker/.gitignore b/examples/docker/.gitignore new file mode 100644 index 000000000..8e0776e8d --- /dev/null +++ b/examples/docker/.gitignore @@ -0,0 +1 @@ +!.env diff --git a/examples/docker/README.md b/examples/docker/README.md new file mode 100644 index 000000000..f0010695c --- /dev/null +++ b/examples/docker/README.md @@ -0,0 +1,76 @@ +# Alchemy Docker Provider Example + +This example demonstrates how to use the Alchemy Docker provider to manage Docker resources declaratively. It follows the [Pulumi Fundamentals tutorial](https://www.pulumi.com/tutorials/pulumi-fundamentals/) for Docker, setting up a three-tier web application with a frontend, backend, and MongoDB database. + +## Overview + +This example: + +1. Creates a Docker network for all services +2. Pulls the necessary container images (frontend, backend, MongoDB) +3. Deploys a MongoDB container +4. Deploys a backend API container +5. Deploys a frontend container +6. Connects all containers to the network + +## Prerequisites + +- Docker installed and running on your machine +- Alchemy installed + +## Configuration + +This example uses environment variables to configure the application. The variables are defined in the `.env` file at the root of the example: + +``` +mongoHost=mongodb://mongo:27017 +database=cart +nodeEnvironment=development +protocol=http:// +``` + +You can modify these values to customize the application behavior. + +## Running the Example + +1. Navigate to the example directory: + ```bash + cd examples/docker + ``` + +2. Run the example with Alchemy: + ```bash + bun run deploy + ``` + +3. Once deployed, you can access the application at http://localhost:3000 + +## Application Structure + +- `alchemy.run.ts` - Main Alchemy configuration file that defines the Docker resources +- `app/` - Sample Node.js application + - `index.js` - Express.js application with Redis integration + - `package.json` - Node.js dependencies + - `Dockerfile` - Docker image definition + +## How It Works + +The example demonstrates key Alchemy concepts: + +1. **Resource Dependencies**: The frontend, backend, and MongoDB containers are deployed in an order that maintains their dependencies. Environment variables are used to connect the containers (like setting `DATABASE_HOST` and `HTTP_PROXY`). + +2. **Container Images**: Shows how to pull and use Docker images with the `Image` resource. + +3. **Networking**: Creates a Docker network and connects all containers to it, allowing inter-container communication. + +4. **Stack-Based Resources**: Uses the Alchemy stack name to create unique resource identifiers, enabling multi-environment deployments. + +## Cleaning Up + +To destroy all resources created by this example, run: + +```bash +bun run destroy +``` + +This will stop and remove all containers and networks created by the example. diff --git a/examples/docker/alchemy.run.ts b/examples/docker/alchemy.run.ts new file mode 100644 index 000000000..db2a933d7 --- /dev/null +++ b/examples/docker/alchemy.run.ts @@ -0,0 +1,95 @@ +import alchemy from "alchemy"; +import * as docker from "alchemy/docker"; + +// Initialize Alchemy +const app = await alchemy("docker", { + // Determine the phase based on command line arguments + phase: process.argv[2] === "destroy" ? "destroy" : "up", + stage: process.argv[3], + quiet: process.argv.includes("--quiet"), +}); + +// Get configuration values (matching the provided Pulumi config) +const frontendPort = 3001; +const backendPort = 3000; +const mongoPort = 27017; +const mongoHost = process.env.mongoHost!; +const database = process.env.database!; +const nodeEnvironment = process.env.nodeEnvironment!; +const protocol = process.env.protocol!; + +const stack = app.stage || "dev"; + +// Create a Docker network +const network = await docker.Network("network", { + name: `services-${stack}`, + driver: "bridge", +}); + +// Pull the images in parallel +const [backend, frontend, mongoImage] = await Promise.all([ + docker.RemoteImage("backendImage", { + name: "pulumi/tutorial-pulumi-fundamentals-backend", + tag: "latest", + }), + docker.RemoteImage("frontendImage", { + name: "pulumi/tutorial-pulumi-fundamentals-frontend", + tag: "latest", + }), + docker.RemoteImage("mongoImage", { + name: "pulumi/tutorial-pulumi-fundamentals-database", + tag: "latest", + }), +]); + +// Create the MongoDB container +const mongoContainer = await docker.Container("mongoContainer", { + image: mongoImage, + name: `mongo-${stack}`, + ports: [{ external: mongoPort, internal: mongoPort }], + networks: [ + { + name: network.name, + aliases: ["mongo"], + }, + ], + restart: "always", + start: true, +}); + +// Create the backend container +const backendContainer = await docker.Container("backendContainer", { + image: backend, + name: `backend-${stack}`, + ports: [{ external: backendPort, internal: backendPort }], + environment: { + DATABASE_HOST: mongoHost, + DATABASE_NAME: database, + NODE_ENV: nodeEnvironment, + }, + networks: [network], + restart: "always", + start: true, +}); + +// Create the frontend container +const frontendContainer = await docker.Container("frontendContainer", { + image: frontend, + name: `frontend-${stack}`, + ports: [{ external: frontendPort, internal: frontendPort }], + environment: { + PORT: frontendPort.toString(), + HTTP_PROXY: `${backendContainer.name}:${backendPort}`, + PROXY_PROTOCOL: protocol, + }, + networks: [network], + restart: "always", + start: true, +}); + +await app.finalize(); + +// Export relevant information +export { backendContainer, frontendContainer, mongoContainer, network }; +export const frontendUrl = `http://localhost:${frontendPort}`; +export const backendUrl = `http://localhost:${backendPort}`; diff --git a/examples/docker/package.json b/examples/docker/package.json new file mode 100644 index 000000000..56e08f347 --- /dev/null +++ b/examples/docker/package.json @@ -0,0 +1,10 @@ +{ + "name": "docker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "deploy": "bun run --env-file .env ./alchemy.run.ts", + "destroy": "bun run --env-file .env ./alchemy.run.ts destroy" + } +} diff --git a/examples/docker/tsconfig.json b/examples/docker/tsconfig.json new file mode 100644 index 000000000..50076aa76 --- /dev/null +++ b/examples/docker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "alchemy.run.ts"], + "compilerOptions": { + "composite": true, + "resolveJsonModule": true + }, + "references": [{ "path": "../../alchemy/tsconfig.json" }] +} From 549014ed3720d649951e4ddfa74f824d3ae4d2d7 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 20 Jun 2025 01:43:48 -0700 Subject: [PATCH 4/4] chore(release): 0.36.0 --- CHANGELOG.md | 15 +++++++++++++++ alchemy/package.json | 2 +- bun.lock | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491baa72b..d850e60a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.36.0 + +###    🚀 Features + +- **docker**: Docker provider  -  by **Pavitra Golchha** in https://github.com/sam-goodwin/alchemy/issues/189 [(6f973)](https://github.com/sam-goodwin/alchemy/commit/6f973983) + +###    🐞 Bug Fixes + +- **cli**: Improve package manager handling in create-alchemy  -  by **Nico Baier** in https://github.com/sam-goodwin/alchemy/issues/423 [(d0c7c)](https://github.com/sam-goodwin/alchemy/commit/d0c7ce83) +- **core**: Allow colors in CI environments, only disable for NO_COLOR  -  by **Sam Goodwin** in https://github.com/sam-goodwin/alchemy/issues/429 [(a194a)](https://github.com/sam-goodwin/alchemy/commit/a194ab5a) + +#####     [View changes on GitHub](https://github.com/sam-goodwin/alchemy/compare/v0.35.1...v0.36.0) + +--- + ## v0.35.1 ###    🐞 Bug Fixes diff --git a/alchemy/package.json b/alchemy/package.json index 635821afb..7b0886669 100644 --- a/alchemy/package.json +++ b/alchemy/package.json @@ -1,6 +1,6 @@ { "name": "alchemy", - "version": "0.35.1", + "version": "0.36.0", "type": "module", "module": "./lib/index.js", "license": "Apache-2.0", diff --git a/bun.lock b/bun.lock index 331692066..55c1fc86b 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ }, "alchemy": { "name": "alchemy", - "version": "0.35.1", + "version": "0.36.0", "bin": { "alchemy": "bin/alchemy.mjs", },