diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a3ab7f5..cc5c644 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,12 @@ jobs:
- name: Install dependencies
run: yarn
+ - name: Type-check
+ run: npx tsc --noEmit
+
+ - name: Unit tests
+ run: yarn test
+
- name: Build the project
run: yarn build
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index fff2d3f..339607c 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -60,12 +60,9 @@ jobs:
HARNESS_SKIP: cloudpods,ephemeral,replicator,chaos
run: |
npm ci --prefix data/sample-cdk
- mkdir -p "$HOME/.localstack-mcp"
node tests/docker/validate-image.mjs -- \
docker run -i --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
- -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
- -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
--add-host host.docker.internal:host-gateway \
--add-host s3.host.docker.internal:host-gateway \
--add-host snowflake.localhost.localstack.cloud:host-gateway \
diff --git a/Dockerfile b/Dockerfile
index 0037552..d4f8a0a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,40 +8,40 @@ RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
-FROM python:3.12-slim-bookworm AS runtime
+# The server drives LocalStack through the Docker Engine API (dockerode) and REST —
+# no localstack CLI, docker CLI, or awscli needed in the image. Python remains only
+# for the IaC/Snowflake CLIs (tflocal, samlocal, snow) used by the deployer and
+# snowflake-client tools.
+FROM node:22-bookworm-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive \
- PYTHONDONTWRITEBYTECODE=1
+ PYTHONDONTWRITEBYTECODE=1 \
+ PATH="/opt/venv/bin:${PATH}"
RUN set -eux; \
apt-get update; \
- apt-get install -y --no-install-recommends ca-certificates curl gnupg unzip git; \
+ apt-get install -y --no-install-recommends ca-certificates curl gnupg unzip git \
+ python3 python3-pip python3-venv; \
install -m 0755 -d /usr/share/keyrings; \
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -; \
curl -fsSL https://apt.releases.hashicorp.com/gpg \
| gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg; \
chmod a+r /usr/share/keyrings/hashicorp-archive-keyring.gpg; \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com bookworm main" \
> /etc/apt/sources.list.d/hashicorp.list; \
- curl -fsSL https://download.docker.com/linux/debian/gpg \
- | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg; \
- chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg; \
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" \
- > /etc/apt/sources.list.d/docker.list; \
apt-get update; \
- apt-get install -y --no-install-recommends nodejs terraform docker-ce-cli; \
+ apt-get install -y --no-install-recommends terraform; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
-RUN pip install --no-cache-dir --upgrade pip \
+# Debian's python carries the PEP 668 externally-managed marker — install the IaC
+# CLIs into a dedicated venv (its bin dir is on PATH for runCommand spawns).
+RUN python3 -m venv /opt/venv \
+ && pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir --no-compile \
- localstack \
- awscli \
- awscli-local \
terraform-local \
aws-sam-cli \
aws-sam-cli-local \
snowflake-cli \
- && find /usr/local/lib/python3.12/site-packages \
+ && find /opt/venv/lib \
\( -type d \( -name __pycache__ -o -name tests -o -name test \) -o -type f \( -name '*.pyc' -o -name '*.pyo' \) \) \
-prune -exec rm -rf '{}' +
@@ -50,7 +50,10 @@ RUN npm install -g aws-cdk@2.1114.0 aws-cdk-local \
RUN node <<'NODE'
const fs = require("fs");
-const file = "/usr/lib/node_modules/aws-cdk/lib/index.js";
+const path = require("path");
+const { execSync } = require("child_process");
+const globalRoot = execSync("npm root -g").toString().trim();
+const file = path.join(globalRoot, "aws-cdk/lib/index.js");
const source = fs.readFileSync(file, "utf8");
const target = ` s3() {\n const client = new import_client_s33.S3Client(this.config);`;
const replacement = ` s3() {\n if (/^(1|true|yes)$/i.test(process.env.AWS_S3_FORCE_PATH_STYLE || "")) {\n this.config.forcePathStyle = true;\n }\n const client = new import_client_s33.S3Client(this.config);`;
@@ -64,7 +67,7 @@ if (!source.includes(replacement)) {
NODE
WORKDIR /app
-RUN mkdir -p /usr/lib/localstack /tmp/dockerode-deps \
+RUN mkdir -p /tmp/dockerode-deps \
&& npm install --prefix /tmp/dockerode-deps --omit=dev --ignore-scripts --no-audit --no-fund dockerode@4.0.7 \
&& mkdir -p /app/node_modules \
&& cp -R /tmp/dockerode-deps/node_modules/. /app/node_modules/ \
@@ -74,20 +77,16 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
RUN set -eux; \
- localstack --version; \
- aws --version; \
- awslocal --version; \
terraform version; \
tflocal --version; \
sam --version; \
command -v samlocal; \
cdklocal --version; \
snow --version; \
- docker --version; \
node -e "require('dockerode'); console.log('dockerode ok')"
LABEL org.opencontainers.image.title="LocalStack MCP Server" \
- org.opencontainers.image.description="Self-contained MCP server for managing LocalStack (CLI, CDK, Terraform, SAM, awslocal baked in)" \
+ org.opencontainers.image.description="Self-contained MCP server for managing LocalStack via the Docker API (CDK, Terraform, SAM, Snowflake CLIs baked in)" \
org.opencontainers.image.source="https://github.com/localstack/localstack-mcp-server" \
org.opencontainers.image.licenses="Apache-2.0"
diff --git a/README.md b/README.md
index b2c590c..ae9e96f 100644
--- a/README.md
+++ b/README.md
@@ -71,8 +71,8 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| [`localstack-chaos-injector`](./src/tools/localstack-chaos-injector.ts) | Injects and manages chaos experiment faults for system resilience testing | - Inject, add, remove, and clear service fault rules
- Configure network latency effects
- Comprehensive fault targeting by service, region, and operation
- Built-in workflow guidance for chaos experiments
- Requires a valid LocalStack Auth Token |
| [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages remote LocalStack Cloud Pods for development workflows | - Save current state as a Cloud Pod
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods from remote cloud-backed storage
- Use this for managed remote state snapshots, not local export/import files
- Requires a valid LocalStack Auth Token |
| [`localstack-state-management`](./src/tools/localstack-state-management.ts) | Manages local file-based LocalStack state export/import workflows | - Export LocalStack state to a local file on disk through the LocalStack State REST API
- Import LocalStack state from a local file
- Inspect current LocalStack state as JSON metamodel data
- Reset all state or only selected services
- Supports service-level granularity for export, reset, and inspect
- Use this for local disk workflows; use Cloud Pods for remote cloud-backed snapshots
- Requires a valid LocalStack Auth Token |
-| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token |
-| [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token and LocalStack CLI |
+| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions (`list`, `install`, `uninstall`) inside the running container
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token |
+| [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token (talks to the LocalStack platform API directly) |
| [`localstack-aws-client`](./src/tools/localstack-aws-client.ts) | Runs AWS CLI commands inside the LocalStack for AWS container | - Executes commands via `awslocal` inside the running container
- Sanitizes commands to block shell chaining
- Auto-detects LocalStack coverage errors and links to docs |
| [`localstack-aws-replicator`](./src/tools/localstack-aws-replicator.ts) | Replicates external AWS resources into a running LocalStack instance | - Start single-resource replication jobs with a resource type and identifier or ARN
- Start batch replication jobs, such as SSM parameters under a path prefix
- Poll job status by job ID and list existing jobs
- List resource types supported by the running Replicator extension
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides |
| [`localstack-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license |
@@ -100,7 +100,7 @@ npx -y @localstack/localstack-mcp-server init
The wizard:
- lets you choose how to run the server (`npx` on your machine, or the self-contained Docker image),
-- checks the prerequisites (Node.js, a LocalStack lifecycle CLI, Docker) and tells you how to fix anything missing,
+- checks the prerequisites (Node.js, Docker) and tells you how to fix anything missing,
- picks up your `LOCALSTACK_AUTH_TOKEN` from the environment, or asks for it,
- lets you pass extra LocalStack config (e.g. `DEBUG=1,PERSISTENCE=1`),
- detects your installed MCP clients (Cursor, Antigravity, Claude Code, Claude Desktop, VS Code, Codex, OpenCode, Amazon Q CLI) and writes the right configuration for each one you select.
@@ -121,7 +121,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options.
### Prerequisites
-- A LocalStack lifecycle CLI (`localstack` or `lstk`) and Docker installed in your system path if you want the MCP server to start or restart LocalStack
+- Docker installed and running — the MCP server manages the LocalStack container directly through the Docker API (no LocalStack CLI needed)
- [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path if you want to deploy CDK, Terraform, or SAM projects
- Snowflake CLI (`snow`) installed in your system path if you want to use the Snowflake tool
- A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**)
@@ -129,7 +129,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options.
### Run with npx
-Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download and install the package if needed. LocalStack and any deployment CLIs used by tools run from your host PATH.
+Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download and install the package if needed. The server manages LocalStack through your Docker daemon; any deployment CLIs used by tools run from your host PATH.
```json
{
@@ -167,7 +167,7 @@ If you installed from source, change `command` and `args` to point to your local
### Run with Docker
-The `localstack/localstack-mcp-server` Docker image bundles the LocalStack CLI, `awslocal`, Terraform/`tflocal`, CDK/`cdklocal`, SAM/`samlocal`, Snowflake CLI, and Docker CLI. The only required host dependency is Docker. The container uses the mounted Docker socket to run LocalStack as a sibling container on the host.
+The `localstack/localstack-mcp-server` Docker image bundles Terraform/`tflocal`, CDK/`cdklocal`, SAM/`samlocal`, and the Snowflake CLI. The only required host dependency is Docker. The container uses the mounted Docker socket to run LocalStack as a sibling container on the host; state lives in a named Docker volume (`localstack-mcp`) unless `LOCALSTACK_VOLUME_DIR` points at a host directory.
If you use the deployer tool with local Terraform, CDK, or SAM projects, bind-mount those project paths into the MCP container and pass the in-container path to the tool. The simplest convention is to mount projects at the same absolute path they use on the host.
@@ -177,16 +177,23 @@ If you use the deployer tool with local Terraform, CDK, or SAM projects, bind-mo
"localstack": {
"command": "docker",
"args": [
- "run", "-i", "--rm",
- "-v", "/var/run/docker.sock:/var/run/docker.sock",
- "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp",
- "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp",
- "--add-host", "host.docker.internal:host-gateway",
- "--add-host", "s3.host.docker.internal:host-gateway",
- "--add-host", "snowflake.localhost.localstack.cloud:host-gateway",
- "-e", "LOCALSTACK_AUTH_TOKEN",
- "-e", "LOCALSTACK_HOSTNAME=host.docker.internal",
- "-v", "/Users/you/projects:/Users/you/projects",
+ "run",
+ "-i",
+ "--rm",
+ "-v",
+ "/var/run/docker.sock:/var/run/docker.sock",
+ "--add-host",
+ "host.docker.internal:host-gateway",
+ "--add-host",
+ "s3.host.docker.internal:host-gateway",
+ "--add-host",
+ "snowflake.localhost.localstack.cloud:host-gateway",
+ "-e",
+ "LOCALSTACK_AUTH_TOKEN",
+ "-e",
+ "LOCALSTACK_HOSTNAME=host.docker.internal",
+ "-v",
+ "/Users/you/projects:/Users/you/projects",
"localstack/localstack-mcp-server:latest"
],
"env": { "LOCALSTACK_AUTH_TOKEN": "" }
@@ -199,15 +206,28 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi
## LocalStack configuration
-| Variable Name | Description | Default Value |
-| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
-| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None |
-| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect |
-| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` |
-| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` |
-| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None |
-| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None |
-| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None |
+| Variable Name | Description | Default Value |
+| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
+| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None |
+| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect |
+| `LOCALSTACK_IMAGE_NAME` / `IMAGE_NAME` | Docker image the `start` action launches for the AWS stack. `LOCALSTACK_IMAGE_NAME` wins when both are set (beware: bare `IMAGE_NAME` is a common CI variable). | `localstack/localstack-pro:latest` |
+| `LOCALSTACK_VOLUME_DIR` | Host directory mounted at `/var/lib/localstack` in the LocalStack container. Defaults to the same per-OS cache path the LocalStack CLI used (state carries over); inside Docker it defaults to the `localstack-mcp` named volume. | Per-OS cache dir |
+| `GATEWAY_LISTEN` | Override the gateway port bindings for `start` (comma-separated `[host]:port`). When set, the implicit `443` binding is skipped. | `:4566,:443` |
+| `DOCKER_HOST` | Docker daemon endpoint used for all container operations (`unix://`, `npipe://`, `tcp://`). | Platform default socket |
+| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` |
+| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` |
+| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None |
+| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None |
+| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None |
+
+### Migration notes (CLI-free lifecycle)
+
+Since v0.6.0 the MCP server no longer uses (or requires) the `localstack`/`lstk` CLI — the LocalStack container is created directly through the Docker Engine API. Behavioral differences from CLI-driven starts:
+
+- `~/.localstack/*.env` config profiles and `DOCKER_FLAGS` are **not** read at start. Pass configuration through the `envVars` argument of the `localstack-management` start action, or set `LOCALSTACK_`-prefixed variables in the MCP server's environment (LocalStack aliases `LOCALSTACK_` to `` natively).
+- Besides `LOCALSTACK_*`/`PROVIDER_OVERRIDE_*`, only a curated set of common unprefixed config variables is forwarded from the host environment (`DEBUG`, `LS_LOG`, `SERVICES`, `PERSISTENCE`, `EAGER_SERVICE_LOADING`, `ENFORCE_IAM`, `IAM_SOFT_MODE`, `EXTENSION_AUTO_INSTALL`, `APP_INSPECTOR`, `DNS_ADDRESS`, `MAIN_DOCKER_NETWORK`, and the `LAMBDA_*`/`CFN_*`/`SNOWFLAKE_*`/`SF_*` families).
+- Port 443 is published by default (matching the current LocalStack CLI). If something else uses port 443 locally, set `GATEWAY_LISTEN=:4566` to skip it.
+- If you previously used `lstk`, note the container the server creates is named `localstack-main` (externally started `localstack-aws` containers are still detected and managed; `restart` preserves their name, image, and volume).
For AWS Replicator-specific source credentials, you can use the `AWS_REPLICATOR_SOURCE_` prefixed variants instead of the unprefixed variants. Do not mix the prefixed and unprefixed source credential groups; when any `AWS_REPLICATOR_SOURCE_` variable is set, the Replicator tool reads the source configuration only from that group.
diff --git a/data/evals/gemini-comprehensive.json b/data/evals/gemini-comprehensive.json
index bb6e341..a03bc9c 100644
--- a/data/evals/gemini-comprehensive.json
+++ b/data/evals/gemini-comprehensive.json
@@ -112,7 +112,7 @@
"id": "scenario-iam-soft-mode",
"description": "localstack-iam-policy-analyzer: read mode, then set SOFT_MODE.",
"mode": "mcp_host",
- "scenario": "Check the current IAM enforcement mode in LocalStack using the IAM policy analyzer, then set the enforcement mode to SOFT_MODE.",
+ "scenario": "Check the current IAM enforcement mode in LocalStack using the IAM policy analyzer, then set the enforcement mode to SOFT_MODE with the set-mode action. Perform the set-mode call even if the current mode is already SOFT_MODE, so the setting is explicitly confirmed.",
"mcpHostConfig": { "provider": "google", "model": "gemini-2.5-flash", "temperature": 0 },
"iterations": 5,
"accuracyThreshold": 0.8,
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index 352184e..319a0dd 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -1,20 +1,21 @@
# Running the LocalStack MCP Server in Docker
-The published image bundles everything the server shells out to — the LocalStack
-CLI, `awslocal`, Terraform + `tflocal`, AWS CDK + `cdklocal`, AWS SAM + `samlocal`,
-the Snowflake `snow` CLI, and the Docker CLI — so the **only dependency on your
-machine is Docker itself**.
+The published image bundles everything the server shells out to — Terraform +
+`tflocal`, AWS CDK + `cdklocal`, AWS SAM + `samlocal`, and the Snowflake `snow`
+CLI — so the **only dependency on your machine is Docker itself**. LocalStack
+lifecycle, logs, and `awslocal` run through the Docker Engine API and LocalStack's
+REST APIs; no LocalStack CLI is involved anywhere.
The image is multi-arch (`linux/amd64` and `linux/arm64`).
## How it works (Docker-out-of-Docker)
The container talks to your **host Docker daemon** through the bind-mounted
-`/var/run/docker.sock`. When you ask the server to start LocalStack, the bundled
-`localstack start` launches a **sibling** `localstack-main` container on the host
-(not nested inside the MCP container). Stop/restart operations then act on the
-detected sibling container through the Docker API. The MCP server and the IaC CLIs
-reach that sibling over the host gateway.
+`/var/run/docker.sock`. When you ask the server to start LocalStack, it creates a
+**sibling** `localstack-main` container on the host (not nested inside the MCP
+container) directly via the Docker API. Stop/restart operations act on the detected
+sibling container the same way. The MCP server and the IaC CLIs reach that sibling
+over the host gateway.
```
MCP client ── stdio ──► docker run … (MCP server)
@@ -24,27 +25,27 @@ MCP client ── stdio ──► docker run … (MCP server)
└─ localstack-main (sibling, publishes :4566 on the host)
```
-Because LocalStack is a sibling container, two things must be configured at run time:
+Because LocalStack is a sibling container, one thing must be configured at run time:
-1. **Reachability** — set `LOCALSTACK_HOSTNAME=host.docker.internal` so the server
- and the IaC CLIs target the sibling's published port instead of the container's
- own `localhost`.
-2. **Host-resolvable mounts** — `localstack start` asks the **host** daemon to
- bind-mount its license/state files into `localstack-main`. Those mount *sources*
- must exist at an **identical path** on the host and inside the MCP container, so
- point LocalStack's cache at a directory you bind-mount one-to-one (via
- `XDG_CACHE_HOME`). Without this you get `Mounts denied: … is not shared from the
- host`.
+- **Reachability** — set `LOCALSTACK_HOSTNAME=host.docker.internal` so the server
+ and the IaC CLIs target the sibling's published port instead of the container's
+ own `localhost`.
+
+LocalStack state lives in a **named Docker volume** (`localstack-mcp`) by default,
+so no host directory needs to be mounted or path-mirrored. To keep state in a host
+directory instead, set `-e LOCALSTACK_VOLUME_DIR=/absolute/host/path` (the path is
+interpreted by the **host** daemon).
+
+> **Upgrading from an older image?** Previous versions required a one-to-one cache
+> mount plus `XDG_CACHE_HOME`. Old configs keep working — when `XDG_CACHE_HOME` is
+> set, the server keeps using `$XDG_CACHE_HOME/localstack/volume` for state, so
+> your persisted resources survive the upgrade. New configs need neither flag.
## Quick start
```bash
-mkdir -p "$HOME/.localstack-mcp"
-
docker run -i --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
- -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
- -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
--add-host host.docker.internal:host-gateway \
--add-host s3.host.docker.internal:host-gateway \
--add-host snowflake.localhost.localstack.cloud:host-gateway \
@@ -53,15 +54,14 @@ docker run -i --rm \
localstack/localstack-mcp-server:latest
```
-| Flag | Why it's needed |
-| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the bundled LocalStack CLI start the sibling container and lets Docker-based tools stop/restart it and run `awslocal` inside it. |
-| `-v "$HOME/.localstack-mcp:$HOME/.localstack-mcp"` + `-e XDG_CACHE_HOME=…` | Puts LocalStack's license/machine/volume files on an **identically-pathed** host directory so the host daemon can bind-mount them into `localstack-main`. |
-| `--add-host host.docker.internal:host-gateway` | Resolves `host.docker.internal` on Linux. Harmless on Docker Desktop (Mac/Windows), where it already resolves. |
-| `--add-host s3.host.docker.internal:host-gateway` | Lets CDK's virtual-hosted S3 endpoint resolve when `cdklocal` uses `AWS_ENDPOINT_URL_S3=http://s3.host.docker.internal:4566`. |
-| `--add-host snowflake.localhost.localstack.cloud:host-gateway` | Lets the Snowflake CLI reach the sibling Snowflake emulator through the hostname the emulator expects for routing. |
-| `-e LOCALSTACK_AUTH_TOKEN` | Required by **every** tool in this server. |
-| `-e LOCALSTACK_HOSTNAME=host.docker.internal` | Tells the server + IaC CLIs where the sibling LocalStack lives. |
+| Flag | Why it's needed |
+| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the server create/stop/restart the sibling LocalStack container, read its logs, and run `awslocal` inside it. |
+| `--add-host host.docker.internal:host-gateway` | Resolves `host.docker.internal` on Linux. Harmless on Docker Desktop (Mac/Windows), where it already resolves. |
+| `--add-host s3.host.docker.internal:host-gateway` | Lets CDK's virtual-hosted S3 endpoint resolve when `cdklocal` uses `AWS_ENDPOINT_URL_S3=http://s3.host.docker.internal:4566`. |
+| `--add-host snowflake.localhost.localstack.cloud:host-gateway` | Lets the Snowflake CLI reach the sibling Snowflake emulator through the hostname the emulator expects for routing. |
+| `-e LOCALSTACK_AUTH_TOKEN` | Required by **every** tool in this server. |
+| `-e LOCALSTACK_HOSTNAME=host.docker.internal` | Tells the server + IaC CLIs where the sibling LocalStack lives. |
## MCP client configuration
@@ -74,21 +74,28 @@ expand `$HOME`/`$PWD` — use absolute paths.
"localstack-mcp-server": {
"command": "docker",
"args": [
- "run", "-i", "--rm",
- "-v", "/var/run/docker.sock:/var/run/docker.sock",
- "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp",
- "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp",
- "--add-host", "host.docker.internal:host-gateway",
- "--add-host", "s3.host.docker.internal:host-gateway",
- "--add-host", "snowflake.localhost.localstack.cloud:host-gateway",
- "-e", "LOCALSTACK_AUTH_TOKEN",
- "-e", "LOCALSTACK_HOSTNAME=host.docker.internal",
- "-v", "/Users/you/projects:/Users/you/projects",
- "localstack/localstack-mcp-server:latest"
+ "run",
+ "-i",
+ "--rm",
+ "-v",
+ "/var/run/docker.sock:/var/run/docker.sock",
+ "--add-host",
+ "host.docker.internal:host-gateway",
+ "--add-host",
+ "s3.host.docker.internal:host-gateway",
+ "--add-host",
+ "snowflake.localhost.localstack.cloud:host-gateway",
+ "-e",
+ "LOCALSTACK_AUTH_TOKEN",
+ "-e",
+ "LOCALSTACK_HOSTNAME=host.docker.internal",
+ "-v",
+ "/Users/you/projects:/Users/you/projects",
+ "localstack/localstack-mcp-server:latest",
],
- "env": { "LOCALSTACK_AUTH_TOKEN": "" }
- }
- }
+ "env": { "LOCALSTACK_AUTH_TOKEN": "" },
+ },
+ },
}
```
@@ -112,21 +119,22 @@ alias covers bootstrap asset uploads.
## Known limitations
- **Extra host aliases.** Include the aliases shown in the quick-start command.
-- **First cold start** of LocalStack can take up to ~2 minutes while the runtime
- initializes; subsequent starts reuse the persisted volume under
- `$XDG_CACHE_HOME`.
+- **First cold start** of LocalStack can take up to ~2 minutes while the image is
+ pulled and the runtime initializes; subsequent starts reuse the persisted volume.
- **Persistence across MCP restarts.** The sibling `localstack-main` keeps running
on the host even if your editor restarts the MCP container — reconnecting finds
- your stack still up. State persists in `$XDG_CACHE_HOME/localstack/volume`.
+ your stack still up. State persists in the `localstack-mcp` named volume (or
+ `LOCALSTACK_VOLUME_DIR` if you set one).
## Troubleshooting
-| Symptom | Cause / fix |
-| ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `Mounts denied: … is not shared from the host` | The cache bind mount / `XDG_CACHE_HOME` is missing or not under a Docker-shared root. Use a path under your home directory and mount it one-to-one. |
-| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). |
-| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). |
-| `LocalStack container not found` or `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you use a custom LocalStack container name. |
+| Symptom | Cause / fix |
+| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). |
+| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). |
+| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). |
+| `LocalStack container not found` or `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you use a custom LocalStack container name. |
+| State disappeared after upgrading the image | Old configs stored state under `$XDG_CACHE_HOME/localstack/volume` — keep that env var, or point `LOCALSTACK_VOLUME_DIR` at the old directory. |
## Validating an image yourself
@@ -139,8 +147,6 @@ HARNESS_TOKEN_REAL=1 \
node tests/docker/validate-image.mjs -- \
docker run -i --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
- -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
- -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
--add-host host.docker.internal:host-gateway \
--add-host s3.host.docker.internal:host-gateway \
--add-host snowflake.localhost.localstack.cloud:host-gateway \
diff --git a/server.json b/server.json
index 363a938..2c9414f 100644
--- a/server.json
+++ b/server.json
@@ -12,7 +12,9 @@
{
"src": "https://raw.githubusercontent.com/localstack/localstack-mcp-server/main/icon.png",
"mimeType": "image/png",
- "sizes": ["256x256"]
+ "sizes": [
+ "256x256"
+ ]
}
],
"packages": [
@@ -101,6 +103,41 @@
"format": "string",
"isSecret": false,
"name": "APP_INSPECTOR"
+ },
+ {
+ "description": "Docker image the management start action launches for the AWS stack (LOCALSTACK_IMAGE_NAME wins over IMAGE_NAME). Defaults to localstack/localstack-pro:latest.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_IMAGE_NAME"
+ },
+ {
+ "description": "Host directory mounted at /var/lib/localstack in the LocalStack container. Defaults to the per-OS cache path the LocalStack CLI used; inside Docker it defaults to the localstack-mcp named volume.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_VOLUME_DIR"
+ },
+ {
+ "description": "Override the gateway port bindings for the start action (comma-separated [host]:port entries). When set, the implicit 443 binding is skipped.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "GATEWAY_LISTEN"
+ },
+ {
+ "description": "Explicit LocalStack container name for Docker-based tools and the container created by start. Defaults to localstack-main (auto-detection covers common names).",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "MAIN_CONTAINER_NAME"
+ },
+ {
+ "description": "Docker daemon endpoint used for all container operations (unix://, npipe://, tcp://). Defaults to the platform's standard socket.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "DOCKER_HOST"
}
]
}
diff --git a/src/cli/help.ts b/src/cli/help.ts
index cbde579..816de0a 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -13,7 +13,7 @@ init options:
Valid: ${ALL_CLIENT_IDS.join(", ")}
--token LocalStack Auth Token (default: $LOCALSTACK_AUTH_TOKEN)
--config Extra LocalStack config vars, e.g. "DEBUG=1,PERSISTENCE=1"
- --cache-dir [docker] State/cache dir mounted into the container
+ --cache-dir [docker] Deprecated and ignored (state lives in a named Docker volume)
(default: ~/.localstack-mcp)
--workspace [docker] Workspace dir to mount for IaC deployments
(default: current directory; pass "" to skip)
diff --git a/src/cli/init.ts b/src/cli/init.ts
index 5e2ff5a..db884f4 100644
--- a/src/cli/init.ts
+++ b/src/cli/init.ts
@@ -85,7 +85,7 @@ async function resolveMethod(
{
value: "npx",
label: "npx (Node on this machine)",
- hint: "uses your local Node 20+, localstack or lstk, and Docker",
+ hint: "uses your local Node 20+ and Docker",
},
{
value: "docker",
@@ -158,25 +158,18 @@ async function resolveDockerOptions(
ctx: ClientContext
): Promise {
const defaults = {
- cacheDir: path.join(ctx.homeDir, ".localstack-mcp"),
workspace: process.cwd(),
imageTag: "latest",
};
const prompts = interactive && !yes;
- let cacheDir = defaults.cacheDir;
+ // The server now creates the LocalStack container itself (named volume by
+ // default), so no cache/state dir needs to be mounted into the MCP container.
if (flags.cacheDir?.trim()) {
- cacheDir = expandPath(flags.cacheDir, ctx.homeDir);
- } else if (prompts) {
- const answer = preWriteAnswer(
- await p.text({
- message: "State/cache directory for Docker runs",
- initialValue: defaults.cacheDir,
- validate: (value) => (value?.trim() ? undefined : "A directory is required"),
- })
+ p.log.warn(
+ "--cache-dir is deprecated and ignored: LocalStack state lives in a named Docker volume (set LOCALSTACK_VOLUME_DIR to use a host directory instead)."
);
- cacheDir = expandPath(answer, ctx.homeDir);
}
// flags.workspace === "" is meaningful: skip the workspace mount.
@@ -204,7 +197,7 @@ async function resolveDockerOptions(
).trim();
}
- return { cacheDir, workspaceDir: workspace || undefined, imageTag };
+ return { workspaceDir: workspace || undefined, imageTag };
}
async function resolveExtraEnv(
diff --git a/src/core/preflight.test.ts b/src/core/preflight.test.ts
index 3014566..1bf1fda 100644
--- a/src/core/preflight.test.ts
+++ b/src/core/preflight.test.ts
@@ -1,19 +1,24 @@
-import { requireLocalStackRunning } from "./preflight";
+import { requireDockerDaemon, requireLocalStackRunning } from "./preflight";
import { getGatewayHealth } from "../lib/localstack/localstack.utils";
+import { DockerApiClient } from "../lib/docker/docker.client";
jest.mock("../lib/localstack/localstack.utils", () => ({
getGatewayHealth: jest.fn(),
- ensureLocalStackCli: jest.fn(),
ensureSnowflakeCli: jest.fn(),
}));
+jest.mock("../lib/docker/docker.client", () => ({
+ DockerApiClient: jest.fn(),
+}));
+
const mockedGetGatewayHealth = getGatewayHealth as jest.MockedFunction;
+const MockedDockerApiClient = DockerApiClient as jest.MockedClass;
describe("requireLocalStackRunning", () => {
beforeEach(() => mockedGetGatewayHealth.mockReset());
- test("passes for any reachable gateway, regardless of container name or CLI", async () => {
- // e.g. an `lstk`-started container named `localstack-aws` with no Python CLI.
+ test("passes for any reachable gateway, regardless of container name or provenance", async () => {
+ // e.g. an externally-started container named `localstack-aws`.
mockedGetGatewayHealth.mockResolvedValueOnce({
reachable: true,
ready: true,
@@ -23,11 +28,38 @@ describe("requireLocalStackRunning", () => {
expect(await requireLocalStackRunning()).toBeNull();
});
- test("blocks with an error when the gateway is unreachable", async () => {
+ test("blocks with an error pointing at the management tool when the gateway is unreachable", async () => {
mockedGetGatewayHealth.mockResolvedValueOnce({ reachable: false, ready: false });
const result = await requireLocalStackRunning();
expect(result).not.toBeNull();
expect(result?.content[0].text).toMatch(/LocalStack Not Running/i);
+ expect(result?.content[0].text).toMatch(/localstack-management/);
+ // No stale advice to install/run a CLI.
+ expect(result?.content[0].text).not.toMatch(/localstack start|lstk/);
+ });
+});
+
+describe("requireDockerDaemon", () => {
+ beforeEach(() => MockedDockerApiClient.mockReset());
+
+ test("passes when the daemon answers the ping", async () => {
+ MockedDockerApiClient.mockImplementation(
+ () => ({ ping: jest.fn().mockResolvedValue(undefined) }) as any
+ );
+ expect(await requireDockerDaemon()).toBeNull();
+ });
+
+ test("blocks with the friendly daemon message when the ping fails", async () => {
+ MockedDockerApiClient.mockImplementation(
+ () =>
+ ({
+ ping: jest.fn().mockRejectedValue(new Error("Docker daemon is not reachable. (ENOENT)")),
+ }) as any
+ );
+ const result = await requireDockerDaemon();
+ expect(result).not.toBeNull();
+ expect(result?.content[0].text).toMatch(/Docker Not Available/);
+ expect(result?.content[0].text).toMatch(/Docker daemon is not reachable/);
});
});
diff --git a/src/core/preflight.ts b/src/core/preflight.ts
index 474a5c8..dc467a5 100644
--- a/src/core/preflight.ts
+++ b/src/core/preflight.ts
@@ -1,24 +1,33 @@
-import {
- ensureLocalStackCli,
- ensureSnowflakeCli,
- getGatewayHealth,
-} from "../lib/localstack/localstack.utils";
+import { ensureSnowflakeCli, getGatewayHealth } from "../lib/localstack/localstack.utils";
+import { DockerApiClient } from "../lib/docker/docker.client";
import { checkProFeature, ProFeature } from "../lib/localstack/license-checker";
import { LOCALSTACK_BASE_URL } from "./config";
import { ResponseBuilder } from "./response-builder";
type ToolResponse = ReturnType;
-export const requireLocalStackCli = async (): Promise => {
- const cliCheck = await ensureLocalStackCli();
- return cliCheck ? (cliCheck as ToolResponse) : null;
-};
-
export const requireSnowflakeCli = async (): Promise => {
const cliCheck = await ensureSnowflakeCli();
return cliCheck ? (cliCheck as ToolResponse) : null;
};
+/**
+ * Gate for actions that talk to the Docker daemon (container lifecycle, log reads,
+ * in-container exec). Surfaces socket/daemon failures as an actionable message
+ * instead of a raw ENOENT.
+ */
+export const requireDockerDaemon = async (): Promise => {
+ try {
+ await new DockerApiClient().ping();
+ return null;
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Docker Not Available",
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+};
+
export const requireProFeature = async (feature: ProFeature): Promise => {
const licenseCheck = await checkProFeature(feature);
return !licenseCheck.isSupported
@@ -45,13 +54,13 @@ export const runPreflights = async (
export const requireLocalStackRunning = async (): Promise => {
// Provenance-agnostic gate: probe the gateway directly instead of looking for a
- // CLI-named container, so an `lstk`-started (or otherwise externally managed)
- // runtime that is healthy and reachable is not falsely reported as "not running".
+ // specific container, so an externally managed runtime that is healthy and
+ // reachable is not falsely reported as "not running".
const health = await getGatewayHealth();
if (!health.reachable) {
return ResponseBuilder.error(
"LocalStack Not Running",
- `LocalStack is not reachable at ${LOCALSTACK_BASE_URL}. Start it with \`localstack start\` (or \`lstk start\`) and try again. ` +
+ `LocalStack is not reachable at ${LOCALSTACK_BASE_URL}. Start it with the localstack-management tool (action: start) and try again. ` +
`If it is running on a non-default host or port, set LOCALSTACK_HOSTNAME / LOCALSTACK_PORT for the MCP server.`
);
}
diff --git a/src/lib/deployment/deployment-utils.ts b/src/lib/deployment/deployment-utils.ts
index 3e97cdb..6105398 100644
--- a/src/lib/deployment/deployment-utils.ts
+++ b/src/lib/deployment/deployment-utils.ts
@@ -9,13 +9,7 @@ export interface DependencyCheckResult {
errorMessage?: string;
}
-export type ProjectType =
- | "cdk"
- | "terraform"
- | "sam"
- | "cloudformation"
- | "ambiguous"
- | "unknown";
+export type ProjectType = "cdk" | "terraform" | "sam" | "cloudformation" | "ambiguous" | "unknown";
/**
* Check if the required deployment tool (cdklocal or tflocal) is available in the system PATH
@@ -108,23 +102,26 @@ export async function inferProjectType(directory: string): Promise
if (hasTemplateYaml) {
const samTemplateFile = files.includes("template.yaml") ? "template.yaml" : "template.yml";
try {
- const templateContent = await fs.promises.readFile(path.join(directory, samTemplateFile), "utf-8");
+ const templateContent = await fs.promises.readFile(
+ path.join(directory, samTemplateFile),
+ "utf-8"
+ );
hasServerlessResources = /AWS::Serverless::[A-Za-z]+/.test(templateContent);
} catch {
hasServerlessResources = false;
}
}
- const hasCloudFormationTemplates = files.some((file) => file.endsWith(".yaml") || file.endsWith(".yml"));
+ const hasCloudFormationTemplates = files.some(
+ (file) => file.endsWith(".yaml") || file.endsWith(".yml")
+ );
const isCdk = hasCdkJson || hasCdkFiles;
const isTerraform = hasTerraformFiles;
const isSam = hasSamConfig || hasServerlessResources;
const isCloudFormation = hasCloudFormationTemplates && !isSam;
- if (
- [isCdk, isTerraform, isSam, isCloudFormation].filter(Boolean).length > 1
- ) {
+ if ([isCdk, isTerraform, isSam, isCloudFormation].filter(Boolean).length > 1) {
return "ambiguous";
} else if (isCdk) {
return "cdk";
diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts
index ca099bf..984d2bc 100644
--- a/src/lib/docker/docker.client.test.ts
+++ b/src/lib/docker/docker.client.test.ts
@@ -324,6 +324,13 @@ describe("DockerApiClient", () => {
Image: "localstack/localstack-pro:latest",
Env: ["MAIN_CONTAINER_NAME=localstack-aws"],
},
+ Mounts: [
+ {
+ Type: "bind",
+ Source: "/home/user/.cache/localstack/volume",
+ Destination: "/var/lib/localstack",
+ },
+ ],
});
const client = new DockerApiClient();
@@ -332,6 +339,14 @@ describe("DockerApiClient", () => {
name: "localstack-aws",
image: "localstack/localstack-pro:latest",
env: ["MAIN_CONTAINER_NAME=localstack-aws"],
+ mounts: [
+ {
+ type: "bind",
+ name: undefined,
+ source: "/home/user/.cache/localstack/volume",
+ destination: "/var/lib/localstack",
+ },
+ ],
});
});
@@ -397,3 +412,51 @@ describe("DockerApiClient", () => {
expect(res.stderr).toContain("something went wrong");
});
});
+
+describe("decodeDockerLogBuffer", () => {
+ const frame = (streamType: number, text: string) => {
+ const payload = Buffer.from(text, "utf8");
+ const header = Buffer.alloc(8);
+ header[0] = streamType;
+ header.writeUInt32BE(payload.length, 4);
+ return Buffer.concat([header, payload]);
+ };
+
+ test("preserves chronological interleaving of stdout and stderr frames", () => {
+ const { decodeDockerLogBuffer } = jest.requireActual("./docker.client");
+ const multiplexed = Buffer.concat([
+ frame(1, "line one (stdout)\n"),
+ frame(2, "line two (stderr)\n"),
+ frame(1, "line three (stdout)\n"),
+ ]);
+ expect(decodeDockerLogBuffer(multiplexed)).toBe(
+ "line one (stdout)\nline two (stderr)\nline three (stdout)\n"
+ );
+ });
+
+ test("passes through raw (TTY) output without multiplex headers", () => {
+ const { decodeDockerLogBuffer } = jest.requireActual("./docker.client");
+ expect(decodeDockerLogBuffer(Buffer.from("plain text log\n", "utf8"))).toBe("plain text log\n");
+ });
+
+ test("handles empty buffers", () => {
+ const { decodeDockerLogBuffer } = jest.requireActual("./docker.client");
+ expect(decodeDockerLogBuffer(Buffer.alloc(0))).toBe("");
+ });
+});
+
+describe("describeDockerConnectivityError", () => {
+ test("maps socket errors to an actionable daemon-unreachable message", () => {
+ const { describeDockerConnectivityError } = jest.requireActual("./docker.client");
+ const err = Object.assign(new Error("connect ENOENT /var/run/docker.sock"), {
+ code: "ENOENT",
+ });
+ expect(describeDockerConnectivityError(err)).toMatch(/Docker daemon is not reachable/);
+ expect(describeDockerConnectivityError(err)).toMatch(/DOCKER_HOST/);
+ });
+
+ test("passes through unrelated errors", () => {
+ const { describeDockerConnectivityError } = jest.requireActual("./docker.client");
+ expect(describeDockerConnectivityError(new Error("kaboom"))).toBe("kaboom");
+ });
+});
diff --git a/src/lib/docker/docker.client.ts b/src/lib/docker/docker.client.ts
index 4394181..bbd4526 100644
--- a/src/lib/docker/docker.client.ts
+++ b/src/lib/docker/docker.client.ts
@@ -1,5 +1,6 @@
import { PassThrough } from "stream";
import { LOCALSTACK_PORT } from "../../core/config";
+import type { LocalStackContainerSpec } from "../localstack/container-spec.logic";
export interface ContainerExecResult {
stdout: string;
@@ -7,11 +8,94 @@ export interface ContainerExecResult {
exitCode: number;
}
+export interface ContainerStateInfo {
+ id: string;
+ name: string;
+ state: string;
+ image?: string;
+ running: boolean;
+}
+
+/**
+ * Rolling log tail attached to a container from the moment it starts. The buffer
+ * survives AutoRemove (the stream stays readable even after the daemon removes the
+ * container), and the stream ending doubles as the container-exit signal — the only
+ * reliable way to diagnose startup crashes of an AutoRemove container.
+ */
+export interface LogBufferHandle {
+ getBuffered(): string;
+ hasExited(): boolean;
+ onExit(callback: () => void): void;
+ destroy(): void;
+}
+
+const LOG_BUFFER_MAX_CHARS = 64 * 1024;
+
+/**
+ * Decode a Docker multiplexed log payload (returned as a single Buffer by
+ * `container.logs({follow: false})`) into chronologically ordered text. Each frame
+ * carries an 8-byte header: [stream_type, 0, 0, 0, size(u32 BE)]. Walking frames in
+ * order preserves the stdout/stderr interleaving `docker logs` shows; demuxing into
+ * separate sinks would reorder lines and naive toString() leaves binary headers that
+ * break line-anchored parsing.
+ */
+export function decodeDockerLogBuffer(buffer: Buffer): string {
+ if (buffer.length === 0) return "";
+ const firstByte = buffer[0];
+ // TTY containers stream raw output without multiplex headers.
+ if (firstByte !== 0 && firstByte !== 1 && firstByte !== 2) return buffer.toString("utf8");
+
+ const chunks: Buffer[] = [];
+ let offset = 0;
+ while (offset + 8 <= buffer.length) {
+ const streamType = buffer[offset];
+ if (streamType !== 0 && streamType !== 1 && streamType !== 2) break;
+ const size = buffer.readUInt32BE(offset + 4);
+ const end = Math.min(offset + 8 + size, buffer.length);
+ chunks.push(buffer.subarray(offset + 8, end));
+ offset += 8 + size;
+ }
+ if (chunks.length === 0) return buffer.toString("utf8");
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+/**
+ * Translate raw Docker connectivity failures into an actionable message. Socket-level
+ * errors otherwise surface as bare "connect ENOENT /var/run/docker.sock" strings.
+ */
+export function describeDockerConnectivityError(error: unknown): string {
+ const err = error as { code?: string; message?: string } | undefined;
+ const code = err?.code || "";
+ const message = err?.message || String(error);
+ if (
+ code === "ENOENT" ||
+ code === "ECONNREFUSED" ||
+ code === "EACCES" ||
+ code === "EPERM" ||
+ /docker_engine|docker\.sock/i.test(message)
+ ) {
+ return (
+ "Docker daemon is not reachable. Start Docker (Desktop) and try again. " +
+ "If your daemon uses a non-default socket, set DOCKER_HOST. " +
+ `(${message})`
+ );
+ }
+ return message;
+}
+
+export interface ContainerMountInfo {
+ type?: string;
+ name?: string;
+ source?: string;
+ destination?: string;
+}
+
export interface ContainerMetadata {
id: string;
name?: string;
image?: string;
env?: string[];
+ mounts?: ContainerMountInfo[];
}
export class LocalStackContainerNotFoundError extends Error {
@@ -166,6 +250,14 @@ export class DockerApiClient {
name: this.normalizeContainerName(inspect?.Name),
image: inspect?.Config?.Image,
env: inspect?.Config?.Env,
+ mounts: (inspect?.Mounts || []).map(
+ (mount: { Type?: string; Name?: string; Source?: string; Destination?: string }) => ({
+ type: mount.Type,
+ name: mount.Name,
+ source: mount.Source,
+ destination: mount.Destination,
+ })
+ ),
};
}
@@ -181,11 +273,16 @@ export class DockerApiClient {
requestTimeoutMs = 60000
): Promise {
const container = this.docker.getContainer(containerId);
- await this.withDockerRequestTimeout(
- container.stop({ t: timeoutSeconds }),
- requestTimeoutMs,
- "Docker container stop"
- );
+ try {
+ await this.withDockerRequestTimeout(
+ container.stop({ t: timeoutSeconds }),
+ requestTimeoutMs,
+ "Docker container stop"
+ );
+ } catch (error) {
+ // 304 = already stopped; continue so the remove below still runs.
+ if ((error as { statusCode?: number })?.statusCode !== 304) throw error;
+ }
try {
await this.withDockerRequestTimeout(
@@ -201,7 +298,8 @@ export class DockerApiClient {
async executeInContainer(
containerId: string,
command: string[],
- stdin?: string
+ stdin?: string,
+ options?: { env?: string[]; timeoutMs?: number }
): Promise {
const container = this.docker.getContainer(containerId);
@@ -209,6 +307,7 @@ export class DockerApiClient {
Cmd: command,
AttachStdout: true,
AttachStderr: true,
+ ...(options?.env?.length ? { Env: options.env } : {}),
...(stdin ? { AttachStdin: true } : {}),
});
@@ -235,8 +334,21 @@ export class DockerApiClient {
await new Promise((resolve, reject) => {
// demux combined docker stream into stdout/stderr
(this.docker as any).modem.demuxStream(stream as any, stdoutStream, stderrStream);
- stream.on("end", () => resolve());
- stream.on("error", (e) => reject(e));
+ let timer: NodeJS.Timeout | undefined;
+ if (options?.timeoutMs) {
+ timer = setTimeout(() => {
+ (stream as unknown as { destroy?: () => void }).destroy?.();
+ reject(new Error(`Container exec timed out after ${options.timeoutMs}ms`));
+ }, options.timeoutMs);
+ }
+ stream.on("end", () => {
+ if (timer) clearTimeout(timer);
+ resolve();
+ });
+ stream.on("error", (e) => {
+ if (timer) clearTimeout(timer);
+ reject(e);
+ });
});
const inspect = (await exec.inspect()) as { ExitCode: number | null };
@@ -247,4 +359,223 @@ export class DockerApiClient {
return { stdout, stderr, exitCode };
}
+
+ /** Docker daemon reachability probe with a friendly error for the common failures. */
+ async ping(timeoutMs = 5000): Promise {
+ try {
+ await this.withDockerRequestTimeout(this.docker.ping(), timeoutMs, "Docker daemon ping");
+ } catch (error) {
+ throw new Error(describeDockerConnectivityError(error));
+ }
+ }
+
+ async imageExists(imageName: string): Promise {
+ try {
+ await this.docker.getImage(imageName).inspect();
+ return true;
+ } catch (error) {
+ const statusCode = (error as { statusCode?: number })?.statusCode;
+ if (statusCode === 404) return false;
+ throw new Error(describeDockerConnectivityError(error));
+ }
+ }
+
+ /**
+ * Pull an image, consuming the progress stream manually. docker-modem's
+ * followProgress reports success even when the stream carries an `error` event and
+ * throws uncaught on malformed progress lines — both fatal for a stdio MCP process.
+ */
+ async pullImage(imageName: string, timeoutMs = 600000): Promise {
+ const stream: NodeJS.ReadableStream = await new Promise((resolve, reject) => {
+ this.docker.pull(imageName, (err: unknown, pullStream: NodeJS.ReadableStream) => {
+ if (err) return reject(new Error(describeDockerConnectivityError(err)));
+ resolve(pullStream);
+ });
+ });
+
+ await this.withDockerRequestTimeout(
+ new Promise((resolve, reject) => {
+ let pending = "";
+ let pullError: string | undefined;
+ stream.on("data", (chunk: Buffer) => {
+ pending += chunk.toString("utf8");
+ const lines = pending.split("\n");
+ pending = lines.pop() || "";
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const event = JSON.parse(line) as {
+ error?: string;
+ errorDetail?: { message?: string };
+ };
+ if (event.error || event.errorDetail) {
+ pullError = event.error || event.errorDetail?.message || "unknown pull error";
+ }
+ } catch {
+ // Malformed progress lines are ignored; only well-formed error events fail the pull.
+ }
+ }
+ });
+ stream.on("error", (err: Error) => reject(err));
+ stream.on("end", () => {
+ if (pullError) reject(new Error(`Failed to pull image ${imageName}: ${pullError}`));
+ else resolve();
+ });
+ }),
+ timeoutMs,
+ `Docker image pull (${imageName})`
+ );
+ }
+
+ /** Create a user-defined network when missing (Lambda containers must reach the gateway on it). */
+ async ensureNetwork(name: string): Promise {
+ if (!name || name === "host" || name === "bridge" || name === "none") return;
+ const networks = (await this.docker.listNetworks({
+ filters: { name: [name] },
+ })) as Array<{ Name?: string }>;
+ if ((networks || []).some((network) => network.Name === name)) return;
+ await this.docker.createNetwork({ Name: name });
+ }
+
+ async createAndStartContainer(spec: LocalStackContainerSpec): Promise {
+ const { name, ...createOptions } = spec;
+ let container;
+ try {
+ container = await this.docker.createContainer({ ...createOptions, name });
+ } catch (error) {
+ const statusCode = (error as { statusCode?: number })?.statusCode;
+ if (statusCode === 409) {
+ throw new LocalStackContainerConflictError(`A container named "${name}" already exists.`);
+ }
+ throw new Error(describeDockerConnectivityError(error));
+ }
+ await container.start();
+ return container.id as string;
+ }
+
+ /**
+ * Attach a follow-mode log stream right after start and keep a rolling tail.
+ * See LogBufferHandle for why this replaces post-exit `container.logs()` reads.
+ */
+ async attachLogBuffer(containerId: string): Promise {
+ const container = this.docker.getContainer(containerId);
+ const stream: NodeJS.ReadableStream = await container.logs({
+ follow: true,
+ stdout: true,
+ stderr: true,
+ tail: 200,
+ });
+
+ // Demux into a single sink so stdout/stderr keep their chronological interleaving.
+ const sink = new PassThrough();
+ (this.docker as any).modem.demuxStream(stream, sink, sink);
+
+ let buffered = "";
+ let exited = false;
+ const exitCallbacks: Array<() => void> = [];
+ sink.on("data", (data: Buffer) => {
+ buffered += data.toString("utf8");
+ if (buffered.length > LOG_BUFFER_MAX_CHARS) {
+ buffered = buffered.slice(-LOG_BUFFER_MAX_CHARS);
+ }
+ });
+ const markExited = () => {
+ if (exited) return;
+ exited = true;
+ for (const callback of exitCallbacks) callback();
+ };
+ stream.on("end", markExited);
+ stream.on("close", markExited);
+ stream.on("error", markExited);
+
+ return {
+ getBuffered: () => buffered,
+ hasExited: () => exited,
+ onExit: (callback) => {
+ if (exited) callback();
+ else exitCallbacks.push(callback);
+ },
+ destroy: () => {
+ (stream as unknown as { destroy?: () => void }).destroy?.();
+ },
+ };
+ }
+
+ /** One-shot chronologically ordered log fetch (docker logs equivalent). */
+ async getContainerLogs(
+ containerId: string,
+ { tail, timeoutMs = 30000 }: { tail: number; timeoutMs?: number }
+ ): Promise {
+ const container = this.docker.getContainer(containerId);
+ const result: Buffer | string = await this.withDockerRequestTimeout(
+ container.logs({ follow: false, stdout: true, stderr: true, tail }),
+ timeoutMs,
+ "Docker container logs"
+ );
+ if (typeof result === "string") return result;
+ return decodeDockerLogBuffer(result);
+ }
+
+ /**
+ * Find a container by exact name in ANY state — the pre-start conflict check.
+ * `findLocalStackContainer` deliberately sees only running containers.
+ */
+ async findContainerByNameAnyState(name: string): Promise {
+ const containers = (await this.withDockerRequestTimeout(
+ (this.docker.listContainers as any)({ all: true, filters: { name: [name] } }),
+ 10000,
+ "Docker container list"
+ )) as Array<{ Id: string; Names?: string[]; State?: string; Image?: string }>;
+
+ const match = (containers || []).find((container) =>
+ this.matchesConfiguredContainerName(container, name)
+ );
+ if (!match) return null;
+ return {
+ id: match.Id,
+ name,
+ state: match.State || "unknown",
+ image: match.Image,
+ running: match.State === "running",
+ };
+ }
+
+ /** Remove a container, tolerating it already being gone. */
+ async removeContainer(containerId: string, requestTimeoutMs = 60000): Promise {
+ const container = this.docker.getContainer(containerId);
+ try {
+ await this.withDockerRequestTimeout(
+ container.remove({ force: true }),
+ requestTimeoutMs,
+ "Docker container remove"
+ );
+ } catch (error) {
+ if (!this.isContainerAlreadyGone(error)) throw error;
+ }
+ }
+
+ /**
+ * Wait until a container is fully removed (AutoRemove removal is asynchronous —
+ * recreating the same name too early races a 409).
+ */
+ async waitForRemoval(containerId: string, timeoutMs = 60000): Promise {
+ const container = this.docker.getContainer(containerId);
+ try {
+ await this.withDockerRequestTimeout(
+ container.wait({ condition: "removed" }),
+ timeoutMs,
+ "Docker container removal wait"
+ );
+ } catch (error) {
+ if (this.isContainerAlreadyGone(error)) return;
+ throw error;
+ }
+ }
+}
+
+export class LocalStackContainerConflictError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "LocalStackContainerConflictError";
+ }
}
diff --git a/src/lib/localstack/container-spec.logic.test.ts b/src/lib/localstack/container-spec.logic.test.ts
new file mode 100644
index 0000000..5b8dc5c
--- /dev/null
+++ b/src/lib/localstack/container-spec.logic.test.ts
@@ -0,0 +1,310 @@
+import {
+ buildLocalStackContainerSpec,
+ detectAiAgent,
+ parseGatewayListen,
+ resolveContainerName,
+ resolveImage,
+ resolveVolume,
+ type ContainerSpecInput,
+} from "./container-spec.logic";
+
+const baseInput = (overrides: Partial = {}): ContainerSpecInput => ({
+ stack: "aws",
+ hostEnv: {},
+ authToken: "ls-test-token",
+ isInDocker: false,
+ volume: { type: "bind", source: "/home/user/.cache/localstack/volume" },
+ serverVersion: "0.5.0",
+ ...overrides,
+});
+
+const envMap = (spec: ReturnType) =>
+ Object.fromEntries(
+ spec.Env.map((entry) => [
+ entry.slice(0, entry.indexOf("=")),
+ entry.slice(entry.indexOf("=") + 1),
+ ])
+ );
+
+describe("resolveImage", () => {
+ test("defaults to the pro image for the aws stack", () => {
+ expect(resolveImage("aws", {})).toBe("localstack/localstack-pro:latest");
+ });
+
+ test("honors LOCALSTACK_IMAGE_NAME over IMAGE_NAME", () => {
+ expect(
+ resolveImage("aws", { LOCALSTACK_IMAGE_NAME: "my/img:1", IMAGE_NAME: "other/img:2" })
+ ).toBe("my/img:1");
+ expect(resolveImage("aws", { IMAGE_NAME: "other/img:2" })).toBe("other/img:2");
+ });
+
+ test("snowflake stack always uses the snowflake image (stack wins over IMAGE_NAME)", () => {
+ expect(resolveImage("snowflake", { IMAGE_NAME: "other/img:2" })).toBe(
+ "localstack/snowflake:latest"
+ );
+ });
+});
+
+describe("resolveContainerName", () => {
+ test("defaults to localstack-main and honors MAIN_CONTAINER_NAME", () => {
+ expect(resolveContainerName({})).toBe("localstack-main");
+ expect(resolveContainerName({ MAIN_CONTAINER_NAME: "my-ls" })).toBe("my-ls");
+ });
+});
+
+describe("parseGatewayListen", () => {
+ test("fills in the default bind IP for host-less entries", () => {
+ expect(parseGatewayListen(":4566", "127.0.0.1")).toEqual([{ host: "127.0.0.1", port: 4566 }]);
+ });
+
+ test("parses multiple entries with explicit hosts", () => {
+ expect(parseGatewayListen("127.0.0.1:4566,0.0.0.0:5000", "127.0.0.1")).toEqual([
+ { host: "127.0.0.1", port: 4566 },
+ { host: "0.0.0.0", port: 5000 },
+ ]);
+ });
+
+ test("rejects invalid ports", () => {
+ expect(() => parseGatewayListen("abc", "127.0.0.1")).toThrow(/Invalid GATEWAY_LISTEN/);
+ });
+});
+
+describe("resolveVolume", () => {
+ test("host darwin defaults to the CLI-identical caches path", () => {
+ expect(
+ resolveVolume({ hostEnv: {}, isInDocker: false, platform: "darwin", homedir: "/Users/me" })
+ ).toEqual({ type: "bind", source: "/Users/me/Library/Caches/localstack/volume" });
+ });
+
+ test("host linux honors XDG_CACHE_HOME when absolute", () => {
+ expect(
+ resolveVolume({
+ hostEnv: { XDG_CACHE_HOME: "/custom/cache" },
+ isInDocker: false,
+ platform: "linux",
+ homedir: "/home/me",
+ })
+ ).toEqual({ type: "bind", source: "/custom/cache/localstack/volume" });
+ expect(
+ resolveVolume({ hostEnv: {}, isInDocker: false, platform: "linux", homedir: "/home/me" })
+ ).toEqual({ type: "bind", source: "/home/me/.cache/localstack/volume" });
+ });
+
+ test("host windows builds a forward-slash LOCALAPPDATA path", () => {
+ expect(
+ resolveVolume({
+ hostEnv: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" },
+ isInDocker: false,
+ platform: "win32",
+ homedir: "C:\\Users\\me",
+ })
+ ).toEqual({ type: "bind", source: "C:/Users/me/AppData/Local/cache/localstack/volume" });
+ });
+
+ test("explicit LOCALSTACK_VOLUME_DIR always wins", () => {
+ expect(
+ resolveVolume({
+ hostEnv: { LOCALSTACK_VOLUME_DIR: "/data/ls" },
+ isInDocker: true,
+ platform: "linux",
+ homedir: "/root",
+ })
+ ).toEqual({ type: "bind", source: "/data/ls" });
+ });
+
+ test("in docker: legacy XDG_CACHE_HOME keeps pre-migration DooD state", () => {
+ expect(
+ resolveVolume({
+ hostEnv: { XDG_CACHE_HOME: "/Users/me/.localstack-mcp" },
+ isInDocker: true,
+ platform: "linux",
+ homedir: "/root",
+ })
+ ).toEqual({ type: "bind", source: "/Users/me/.localstack-mcp/localstack/volume" });
+ });
+
+ test("in docker without hints: named volume (no host-path guessing)", () => {
+ expect(
+ resolveVolume({ hostEnv: {}, isInDocker: true, platform: "linux", homedir: "/root" })
+ ).toEqual({ type: "volume", name: "localstack-mcp" });
+ });
+});
+
+describe("detectAiAgent", () => {
+ test("maps agent marker env vars like the CLI", () => {
+ expect(detectAiAgent({ CLAUDECODE: "1" })).toBe("claude-code");
+ expect(detectAiAgent({ CURSOR_TRACE_ID: "x" })).toBe("cursor");
+ expect(detectAiAgent({})).toBeUndefined();
+ });
+});
+
+describe("buildLocalStackContainerSpec", () => {
+ test("publishes 4566 + 443 + the 51-port service range (53 total) on 127.0.0.1 by default", () => {
+ const spec = buildLocalStackContainerSpec(baseInput());
+ const keys = Object.keys(spec.HostConfig.PortBindings);
+ expect(keys).toHaveLength(53);
+ expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([
+ { HostIp: "127.0.0.1", HostPort: "4566" },
+ ]);
+ expect(spec.HostConfig.PortBindings["443/tcp"]).toEqual([
+ { HostIp: "127.0.0.1", HostPort: "443" },
+ ]);
+ expect(spec.HostConfig.PortBindings["4510/tcp"]).toBeDefined();
+ expect(spec.HostConfig.PortBindings["4560/tcp"]).toBeDefined();
+ expect(Object.keys(spec.ExposedPorts)).toHaveLength(53);
+ });
+
+ test("binds to 0.0.0.0 when the server runs inside Docker (DooD reachability)", () => {
+ const spec = buildLocalStackContainerSpec(baseInput({ isInDocker: true }));
+ expect(spec.HostConfig.PortBindings["4566/tcp"][0].HostIp).toBe("0.0.0.0");
+ expect(spec.HostConfig.PortBindings["443/tcp"][0].HostIp).toBe("0.0.0.0");
+ });
+
+ test("LOCALSTACK_PORT changes only the host side of the gateway mapping", () => {
+ const spec = buildLocalStackContainerSpec(baseInput({ hostEnv: { LOCALSTACK_PORT: "4567" } }));
+ expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([
+ { HostIp: "127.0.0.1", HostPort: "4567" },
+ ]);
+ expect(envMap(spec).GATEWAY_LISTEN).toBe(":4566,:443");
+ expect(envMap(spec).LOCALSTACK_PORT).toBeUndefined();
+ });
+
+ test("GATEWAY_LISTEN skips the implicit 443 and strips the default host in container env", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({ hostEnv: { GATEWAY_LISTEN: "127.0.0.1:4566" } })
+ );
+ expect(spec.HostConfig.PortBindings["443/tcp"]).toBeUndefined();
+ expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([
+ { HostIp: "127.0.0.1", HostPort: "4566" },
+ ]);
+ // Forwarding "127.0.0.1:4566" verbatim would make the gateway bind the
+ // container's loopback — must be host-stripped exactly like the CLI does.
+ expect(envMap(spec).GATEWAY_LISTEN).toBe(":4566");
+ });
+
+ test("GATEWAY_LISTEN keeps non-default hosts in the container env (CLI parity)", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({ hostEnv: { GATEWAY_LISTEN: "0.0.0.0:5000" } })
+ );
+ expect(spec.HostConfig.PortBindings["5000/tcp"]).toEqual([
+ { HostIp: "0.0.0.0", HostPort: "5000" },
+ ]);
+ expect(envMap(spec).GATEWAY_LISTEN).toBe("0.0.0.0:5000");
+ });
+
+ test("EXTERNAL_SERVICE_PORTS overrides move both the published range and the env", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({
+ hostEnv: { EXTERNAL_SERVICE_PORTS_START: "4600", EXTERNAL_SERVICE_PORTS_END: "4610" },
+ })
+ );
+ expect(spec.HostConfig.PortBindings["4600/tcp"]).toBeDefined();
+ expect(spec.HostConfig.PortBindings["4610/tcp"]).toBeDefined();
+ expect(spec.HostConfig.PortBindings["4510/tcp"]).toBeUndefined();
+ expect(envMap(spec).EXTERNAL_SERVICE_PORTS_START).toBe("4600");
+ expect(envMap(spec).EXTERNAL_SERVICE_PORTS_END).toBe("4610");
+ });
+
+ test("env layering: shortlist < LOCALSTACK_* < envVars < reserved", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({
+ hostEnv: {
+ DEBUG: "0",
+ LOCALSTACK_DEBUG: "0",
+ SERVICES: "s3,sqs",
+ LOCALSTACK_HOSTNAME: "host.docker.internal", // client-only: never forwarded
+ LOCALSTACK_PORT: "4566", // client-only
+ LOCALSTACK_AUTH_TOKEN: "ls-host-env-token", // reserved key wins
+ LOCALSTACK_IMAGE_NAME: "custom/image:1", // image selector, not runtime config
+ PROVIDER_OVERRIDE_S3: "v2",
+ UNRELATED_VAR: "nope",
+ },
+ envVars: { DEBUG: "1", PERSISTENCE: "1" },
+ authToken: "ls-real-token",
+ })
+ );
+ const env = envMap(spec);
+ expect(env.DEBUG).toBe("1"); // envVars beat host env
+ expect(env.PERSISTENCE).toBe("1");
+ expect(env.SERVICES).toBe("s3,sqs");
+ expect(env.PROVIDER_OVERRIDE_S3).toBe("v2");
+ expect(env.LOCALSTACK_HOSTNAME).toBeUndefined();
+ expect(env.LOCALSTACK_PORT).toBeUndefined();
+ expect(env.LOCALSTACK_IMAGE_NAME).toBeUndefined();
+ expect(env.UNRELATED_VAR).toBeUndefined();
+ expect(env.LOCALSTACK_AUTH_TOKEN).toBe("ls-real-token");
+ expect(env.MAIN_CONTAINER_NAME).toBe("localstack-main");
+ expect(env.DOCKER_HOST).toBe("unix:///var/run/docker.sock");
+ expect(env.LOCALSTACK_CLIENT_NAME).toBe("localstack-mcp-server");
+ expect(env.LOCALSTACK_CLIENT_VERSION).toBe("0.5.0");
+ });
+
+ test("uses structured Mounts (bind + docker socket) — no Binds strings", () => {
+ const spec = buildLocalStackContainerSpec(baseInput());
+ expect(spec.HostConfig.Mounts).toEqual([
+ {
+ Type: "bind",
+ Source: "/home/user/.cache/localstack/volume",
+ Target: "/var/lib/localstack",
+ },
+ { Type: "bind", Source: "/var/run/docker.sock", Target: "/var/run/docker.sock" },
+ ]);
+ expect((spec.HostConfig as any).Binds).toBeUndefined();
+ });
+
+ test("named volume mount for DooD", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({ isInDocker: true, volume: { type: "volume", name: "localstack-mcp" } })
+ );
+ expect(spec.HostConfig.Mounts[0]).toEqual({
+ Type: "volume",
+ Source: "localstack-mcp",
+ Target: "/var/lib/localstack",
+ });
+ });
+
+ test("honors DOCKER_SOCK for the socket mount source", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({ hostEnv: { DOCKER_SOCK: "/run/user/1000/docker.sock" } })
+ );
+ expect(spec.HostConfig.Mounts[1].Source).toBe("/run/user/1000/docker.sock");
+ expect(envMap(spec).DOCKER_HOST).toBe("unix:///var/run/docker.sock");
+ });
+
+ test("MAIN_DOCKER_NETWORK becomes NetworkMode and is forwarded as env", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({ hostEnv: { MAIN_DOCKER_NETWORK: "ls-net" } })
+ );
+ expect(spec.HostConfig.NetworkMode).toBe("ls-net");
+ expect(envMap(spec).MAIN_DOCKER_NETWORK).toBe("ls-net");
+ const withoutNetwork = buildLocalStackContainerSpec(baseInput());
+ expect(withoutNetwork.HostConfig.NetworkMode).toBeUndefined();
+ });
+
+ test("AutoRemove is set (CLI --rm parity)", () => {
+ expect(buildLocalStackContainerSpec(baseInput()).HostConfig.AutoRemove).toBe(true);
+ });
+
+ test("snowflake stack: snowflake image, same container name", () => {
+ const spec = buildLocalStackContainerSpec(baseInput({ stack: "snowflake" }));
+ expect(spec.Image).toBe("localstack/snowflake:latest");
+ expect(spec.name).toBe("localstack-main");
+ });
+
+ test("restart recreation can pin the original image and name", () => {
+ const spec = buildLocalStackContainerSpec(
+ baseInput({
+ imageOverride: "localstack/localstack-pro:3.9",
+ containerNameOverride: "localstack-aws",
+ })
+ );
+ expect(spec.Image).toBe("localstack/localstack-pro:3.9");
+ expect(spec.name).toBe("localstack-aws");
+ expect(envMap(spec).MAIN_CONTAINER_NAME).toBe("localstack-aws");
+ });
+
+ test("tags agent-driven starts with AI_AGENT like the CLI", () => {
+ const spec = buildLocalStackContainerSpec(baseInput({ hostEnv: { CLAUDECODE: "1" } }));
+ expect(envMap(spec).AI_AGENT).toBe("claude-code");
+ });
+});
diff --git a/src/lib/localstack/container-spec.logic.ts b/src/lib/localstack/container-spec.logic.ts
new file mode 100644
index 0000000..2590bad
--- /dev/null
+++ b/src/lib/localstack/container-spec.logic.ts
@@ -0,0 +1,403 @@
+/**
+ * Pure builder for the LocalStack container spec (dockerode `createContainer` options).
+ *
+ * Replicates what `localstack start` used to assemble via the Python CLI so the MCP
+ * server can create the runtime with no CLI installed. Behavioral anchors (verified
+ * against the 2026.x `localstack_cli` wheel source):
+ * - image `localstack/localstack-pro:latest` (pro unconditional), `--stack snowflake`
+ * ⇒ `localstack/snowflake:latest`
+ * - container name `localstack-main`, also injected as `MAIN_CONTAINER_NAME` env
+ * - ports 4566, 443 (only when GATEWAY_LISTEN is unset) and the external service
+ * range 4510–4560 inclusive; bind host 127.0.0.1 on a host, 0.0.0.0 in Docker
+ * - volume dir → /var/lib/localstack, docker socket → /var/run/docker.sock
+ * - GATEWAY_LISTEN forwarded host-stripped (`:4566,:443`) — the host part belongs
+ * only in the port bindings, never inside the container
+ */
+
+export const DEFAULT_CONTAINER_NAME = "localstack-main";
+export const DEFAULT_AWS_IMAGE = "localstack/localstack-pro:latest";
+export const DEFAULT_SNOWFLAKE_IMAGE = "localstack/snowflake:latest";
+export const DEFAULT_GATEWAY_CONTAINER_PORT = 4566;
+export const DEFAULT_HTTPS_GATEWAY_PORT = 443;
+export const DEFAULT_SERVICE_PORT_START = 4510;
+export const DEFAULT_SERVICE_PORT_END = 4560; // inclusive — 51 ports, CLI parity
+export const NAMED_VOLUME_NAME = "localstack-mcp";
+const VOLUME_CONTAINER_PATH = "/var/lib/localstack";
+const DOCKER_SOCKET_CONTAINER_PATH = "/var/run/docker.sock";
+
+/**
+ * Env vars that configure how MCP clients/tools reach LocalStack. They must never
+ * leak into the runtime container, where they would corrupt its own URL generation.
+ */
+const CLIENT_ONLY_ENV_KEYS = new Set([
+ "HOSTNAME",
+ "LOCALSTACK_HOSTNAME",
+ "LOCALSTACK_PORT",
+ "AWS_ENDPOINT_URL",
+ "AWS_ENDPOINT_URL_S3",
+ "S3_ENDPOINT",
+ "AWS_S3_FORCE_PATH_STYLE",
+ "LOCALSTACK_AUTH_TOKEN", // re-added deliberately as a reserved key
+ "LOCALSTACK_API_KEY",
+ "LOCALSTACK_IMAGE_NAME", // selects the image; not runtime config
+ "LOCALSTACK_VOLUME_DIR", // host-side path; meaningless inside the container
+]);
+
+/**
+ * Unprefixed LocalStack config vars commonly set on hosts that the builder forwards.
+ * The CLI forwarded ~250 documented names; anything not listed here still reaches the
+ * container via `LOCALSTACK_` prefixing, which the runtime aliases natively.
+ */
+const FORWARDED_CONFIG_ENV_NAMES = new Set([
+ "DEBUG",
+ "LS_LOG",
+ "SERVICES",
+ "PERSISTENCE",
+ "EAGER_SERVICE_LOADING",
+ "ENFORCE_IAM",
+ "IAM_SOFT_MODE",
+ "EXTENSION_AUTO_INSTALL",
+ "APP_INSPECTOR",
+ "DNS_ADDRESS",
+ "MAIN_DOCKER_NETWORK",
+]);
+const FORWARDED_CONFIG_ENV_PREFIXES = ["LAMBDA_", "CFN_", "SNOWFLAKE_", "SF_"];
+
+/** Same detector list the localstack CLI uses to tag agent-driven starts. */
+const AI_AGENT_DETECTORS: Array<[string, string[]]> = [
+ ["cursor", ["CURSOR_TRACE_ID"]],
+ ["cursor-cli", ["CURSOR_AGENT"]],
+ ["gemini", ["GEMINI_CLI"]],
+ ["codex", ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]],
+ ["cowork", ["CLAUDE_CODE_IS_COWORK"]],
+ ["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
+ ["github-copilot", ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]],
+ ["goose", ["GOOSE_PROVIDER"]],
+ ["augment", ["AUGMENT_AGENT"]],
+ ["opencode", ["OPENCODE", "OPENCODE_CALLER", "OPENCODE_CLIENT"]],
+ ["antigravity", ["ANTIGRAVITY_AGENT"]],
+ ["devin", ["__COG_BASHRC_SOURCED", "__COG_SHELL_INTEGRATION_SCRIPT", "__COG_SKIP_PYENV"]],
+ ["replit", ["REPL_ID"]],
+];
+
+export type LocalStackStack = "aws" | "snowflake";
+
+export type VolumeResolution = { type: "bind"; source: string } | { type: "volume"; name: string };
+
+export interface GatewayListenEntry {
+ host: string;
+ port: number;
+}
+
+export interface ContainerSpecInput {
+ stack: LocalStackStack;
+ /** Tool-level env overrides (`envVars` argument of localstack-management start). */
+ envVars?: Record;
+ hostEnv: Record;
+ authToken: string;
+ /** Whether the MCP server itself runs inside a container (changes bind IP + volume). */
+ isInDocker: boolean;
+ volume: VolumeResolution;
+ serverVersion: string;
+ /** Overrides for restart-in-place recreation: reuse the original container's identity. */
+ imageOverride?: string;
+ containerNameOverride?: string;
+}
+
+export interface PortBindingEntry {
+ containerPort: number;
+ hostIp: string;
+ hostPort: number;
+}
+
+export interface LocalStackContainerSpec {
+ name: string;
+ Image: string;
+ Env: string[];
+ ExposedPorts: Record>;
+ HostConfig: {
+ AutoRemove: boolean;
+ PortBindings: Record>;
+ Mounts: Array<{ Type: "bind" | "volume"; Source: string; Target: string }>;
+ NetworkMode?: string;
+ };
+}
+
+export function resolveImage(
+ stack: LocalStackStack,
+ hostEnv: Record
+): string {
+ if (stack === "snowflake") return DEFAULT_SNOWFLAKE_IMAGE;
+ const override = hostEnv.LOCALSTACK_IMAGE_NAME?.trim() || hostEnv.IMAGE_NAME?.trim();
+ return override || DEFAULT_AWS_IMAGE;
+}
+
+export function resolveContainerName(hostEnv: Record): string {
+ return (
+ hostEnv.MAIN_CONTAINER_NAME?.trim() ||
+ hostEnv.LOCALSTACK_MAIN_CONTAINER_NAME?.trim() ||
+ DEFAULT_CONTAINER_NAME
+ );
+}
+
+/**
+ * Parse a GATEWAY_LISTEN value ("[host]:port" entries, comma-separated). Entries
+ * without a host get the platform default bind IP.
+ */
+export function parseGatewayListen(value: string, defaultIp: string): GatewayListenEntry[] {
+ return value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const separator = entry.lastIndexOf(":");
+ const host = separator > 0 ? entry.slice(0, separator) : "";
+ const portText = separator >= 0 ? entry.slice(separator + 1) : entry;
+ const port = Number(portText);
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
+ throw new Error(`Invalid GATEWAY_LISTEN entry "${entry}": port must be 1-65535.`);
+ }
+ return { host: host || defaultIp, port };
+ });
+}
+
+export function detectAiAgent(hostEnv: Record): string | undefined {
+ for (const [agent, markers] of AI_AGENT_DETECTORS) {
+ if (markers.some((marker) => hostEnv[marker])) return agent;
+ }
+ return undefined;
+}
+
+export interface VolumeResolutionInput {
+ hostEnv: Record;
+ isInDocker: boolean;
+ platform: NodeJS.Platform;
+ homedir: string;
+}
+
+/**
+ * Where LocalStack state lives. Host runs default to the exact per-OS directory the
+ * localstack CLI used, so existing users' state carries over. Inside Docker the
+ * source path must be meaningful to the HOST daemon: an explicit LOCALSTACK_VOLUME_DIR
+ * wins; a legacy XDG_CACHE_HOME (identical-path mount from the pre-migration setup)
+ * keeps old DooD state; otherwise a named volume avoids host paths entirely.
+ */
+export function resolveVolume({
+ hostEnv,
+ isInDocker,
+ platform,
+ homedir,
+}: VolumeResolutionInput): VolumeResolution {
+ const explicit = hostEnv.LOCALSTACK_VOLUME_DIR?.trim();
+ if (explicit) return { type: "bind", source: normalizeBindPath(explicit) };
+
+ if (isInDocker) {
+ const legacyCache = hostEnv.XDG_CACHE_HOME?.trim();
+ if (legacyCache) {
+ return { type: "bind", source: joinPosix(legacyCache, "localstack", "volume") };
+ }
+ return { type: "volume", name: NAMED_VOLUME_NAME };
+ }
+
+ if (platform === "darwin") {
+ return {
+ type: "bind",
+ source: joinPosix(homedir, "Library", "Caches", "localstack", "volume"),
+ };
+ }
+ if (platform === "win32") {
+ const localAppData = hostEnv.LOCALAPPDATA?.trim() || `${homedir}\\AppData\\Local`;
+ return {
+ type: "bind",
+ source: normalizeBindPath(`${localAppData}\\cache\\localstack\\volume`),
+ };
+ }
+ const cacheHome = hostEnv.XDG_CACHE_HOME?.trim();
+ const cacheBase =
+ cacheHome && cacheHome.startsWith("/") ? cacheHome : joinPosix(homedir, ".cache");
+ return { type: "bind", source: joinPosix(cacheBase, "localstack", "volume") };
+}
+
+function joinPosix(...parts: string[]): string {
+ return parts.join("/").replace(/\/+/g, "/");
+}
+
+/** Docker Desktop accepts drive-letter paths with forward slashes; avoids `\` escaping woes. */
+function normalizeBindPath(p: string): string {
+ return p.replace(/\\/g, "/");
+}
+
+interface ResolvedPorts {
+ bindings: PortBindingEntry[];
+ containerGatewayListen: string;
+ servicePortStart: number;
+ servicePortEnd: number;
+}
+
+function firstDefined(
+ keys: string[],
+ ...sources: Array>
+): string | undefined {
+ for (const source of sources) {
+ for (const key of keys) {
+ const value = source[key];
+ if (value !== undefined && value !== "") return value;
+ }
+ }
+ return undefined;
+}
+
+function resolvePorts(input: ContainerSpecInput): ResolvedPorts {
+ const { hostEnv, envVars = {} } = input;
+ const defaultIp = input.isInDocker ? "0.0.0.0" : "127.0.0.1";
+
+ const startRaw = firstDefined(
+ ["EXTERNAL_SERVICE_PORTS_START", "LOCALSTACK_EXTERNAL_SERVICE_PORTS_START"],
+ envVars,
+ hostEnv
+ );
+ const servicePortStart = startRaw ? Number(startRaw) : DEFAULT_SERVICE_PORT_START;
+ const endRaw = firstDefined(
+ ["EXTERNAL_SERVICE_PORTS_END", "LOCALSTACK_EXTERNAL_SERVICE_PORTS_END"],
+ envVars,
+ hostEnv
+ );
+ const servicePortEnd = endRaw ? Number(endRaw) : servicePortStart + 50;
+ if (
+ !Number.isInteger(servicePortStart) ||
+ !Number.isInteger(servicePortEnd) ||
+ servicePortEnd < servicePortStart
+ ) {
+ throw new Error(`Invalid EXTERNAL_SERVICE_PORTS range: ${servicePortStart}-${servicePortEnd}.`);
+ }
+
+ const bindings: PortBindingEntry[] = [];
+ let containerGatewayListen: string;
+
+ const gatewayListenRaw = firstDefined(
+ ["GATEWAY_LISTEN", "LOCALSTACK_GATEWAY_LISTEN"],
+ envVars,
+ hostEnv
+ );
+ if (gatewayListenRaw) {
+ // CLI parity: each entry publishes port:port; the container-side env strips
+ // hosts equal to the default bind IP (`:4566`) and keeps explicit others.
+ const entries = parseGatewayListen(gatewayListenRaw, defaultIp);
+ for (const entry of entries) {
+ bindings.push({ containerPort: entry.port, hostIp: entry.host, hostPort: entry.port });
+ }
+ containerGatewayListen = entries
+ .map((entry) => `${entry.host === defaultIp ? "" : entry.host}:${entry.port}`)
+ .join(",");
+ } else {
+ // Default: gateway on host-side (container always 4566),
+ // plus the HTTPS gateway on 443 — the pro CLI adds it only when GATEWAY_LISTEN
+ // is unset, and so do we.
+ const hostGatewayPort = Number(hostEnv.LOCALSTACK_PORT || DEFAULT_GATEWAY_CONTAINER_PORT);
+ bindings.push({
+ containerPort: DEFAULT_GATEWAY_CONTAINER_PORT,
+ hostIp: defaultIp,
+ hostPort: hostGatewayPort,
+ });
+ bindings.push({
+ containerPort: DEFAULT_HTTPS_GATEWAY_PORT,
+ hostIp: defaultIp,
+ hostPort: DEFAULT_HTTPS_GATEWAY_PORT,
+ });
+ containerGatewayListen = `:${DEFAULT_GATEWAY_CONTAINER_PORT},:${DEFAULT_HTTPS_GATEWAY_PORT}`;
+ }
+
+ // The Engine API has no range syntax — enumerate every service port.
+ for (let port = servicePortStart; port <= servicePortEnd; port++) {
+ bindings.push({ containerPort: port, hostIp: defaultIp, hostPort: port });
+ }
+
+ return { bindings, containerGatewayListen, servicePortStart, servicePortEnd };
+}
+
+function buildEnv(input: ContainerSpecInput, ports: ResolvedPorts, name: string): string[] {
+ const { hostEnv, envVars = {} } = input;
+ const env = new Map();
+
+ // (a) curated unprefixed config vars set on the host
+ for (const [key, value] of Object.entries(hostEnv)) {
+ if (value === undefined) continue;
+ const forwarded =
+ FORWARDED_CONFIG_ENV_NAMES.has(key) ||
+ FORWARDED_CONFIG_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
+ if (forwarded) env.set(key, value);
+ }
+
+ // (b) all LOCALSTACK_* / PROVIDER_OVERRIDE_* host vars minus client-only keys
+ for (const [key, value] of Object.entries(hostEnv)) {
+ if (value === undefined || CLIENT_ONLY_ENV_KEYS.has(key)) continue;
+ if (key.startsWith("LOCALSTACK_") || key.startsWith("PROVIDER_OVERRIDE_")) {
+ env.set(key, value);
+ }
+ }
+
+ // (c) explicit tool envVars win over anything host-derived
+ for (const [key, value] of Object.entries(envVars)) {
+ if (CLIENT_ONLY_ENV_KEYS.has(key) && key !== "LOCALSTACK_AUTH_TOKEN") continue;
+ env.set(key, value);
+ }
+
+ // (d) reserved keys the launcher owns — always last, always consistent with the
+ // published ports and mounts.
+ env.set("MAIN_CONTAINER_NAME", name);
+ env.set("GATEWAY_LISTEN", ports.containerGatewayListen);
+ env.set("EXTERNAL_SERVICE_PORTS_START", String(ports.servicePortStart));
+ env.set("EXTERNAL_SERVICE_PORTS_END", String(ports.servicePortEnd));
+ env.set("DOCKER_HOST", `unix://${DOCKER_SOCKET_CONTAINER_PATH}`);
+ env.set("LOCALSTACK_AUTH_TOKEN", input.authToken);
+ env.set("LOCALSTACK_CLIENT_NAME", "localstack-mcp-server");
+ env.set("LOCALSTACK_CLIENT_VERSION", input.serverVersion);
+ const aiAgent = detectAiAgent(hostEnv);
+ if (aiAgent) env.set("AI_AGENT", aiAgent);
+
+ return Array.from(env.entries()).map(([key, value]) => `${key}=${value}`);
+}
+
+export function buildLocalStackContainerSpec(input: ContainerSpecInput): LocalStackContainerSpec {
+ const name = input.containerNameOverride?.trim() || resolveContainerName(input.hostEnv);
+ const image = input.imageOverride?.trim() || resolveImage(input.stack, input.hostEnv);
+ const ports = resolvePorts(input);
+
+ const exposedPorts: Record> = {};
+ const portBindings: Record> = {};
+ for (const binding of ports.bindings) {
+ const key = `${binding.containerPort}/tcp`;
+ exposedPorts[key] = {};
+ portBindings[key] = portBindings[key] || [];
+ portBindings[key].push({ HostIp: binding.hostIp, HostPort: String(binding.hostPort) });
+ }
+
+ const mounts: LocalStackContainerSpec["HostConfig"]["Mounts"] = [
+ input.volume.type === "bind"
+ ? { Type: "bind", Source: input.volume.source, Target: VOLUME_CONTAINER_PATH }
+ : { Type: "volume", Source: input.volume.name, Target: VOLUME_CONTAINER_PATH },
+ {
+ Type: "bind",
+ Source: input.hostEnv.DOCKER_SOCK?.trim() || DOCKER_SOCKET_CONTAINER_PATH,
+ Target: DOCKER_SOCKET_CONTAINER_PATH,
+ },
+ ];
+
+ const networkMode =
+ input.envVars?.MAIN_DOCKER_NETWORK?.trim() ||
+ input.hostEnv.MAIN_DOCKER_NETWORK?.trim() ||
+ input.hostEnv.LOCALSTACK_MAIN_DOCKER_NETWORK?.trim();
+
+ return {
+ name,
+ Image: image,
+ Env: buildEnv(input, ports, name),
+ ExposedPorts: exposedPorts,
+ HostConfig: {
+ AutoRemove: true,
+ PortBindings: portBindings,
+ Mounts: mounts,
+ ...(networkMode ? { NetworkMode: networkMode } : {}),
+ },
+ };
+}
diff --git a/src/lib/localstack/extensions.logic.test.ts b/src/lib/localstack/extensions.logic.test.ts
new file mode 100644
index 0000000..f06123e
--- /dev/null
+++ b/src/lib/localstack/extensions.logic.test.ts
@@ -0,0 +1,140 @@
+import {
+ formatInstalledExtensions,
+ parseExtensionEvents,
+ parseInstalledExtensions,
+ summarizeInstall,
+ summarizeUninstall,
+} from "./extensions.logic";
+
+const installSuccessStream = [
+ "2026-07-04T10:00:00.000 INFO --- [MainThread] l.p.c.b.licensingv2 : Successfully activated cached license",
+ '{"event": "status", "message": "Checking installed extensions"}',
+ '{"event": "status", "message": "Installing extension"}',
+ '{"event": "pip", "message": "Collecting localstack-extension-httpbin"}',
+ "not json at all",
+ '{"event": "log", "message": "Extension successfully installed"}',
+ '{"event": "extension", "message": "", "extra": {"name": "httpbin"}}',
+ '{"event": "status", "message": "Extension installation completed"}',
+].join("\n");
+
+describe("parseExtensionEvents", () => {
+ test("parses JSON-lines events and skips interleaved log noise", () => {
+ const events = parseExtensionEvents(installSuccessStream);
+ expect(events.map((event) => event.event)).toEqual([
+ "status",
+ "status",
+ "pip",
+ "log",
+ "extension",
+ "status",
+ ]);
+ });
+});
+
+describe("summarizeInstall", () => {
+ test("recognizes a successful install", () => {
+ const outcome = summarizeInstall(parseExtensionEvents(installSuccessStream));
+ expect(outcome.kind).toBe("installed");
+ expect(outcome.success).toBe(true);
+ expect(outcome.summaryLines).toContain("Extension successfully installed");
+ // pip noise excluded from the summary
+ expect(outcome.summaryLines.join("\n")).not.toContain("Collecting");
+ });
+
+ test("recognizes already-installed as success", () => {
+ const outcome = summarizeInstall(
+ parseExtensionEvents(
+ '{"event": "log", "message": "Extension localstack-keycloak (0.1.0 by LocalStack Team) already installed"}'
+ )
+ );
+ expect(outcome.kind).toBe("already-installed");
+ expect(outcome.success).toBe(true);
+ });
+
+ test("maps unresolvable packages (incl. git URLs) to not-found", () => {
+ const outcome = summarizeInstall(
+ parseExtensionEvents(
+ [
+ '{"event": "status", "message": "Installing extension"}',
+ '{"event": "pip", "message": "ERROR: No matching distribution found for no-such-ext"}',
+ '{"event": "error", "message": "Could not resolve package no-such-ext, please check the URL or that the package exists in pypi."}',
+ ].join("\n")
+ )
+ );
+ expect(outcome.kind).toBe("not-found");
+ expect(outcome.success).toBe(false);
+ expect(outcome.errorDetail).toContain("Could not resolve package");
+ });
+
+ test("maps module exceptions to failed", () => {
+ const outcome = summarizeInstall(
+ parseExtensionEvents(
+ '{"event": "exception", "message": "Error while installing extension: boom", "extra": {"traceback": "..."}}'
+ )
+ );
+ expect(outcome.kind).toBe("failed");
+ expect(outcome.errorDetail).toContain("boom");
+ });
+
+ test("treats a pip install that registered no extension as no-change failure", () => {
+ const outcome = summarizeInstall(
+ parseExtensionEvents('{"event": "log", "message": "No change"}')
+ );
+ expect(outcome.kind).toBe("no-change");
+ expect(outcome.success).toBe(false);
+ });
+});
+
+describe("summarizeUninstall", () => {
+ test("recognizes a successful uninstall", () => {
+ const outcome = summarizeUninstall(
+ parseExtensionEvents(
+ [
+ '{"event": "log", "message": "Uninstalling extension localstack-extension-httpbin (0.1.0)"}',
+ '{"event": "log", "message": "Extension successfully uninstalled"}',
+ ].join("\n")
+ )
+ );
+ expect(outcome.kind).toBe("uninstalled");
+ expect(outcome.success).toBe(true);
+ });
+
+ test("maps not-installed to a distinct failure kind", () => {
+ const outcome = summarizeUninstall(
+ parseExtensionEvents('{"event": "log", "message": "Extension no-such is not installed"}')
+ );
+ expect(outcome.kind).toBe("not-installed");
+ expect(outcome.success).toBe(false);
+ });
+});
+
+describe("parseInstalledExtensions / formatInstalledExtensions", () => {
+ test("parses list output (one plux metadata object per line)", () => {
+ const output = [
+ "2026-07-04 INFO license activated",
+ JSON.stringify({
+ namespace: "localstack.extensions",
+ name: "keycloak",
+ distribution: {
+ name: "localstack-keycloak",
+ version: "0.1.0",
+ summary: "Keycloak for IAM",
+ author: "LocalStack Team",
+ },
+ }),
+ ].join("\n");
+
+ const extensions = parseInstalledExtensions(output);
+ expect(extensions).toHaveLength(1);
+ const markdown = formatInstalledExtensions(extensions);
+ expect(markdown).toContain("Installed LocalStack Extensions");
+ expect(markdown).toContain("localstack-keycloak");
+ expect(markdown).toContain("0.1.0");
+ });
+
+ test("formats an empty list with marketplace guidance", () => {
+ expect(formatInstalledExtensions([])).toContain(
+ "No LocalStack extensions are currently installed"
+ );
+ });
+});
diff --git a/src/lib/localstack/extensions.logic.ts b/src/lib/localstack/extensions.logic.ts
new file mode 100644
index 0000000..763d407
--- /dev/null
+++ b/src/lib/localstack/extensions.logic.ts
@@ -0,0 +1,200 @@
+/**
+ * Parsing for the in-container LocalStack extensions manager
+ * (`python -m localstack.pro.core.bootstrap.extensions `), which streams
+ * JSON-lines events: {event: "status"|"log"|"pip"|"error"|"extension"|"exception",
+ * message, extra?}. `list` emits one plux metadata JSON object per line instead.
+ *
+ * Verified against the 2026.x pro image: the module exits 0 even on failure (errors
+ * are reported as `error`/`exception` events), so outcomes MUST be derived from the
+ * event stream, not the exit code.
+ */
+
+export const EXTENSIONS_MANAGER_COMMAND = [
+ "/opt/code/localstack/.venv/bin/python",
+ "-m",
+ "localstack.pro.core.bootstrap.extensions",
+];
+
+/**
+ * Ensure the extensions venv on /var/lib/localstack is usable before install /
+ * uninstall (the manager's `pip` calls need a working interpreter):
+ * - missing venv → `init` (what the CLI's _ensure_venv_initialized did)
+ * - broken interpreter symlinks → the venv on the shared volume was created by an
+ * older image whose python lived at a different path (e.g. /usr/local/bin vs
+ * /usr/bin). Re-link via `venv --upgrade` — repairs scripts without touching
+ * installed packages. (The official CLI fails hard on this case.)
+ * - missing pip → bootstrap via ensurepip.
+ */
+export const EXTENSIONS_VENV_REPAIR_SCRIPT = [
+ "V=/var/lib/localstack/lib/extensions/python_venv",
+ "PY=/opt/code/localstack/.venv/bin/python",
+ 'if [ ! -e "$V/pyvenv.cfg" ]; then "$PY" -m localstack.pro.core.bootstrap.extensions init; fi',
+ 'if ! "$V/bin/python" -c "import sys" >/dev/null 2>&1; then rm -f "$V/bin/python" "$V/bin/python3" "$V"/bin/python3.*; "$PY" -m venv --upgrade "$V"; fi',
+ 'if [ ! -x "$V/bin/pip" ]; then "$V/bin/python" -m ensurepip --upgrade >/dev/null 2>&1 || true; fi',
+].join("\n");
+
+export interface ExtensionEvent {
+ event: string;
+ message?: string;
+ extra?: unknown;
+}
+
+export interface InstalledExtension {
+ name?: string;
+ distribution?: {
+ name?: string;
+ version?: string;
+ summary?: string;
+ author?: string;
+ };
+}
+
+/** Parse a JSON-lines stream, skipping non-JSON noise (log lines, pip banners). */
+export function parseJsonLines(output: string): unknown[] {
+ const parsed: unknown[] = [];
+ for (const line of output.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("{")) continue;
+ try {
+ parsed.push(JSON.parse(trimmed));
+ } catch {
+ // Interleaved runtime logging (e.g. license activation INFO lines) — skip.
+ }
+ }
+ return parsed;
+}
+
+export function parseExtensionEvents(output: string): ExtensionEvent[] {
+ return parseJsonLines(output).filter(
+ (value): value is ExtensionEvent =>
+ !!value && typeof value === "object" && typeof (value as ExtensionEvent).event === "string"
+ );
+}
+
+export function parseInstalledExtensions(output: string): InstalledExtension[] {
+ return parseJsonLines(output).filter(
+ (value): value is InstalledExtension =>
+ !!value &&
+ typeof value === "object" &&
+ // plux metadata objects have name + factory/distribution, no `event` key
+ (value as ExtensionEvent).event === undefined &&
+ typeof (value as InstalledExtension).name === "string"
+ );
+}
+
+export type ExtensionOutcomeKind =
+ | "installed"
+ | "already-installed"
+ | "no-change"
+ | "uninstalled"
+ | "not-installed"
+ | "not-found"
+ | "failed";
+
+export interface ExtensionOutcome {
+ kind: ExtensionOutcomeKind;
+ success: boolean;
+ /** Human-readable log/status/error lines in stream order (pip noise excluded). */
+ summaryLines: string[];
+ errorDetail?: string;
+}
+
+function collectSummaryLines(events: ExtensionEvent[]): string[] {
+ return events
+ .filter((event) => ["log", "status", "error", "exception"].includes(event.event))
+ .map((event) => event.message || "")
+ .filter(Boolean);
+}
+
+export function summarizeInstall(events: ExtensionEvent[]): ExtensionOutcome {
+ const summaryLines = collectSummaryLines(events);
+ const logMessages = events
+ .filter((event) => event.event === "log")
+ .map((event) => event.message || "");
+ const errorEvent = events.find((event) => event.event === "error");
+ const exceptionEvent = events.find((event) => event.event === "exception");
+
+ if (errorEvent?.message?.includes("Could not resolve package")) {
+ return { kind: "not-found", success: false, summaryLines, errorDetail: errorEvent.message };
+ }
+ if (exceptionEvent || errorEvent) {
+ const detail = (exceptionEvent || errorEvent)?.message || "Extension installation failed.";
+ return { kind: "failed", success: false, summaryLines, errorDetail: detail };
+ }
+ if (logMessages.some((message) => message.includes("already installed"))) {
+ return { kind: "already-installed", success: true, summaryLines };
+ }
+ if (logMessages.some((message) => message.includes("Extension successfully installed"))) {
+ return { kind: "installed", success: true, summaryLines };
+ }
+ if (logMessages.some((message) => message.includes("No change"))) {
+ return {
+ kind: "no-change",
+ success: false,
+ summaryLines,
+ errorDetail: "The package was installed but did not register any LocalStack extension.",
+ };
+ }
+ return {
+ kind: "failed",
+ success: false,
+ summaryLines,
+ errorDetail: "The extension manager did not report a successful installation.",
+ };
+}
+
+export function summarizeUninstall(events: ExtensionEvent[]): ExtensionOutcome {
+ const summaryLines = collectSummaryLines(events);
+ const logMessages = events
+ .filter((event) => event.event === "log")
+ .map((event) => event.message || "");
+ const exceptionEvent = events.find(
+ (event) => event.event === "exception" || event.event === "error"
+ );
+
+ if (exceptionEvent) {
+ return {
+ kind: "failed",
+ success: false,
+ summaryLines,
+ errorDetail: exceptionEvent.message || "Extension uninstall failed.",
+ };
+ }
+ if (logMessages.some((message) => message.includes("is not installed"))) {
+ return {
+ kind: "not-installed",
+ success: false,
+ summaryLines,
+ errorDetail: logMessages.find((m) => m.includes("is not installed")),
+ };
+ }
+ if (logMessages.some((message) => message.includes("Extension successfully uninstalled"))) {
+ return { kind: "uninstalled", success: true, summaryLines };
+ }
+ return {
+ kind: "failed",
+ success: false,
+ summaryLines,
+ errorDetail: "The extension manager did not report a successful uninstall.",
+ };
+}
+
+export function formatInstalledExtensions(extensions: InstalledExtension[]): string {
+ if (extensions.length === 0) {
+ return "No LocalStack extensions are currently installed.\n\nUse the `available` action to browse the marketplace.";
+ }
+ let markdown = `## Installed LocalStack Extensions\n\n${extensions.length} extension(s) installed.\n`;
+ for (const extension of extensions) {
+ const dist = extension.distribution || {};
+ markdown += `\n### ${dist.name || extension.name}\n`;
+ const meta = [
+ dist.version ? `**Version:** ${dist.version}` : null,
+ dist.author ? `**Author:** ${dist.author}` : null,
+ ]
+ .filter(Boolean)
+ .join(" | ");
+ if (meta) markdown += `${meta}\n`;
+ if (dist.summary) markdown += `${dist.summary}\n`;
+ }
+ return markdown;
+}
diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts
index ccbc913..8e2996a 100644
--- a/src/lib/localstack/localstack.utils.test.ts
+++ b/src/lib/localstack/localstack.utils.test.ts
@@ -1,57 +1,98 @@
import {
- checkLocalStackCli,
- detectLifecycleCli,
getGatewayHealth,
getLocalStackStatus,
getSnowflakeEmulatorStatus,
- startRuntime,
+ launchRuntime,
+ restartRuntimeInPlace,
} from "./localstack.utils";
-import { runCommand } from "../../core/command-runner";
import { httpClient } from "../../core/http-client";
-import { spawn } from "child_process";
+import { request as httpRequest } from "http";
import { EventEmitter } from "events";
-import { PassThrough } from "stream";
-
-jest.mock("../../core/command-runner", () => ({
- runCommand: jest.fn(),
-}));
jest.mock("../../core/http-client", () => ({
httpClient: { request: jest.fn() },
HttpError: class HttpError extends Error {},
}));
-jest.mock("child_process", () => ({
- spawn: jest.fn(),
+jest.mock("http", () => ({
+ request: jest.fn(),
}));
-const mockedRunCommand = runCommand as jest.MockedFunction;
const mockedRequest = httpClient.request as jest.MockedFunction;
-const mockedSpawn = spawn as jest.MockedFunction;
-
-function mockChildProcess() {
- const child = new EventEmitter() as any;
- child.stdout = new PassThrough();
- child.stderr = new PassThrough();
- mockedSpawn.mockReturnValueOnce(child);
- return child;
+const mockedHttpRequest = httpRequest as jest.MockedFunction;
+
+const gatewayUnreachable = () => mockedRequest.mockRejectedValue(new Error("ECONNREFUSED"));
+
+/** Simulate one node:http request/response round-trip for the Snowflake probe. */
+function mockHttpResponse({ statusCode = 200, body = "" }: { statusCode?: number; body?: string }) {
+ mockedHttpRequest.mockImplementationOnce(((options: any, callback: any) => {
+ const req = new EventEmitter() as any;
+ req.write = jest.fn();
+ req.end = jest.fn(() => {
+ const res = new EventEmitter() as any;
+ res.statusCode = statusCode;
+ callback(res);
+ setImmediate(() => {
+ if (body) res.emit("data", Buffer.from(body));
+ res.emit("end");
+ });
+ });
+ req.destroy = jest.fn((err: Error) => req.emit("error", err));
+ return req;
+ }) as any);
}
-const cliUnavailable = () =>
- mockedRunCommand.mockResolvedValue({
- stdout: "",
- stderr: "command not found: localstack",
- error: new Error("spawn localstack ENOENT"),
- exitCode: null,
- } as any);
+function mockHttpError(message: string) {
+ mockedHttpRequest.mockImplementationOnce(((_options: any, _callback: any) => {
+ const req = new EventEmitter() as any;
+ req.write = jest.fn();
+ req.end = jest.fn(() => {
+ setImmediate(() => req.emit("error", new Error(message)));
+ });
+ req.destroy = jest.fn();
+ return req;
+ }) as any);
+}
-const gatewayUnreachable = () => mockedRequest.mockRejectedValue(new Error("ECONNREFUSED"));
+/** Minimal DockerApiClient stand-in for launchRuntime. */
+function mockDockerClient(overrides: Record = {}) {
+ const logHandle = {
+ getBuffered: jest.fn(() => ""),
+ hasExited: jest.fn(() => false),
+ onExit: jest.fn(),
+ destroy: jest.fn(),
+ };
+ const client = {
+ ping: jest.fn().mockResolvedValue(undefined),
+ findContainerByNameAnyState: jest.fn().mockResolvedValue(null),
+ removeContainer: jest.fn().mockResolvedValue(undefined),
+ waitForRemoval: jest.fn().mockResolvedValue(undefined),
+ ensureNetwork: jest.fn().mockResolvedValue(undefined),
+ imageExists: jest.fn().mockResolvedValue(true),
+ pullImage: jest.fn().mockResolvedValue(undefined),
+ createAndStartContainer: jest.fn().mockResolvedValue("container-123"),
+ attachLogBuffer: jest.fn().mockResolvedValue(logHandle),
+ ...overrides,
+ };
+ return { client: client as any, logHandle };
+}
+
+const launchDefaults = {
+ stack: "aws" as const,
+ processLabel: "LocalStack",
+ alreadyRunningMessage: "already running",
+ successTitle: "🚀 LocalStack started successfully!",
+ statusHeading: "Status",
+ timeoutMessage: "❌ timed out",
+ pollIntervalMs: 10,
+ maxWaitMs: 100,
+};
describe("localstack.utils", () => {
beforeEach(() => {
- mockedRunCommand.mockReset();
mockedRequest.mockReset();
- mockedSpawn.mockReset();
+ mockedHttpRequest.mockReset();
+ process.env.LOCALSTACK_AUTH_TOKEN = "ls-test-token";
});
describe("getGatewayHealth", () => {
@@ -96,224 +137,286 @@ describe("localstack.utils", () => {
});
describe("getLocalStackStatus", () => {
- test("does not mark instance as running from CLI output when the gateway is unreachable", async () => {
+ test("reports not running as informational status (never an error) when the gateway is down", async () => {
+ // The Docker smoke test's pre-start `status` scenario: must come back as plain
+ // status text, not an errorMessage — the management tool renders statusOutput
+ // without a leading ❌, which is exactly what the harness asserts.
gatewayUnreachable();
- mockedRunCommand.mockResolvedValueOnce({
- stdout: "Runtime status: running (Ready)",
- stderr: "",
- exitCode: 0,
- } as any);
const result = await getLocalStackStatus();
expect(result.isRunning).toBe(false);
expect(result.isReady).toBe(false);
- expect(result.statusOutput).toContain("running");
+ expect(result.statusOutput).toMatch(/not running/i);
+ expect(result.statusOutput?.startsWith("❌")).toBe(false);
+ expect(result.errorMessage).toBeUndefined();
});
- test("detects an lstk-managed runtime via the gateway even when the CLI is absent", async () => {
- // The Python `localstack` binary is missing (lstk-only host), so the CLI yields
- // nothing — but the gateway is healthy, so detection must still report running.
- cliUnavailable();
- mockedRequest.mockResolvedValueOnce({
- services: { s3: "available" },
- edition: "pro",
- } as any);
+ test("enriches running status from /_localstack/info", async () => {
+ mockedRequest.mockImplementation(async (endpoint: any) => {
+ if (String(endpoint).includes("/_localstack/health")) {
+ return { services: { s3: "available" }, edition: "pro", version: "4.0.0" } as any;
+ }
+ if (String(endpoint).includes("/_localstack/info")) {
+ return { is_license_activated: true, uptime: 42, session_id: "abc" } as any;
+ }
+ throw new Error(`unexpected endpoint ${endpoint}`);
+ });
const result = await getLocalStackStatus();
expect(result.isRunning).toBe(true);
expect(result.isReady).toBe(true);
expect(result.statusOutput).toContain("/_localstack/health");
+ expect(result.statusOutput).toContain("License activated: yes");
+ expect(result.statusOutput).toContain("Uptime: 42s");
});
- test("can skip CLI enrichment for lifecycle polling", async () => {
- mockedRequest.mockResolvedValueOnce({
- services: { s3: "available" },
- } as any);
+ test("still reports running when /_localstack/info is unavailable", async () => {
+ mockedRequest.mockImplementation(async (endpoint: any) => {
+ if (String(endpoint).includes("/_localstack/health")) {
+ return { services: { s3: "available" } } as any;
+ }
+ throw new Error("info not available");
+ });
- const result = await getLocalStackStatus({ includeCliStatus: false });
+ const result = await getLocalStackStatus();
+ expect(result.isRunning).toBe(true);
+ expect(result.statusOutput).toContain("Services initialized: 1/1");
+ });
+ });
+
+ describe("getSnowflakeEmulatorStatus", () => {
+ test("marks emulator healthy on success payload", async () => {
+ mockHttpResponse({ body: '{"success": true}' });
+
+ const result = await getSnowflakeEmulatorStatus();
expect(result.isRunning).toBe(true);
expect(result.isReady).toBe(true);
- expect(mockedRunCommand).not.toHaveBeenCalled();
+ expect(result.statusOutput).toContain('"success": true');
+ // The probe must send the Snowflake routing Host header (undici fetch drops it).
+ const options = mockedHttpRequest.mock.calls[0][0] as any;
+ expect(options.headers.Host).toMatch(/^snowflake\.localhost\.localstack\.cloud:/);
});
- test("reports not running as informational status (never an error) when neither gateway nor CLI is available", async () => {
- // This is the Docker smoke test's pre-start `status` scenario: the gateway is
- // down and `localstack status` yields nothing usable. It must come back as plain
- // status text, not an errorMessage — the management tool renders statusOutput
- // without a leading ❌, which is exactly what the harness asserts.
- gatewayUnreachable();
- cliUnavailable();
+ test("reports unhealthy response", async () => {
+ mockHttpResponse({ body: '{"success": false}' });
- const result = await getLocalStackStatus();
+ const result = await getSnowflakeEmulatorStatus();
expect(result.isRunning).toBe(false);
expect(result.isReady).toBe(false);
- expect(result.statusOutput).toMatch(/not running/i);
- expect(result.statusOutput?.startsWith("❌")).toBe(false);
- expect(result.errorMessage).toBeUndefined();
});
- test("keeps the CLI 'stopped' output as informational status when it exits non-zero", async () => {
- // `localstack status` exits non-zero when LocalStack isn't running (on Linux),
- // but still prints a "stopped" table. That output must surface as a normal
- // status (no error), not be discarded as "CLI unavailable" — regression guard
- // for the Docker image smoke test's pre-start `status` check.
- gatewayUnreachable();
- mockedRunCommand.mockResolvedValueOnce({
- stdout: "Runtime status: stopped",
- stderr: "",
- error: new Error("Command failed with exit code 1"),
- exitCode: 1,
- } as any);
+ test("reports connection failures without throwing", async () => {
+ mockHttpError("connect ECONNREFUSED");
- const result = await getLocalStackStatus();
+ const result = await getSnowflakeEmulatorStatus();
expect(result.isRunning).toBe(false);
- expect(result.statusOutput).toContain("stopped");
- expect(result.errorMessage).toBeUndefined();
+ expect(result.errorMessage).toContain("ECONNREFUSED");
});
});
- describe("detectLifecycleCli", () => {
- const onlyAvailable = (bin: string) =>
- mockedRunCommand.mockImplementation(async (cmd: string) =>
- cmd === bin
- ? ({ stdout: `${bin} 1.0.0`, stderr: "", exitCode: 0 } as never)
- : ({ stdout: "", stderr: "", exitCode: null, error: new Error("ENOENT") } as never)
- );
-
- test("prefers the localstack CLI when available", async () => {
- onlyAvailable("localstack");
- expect(await detectLifecycleCli()).toBe("localstack");
+ describe("launchRuntime", () => {
+ test("returns the already-running message without touching Docker", async () => {
+ const { client } = mockDockerClient();
+ const result = await launchRuntime({
+ ...launchDefaults,
+ getStatus: jest.fn().mockResolvedValue({ isRunning: true, isReady: true }),
+ dockerClient: client,
+ });
+ expect(result.content[0].text).toBe("already running");
+ expect(client.createAndStartContainer).not.toHaveBeenCalled();
});
- test("falls back to lstk when only lstk is present", async () => {
- onlyAvailable("lstk");
- expect(await detectLifecycleCli()).toBe("lstk");
- });
+ test("starts the container and succeeds once the gateway becomes reachable", async () => {
+ const { client } = mockDockerClient();
+ const getStatus = jest
+ .fn()
+ .mockResolvedValueOnce({ isRunning: false })
+ .mockResolvedValue({ isRunning: true, isReady: true, statusOutput: "gateway reachable" });
+
+ const result = await launchRuntime({
+ ...launchDefaults,
+ getStatus,
+ envVars: { DEBUG: "1" },
+ dockerClient: client,
+ });
- test("uses the requested CLI preference order", async () => {
- mockedRunCommand.mockResolvedValue({
- stdout: "ok",
- stderr: "",
- exitCode: 0,
- } as never);
-
- expect(await detectLifecycleCli(["lstk", "localstack"])).toBe("lstk");
- expect(mockedRunCommand).toHaveBeenCalledWith(
- "lstk",
- ["--version"],
- expect.objectContaining({ timeout: 5000 })
+ const text = result.content[0].text;
+ expect(text).toContain("started successfully");
+ expect(text).toContain(
+ "Custom environment variables passed to the LocalStack container: DEBUG"
);
+ expect(client.createAndStartContainer).toHaveBeenCalledTimes(1);
+ const spec = client.createAndStartContainer.mock.calls[0][0];
+ expect(spec.Image).toBe("localstack/localstack-pro:latest");
+ expect(spec.name).toBe("localstack-main");
});
- test("returns null when neither CLI is installed", async () => {
- mockedRunCommand.mockResolvedValue({
- stdout: "",
- stderr: "",
- exitCode: null,
- error: new Error("ENOENT"),
- } as never);
- expect(await detectLifecycleCli()).toBeNull();
+ test("pulls the image when missing", async () => {
+ const { client } = mockDockerClient({
+ imageExists: jest.fn().mockResolvedValue(false),
+ });
+ const getStatus = jest
+ .fn()
+ .mockResolvedValueOnce({ isRunning: false })
+ .mockResolvedValue({ isRunning: true, isReady: true });
+
+ await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client });
+ expect(client.pullImage).toHaveBeenCalledWith("localstack/localstack-pro:latest");
});
- });
- describe("checkLocalStackCli", () => {
- test("reports unavailable when runCommand resolves with ENOENT", async () => {
- mockedRunCommand.mockResolvedValue({
- stdout: "",
- stderr: "",
- exitCode: null,
- error: new Error("spawn localstack ENOENT"),
- } as never);
-
- const result = await checkLocalStackCli();
- expect(result.isAvailable).toBe(false);
- expect(result.errorMessage).toMatch(/not installed|not available/i);
+ test("reports an actionable conflict when the container name is already running", async () => {
+ const { client } = mockDockerClient({
+ findContainerByNameAnyState: jest.fn().mockResolvedValue({
+ id: "abc",
+ name: "localstack-main",
+ state: "running",
+ running: true,
+ image: "localstack/snowflake:latest",
+ }),
+ });
+ const getStatus = jest.fn().mockResolvedValue({ isRunning: false });
+
+ const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client });
+ const text = result.content[0].text;
+ expect(text).toContain("already running");
+ expect(text).toContain("Snowflake");
+ expect(text).toContain("action: stop");
+ expect(client.createAndStartContainer).not.toHaveBeenCalled();
});
- });
- describe("getSnowflakeEmulatorStatus", () => {
- test("marks emulator healthy on success payload", async () => {
- mockedRunCommand.mockResolvedValueOnce({
- stdout: '{"success": true}',
- stderr: "",
- exitCode: 0,
- } as any);
+ test("removes a stale stopped container and proceeds", async () => {
+ const { client } = mockDockerClient({
+ findContainerByNameAnyState: jest.fn().mockResolvedValue({
+ id: "stale-1",
+ name: "localstack-main",
+ state: "exited",
+ running: false,
+ }),
+ });
+ const getStatus = jest
+ .fn()
+ .mockResolvedValueOnce({ isRunning: false })
+ .mockResolvedValue({ isRunning: true, isReady: true });
- const result = await getSnowflakeEmulatorStatus();
- expect(result.isRunning).toBe(true);
- expect(result.isReady).toBe(true);
- expect(result.statusOutput).toContain('"success": true');
+ const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client });
+ expect(client.removeContainer).toHaveBeenCalledWith("stale-1");
+ expect(client.waitForRemoval).toHaveBeenCalledWith("stale-1");
+ expect(result.content[0].text).toContain("started successfully");
});
- test("reports unhealthy response", async () => {
- mockedRunCommand.mockResolvedValueOnce({
- stdout: '{"success": false}',
- stderr: "",
- exitCode: 0,
- } as any);
+ test("fails with the buffered log tail when the container exits during startup", async () => {
+ let exitCallback: (() => void) | undefined;
+ const logHandle = {
+ getBuffered: jest.fn(() => "boot line\nLicense activation failed\n"),
+ hasExited: jest.fn(() => true),
+ onExit: jest.fn((cb: () => void) => {
+ exitCallback = cb;
+ }),
+ destroy: jest.fn(),
+ };
+ const { client } = mockDockerClient({
+ attachLogBuffer: jest.fn().mockResolvedValue(logHandle),
+ });
+ const getStatus = jest.fn().mockResolvedValue({ isRunning: false });
- const result = await getSnowflakeEmulatorStatus();
- expect(result.isRunning).toBe(false);
- expect(result.isReady).toBe(false);
+ const resultPromise = launchRuntime({ ...launchDefaults, getStatus, dockerClient: client });
+ // wait for the launch flow to attach the buffer, then simulate container exit
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ exitCallback?.();
+
+ const text = (await resultPromise).content[0].text;
+ expect(text).toContain("exited unexpectedly");
+ expect(text).toContain("License activation failed");
+ expect(logHandle.destroy).toHaveBeenCalled();
});
- });
- describe("startRuntime", () => {
- test("reports lstk zero-exit failures immediately with stdout when readiness is absent", async () => {
- const child = mockChildProcess();
+ test("times out with the timeout message when readiness never arrives", async () => {
+ const { client } = mockDockerClient();
+ const getStatus = jest.fn().mockResolvedValue({ isRunning: false });
+
+ const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client });
+ expect(result.content[0].text).toContain("timed out");
+ });
+
+ test("runs the onReady gate before reporting success", async () => {
+ const { client } = mockDockerClient();
const getStatus = jest
.fn()
- .mockResolvedValueOnce({ isRunning: false, isReady: false })
- .mockResolvedValueOnce({ isRunning: false, isReady: false });
+ .mockResolvedValueOnce({ isRunning: false })
+ .mockResolvedValue({ isRunning: true, isReady: true });
+ const onReady = jest.fn().mockResolvedValue({
+ content: [{ type: "text", text: "❌ **Feature Not Available**" }],
+ });
- const resultPromise = startRuntime({
- cli: "lstk",
- startArgs: ["start", "--non-interactive"],
+ const result = await launchRuntime({
+ ...launchDefaults,
getStatus,
- processLabel: "LocalStack",
- alreadyRunningMessage: "already running",
- successTitle: "started",
- statusHeading: "Status",
- timeoutMessage: "timed out",
+ onReady,
+ dockerClient: client,
});
+ expect(onReady).toHaveBeenCalled();
+ expect(result.content[0].text).toContain("Feature Not Available");
+ });
- await Promise.resolve();
- child.stdout.write("Error: Port 4566 already in use\n");
- child.emit("close", 0);
+ test("fails cleanly when the auth token is missing", async () => {
+ delete process.env.LOCALSTACK_AUTH_TOKEN;
+ const { client } = mockDockerClient();
+ const result = await launchRuntime({
+ ...launchDefaults,
+ getStatus: jest.fn().mockResolvedValue({ isRunning: false }),
+ dockerClient: client,
+ });
+ expect(result.content[0].text).toContain("LOCALSTACK_AUTH_TOKEN");
+ });
+ });
- const text = (await resultPromise).content[0].text;
- expect(text).toMatch(/exited before it became ready/i);
- expect(text).toContain("Port 4566 already in use");
+ describe("restartRuntimeInPlace", () => {
+ test("waits for the session transition before reporting ready", async () => {
+ // Old process stays "ready" for a moment after the restart POST; the helper
+ // must not report success until the session changes (uptime reset).
+ let infoCalls = 0;
+ mockedRequest.mockImplementation(async (endpoint: any, options: any) => {
+ const url = String(endpoint);
+ if (url.includes("/_localstack/info")) {
+ infoCalls += 1;
+ return infoCalls <= 2
+ ? ({ session_id: "old", uptime: 500 } as any)
+ : ({ session_id: "new", uptime: 3 } as any);
+ }
+ if (url.includes("/_localstack/health") && options?.method === "POST") return {} as any;
+ return { services: { s3: "available" } } as any;
+ });
+
+ const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 500 });
+ expect(result.ok).toBe(true);
+ expect(infoCalls).toBeGreaterThanOrEqual(3);
});
- test("treats lstk zero-exit as success when readiness is already available", async () => {
- const child = mockChildProcess();
- const getStatus = jest
- .fn()
- .mockResolvedValueOnce({ isRunning: false, isReady: false })
- .mockResolvedValueOnce({
- isRunning: true,
- isReady: true,
- statusOutput: "LocalStack gateway is reachable",
- });
-
- const resultPromise = startRuntime({
- cli: "lstk",
- startArgs: ["start", "--non-interactive"],
- getStatus,
- processLabel: "LocalStack",
- alreadyRunningMessage: "already running",
- successTitle: "started",
- statusHeading: "Status",
- timeoutMessage: "timed out",
+ test("treats a gateway-down window as the restart transition", async () => {
+ let healthGets = 0;
+ mockedRequest.mockImplementation(async (endpoint: any, options: any) => {
+ const url = String(endpoint);
+ if (url.includes("/_localstack/info")) return { session_id: "old", uptime: 500 } as any;
+ if (url.includes("/_localstack/health") && options?.method === "POST") return {} as any;
+ healthGets += 1;
+ if (healthGets <= 2) throw new Error("ECONNREFUSED");
+ return { services: { s3: "available" } } as any;
});
- await Promise.resolve();
- child.emit("close", 0);
+ const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 500 });
+ expect(result.ok).toBe(true);
+ });
+
+ test("reports failure when the restart request itself fails", async () => {
+ mockedRequest.mockImplementation(async (endpoint: any, options: any) => {
+ if (String(endpoint).includes("/_localstack/info")) return { session_id: "old" } as any;
+ if (options?.method === "POST") throw new Error("connection refused");
+ return { services: {} } as any;
+ });
- const text = (await resultPromise).content[0].text;
- expect(text).toContain("started");
- expect(text).toContain("LocalStack gateway is reachable");
+ const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 100 });
+ expect(result.ok).toBe(false);
+ expect(result.detail).toContain("Restart request failed");
});
});
});
diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts
index e73fee5..5fba420 100644
--- a/src/lib/localstack/localstack.utils.ts
+++ b/src/lib/localstack/localstack.utils.ts
@@ -1,14 +1,23 @@
-import { spawn } from "child_process";
+import { existsSync } from "fs";
+import { mkdir } from "fs/promises";
+import { homedir } from "os";
+import { request as httpRequest } from "http";
import { LOCALSTACK_BASE_URL, LOCALSTACK_HOSTNAME, LOCALSTACK_PORT } from "../../core/config";
import { runCommand } from "../../core/command-runner";
import { httpClient } from "../../core/http-client";
import { ResponseBuilder } from "../../core/response-builder";
-
-export interface LocalStackCliCheckResult {
- isAvailable: boolean;
- version?: string;
- errorMessage?: string;
-}
+import {
+ DockerApiClient,
+ LocalStackContainerConflictError,
+ type LogBufferHandle,
+} from "../docker/docker.client";
+import {
+ buildLocalStackContainerSpec,
+ resolveContainerName,
+ resolveVolume,
+ type LocalStackStack,
+ type VolumeResolution,
+} from "./container-spec.logic";
export interface SnowflakeCliCheckResult {
isAvailable: boolean;
@@ -16,85 +25,21 @@ export interface SnowflakeCliCheckResult {
errorMessage?: string;
}
-const CLI_CHECK_TIMEOUT = 5000;
const SNOWFLAKE_CLI_CHECK_TIMEOUT = 30000;
-function cliSpawnOptions(timeoutMs: number = CLI_CHECK_TIMEOUT) {
- return {
- timeout: timeoutMs,
- shell: process.platform === "win32",
- };
+/** Version baked in at build time; used to tag containers we launch. */
+let SERVER_VERSION = "unknown";
+try {
+ // Statically inlined by the bundler.
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ SERVER_VERSION = require("../../../package.json").version || "unknown";
+} catch {
+ // keep "unknown"
}
-function localStackCliUnavailable(details?: string): LocalStackCliCheckResult {
- const suffix = details?.trim() ? `\n\nDetails: ${details.trim()}` : "";
- return {
- isAvailable: false,
- errorMessage: `❌ LocalStack CLI is not installed or not available in PATH.
-
-Please install LocalStack by following the official documentation:
-https://docs.localstack.cloud/aws/getting-started/installation/
-
-Installation options:
-- Using pip: pip install localstack
-- Using Docker: Use the LocalStack Docker image
-- Using Homebrew (macOS): brew install localstack/tap/localstack-cli
-
-After installation, make sure the 'localstack' command is available in your PATH.${suffix}`,
- };
-}
-
-/**
- * Check if LocalStack CLI is installed and available in the system PATH
- * @returns Promise with availability status, version (if available), and error message (if not available)
- */
-export async function checkLocalStackCli(): Promise {
- try {
- const help = await runCommand("localstack", ["--help"], cliSpawnOptions());
- if (help.error || help.exitCode !== 0) {
- return localStackCliUnavailable(help.error?.message || help.stderr);
- }
-
- const versionResult = await runCommand("localstack", ["--version"], cliSpawnOptions());
- if (versionResult.error || versionResult.exitCode !== 0) {
- return localStackCliUnavailable(versionResult.error?.message || versionResult.stderr);
- }
-
- return {
- isAvailable: true,
- version: versionResult.stdout.trim(),
- };
- } catch (error) {
- return localStackCliUnavailable(error instanceof Error ? error.message : String(error));
- }
-}
-
-export type LifecycleCli = "localstack" | "lstk";
-
-/**
- * Whether a CLI is usable. `runCommand` resolves (rather than throws) on a missing
- * binary, so we inspect the actual exit code / error — this returns false when the
- * binary isn't on PATH.
- */
-async function cliAvailable(bin: string): Promise {
- const { error, exitCode } = await runCommand(bin, ["--version"], cliSpawnOptions());
- return !error && exitCode === 0;
-}
-
-/**
- * Pick a CLI capable of starting LocalStack. Prefers the Python `localstack` CLI for
- * backward compatibility, falling back to `lstk` (the newer Go CLI, which also forwards
- * `LOCALSTACK_*` env). Returns null when neither is installed: a running container can
- * still be detected and driven via the gateway + Docker API, but *creating* one needs a
- * CLI.
- */
-export async function detectLifecycleCli(
- preferredOrder: LifecycleCli[] = ["localstack", "lstk"]
-): Promise {
- for (const cli of preferredOrder) {
- if (await cliAvailable(cli)) return cli;
- }
- return null;
+/** Whether the MCP server itself runs inside a container (changes bind IP + volume default). */
+export function isRunningInDocker(): boolean {
+ return existsSync("/.dockerenv");
}
/**
@@ -103,11 +48,10 @@ export async function detectLifecycleCli(
*/
export async function checkSnowflakeCli(): Promise {
try {
- const { stdout, error, exitCode, stderr } = await runCommand(
- "snow",
- ["--version"],
- cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)
- );
+ const { stdout, error, exitCode, stderr } = await runCommand("snow", ["--version"], {
+ timeout: SNOWFLAKE_CLI_CHECK_TIMEOUT,
+ shell: process.platform === "win32",
+ });
if (error || exitCode !== 0) {
throw error || new Error(stderr || `snow --version exited with code ${exitCode}`);
}
@@ -154,6 +98,14 @@ export interface GatewayHealth {
version?: string;
}
+export interface SessionInfo {
+ version?: string;
+ edition?: string;
+ is_license_activated?: boolean;
+ session_id?: string;
+ uptime?: number;
+}
+
export interface SnowflakeStatusResult {
isRunning: boolean;
statusOutput?: string;
@@ -168,14 +120,7 @@ export interface RuntimeStatus {
}
const SNOWFLAKE_ROUTING_HOST = "snowflake.localhost.localstack.cloud";
-const CLIENT_ONLY_ENV_KEYS = [
- "HOSTNAME",
- "LOCALSTACK_HOSTNAME",
- "AWS_ENDPOINT_URL",
- "AWS_ENDPOINT_URL_S3",
- "S3_ENDPOINT",
- "AWS_S3_FORCE_PATH_STYLE",
-];
+const SNOWFLAKE_PROBE_TIMEOUT = 10000;
function getLocalStackEndpointHost() {
return process.env.LOCALSTACK_HOSTNAME?.trim() || LOCALSTACK_HOSTNAME;
@@ -186,7 +131,7 @@ function getLocalStackEndpointPort() {
}
const GATEWAY_HEALTH_TIMEOUT = 3000;
-const CLI_STATUS_TIMEOUT = 5000;
+const SESSION_INFO_TIMEOUT = 3000;
const READY_SERVICE_STATES = new Set(["available", "running"]);
/**
@@ -194,12 +139,10 @@ const READY_SERVICE_STATES = new Set(["available", "running"]);
*
* Probes the LocalStack gateway health endpoint (`/_localstack/health`) directly over
* HTTP. Any container exposing the gateway on :4566 answers this — regardless of who
- * started it (`localstack` CLI, `lstk`, docker-compose, raw `docker run`), what the
- * container is named, or whether a host-side CLI is installed.
+ * started it (this server, `lstk`, docker-compose, raw `docker run`) or what the
+ * container is named.
*
- * This is the source of truth for "is LocalStack running?". A name + CLI check misses
- * runtimes started by `lstk` (container `localstack-aws`) or any external tool, even
- * though their gateway is healthy and reachable.
+ * This is the source of truth for "is LocalStack running?".
*/
export async function getGatewayHealth(): Promise {
try {
@@ -231,12 +174,28 @@ export async function getGatewayHealth(): Promise {
}
}
-function describeGatewayHealth(health: GatewayHealth): string {
+/** Best-effort read of `/_localstack/info` for status enrichment and restart detection. */
+export async function getSessionInfo(): Promise {
+ try {
+ const info = await httpClient.request("/_localstack/info", {
+ method: "GET",
+ timeout: SESSION_INFO_TIMEOUT,
+ });
+ if (!info || typeof info !== "object") return null;
+ return info;
+ } catch {
+ return null;
+ }
+}
+
+function describeGatewayHealth(health: GatewayHealth, info?: SessionInfo | null): string {
const lines = [
`LocalStack gateway is reachable at ${LOCALSTACK_BASE_URL} (detected via /_localstack/health).`,
];
- if (health.edition) lines.push(`Edition: ${health.edition}`);
- if (health.version) lines.push(`Version: ${health.version}`);
+ const edition = info?.edition || health.edition;
+ const version = info?.version || health.version;
+ if (edition) lines.push(`Edition: ${edition}`);
+ if (version) lines.push(`Version: ${version}`);
if (health.services) {
const total = Object.keys(health.services).length;
const initialized = Object.values(health.services).filter((state) =>
@@ -244,211 +203,295 @@ function describeGatewayHealth(health: GatewayHealth): string {
).length;
lines.push(`Services initialized: ${initialized}/${total}`);
}
+ if (info?.is_license_activated !== undefined) {
+ lines.push(`License activated: ${info.is_license_activated ? "yes" : "no"}`);
+ }
+ if (typeof info?.uptime === "number") {
+ lines.push(`Uptime: ${info.uptime}s`);
+ }
return lines.join("\n");
}
-interface CliStatus {
- output?: string;
- running: boolean;
- ready: boolean;
-}
-
/**
- * Best-effort read of `localstack status` for human-readable output only.
- *
- * Crucially, a non-zero exit is NOT treated as "CLI unavailable": `localstack status`
- * exits non-zero when LocalStack isn't running (the exit code even differs by host —
- * 0 on Windows, non-zero on Linux) yet still prints a useful "stopped" table to
- * stdout. We use whatever stdout it produced and only report "unavailable" when there
- * is genuinely nothing to show.
+ * Get LocalStack status information. Running state is decided by the gateway probe;
+ * display detail is enriched from `/_localstack/info` when available.
*/
-async function tryCliStatus(timeoutMs = CLI_STATUS_TIMEOUT): Promise {
- const unavailable: CliStatus = { running: false, ready: false };
- try {
- const result = await runCommand("localstack", ["status"], { timeout: timeoutMs });
- if (!result.stdout?.trim()) return unavailable;
+export async function getLocalStackStatus(): Promise {
+ const health = await getGatewayHealth();
- const stdout = result.stdout;
+ if (!health.reachable) {
return {
- output: stdout.trim(),
- running: stdout.includes("running"),
- ready: stdout.includes("Ready") || stdout.includes("ready"),
+ isRunning: false,
+ isReady: false,
+ statusOutput: `LocalStack is not running — the gateway at ${LOCALSTACK_BASE_URL} is not reachable.`,
};
- } catch {
- return unavailable;
- }
-}
-
-interface LocalStackStatusOptions {
- includeCliStatus?: boolean;
-}
-
-/**
- * Get LocalStack status information.
- *
- * Running state is decided by the gateway probe. The Python CLI's `status`
- * output is layered on as display-only detail when requested.
- *
- * @returns Promise with status details including running state and raw output
- */
-export async function getLocalStackStatus({
- includeCliStatus = true,
-}: LocalStackStatusOptions = {}): Promise {
- const [health, cli] = await Promise.all([
- getGatewayHealth(),
- includeCliStatus ? tryCliStatus() : Promise.resolve(undefined),
- ]);
-
- const isRunning = health.reachable;
- const isReady = health.ready;
-
- if (!isRunning) {
- const statusOutput =
- cli?.output ||
- `LocalStack is not running — the gateway at ${LOCALSTACK_BASE_URL} is not reachable.`;
- return { isRunning: false, isReady: false, statusOutput };
}
+ const info = await getSessionInfo();
return {
- isRunning,
- isReady,
- statusOutput: cli?.running && cli.output ? cli.output : describeGatewayHealth(health),
+ isRunning: true,
+ isReady: health.ready,
+ statusOutput: describeGatewayHealth(health, info),
};
}
/**
- * Get Snowflake emulator status information by checking the Snowflake session endpoint
- * @returns Promise with status details including running state and raw output
+ * Get Snowflake emulator status by POSTing to its session endpoint. Uses node:http
+ * directly because the probe needs an explicit Host header
+ * (`snowflake.localhost.localstack.cloud`) — fetch/undici silently drops Host as a
+ * forbidden header.
*/
export async function getSnowflakeEmulatorStatus(): Promise {
+ const host = getLocalStackEndpointHost();
+ const port = Number(getLocalStackEndpointPort());
+ const body = "{}";
+
try {
- const host = getLocalStackEndpointHost();
- const port = getLocalStackEndpointPort();
- const { stdout, stderr, error, exitCode } = await runCommand("curl", [
- "-sS",
- "-X",
- "POST",
- "-H",
- "Content-Type: application/json",
- "-H",
- `Host: ${SNOWFLAKE_ROUTING_HOST}:${port}`,
- "-d",
- "{}",
- `http://${host}:${port}/session`,
- ]);
-
- const output = (stdout || "").trim();
+ const response = await new Promise<{ statusCode: number; body: string }>((resolve, reject) => {
+ const req = httpRequest(
+ {
+ host,
+ port,
+ path: "/session",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Host: `${SNOWFLAKE_ROUTING_HOST}:${port}`,
+ "Content-Length": Buffer.byteLength(body),
+ },
+ timeout: SNOWFLAKE_PROBE_TIMEOUT,
+ },
+ (res) => {
+ let data = "";
+ res.on("data", (chunk) => {
+ data += chunk.toString();
+ });
+ res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data }));
+ }
+ );
+ req.on("timeout", () => req.destroy(new Error("Snowflake health probe timed out")));
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+
+ const output = response.body.trim();
const isSuccess = /"success"\s*:\s*true/.test(output);
return {
- isRunning: exitCode === 0 && isSuccess,
- isReady: exitCode === 0 && isSuccess,
- statusOutput: output || stderr.trim(),
- ...(error
- ? {
- errorMessage: `Failed to reach Snowflake emulator endpoint: ${error.message}`,
- }
- : {}),
+ isRunning: isSuccess,
+ isReady: isSuccess,
+ statusOutput: output,
};
} catch (error) {
return {
isRunning: false,
- errorMessage: `Failed to get Snowflake emulator status: ${
+ isReady: false,
+ errorMessage: `Failed to reach Snowflake emulator endpoint: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
}
-/**
- * Start a LocalStack runtime flavor and poll until it becomes available.
- * Supports custom startup args (e.g. default stack vs Snowflake stack), optional
- * environment overrides, and optional post-start validation hooks.
- */
-export async function startRuntime({
- cli = "localstack",
- startArgs,
- getStatus,
- processLabel,
- alreadyRunningMessage,
- successTitle,
- statusHeading,
- timeoutMessage,
- envVars,
- onReady,
-}: {
- /** Lifecycle CLI binary to spawn — `localstack` (default) or `lstk`. */
- cli?: LifecycleCli;
- startArgs: string[];
+export interface LaunchRuntimeOptions {
+ stack: LocalStackStack;
+ envVars?: Record;
getStatus: () => Promise;
processLabel: string;
alreadyRunningMessage: string;
successTitle: string;
statusHeading: string;
timeoutMessage: string;
- envVars?: Record;
onReady?: () => Promise | null>;
-}): Promise> {
+ /** Restart-in-place recreation: pin the original container's identity. */
+ imageOverride?: string;
+ containerNameOverride?: string;
+ volumeOverride?: VolumeResolution;
+ /** Injectable for tests. */
+ dockerClient?: DockerApiClient;
+ pollIntervalMs?: number;
+ maxWaitMs?: number;
+}
+
+const POLL_INTERVAL_MS = 5000;
+const MAX_WAIT_MS = 120000;
+const CRASH_LOG_TAIL_LINES = 50;
+
+function tailLines(text: string, lines: number): string {
+ const allLines = text.split(/\r?\n/).filter((line) => line.trim());
+ return allLines.slice(-lines).join("\n");
+}
+
+function conflictResponse(processLabel: string, containerName: string, image?: string) {
+ const otherStack = image?.includes("/snowflake")
+ ? "the Snowflake emulator"
+ : "another LocalStack stack (likely the AWS emulator)";
+ return ResponseBuilder.error(
+ "LocalStack container already running",
+ `Starting ${processLabel} failed because a LocalStack container named "${containerName}"${
+ image ? ` (image: ${image})` : ""
+ } is already running — it belongs to ${otherStack}. ` +
+ `Stop it first with the localstack-management tool (action: stop), then retry this start.`
+ );
+}
+
+/**
+ * Start a LocalStack runtime flavor directly through the Docker Engine API (no
+ * localstack/lstk CLI involved) and poll until it becomes available.
+ */
+export async function launchRuntime(
+ options: LaunchRuntimeOptions
+): Promise> {
+ const {
+ stack,
+ envVars,
+ getStatus,
+ processLabel,
+ alreadyRunningMessage,
+ successTitle,
+ statusHeading,
+ timeoutMessage,
+ onReady,
+ } = options;
+
const statusCheck = await getStatus();
if (statusCheck.isReady || statusCheck.isRunning) {
return ResponseBuilder.markdown(alreadyRunningMessage);
}
- const environment = { ...process.env } as Record;
- for (const key of CLIENT_ONLY_ENV_KEYS) {
- delete environment[key];
- }
- Object.assign(environment, envVars || {});
- if (process.env.LOCALSTACK_AUTH_TOKEN) {
- environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN;
+ const authToken = process.env.LOCALSTACK_AUTH_TOKEN?.trim();
+ if (!authToken) {
+ return ResponseBuilder.error(
+ "Auth Token Required",
+ "LOCALSTACK_AUTH_TOKEN is required to start LocalStack."
+ );
}
- // Force UTF-8 for the spawned Python `localstack` CLI so its emoji output doesn't
- // throw UnicodeEncodeError under the Windows cp1252 code page.
- if (cli === "localstack") {
- if (!environment.PYTHONIOENCODING) environment.PYTHONIOENCODING = "utf-8";
- if (!environment.PYTHONUTF8) environment.PYTHONUTF8 = "1";
+
+ const docker = options.dockerClient ?? new DockerApiClient();
+ try {
+ await docker.ping();
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Docker Not Available",
+ error instanceof Error ? error.message : String(error)
+ );
}
- return new Promise((resolve) => {
- const child = spawn(cli, startArgs, {
- env: environment,
- stdio: ["ignore", "pipe", "pipe"],
- shell: process.platform === "win32",
+ const inDocker = isRunningInDocker();
+ const volume =
+ options.volumeOverride ??
+ resolveVolume({
+ hostEnv: process.env,
+ isInDocker: inDocker,
+ platform: process.platform,
+ homedir: homedir(),
});
+ if (volume.type === "bind" && !inDocker) {
+ try {
+ await mkdir(volume.source, { recursive: true });
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Volume Directory Error",
+ `Could not create the LocalStack volume directory at ${volume.source}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+ }
- let stdout = "";
- let stderr = "";
- child.stdout?.on("data", (data) => {
- stdout += data.toString();
- });
- child.stderr?.on("data", (data) => {
- stderr += data.toString();
- });
+ const spec = buildLocalStackContainerSpec({
+ stack,
+ envVars,
+ hostEnv: process.env,
+ authToken,
+ isInDocker: inDocker,
+ volume,
+ serverVersion: SERVER_VERSION,
+ imageOverride: options.imageOverride,
+ containerNameOverride: options.containerNameOverride,
+ });
- let earlyExit = false;
+ // Fail fast (or clean up) when the container name is taken. A running container is
+ // an actionable conflict; a stopped one is a stale leftover we remove ourselves.
+ try {
+ const existing = await docker.findContainerByNameAnyState(spec.name);
+ if (existing) {
+ if (existing.running) {
+ return conflictResponse(processLabel, spec.name, existing.image);
+ }
+ await docker.removeContainer(existing.id);
+ await docker.waitForRemoval(existing.id);
+ }
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Docker lookup failed",
+ `Could not check for existing LocalStack containers: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+
+ try {
+ if (spec.HostConfig.NetworkMode) {
+ await docker.ensureNetwork(spec.HostConfig.NetworkMode);
+ }
+ if (!(await docker.imageExists(spec.Image))) {
+ await docker.pullImage(spec.Image);
+ }
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Failed to prepare LocalStack start",
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+
+ let containerId: string;
+ try {
+ containerId = await docker.createAndStartContainer(spec);
+ } catch (error) {
+ if (error instanceof LocalStackContainerConflictError) {
+ return conflictResponse(processLabel, spec.name);
+ }
+ return ResponseBuilder.error(
+ `Failed to start ${processLabel}`,
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+
+ let logBuffer: LogBufferHandle | undefined;
+ try {
+ logBuffer = await docker.attachLogBuffer(containerId);
+ } catch {
+ // Diagnostics-only: readiness polling still works without the log stream.
+ }
+
+ return new Promise((resolve) => {
let poll: NodeJS.Timeout;
let resolved = false;
const finish = (response: ReturnType) => {
if (resolved) return;
resolved = true;
if (poll) clearInterval(poll);
+ logBuffer?.destroy();
resolve(response);
};
+
const failureDetails = () => {
- const details: string[] = [];
- const trimmedStdout = stdout.trim();
- const trimmedStderr = stderr.trim();
- if (trimmedStdout) details.push(`Stdout:\n${trimmedStdout}`);
- if (trimmedStderr) details.push(`Stderr:\n${trimmedStderr}`);
- return details.length ? `\n\n${details.join("\n\n")}` : "";
+ const buffered = logBuffer ? tailLines(logBuffer.getBuffered(), CRASH_LOG_TAIL_LINES) : "";
+ return buffered
+ ? `\n\nContainer logs (last ${CRASH_LOG_TAIL_LINES} lines):\n${buffered}`
+ : "";
};
+
const successResponse = (status: RuntimeStatus) => {
let resultMessage = `${successTitle}\n\n`;
if (envVars)
- resultMessage += `✅ Custom environment variables passed to lifecycle CLI: ${Object.keys(envVars).join(", ")}\n`;
+ resultMessage += `✅ Custom environment variables passed to the LocalStack container: ${Object.keys(envVars).join(", ")}\n`;
if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`;
return ResponseBuilder.markdown(resultMessage);
};
+
const finishIfReady = async (): Promise => {
const status = await getStatus();
if (!(status.isReady || status.isRunning)) return false;
@@ -465,58 +508,27 @@ export async function startRuntime({
return true;
};
- child.on("error", (err) => {
- earlyExit = true;
+ // The follow-stream ending means the container exited (AutoRemove removes it
+ // immediately, so this buffered tail is the only surviving diagnostic).
+ logBuffer?.onExit(async () => {
+ // The runtime may have become ready in the same instant (unlikely but cheap to check).
+ try {
+ if (await finishIfReady()) return;
+ } catch {
+ // fall through to the failure response
+ }
finish(
- ResponseBuilder.markdown(`❌ Failed to start ${processLabel} process: ${err.message}`)
+ ResponseBuilder.markdown(
+ `❌ ${processLabel} container exited unexpectedly before becoming ready.${failureDetails()}`
+ )
);
});
- child.on("close", async (code) => {
- if (earlyExit) return;
- // A non-zero exit is a real failure: stop polling and report it.
- // A zero exit is expected in non-interactive environments (e.g. inside a
- // container, where `localstack start` launches the runtime and returns
- // instead of staying attached to stream logs). In that case we must keep
- // polling so readiness is still detected and the promise resolves — clearing
- // the interval here would leave the start call hanging forever.
- if (code !== 0) {
- finish(
- ResponseBuilder.markdown(
- `❌ ${processLabel} process exited unexpectedly with code ${code}.${failureDetails()}`
- )
- );
- return;
- }
-
- if (cli === "lstk") {
- try {
- if (await finishIfReady()) return;
- } catch (error) {
- finish(
- ResponseBuilder.markdown(
- `❌ ${processLabel} process exited before it became ready. Failed to verify status: ${
- error instanceof Error ? error.message : String(error)
- }${failureDetails()}`
- )
- );
- return;
- }
-
- finish(
- ResponseBuilder.markdown(
- `❌ ${processLabel} process exited before it became ready.${failureDetails()}`
- )
- );
- }
- });
-
- const pollInterval = 5000;
- const maxWaitTime = 120000;
+ const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
+ const maxWaitMs = options.maxWaitMs ?? MAX_WAIT_MS;
let timeWaited = 0;
-
poll = setInterval(async () => {
- timeWaited += pollInterval;
+ timeWaited += pollIntervalMs;
try {
if (await finishIfReady()) return;
} catch (error) {
@@ -530,28 +542,79 @@ export async function startRuntime({
return;
}
- if (timeWaited >= maxWaitTime) {
+ if (timeWaited >= maxWaitMs) {
const details = failureDetails();
finish(ResponseBuilder.markdown(details ? `${timeoutMessage}${details}` : timeoutMessage));
}
- }, pollInterval);
+ }, pollIntervalMs);
});
}
+export interface InPlaceRestartResult {
+ ok: boolean;
+ detail: string;
+}
+
/**
- * Validate LocalStack CLI availability and return early if not available
- * This is a helper function for tools that require LocalStack CLI
+ * Restart the LocalStack runtime in place (`POST /_localstack/health {"action":
+ * "restart"}`) and wait for the NEW process to become ready. The POST returns
+ * immediately while the old process keeps answering health checks for a moment, so
+ * readiness is detected via the session transition (session_id change / uptime
+ * reset), not via the first successful health poll.
*/
-export async function ensureLocalStackCli() {
- const cliCheck = await checkLocalStackCli();
+export async function restartRuntimeInPlace({
+ pollIntervalMs = 2000,
+ maxWaitMs = 120000,
+}: { pollIntervalMs?: number; maxWaitMs?: number } = {}): Promise {
+ const before = await getSessionInfo();
- if (!cliCheck.isAvailable) {
+ try {
+ await httpClient.request("/_localstack/health", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "restart" }),
+ });
+ } catch (error) {
return {
- content: [{ type: "text", text: cliCheck.errorMessage! }],
+ ok: false,
+ detail: `Restart request failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
- return null; // CLI is available, continue with tool execution
+ let sawTransition = false;
+ const deadline = Date.now() + maxWaitMs;
+ while (Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
+
+ const health = await getGatewayHealth();
+ if (!health.reachable) {
+ // Gateway went down — the restart is in progress.
+ sawTransition = true;
+ continue;
+ }
+
+ if (!sawTransition && before) {
+ const info = await getSessionInfo();
+ if (info) {
+ const sessionChanged =
+ (info.session_id && before.session_id && info.session_id !== before.session_id) ||
+ (typeof info.uptime === "number" &&
+ typeof before.uptime === "number" &&
+ info.uptime < before.uptime);
+ if (sessionChanged) sawTransition = true;
+ }
+ }
+
+ const restarted = sawTransition || !before;
+ if (restarted && health.ready) {
+ return { ok: true, detail: "LocalStack runtime restarted and is ready." };
+ }
+ }
+
+ return {
+ ok: false,
+ detail: `LocalStack did not report ready within ${Math.round(maxWaitMs / 1000)}s after the restart request. It may still be restarting in the background.`,
+ };
}
/**
@@ -569,3 +632,5 @@ export async function ensureSnowflakeCli() {
return null; // CLI is available, continue with tool execution
}
+
+export { resolveContainerName };
diff --git a/src/lib/localstack/platform.client.test.ts b/src/lib/localstack/platform.client.test.ts
new file mode 100644
index 0000000..c647aed
--- /dev/null
+++ b/src/lib/localstack/platform.client.test.ts
@@ -0,0 +1,82 @@
+import { PlatformApiClient, describePlatformError } from "./platform.client";
+import { HttpError } from "../../core/http-client";
+
+const fetchMock = jest.fn();
+global.fetch = fetchMock as any;
+
+function jsonResponse(body: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: "OK",
+ url: "https://api.localstack.cloud/v1/test",
+ headers: new Map([["content-type", "application/json"]]) as any,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+}
+
+describe("PlatformApiClient", () => {
+ beforeEach(() => fetchMock.mockReset());
+
+ test("sends Basic auth with empty username and the token as password", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
+ const client = new PlatformApiClient("ls-test-token");
+ await client.listEphemeralInstances();
+
+ const [url, options] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://api.localstack.cloud/v1/compute/instances");
+ const expected = `Basic ${Buffer.from(":ls-test-token").toString("base64")}`;
+ expect(options.headers.Authorization).toBe(expected);
+ });
+
+ test("create posts instance_name/lifetime/env_vars in the CLI wire format", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ instance_name: "demo", status: "creating" }));
+ const client = new PlatformApiClient("ls-test-token");
+ await client.createEphemeralInstance({
+ name: "demo",
+ lifetime: 30,
+ envVars: { EXTENSION_AUTO_INSTALL: "localstack-extension-httpbin" },
+ });
+
+ const [, options] = fetchMock.mock.calls[0];
+ expect(options.method).toBe("POST");
+ expect(JSON.parse(options.body)).toEqual({
+ instance_name: "demo",
+ lifetime: 30,
+ env_vars: { EXTENSION_AUTO_INSTALL: "localstack-extension-httpbin" },
+ });
+ });
+
+ test("list tolerates both bare-array and wrapped responses", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ instances: [{ instance_name: "a" }] }));
+ const client = new PlatformApiClient("t");
+ expect(await client.listEphemeralInstances()).toEqual([{ instance_name: "a" }]);
+ });
+
+ test("logs joins content entries into plain text", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([{ content: "line 1" }, { content: "line 2" }]));
+ const client = new PlatformApiClient("t");
+ expect(await client.getEphemeralInstanceLogs("demo")).toBe("line 1\nline 2");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://api.localstack.cloud/v1/compute/instances/demo/logs");
+ });
+});
+
+describe("describePlatformError", () => {
+ test("maps 401/403 to a token hint", () => {
+ const error = new HttpError(401, "Unauthorized", "", "HTTP Error");
+ expect(describePlatformError(error, "ephemeral instances")).toMatch(/LOCALSTACK_AUTH_TOKEN/);
+ });
+
+ test("maps 404 to not-found for the subject", () => {
+ const error = new HttpError(404, "Not Found", "", "HTTP Error");
+ expect(describePlatformError(error, "ephemeral instance 'x'")).toBe(
+ "ephemeral instance 'x' was not found."
+ );
+ });
+
+ test("passes through non-HTTP errors", () => {
+ expect(describePlatformError(new Error("boom"), "x")).toBe("boom");
+ });
+});
diff --git a/src/lib/localstack/platform.client.ts b/src/lib/localstack/platform.client.ts
new file mode 100644
index 0000000..89f9ce9
--- /dev/null
+++ b/src/lib/localstack/platform.client.ts
@@ -0,0 +1,127 @@
+import { HttpClient, HttpError } from "../../core/http-client";
+
+export const PLATFORM_API_BASE_URL = "https://api.localstack.cloud/v1";
+
+export interface EphemeralInstance {
+ instance_name?: string;
+ id?: string;
+ status?: string;
+ endpoint_url?: string;
+ creation_time?: string | number;
+ expiry_time?: string | number;
+ [key: string]: unknown;
+}
+
+export interface CreateEphemeralInstanceRequest {
+ name: string;
+ lifetime?: number;
+ envVars?: Record;
+}
+
+export interface MarketplaceExtension {
+ name?: string;
+ summary?: string;
+ description?: string;
+ author?: string;
+ version?: string;
+}
+
+/**
+ * Client for the LocalStack Cloud platform API — the same REST surface the
+ * `localstack ephemeral` CLI commands wrap. Auth is HTTP Basic with an empty
+ * username and the auth token as password.
+ */
+export class PlatformApiClient {
+ private readonly http = new HttpClient();
+
+ constructor(private readonly authToken: string) {}
+
+ private headers(): Record {
+ const encoded = Buffer.from(`:${this.authToken}`).toString("base64");
+ return {
+ Authorization: `Basic ${encoded}`,
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ };
+ }
+
+ private request(path: string, options: RequestInit & { timeout?: number } = {}): Promise {
+ return this.http.request(path, {
+ baseUrl: PLATFORM_API_BASE_URL,
+ timeout: options.timeout ?? 60000,
+ ...options,
+ headers: { ...this.headers(), ...(options.headers as Record | undefined) },
+ });
+ }
+
+ async createEphemeralInstance({
+ name,
+ lifetime,
+ envVars,
+ }: CreateEphemeralInstanceRequest): Promise {
+ return this.request("/compute/instances", {
+ method: "POST",
+ body: JSON.stringify({
+ instance_name: name,
+ ...(lifetime !== undefined ? { lifetime } : {}),
+ ...(envVars && Object.keys(envVars).length > 0 ? { env_vars: envVars } : {}),
+ }),
+ // Instance provisioning can take a while server-side.
+ timeout: 180000,
+ });
+ }
+
+ async listEphemeralInstances(): Promise {
+ const result = await this.request(
+ "/compute/instances",
+ { method: "GET" }
+ );
+ if (Array.isArray(result)) return result;
+ if (result && Array.isArray(result.instances)) return result.instances;
+ return [];
+ }
+
+ async getEphemeralInstanceLogs(name: string): Promise {
+ const result = await this.request | string>(
+ `/compute/instances/${encodeURIComponent(name)}/logs`,
+ { method: "GET", timeout: 120000 }
+ );
+ if (typeof result === "string") return result;
+ if (Array.isArray(result)) {
+ return result
+ .map((entry) => (typeof entry === "string" ? entry : entry?.content || ""))
+ .filter(Boolean)
+ .join("\n");
+ }
+ return JSON.stringify(result, null, 2);
+ }
+
+ async deleteEphemeralInstance(name: string): Promise {
+ await this.request(`/compute/instances/${encodeURIComponent(name)}`, {
+ method: "DELETE",
+ timeout: 120000,
+ });
+ }
+
+ async getExtensionsMarketplace(): Promise {
+ const result = await this.request("/extensions/marketplace", {
+ method: "GET",
+ });
+ return Array.isArray(result) ? result : [];
+ }
+}
+
+/** Map platform API failures to the user-facing message pattern used by tools. */
+export function describePlatformError(error: unknown, subject: string): string {
+ if (error instanceof HttpError) {
+ if (error.status === 401 || error.status === 403) {
+ return `Authentication with the LocalStack platform failed. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and your workspace has access to ${subject}.`;
+ }
+ if (error.status === 404) {
+ return `${subject} was not found.`;
+ }
+ const body = error.body?.slice(0, 500);
+ return `Platform API error (${error.status} ${error.statusText})${body ? `: ${body}` : ""}`;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/lib/logs/log-retriever.test.ts b/src/lib/logs/log-retriever.test.ts
index afa5156..a95bd50 100644
--- a/src/lib/logs/log-retriever.test.ts
+++ b/src/lib/logs/log-retriever.test.ts
@@ -1,28 +1,104 @@
-import { runCommand } from "../../core/command-runner";
import { LocalStackLogRetriever } from "./log-retriever";
+import { DockerApiClient } from "../docker/docker.client";
-jest.mock("../../core/command-runner", () => ({
- runCommand: jest.fn(),
-}));
+jest.mock("../docker/docker.client", () => {
+ const actual = jest.requireActual("../docker/docker.client");
+ return {
+ ...actual,
+ DockerApiClient: jest.fn(),
+ };
+});
+
+const MockedDockerApiClient = DockerApiClient as jest.MockedClass;
-const mockedRunCommand = runCommand as jest.MockedFunction;
+function mockDocker({
+ findLocalStackContainer = jest.fn().mockResolvedValue("container-1"),
+ getContainerLogs = jest.fn().mockResolvedValue(""),
+}: Record = {}) {
+ MockedDockerApiClient.mockImplementation(
+ () => ({ findLocalStackContainer, getContainerLogs }) as any
+ );
+ return { findLocalStackContainer, getContainerLogs };
+}
describe("LocalStackLogRetriever", () => {
- beforeEach(() => {
- mockedRunCommand.mockReset();
+ beforeEach(() => MockedDockerApiClient.mockReset());
+
+ test("retrieves and parses container logs via the Docker API", async () => {
+ const { getContainerLogs } = mockDocker({
+ getContainerLogs: jest
+ .fn()
+ .mockResolvedValue(
+ "2025-07-23T10:58:58.710 INFO --- AWS s3.CreateBucket => 200\n" +
+ "2025-07-23T10:58:59.100 ERROR --- AWS s3.PutObject => 404 (NoSuchBucket)\n"
+ ),
+ });
+
+ const result = await new LocalStackLogRetriever().retrieveLogs(100);
+ expect(result.success).toBe(true);
+ expect(result.totalLines).toBe(2);
+ expect(getContainerLogs).toHaveBeenCalledWith("container-1", { tail: 100 });
+ expect(result.logs[1].isError).toBe(true);
+ expect(result.logs[1].service).toBe("s3");
+ expect(result.logs[1].operation).toBe("PutObject");
+ expect(result.logs[1].statusCode).toBe(404);
+ });
+
+ test("reports a missing LocalStack container as a retrieval failure", async () => {
+ const actual = jest.requireActual("../docker/docker.client");
+ mockDocker({
+ findLocalStackContainer: jest
+ .fn()
+ .mockRejectedValue(
+ new actual.LocalStackContainerNotFoundError(
+ 'Could not find a running LocalStack container named "localstack-main".'
+ )
+ ),
+ });
+
+ const result = await new LocalStackLogRetriever().retrieveLogs(10);
+ expect(result.success).toBe(false);
+ expect(result.errorMessage).toMatch(/no running LocalStack container/i);
});
- test("reports command failures instead of parsing empty output as clean logs", async () => {
- mockedRunCommand.mockResolvedValueOnce({
- stdout: "",
- stderr: "",
- exitCode: null,
- error: new Error("spawn localstack ENOENT"),
- } as never);
+ test("surfaces Docker daemon failures without CLI-era wording", async () => {
+ mockDocker({
+ findLocalStackContainer: jest
+ .fn()
+ .mockRejectedValue(new Error("connect ENOENT /var/run/docker.sock")),
+ });
const result = await new LocalStackLogRetriever().retrieveLogs(10);
expect(result.success).toBe(false);
- expect(result.errorMessage).toMatch(/Failed to retrieve logs/i);
- expect(result.errorMessage).toMatch(/ENOENT/i);
+ expect(result.errorMessage).toMatch(/Docker API/i);
+ expect(result.errorMessage).toMatch(/ENOENT/);
+ expect(result.errorMessage).not.toMatch(/CLI/);
+ });
+
+ test("maps log-read timeouts to the friendly guidance", async () => {
+ mockDocker({
+ getContainerLogs: jest
+ .fn()
+ .mockRejectedValue(new Error("Docker container logs timed out after 30000ms")),
+ });
+
+ const result = await new LocalStackLogRetriever().retrieveLogs(10);
+ expect(result.success).toBe(false);
+ expect(result.errorMessage).toMatch(/timed out/i);
+ expect(result.errorMessage).toMatch(/reducing the number of lines/i);
+ });
+
+ test("applies the case-insensitive substring filter", async () => {
+ mockDocker({
+ getContainerLogs: jest
+ .fn()
+ .mockResolvedValue("line about S3 bucket\nline about lambda\nanother S3 line\n"),
+ });
+
+ const result = await new LocalStackLogRetriever().retrieveLogs(10, "s3");
+ expect(result.success).toBe(true);
+ expect(result.totalLines).toBe(3);
+ expect(result.filteredLines).toBe(2);
+ expect(result.logs).toHaveLength(2);
});
});
diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts
index bcfe9b4..232d316 100644
--- a/src/lib/logs/log-retriever.ts
+++ b/src/lib/logs/log-retriever.ts
@@ -1,4 +1,4 @@
-import { runCommand } from "../../core/command-runner";
+import { DockerApiClient, isLocalStackContainerNotFoundError } from "../docker/docker.client";
export interface LogEntry {
timestamp?: string;
@@ -31,40 +31,40 @@ export interface LogRetrievalResult {
*/
export class LocalStackLogRetriever {
/**
- * Retrieve logs from LocalStack
+ * Retrieve logs from the LocalStack container via the Docker Engine API
+ * (equivalent to `docker logs --tail N` on the main container).
*/
async retrieveLogs(lines: number = 10000, filter?: string): Promise {
try {
- const cmd = await runCommand("localstack", ["logs", "--tail", String(lines)], {
- timeout: 30000,
- });
-
- if (cmd.error || cmd.exitCode !== 0) {
- const details = [cmd.stderr, cmd.stdout, cmd.error?.message]
- .filter((part) => part && part.trim())
- .join("\n");
- return {
- success: false,
- logs: [],
- totalLines: 0,
- errorMessage: details
- ? `Failed to retrieve logs: ${details}`
- : "Failed to retrieve logs from the LocalStack CLI.",
- };
- }
-
- if (!cmd.stdout && cmd.stderr) {
+ const docker = new DockerApiClient();
+ let output: string;
+ try {
+ const containerId = await docker.findLocalStackContainer();
+ output = await docker.getContainerLogs(containerId, { tail: lines });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (message.toLowerCase().includes("timed out")) {
+ return {
+ success: false,
+ logs: [],
+ totalLines: 0,
+ errorMessage:
+ "Log retrieval timed out. LocalStack may be generating large amounts of logs. Try reducing the number of lines or check if LocalStack is experiencing issues.",
+ };
+ }
return {
success: false,
logs: [],
totalLines: 0,
- errorMessage: `Failed to retrieve logs: ${cmd.stderr}`,
+ errorMessage: isLocalStackContainerNotFoundError(error)
+ ? `Failed to retrieve logs: no running LocalStack container was found. ${message}`
+ : `Failed to retrieve logs from the Docker API: ${message}`,
};
}
- // Split on both LF and CRLF so a trailing \r from Windows child-process
- // stdout doesn't pollute the parsed line text (fullLine/message).
- const rawLines = (cmd.stdout || "").split(/\r?\n/).filter((line) => line.trim());
+ // Split on both LF and CRLF so stray \r characters don't pollute the parsed
+ // line text (fullLine/message).
+ const rawLines = (output || "").split(/\r?\n/).filter((line) => line.trim());
let filteredLines = rawLines;
if (filter) {
@@ -84,7 +84,7 @@ export class LocalStackLogRetriever {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("timeout")) {
+ if (errorMessage.toLowerCase().includes("timed out")) {
return {
success: false,
logs: [],
@@ -94,16 +94,6 @@ export class LocalStackLogRetriever {
};
}
- if (errorMessage.includes("Command failed") || errorMessage.includes("not found")) {
- return {
- success: false,
- logs: [],
- totalLines: 0,
- errorMessage:
- "Unable to execute 'localstack logs' command. Please ensure LocalStack CLI is installed and LocalStack is running.",
- };
- }
-
return {
success: false,
logs: [],
diff --git a/src/lib/wizard/cli-args.logic.ts b/src/lib/wizard/cli-args.logic.ts
index ceffb23..72a3ce1 100644
--- a/src/lib/wizard/cli-args.logic.ts
+++ b/src/lib/wizard/cli-args.logic.ts
@@ -7,6 +7,7 @@ export interface InitFlags {
clients?: ClientId[];
token?: string;
config?: string;
+ /** Deprecated: accepted for backward compatibility, ignored. */
cacheDir?: string;
workspace?: string;
imageTag?: string;
diff --git a/src/lib/wizard/paths.logic.ts b/src/lib/wizard/paths.logic.ts
index fe2e4e8..01d5331 100644
--- a/src/lib/wizard/paths.logic.ts
+++ b/src/lib/wizard/paths.logic.ts
@@ -31,7 +31,13 @@ export function antigravityConfigPath(ctx: ClientContext): string {
export function claudeDesktopConfigPath(ctx: ClientContext): string | null {
const p = pathFor(ctx);
if (ctx.platform === "darwin") {
- return p.join(ctx.homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
+ return p.join(
+ ctx.homeDir,
+ "Library",
+ "Application Support",
+ "Claude",
+ "claude_desktop_config.json"
+ );
}
if (ctx.platform === "win32") {
const appData = ctx.env.APPDATA || p.join(ctx.homeDir, "AppData", "Roaming");
diff --git a/src/lib/wizard/prereqs.ts b/src/lib/wizard/prereqs.ts
index 2c257f7..8b1230c 100644
--- a/src/lib/wizard/prereqs.ts
+++ b/src/lib/wizard/prereqs.ts
@@ -49,22 +49,9 @@ export async function checkPrereqs(method: InstallMethod): Promise localstackOk || lstkOk)
- : Promise.resolve(false);
if (method === "npx") {
results.push(checkNodeVersion());
- results.push({
- name: "LocalStack lifecycle CLI",
- ok: await lifecycleCliPromise,
- fatal: false,
- hint: "install either `localstack` (`brew install localstack/tap/localstack-cli` or `pip install localstack`) or `lstk` — needed when the MCP server starts or restarts LocalStack",
- });
}
const { installed: dockerInstalled, running: dockerRunning } = await dockerPromise;
@@ -72,8 +59,8 @@ export async function checkPrereqs(method: InstallMethod): Promise {
describe("buildDockerServerSpec", () => {
const options = {
- cacheDir: "/Users/you/.localstack-mcp",
workspaceDir: "/Users/you/projects",
imageTag: "latest",
};
@@ -38,10 +37,6 @@ describe("buildDockerServerSpec", () => {
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
- "-v",
- "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp",
- "-e",
- "XDG_CACHE_HOME=/Users/you/.localstack-mcp",
"--add-host",
"host.docker.internal:host-gateway",
"--add-host",
@@ -89,7 +84,7 @@ describe("windowsSpawnSafeSpec", () => {
});
it("leaves non-npx commands untouched", () => {
- const docker = buildDockerServerSpec("tok", {}, { cacheDir: "/c", imageTag: "latest" });
+ const docker = buildDockerServerSpec("tok", {}, { imageTag: "latest" });
expect(windowsSpawnSafeSpec(docker)).toBe(docker);
});
});
diff --git a/src/lib/wizard/server-config.logic.ts b/src/lib/wizard/server-config.logic.ts
index 4e37e28..87d7948 100644
--- a/src/lib/wizard/server-config.logic.ts
+++ b/src/lib/wizard/server-config.logic.ts
@@ -33,10 +33,6 @@ export function buildDockerServerSpec(
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
- "-v",
- `${options.cacheDir}:${options.cacheDir}`,
- "-e",
- `XDG_CACHE_HOME=${options.cacheDir}`,
"--add-host",
"host.docker.internal:host-gateway",
"--add-host",
diff --git a/src/lib/wizard/types.ts b/src/lib/wizard/types.ts
index 1497f92..0b21198 100644
--- a/src/lib/wizard/types.ts
+++ b/src/lib/wizard/types.ts
@@ -22,7 +22,6 @@ export interface ServerSpec {
}
export interface DockerOptions {
- cacheDir: string;
workspaceDir?: string;
imageTag: string;
}
diff --git a/src/prompts/infrastructure-tester.ts b/src/prompts/infrastructure-tester.ts
index a7e4d03..fcceadf 100644
--- a/src/prompts/infrastructure-tester.ts
+++ b/src/prompts/infrastructure-tester.ts
@@ -10,7 +10,9 @@ export const schema = {
iac_type: z
.string()
.optional()
- .describe("(Optional) What IaC framework is it? Use auto, cdk, terraform, sam, or cloudformation."),
+ .describe(
+ "(Optional) What IaC framework is it? Use auto, cdk, terraform, sam, or cloudformation."
+ ),
test_language: z
.string()
.optional()
@@ -22,11 +24,15 @@ export const schema = {
mode: z
.string()
.optional()
- .describe("(Optional) Run validation only, or also write and run tests? Use 'validate-only' or 'full'."),
+ .describe(
+ "(Optional) Run validation only, or also write and run tests? Use 'validate-only' or 'full'."
+ ),
services_focus: z
.string()
.optional()
- .describe("(Optional) Which AWS services should get extra attention? Example: s3,lambda,dynamodb."),
+ .describe(
+ "(Optional) Which AWS services should get extra attention? Example: s3,lambda,dynamodb."
+ ),
user_focus: z
.string()
.optional()
diff --git a/src/tools/localstack-aws-client.ts b/src/tools/localstack-aws-client.ts
index 17f1e7f..79d7b7b 100644
--- a/src/tools/localstack-aws-client.ts
+++ b/src/tools/localstack-aws-client.ts
@@ -49,8 +49,12 @@ export default async function localstackAwsClient({ command }: InferSchema
- rulesMatch(currentRule, ruleToRemove)
- );
- if (!ruleExists) {
- return ResponseBuilder.markdown(`⚠️ The specified rule was not found in the current configuration. No changes were made.
+ case "remove-fault-rule": {
+ if (!rules || rules.length === 0) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "❌ **Error:** The `remove-fault-rule` action requires the `rules` parameter to be specified.",
+ },
+ ],
+ };
+ }
+
+ // First get current rules to check if the rule exists
+ const getCurrentResult = await client.getFaults();
+ if (!getCurrentResult.success) {
+ return ResponseBuilder.error("Chaos API Error", getCurrentResult.message);
+ }
+
+ // Check if all rules to remove exist in current configuration
+ const currentRules = getCurrentResult.data || [];
+ const rulesToRemove = rules;
+
+ for (const ruleToRemove of rulesToRemove) {
+ const ruleExists = currentRules.some((currentRule: any) =>
+ rulesMatch(currentRule, ruleToRemove)
+ );
+ if (!ruleExists) {
+ return ResponseBuilder.markdown(`⚠️ The specified rule was not found in the current configuration. No changes were made.
Current configuration:
${formatFaultRules(currentRules)}`);
- }
- }
+ }
+ }
- // Rule exists, proceed with removal
- const removeResult = await client.removeFaultRules(rulesToRemove);
- if (!removeResult.success) {
- return ResponseBuilder.error("Chaos API Error", removeResult.message);
- }
+ // Rule exists, proceed with removal
+ const removeResult = await client.removeFaultRules(rulesToRemove);
+ if (!removeResult.success) {
+ return ResponseBuilder.error("Chaos API Error", removeResult.message);
+ }
- // Get current state after removal to confirm
- const getUpdatedResult = await client.getFaults();
- if (!getUpdatedResult.success) {
- return ResponseBuilder.error("Chaos API Error", getUpdatedResult.message);
- }
+ // Get current state after removal to confirm
+ const getUpdatedResult = await client.getFaults();
+ if (!getUpdatedResult.success) {
+ return ResponseBuilder.error("Chaos API Error", getUpdatedResult.message);
+ }
- const message = `✅ The specified fault rule(s) have been removed. The current active faults are:
+ const message = `✅ The specified fault rule(s) have been removed. The current active faults are:
${formatFaultRules(getUpdatedResult.data)}`;
- return ResponseBuilder.markdown(message);
- }
+ return ResponseBuilder.markdown(message);
+ }
- case "get-latency": {
- const result = await client.getEffects();
- if (!result.success) {
- return ResponseBuilder.error("Chaos API Error", result.message);
- }
+ case "get-latency": {
+ const result = await client.getEffects();
+ if (!result.success) {
+ return ResponseBuilder.error("Chaos API Error", result.message);
+ }
- const latency = (result.data as any)?.latency || 0;
- return ResponseBuilder.markdown(`The current network latency is ${latency}ms.`);
- }
+ const latency = (result.data as any)?.latency || 0;
+ return ResponseBuilder.markdown(`The current network latency is ${latency}ms.`);
+ }
- case "clear-latency": {
- const result = await client.setEffects({ latency: 0 });
- if (!result.success) {
- return ResponseBuilder.error("Chaos API Error", result.message);
- }
+ case "clear-latency": {
+ const result = await client.setEffects({ latency: 0 });
+ if (!result.success) {
+ return ResponseBuilder.error("Chaos API Error", result.message);
+ }
- // Get current state to confirm
- const getCurrentResult = await client.getEffects();
- if (!getCurrentResult.success) {
- return ResponseBuilder.error("Chaos API Error", getCurrentResult.message);
- }
+ // Get current state to confirm
+ const getCurrentResult = await client.getEffects();
+ if (!getCurrentResult.success) {
+ return ResponseBuilder.error("Chaos API Error", getCurrentResult.message);
+ }
- const message = `✅ Network latency has been cleared. The current effects are:
+ const message = `✅ Network latency has been cleared. The current effects are:
\`\`\`json
${JSON.stringify(getCurrentResult.data, null, 2)}
\`\`\``;
- return ResponseBuilder.markdown(message);
- }
-
- case "inject-latency": {
- if (latency_ms === undefined || latency_ms === null) {
- return {
- content: [
- {
- type: "text",
- text: "❌ **Error:** The `inject-latency` action requires the `latency_ms` parameter to be specified.",
- },
- ],
- };
- }
-
- const result = await client.setEffects({ latency: latency_ms });
- if (!result.success) {
- return ResponseBuilder.error("Chaos API Error", result.message);
- }
-
- // Get current state to confirm
- const getCurrentResult = await client.getEffects();
- if (!getCurrentResult.success) {
- return ResponseBuilder.error("Chaos API Error", getCurrentResult.message);
- }
+ return ResponseBuilder.markdown(message);
+ }
- const message = `✅ Latency of ${latency_ms}ms has been injected. The current network effects are:
+ case "inject-latency": {
+ if (latency_ms === undefined || latency_ms === null) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "❌ **Error:** The `inject-latency` action requires the `latency_ms` parameter to be specified.",
+ },
+ ],
+ };
+ }
+
+ const result = await client.setEffects({ latency: latency_ms });
+ if (!result.success) {
+ return ResponseBuilder.error("Chaos API Error", result.message);
+ }
+
+ // Get current state to confirm
+ const getCurrentResult = await client.getEffects();
+ if (!getCurrentResult.success) {
+ return ResponseBuilder.error("Chaos API Error", getCurrentResult.message);
+ }
+
+ const message = `✅ Latency of ${latency_ms}ms has been injected. The current network effects are:
\`\`\`json
${JSON.stringify(getCurrentResult.data, null, 2)}
\`\`\``;
- return ResponseBuilder.markdown(addWorkflowGuidance(message));
- }
+ return ResponseBuilder.markdown(addWorkflowGuidance(message));
+ }
default:
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
diff --git a/src/tools/localstack-cloud-pods.ts b/src/tools/localstack-cloud-pods.ts
index 4206173..7330f39 100644
--- a/src/tools/localstack-cloud-pods.ts
+++ b/src/tools/localstack-cloud-pods.ts
@@ -7,18 +7,13 @@ import {
runPreflights,
requireAuthToken,
requireLocalStackRunning,
- requireLocalStackCli,
requireProFeature,
} from "../core/preflight";
import { withToolAnalytics } from "../core/analytics";
// Define the schema for tool parameters
export const schema = {
- action: z
- .enum(["save", "load", "delete"])
- .describe(
- "The Cloud Pods action to perform."
- ),
+ action: z.enum(["save", "load", "delete"]).describe("The Cloud Pods action to perform."),
pod_name: z
.string()
@@ -55,7 +50,6 @@ export default async function localstackCloudPods({
const preflightError = await runPreflights([
requireAuthToken(),
requireLocalStackRunning(),
- requireLocalStackCli(),
requireProFeature(ProFeature.CLOUD_PODS),
]);
if (preflightError) return preflightError;
diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts
index e8e8a24..e96e4cc 100644
--- a/src/tools/localstack-deployer.ts
+++ b/src/tools/localstack-deployer.ts
@@ -3,11 +3,7 @@ import { type ToolMetadata, type InferSchema } from "xmcp";
import { runCommand, stripAnsiCodes } from "../core/command-runner";
import path from "path";
import fs from "fs";
-import {
- runPreflights,
- requireAuthToken,
- requireLocalStackRunning,
-} from "../core/preflight";
+import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight";
import { DockerApiClient } from "../lib/docker/docker.client";
import {
checkDependencies,
@@ -102,153 +98,161 @@ export default async function localstackDeployer({
}: InferSchema) {
return withToolAnalytics(
"localstack-deployer",
- { action, projectType, directory, stackName, templatePath, variables, s3Bucket, resolveS3, saveParams },
+ {
+ action,
+ projectType,
+ directory,
+ stackName,
+ templatePath,
+ variables,
+ s3Bucket,
+ resolveS3,
+ saveParams,
+ },
async () => {
const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]);
if (preflightError) return preflightError;
- if (action === "create-stack") {
- if (!stackName) {
- return ResponseBuilder.error(
- "Missing Parameter",
- "The parameter 'stackName' is required for action 'create-stack'."
- );
- }
- let resolvedTemplatePath = templatePath;
- if (!resolvedTemplatePath) {
- if (!directory) {
- return ResponseBuilder.error(
- "Missing Parameter",
- "Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template."
- );
- }
- try {
- const files = await fs.promises.readdir(directory);
- const yamlFiles = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
- if (yamlFiles.length === 0) {
+ if (action === "create-stack") {
+ if (!stackName) {
return ResponseBuilder.error(
- "Template Not Found",
- `No .yaml/.yml template found in directory '${directory}'.`
+ "Missing Parameter",
+ "The parameter 'stackName' is required for action 'create-stack'."
);
}
- if (yamlFiles.length > 1) {
+ let resolvedTemplatePath = templatePath;
+ if (!resolvedTemplatePath) {
+ if (!directory) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template."
+ );
+ }
+ try {
+ const files = await fs.promises.readdir(directory);
+ const yamlFiles = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
+ if (yamlFiles.length === 0) {
+ return ResponseBuilder.error(
+ "Template Not Found",
+ `No .yaml/.yml template found in directory '${directory}'.`
+ );
+ }
+ if (yamlFiles.length > 1) {
+ return ResponseBuilder.error(
+ "Multiple Templates Found",
+ `Multiple .yaml/.yml templates found in '${directory}'. Please specify 'templatePath'.\n\nFound:\n${yamlFiles
+ .map((f) => `- ${f}`)
+ .join("\n")}`
+ );
+ }
+ resolvedTemplatePath = path.join(directory, yamlFiles[0]);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return ResponseBuilder.error(
+ "Directory Read Error",
+ `Failed to read directory '${directory}'. ${message}`
+ );
+ }
+ }
+
+ let templateBody = "";
+ try {
+ templateBody = await fs.promises.readFile(resolvedTemplatePath, "utf-8");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
return ResponseBuilder.error(
- "Multiple Templates Found",
- `Multiple .yaml/.yml templates found in '${directory}'. Please specify 'templatePath'.\n\nFound:\n${yamlFiles
- .map((f) => `- ${f}`)
- .join("\n")}`
+ "Template Read Error",
+ `Failed to read template file at '${resolvedTemplatePath}'. ${message}`
);
}
- resolvedTemplatePath = path.join(directory, yamlFiles[0]);
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- return ResponseBuilder.error(
- "Directory Read Error",
- `Failed to read directory '${directory}'. ${message}`
- );
- }
- }
-
- let templateBody = "";
- try {
- templateBody = await fs.promises.readFile(resolvedTemplatePath, "utf-8");
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- return ResponseBuilder.error(
- "Template Read Error",
- `Failed to read template file at '${resolvedTemplatePath}'. ${message}`
- );
- }
-
- try {
- const dockerClient = new DockerApiClient();
- const containerId = await dockerClient.findLocalStackContainer();
-
- const tempPath = `/tmp/ls-cfn-${Date.now()}.yaml`;
- const writeRes = await dockerClient.executeInContainer(
- containerId,
- ["/bin/sh", "-c", `cat > ${tempPath}`],
- templateBody
- );
- if (writeRes.exitCode !== 0) {
- return ResponseBuilder.error(
- "Template Upload Failed",
- writeRes.stderr || `Failed to write template to ${tempPath}`
- );
- }
- const createCmd = [
- "awslocal",
- "cloudformation",
- "create-stack",
- "--stack-name",
- stackName,
- "--template-body",
- `file://${tempPath}`,
- ];
- const createRes = await dockerClient.executeInContainer(containerId, createCmd);
+ try {
+ const dockerClient = new DockerApiClient();
+ const containerId = await dockerClient.findLocalStackContainer();
- try {
- await dockerClient.executeInContainer(containerId, ["/bin/sh", "-c", `rm -f ${tempPath}`]);
- } catch {}
-
- if (createRes.exitCode === 0) {
- return ResponseBuilder.markdown(
- (createRes.stdout && createRes.stdout.trim())
- ? createRes.stdout
- : `Stack '${stackName}' creation initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor stack status and wait for CREATE_COMPLETE.`
- );
+ const tempPath = `/tmp/ls-cfn-${Date.now()}.yaml`;
+ const writeRes = await dockerClient.executeInContainer(
+ containerId,
+ ["/bin/sh", "-c", `cat > ${tempPath}`],
+ templateBody
+ );
+ if (writeRes.exitCode !== 0) {
+ return ResponseBuilder.error(
+ "Template Upload Failed",
+ writeRes.stderr || `Failed to write template to ${tempPath}`
+ );
+ }
+
+ const createCmd = [
+ "awslocal",
+ "cloudformation",
+ "create-stack",
+ "--stack-name",
+ stackName,
+ "--template-body",
+ `file://${tempPath}`,
+ ];
+ const createRes = await dockerClient.executeInContainer(containerId, createCmd);
+
+ try {
+ await dockerClient.executeInContainer(containerId, [
+ "/bin/sh",
+ "-c",
+ `rm -f ${tempPath}`,
+ ]);
+ } catch {}
+
+ if (createRes.exitCode === 0) {
+ return ResponseBuilder.markdown(
+ createRes.stdout && createRes.stdout.trim()
+ ? createRes.stdout
+ : `Stack '${stackName}' creation initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor stack status and wait for CREATE_COMPLETE.`
+ );
+ }
+ return ResponseBuilder.error(
+ "CloudFormation create-stack failed",
+ createRes.stderr || "Unknown error"
+ );
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return ResponseBuilder.error(
+ "CloudFormation Error",
+ `An unexpected error occurred: ${errorMessage}`
+ );
+ }
}
- return ResponseBuilder.error(
- "CloudFormation create-stack failed",
- createRes.stderr || "Unknown error"
- );
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- return ResponseBuilder.error(
- "CloudFormation Error",
- `An unexpected error occurred: ${errorMessage}`
- );
- }
- }
- if (action === "delete-stack") {
- if (!stackName) {
- return ResponseBuilder.error(
- "Missing Parameter",
- "The parameter 'stackName' is required for action 'delete-stack'."
- );
- }
- try {
- const dockerClient = new DockerApiClient();
- const containerId = await dockerClient.findLocalStackContainer();
- const command = [
- "awslocal",
- "cloudformation",
- "delete-stack",
- "--stack-name",
- stackName,
- ];
- const result = await dockerClient.executeInContainer(containerId, command);
- if (result.exitCode === 0) {
- return ResponseBuilder.markdown(
- (result.stdout && result.stdout.trim())
- ? result.stdout
- : `Stack '${stackName}' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.`
- );
+ if (action === "delete-stack") {
+ if (!stackName) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "The parameter 'stackName' is required for action 'delete-stack'."
+ );
+ }
+ try {
+ const dockerClient = new DockerApiClient();
+ const containerId = await dockerClient.findLocalStackContainer();
+ const command = ["awslocal", "cloudformation", "delete-stack", "--stack-name", stackName];
+ const result = await dockerClient.executeInContainer(containerId, command);
+ if (result.exitCode === 0) {
+ return ResponseBuilder.markdown(
+ result.stdout && result.stdout.trim()
+ ? result.stdout
+ : `Stack '${stackName}' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.`
+ );
+ }
+ return ResponseBuilder.error(
+ "CloudFormation delete-stack failed",
+ result.stderr || "Unknown error"
+ );
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return ResponseBuilder.error(
+ "CloudFormation Error",
+ `An unexpected error occurred: ${errorMessage}`
+ );
+ }
}
- return ResponseBuilder.error(
- "CloudFormation delete-stack failed",
- result.stderr || "Unknown error"
- );
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- return ResponseBuilder.error(
- "CloudFormation Error",
- `An unexpected error occurred: ${errorMessage}`
- );
- }
- }
let resolvedProjectType: "cdk" | "terraform" | "sam";
@@ -261,38 +265,41 @@ export default async function localstackDeployer({
}
const nonNullDirectory = directory as string;
- // Validate the directory up front; a missing cwd otherwise surfaces as a misleading
- // "spawn cdklocal/tflocal/samlocal ENOENT". Hint differs by run mode (Docker vs npx).
- const resolvedDir = path.resolve(nonNullDirectory);
- const dirExists = await fs.promises.stat(resolvedDir).then((s) => s.isDirectory()).catch(() => false);
- if (!dirExists) {
- return ResponseBuilder.error(
- "Project Directory Not Found",
- fs.existsSync("/.dockerenv")
- ? `The directory "${resolvedDir}" was not found inside the container. Bind-mount your project at the same absolute path (\`-v ${resolvedDir}:${resolvedDir}\`) and pass that path as 'directory'.`
- : `The directory "${resolvedDir}" was not found. Please check the path and try again.`
- );
- }
+ // Validate the directory up front; a missing cwd otherwise surfaces as a misleading
+ // "spawn cdklocal/tflocal/samlocal ENOENT". Hint differs by run mode (Docker vs npx).
+ const resolvedDir = path.resolve(nonNullDirectory);
+ const dirExists = await fs.promises
+ .stat(resolvedDir)
+ .then((s) => s.isDirectory())
+ .catch(() => false);
+ if (!dirExists) {
+ return ResponseBuilder.error(
+ "Project Directory Not Found",
+ fs.existsSync("/.dockerenv")
+ ? `The directory "${resolvedDir}" was not found inside the container. Bind-mount your project at the same absolute path (\`-v ${resolvedDir}:${resolvedDir}\`) and pass that path as 'directory'.`
+ : `The directory "${resolvedDir}" was not found. Please check the path and try again.`
+ );
+ }
- // Step 1: Project Type Resolution
- if (projectType === "auto") {
- const inferredType = await inferProjectType(nonNullDirectory);
+ // Step 1: Project Type Resolution
+ if (projectType === "auto") {
+ const inferredType = await inferProjectType(nonNullDirectory);
- if (inferredType === "ambiguous") {
- return ResponseBuilder.error(
- "Ambiguous Project Type",
- `The directory "${directory}" contains multiple infrastructure project types. Please specify the project type explicitly:
+ if (inferredType === "ambiguous") {
+ return ResponseBuilder.error(
+ "Ambiguous Project Type",
+ `The directory "${directory}" contains multiple infrastructure project types. Please specify the project type explicitly:
- Use \`projectType: 'cdk'\` to deploy as a CDK project
- Use \`projectType: 'terraform'\` to deploy as a Terraform project
- Use \`projectType: 'sam'\` to deploy as a SAM project`
- );
- }
+ );
+ }
- if (inferredType === "unknown") {
- return ResponseBuilder.error(
- "Unknown Project Type",
- `The directory "${directory}" does not appear to contain recognizable infrastructure-as-code files.
+ if (inferredType === "unknown") {
+ return ResponseBuilder.error(
+ "Unknown Project Type",
+ `The directory "${directory}" does not appear to contain recognizable infrastructure-as-code files.
Expected files:
- **CDK**: \`cdk.json\`, \`app.py\`, \`app.js\`, or \`app.ts\`
@@ -300,34 +307,34 @@ Expected files:
- **SAM**: \`samconfig.toml\` or \`template.yaml/.yml\` with \`AWS::Serverless::*\` resources
Please check the directory path or specify the project type explicitly.`
- );
- }
+ );
+ }
- resolvedProjectType = inferredType as "cdk" | "terraform" | "sam";
- } else {
- resolvedProjectType = projectType as "cdk" | "terraform" | "sam";
- }
+ resolvedProjectType = inferredType as "cdk" | "terraform" | "sam";
+ } else {
+ resolvedProjectType = projectType as "cdk" | "terraform" | "sam";
+ }
- // Check Dependencies
- const dependencyCheck = await checkDependencies(resolvedProjectType);
- if (!dependencyCheck.isAvailable) {
- return ResponseBuilder.error("Dependency Not Available", dependencyCheck.errorMessage!);
- }
+ // Check Dependencies
+ const dependencyCheck = await checkDependencies(resolvedProjectType);
+ if (!dependencyCheck.isAvailable) {
+ return ResponseBuilder.error("Dependency Not Available", dependencyCheck.errorMessage!);
+ }
- // Security Validation
- const validationErrors = validateVariables(variables);
- if (validationErrors.length > 0) {
- return ResponseBuilder.error(
- "Security Violation Detected",
- `🛡️ **Security Violation Detected**
+ // Security Validation
+ const validationErrors = validateVariables(variables);
+ if (validationErrors.length > 0) {
+ return ResponseBuilder.error(
+ "Security Violation Detected",
+ `🛡️ **Security Violation Detected**
Command injection attempt prevented. The following issues were found:
${validationErrors.map((error) => `- ${error}`).join("\n")}
Please review your variables and ensure they don't contain shell metacharacters or invalid identifiers.`
- );
- }
+ );
+ }
// Execute Commands Based on Project Type and Action
return await executeDeploymentCommands(
@@ -394,7 +401,10 @@ async function executeTerraformCommands(
if (action === "deploy") {
events.push({ type: "header", title: "📦 Initializing Terraform", content: "" });
- const initRes = await runCommand(baseCommand, ["init"], { cwd: directory, env: buildIacCliEnv() });
+ const initRes = await runCommand(baseCommand, ["init"], {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
events.push({ type: "output", content: stripAnsiCodes(initRes.stdout) });
if (initRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(initRes.stderr) });
if (initRes.error) {
@@ -408,7 +418,10 @@ async function executeTerraformCommands(
events.push({ type: "header", title: "🔨 Applying Terraform Configuration", content: "" });
const applyArgs = ["apply", "-auto-approve", ...varArgs];
- const applyRes = await runCommand(baseCommand, applyArgs, { cwd: directory, env: buildIacCliEnv() });
+ const applyRes = await runCommand(baseCommand, applyArgs, {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
events.push({ type: "output", content: stripAnsiCodes(applyRes.stdout) });
if (applyRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(applyRes.stderr) });
if (applyRes.error) {
@@ -420,7 +433,10 @@ async function executeTerraformCommands(
return events;
}
- const outputRes = await runCommand(baseCommand, ["output", "-json"], { cwd: directory, env: buildIacCliEnv() });
+ const outputRes = await runCommand(baseCommand, ["output", "-json"], {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
if (outputRes.stdout.trim()) {
const parsed = parseTerraformOutputs(outputRes.stdout);
events.push({ type: "output", content: parsed });
@@ -429,7 +445,10 @@ async function executeTerraformCommands(
} else {
events.push({ type: "header", title: "💥 Destroying Terraform Resources", content: "" });
const destroyArgs = ["destroy", "-auto-approve", ...varArgs];
- const destroyRes = await runCommand(baseCommand, destroyArgs, { cwd: directory, env: buildIacCliEnv() });
+ const destroyRes = await runCommand(baseCommand, destroyArgs, {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
events.push({ type: "output", content: stripAnsiCodes(destroyRes.stdout) });
if (destroyRes.stderr)
events.push({ type: "warning", content: stripAnsiCodes(destroyRes.stderr) });
@@ -514,7 +533,10 @@ async function executeSamCommands(
buildArgs.push("--template-file", resolvedTemplatePath);
}
events.push({ type: "command", content: `${baseCommand} ${buildArgs.join(" ")}` });
- const buildRes = await runCommand(baseCommand, buildArgs, { cwd: directory, env: buildIacCliEnv() });
+ const buildRes = await runCommand(baseCommand, buildArgs, {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
events.push({ type: "output", content: stripAnsiCodes(buildRes.stdout) });
if (buildRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(buildRes.stderr) });
if (buildRes.error) {
@@ -551,7 +573,10 @@ async function executeSamCommands(
deployArgs.push("--save-params");
}
if (variables && Object.keys(variables).length > 0) {
- deployArgs.push("--parameter-overrides", ...Object.entries(variables).map(([k, v]) => `${k}=${v}`));
+ deployArgs.push(
+ "--parameter-overrides",
+ ...Object.entries(variables).map(([k, v]) => `${k}=${v}`)
+ );
}
events.push({ type: "command", content: `${baseCommand} ${deployArgs.join(" ")}` });
@@ -560,7 +585,8 @@ async function executeSamCommands(
env: buildIacCliEnv({ CI: "true" }),
});
events.push({ type: "output", content: stripAnsiCodes(deployRes.stdout) });
- if (deployRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) });
+ if (deployRes.stderr)
+ events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) });
if (deployRes.error) {
events.push({
type: "error",
@@ -572,11 +598,22 @@ async function executeSamCommands(
events.push({ type: "success", content: "SAM application deployed successfully!" });
} else {
events.push({ type: "header", title: "💥 Deleting SAM Application", content: "" });
- const deleteArgs = ["delete", "--no-prompts", "--region", resolvedRegion, "--stack-name", resolvedStackName];
+ const deleteArgs = [
+ "delete",
+ "--no-prompts",
+ "--region",
+ resolvedRegion,
+ "--stack-name",
+ resolvedStackName,
+ ];
events.push({ type: "command", content: `${baseCommand} ${deleteArgs.join(" ")}` });
- const deleteRes = await runCommand(baseCommand, deleteArgs, { cwd: directory, env: buildIacCliEnv() });
+ const deleteRes = await runCommand(baseCommand, deleteArgs, {
+ cwd: directory,
+ env: buildIacCliEnv(),
+ });
events.push({ type: "output", content: stripAnsiCodes(deleteRes.stdout) });
- if (deleteRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) });
+ if (deleteRes.stderr)
+ events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) });
if (deleteRes.error) {
events.push({
type: "error",
diff --git a/src/tools/localstack-docs.ts b/src/tools/localstack-docs.ts
index 9098dbc..809f8d7 100644
--- a/src/tools/localstack-docs.ts
+++ b/src/tools/localstack-docs.ts
@@ -5,8 +5,7 @@ import { runPreflights, requireAuthToken } from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { withToolAnalytics } from "../core/analytics";
-const CRAWLCHAT_DOCS_ENDPOINT =
- "https://wings.crawlchat.app/mcp/698f2c11e688991df3c7e020";
+const CRAWLCHAT_DOCS_ENDPOINT = "https://wings.crawlchat.app/mcp/698f2c11e688991df3c7e020";
type CrawlChatDocsResult = {
content: string;
diff --git a/src/tools/localstack-ephemeral-instances.ts b/src/tools/localstack-ephemeral-instances.ts
index 0862338..d95620e 100644
--- a/src/tools/localstack-ephemeral-instances.ts
+++ b/src/tools/localstack-ephemeral-instances.ts
@@ -1,9 +1,13 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
-import { runCommand, stripAnsiCodes } from "../core/command-runner";
-import { runPreflights, requireLocalStackCli, requireAuthToken } from "../core/preflight";
+import { requireAuthToken } from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { withToolAnalytics } from "../core/analytics";
+import {
+ PlatformApiClient,
+ describePlatformError,
+ type EphemeralInstance,
+} from "../lib/localstack/platform.client";
export const schema = {
action: z
@@ -18,7 +22,9 @@ export const schema = {
.int()
.positive()
.optional()
- .describe("Lifetime in minutes for create action. Defaults to CLI default when omitted."),
+ .describe(
+ "Lifetime in minutes for create action. Defaults to the platform default when omitted."
+ ),
extension: z
.string()
.optional()
@@ -35,7 +41,7 @@ export const schema = {
.record(z.string(), z.string())
.optional()
.describe(
- "Additional environment variables to pass to the ephemeral instance (create action only), translated to repeated --env KEY=VALUE flags."
+ "Additional environment variables to pass to the ephemeral instance (create action only)."
),
};
@@ -73,18 +79,19 @@ export default async function localstackEphemeralInstances({
const authError = requireAuthToken();
if (authError) return authError;
- const preflightError = await runPreflights([requireLocalStackCli()]);
- if (preflightError) return preflightError;
+ // Ephemeral instances are cloud-hosted: everything goes through the LocalStack
+ // platform API — no local container, CLI, or Docker daemon involved.
+ const client = new PlatformApiClient(process.env.LOCALSTACK_AUTH_TOKEN!.trim());
switch (action) {
case "create":
- return await handleCreate({ name, lifetime, extension, cloudPod, envVars });
+ return await handleCreate(client, { name, lifetime, extension, cloudPod, envVars });
case "list":
- return await handleList();
+ return await handleList(client);
case "logs":
- return await handleLogs({ name });
+ return await handleLogs(client, { name });
case "delete":
- return await handleDelete({ name });
+ return await handleDelete(client, { name });
default:
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
}
@@ -92,46 +99,9 @@ export default async function localstackEphemeralInstances({
);
}
-function cleanOutput(stdout: string, stderr: string): { stdout: string; stderr: string; combined: string } {
- const cleanStdout = stripAnsiCodes(stdout || "").trim();
- const cleanStderr = stripAnsiCodes(stderr || "").trim();
- const combined = [cleanStdout, cleanStderr].filter((part) => part.length > 0).join("\n").trim();
- return { stdout: cleanStdout, stderr: cleanStderr, combined };
-}
-
-function parseJsonFromText(text: string): unknown {
- const trimmed = text.trim();
- if (!trimmed) return null;
- try {
- return JSON.parse(trimmed);
- } catch {
- const startObject = trimmed.indexOf("{");
- const endObject = trimmed.lastIndexOf("}");
- if (startObject !== -1 && endObject > startObject) {
- const candidate = trimmed.slice(startObject, endObject + 1);
- try {
- return JSON.parse(candidate);
- } catch {
- // continue
- }
- }
- const startArray = trimmed.indexOf("[");
- const endArray = trimmed.lastIndexOf("]");
- if (startArray !== -1 && endArray > startArray) {
- const candidate = trimmed.slice(startArray, endArray + 1);
- try {
- return JSON.parse(candidate);
- } catch {
- // continue
- }
- }
- return null;
- }
-}
-
-function formatCreateResponse(payload: Record): string {
+function formatCreateResponse(payload: EphemeralInstance): string {
const endpoint = String(payload.endpoint_url ?? "N/A");
- const id = String(payload.id ?? "N/A");
+ const id = String(payload.id ?? payload.instance_name ?? "N/A");
const status = String(payload.status ?? "unknown");
const creationTime = String(payload.creation_time ?? "N/A");
const expiryTime = String(payload.expiry_time ?? "N/A");
@@ -152,19 +122,22 @@ Use this endpoint with your tools, for example:
\`aws --endpoint-url=${endpoint} s3 ls\``;
}
-async function handleCreate({
- name,
- lifetime,
- extension,
- cloudPod,
- envVars,
-}: {
- name?: string;
- lifetime?: number;
- extension?: string;
- cloudPod?: string;
- envVars?: Record;
-}) {
+async function handleCreate(
+ client: PlatformApiClient,
+ {
+ name,
+ lifetime,
+ extension,
+ cloudPod,
+ envVars,
+ }: {
+ name?: string;
+ lifetime?: number;
+ extension?: string;
+ cloudPod?: string;
+ envVars?: Record;
+ }
+) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
@@ -172,11 +145,6 @@ async function handleCreate({
);
}
- const args = ["ephemeral", "create", "--name", name.trim()];
- if (lifetime !== undefined) {
- args.push("--lifetime", String(lifetime));
- }
-
const mergedEnvVars: Record = { ...(envVars || {}) };
if (extension) {
mergedEnvVars.EXTENSION_AUTO_INSTALL = extension;
@@ -185,63 +153,50 @@ async function handleCreate({
mergedEnvVars.CLOUD_POD_NAME = cloudPod;
}
- for (const [key, value] of Object.entries(mergedEnvVars)) {
+ for (const key of Object.keys(mergedEnvVars)) {
if (!key || key.includes("=")) {
return ResponseBuilder.error(
"Invalid Environment Variable Key",
`Invalid env var key '${key}'. Keys must be non-empty and cannot contain '='.`
);
}
- args.push("--env", `${key}=${value}`);
}
- const result = await runCommand("localstack", args, {
- env: { ...process.env },
- timeout: 180000,
- });
- const cleaned = cleanOutput(result.stdout, result.stderr);
-
- if (result.exitCode !== 0) {
+ try {
+ const instance = await client.createEphemeralInstance({
+ name: name.trim(),
+ lifetime,
+ envVars: mergedEnvVars,
+ });
+ return ResponseBuilder.markdown(formatCreateResponse(instance));
+ } catch (error) {
return ResponseBuilder.error(
"Create Failed",
- cleaned.combined || "Failed to create ephemeral instance."
+ describePlatformError(error, `ephemeral instance '${name}'`)
);
}
-
- const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- return ResponseBuilder.markdown(formatCreateResponse(parsed as Record));
- }
-
- return ResponseBuilder.markdown(
- `## Ephemeral Instance Created\n\n${cleaned.combined || "Instance created successfully."}`
- );
}
-async function handleList() {
- const result = await runCommand("localstack", ["ephemeral", "list"], {
- env: { ...process.env },
- timeout: 120000,
- });
- const cleaned = cleanOutput(result.stdout, result.stderr);
-
- if (result.exitCode !== 0) {
- return ResponseBuilder.error("List Failed", cleaned.combined || "Failed to list ephemeral instances.");
- }
-
- const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
- if (parsed === null) {
+async function handleList(client: PlatformApiClient) {
+ try {
+ const instances = await client.listEphemeralInstances();
+ if (instances.length === 0) {
+ return ResponseBuilder.markdown(
+ "## Ephemeral Instances\n\nNo ephemeral instances found in your LocalStack Cloud workspace."
+ );
+ }
return ResponseBuilder.markdown(
- `## Ephemeral Instances\n\n\`\`\`\n${cleaned.combined || "No instances found."}\n\`\`\``
+ `## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(instances, null, 2)}\n\`\`\``
+ );
+ } catch (error) {
+ return ResponseBuilder.error(
+ "List Failed",
+ describePlatformError(error, "ephemeral instances")
);
}
-
- return ResponseBuilder.markdown(
- `## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``
- );
}
-async function handleLogs({ name }: { name?: string }) {
+async function handleLogs(client: PlatformApiClient, { name }: { name?: string }) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
@@ -249,29 +204,23 @@ async function handleLogs({ name }: { name?: string }) {
);
}
- const result = await runCommand("localstack", ["ephemeral", "logs", "--name", name.trim()], {
- env: { ...process.env },
- timeout: 180000,
- });
- const cleaned = cleanOutput(result.stdout, result.stderr);
-
- if (result.exitCode !== 0) {
+ try {
+ const logs = await client.getEphemeralInstanceLogs(name.trim());
+ if (!logs.trim()) {
+ return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`);
+ }
+ return ResponseBuilder.markdown(
+ `## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${logs}\n\`\`\``
+ );
+ } catch (error) {
return ResponseBuilder.error(
"Logs Failed",
- cleaned.combined || `Failed to fetch logs for instance '${name}'.`
+ describePlatformError(error, `ephemeral instance '${name}'`)
);
}
-
- if (!cleaned.combined) {
- return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`);
- }
-
- return ResponseBuilder.markdown(
- `## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${cleaned.combined}\n\`\`\``
- );
}
-async function handleDelete({ name }: { name?: string }) {
+async function handleDelete(client: PlatformApiClient, { name }: { name?: string }) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
@@ -279,18 +228,13 @@ async function handleDelete({ name }: { name?: string }) {
);
}
- const result = await runCommand("localstack", ["ephemeral", "delete", "--name", name.trim()], {
- env: { ...process.env },
- timeout: 120000,
- });
- const cleaned = cleanOutput(result.stdout, result.stderr);
-
- if (result.exitCode !== 0) {
+ try {
+ await client.deleteEphemeralInstance(name.trim());
+ return ResponseBuilder.markdown(`Successfully deleted instance: ${name} ✅`);
+ } catch (error) {
return ResponseBuilder.error(
"Delete Failed",
- cleaned.combined || `Failed to delete ephemeral instance '${name}'.`
+ describePlatformError(error, `ephemeral instance '${name}'`)
);
}
-
- return ResponseBuilder.markdown(cleaned.combined || `Successfully deleted instance: ${name} ✅`);
}
diff --git a/src/tools/localstack-extensions.ts b/src/tools/localstack-extensions.ts
index 1dc9bc1..b71dd87 100644
--- a/src/tools/localstack-extensions.ts
+++ b/src/tools/localstack-extensions.ts
@@ -1,17 +1,30 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
-import { HttpClient, HttpError } from "../core/http-client";
-import { runCommand, stripAnsiCodes } from "../core/command-runner";
import {
runPreflights,
- requireLocalStackCli,
requireLocalStackRunning,
requireProFeature,
requireAuthToken,
+ requireDockerDaemon,
} from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { ProFeature } from "../lib/localstack/license-checker";
import { withToolAnalytics } from "../core/analytics";
+import { DockerApiClient } from "../lib/docker/docker.client";
+import { restartRuntimeInPlace } from "../lib/localstack/localstack.utils";
+import {
+ EXTENSIONS_MANAGER_COMMAND,
+ EXTENSIONS_VENV_REPAIR_SCRIPT,
+ formatInstalledExtensions,
+ parseExtensionEvents,
+ parseInstalledExtensions,
+ summarizeInstall,
+ summarizeUninstall,
+ type ExtensionOutcome,
+} from "../lib/localstack/extensions.logic";
+import { PlatformApiClient, describePlatformError } from "../lib/localstack/platform.client";
+
+const EXTENSION_EXEC_TIMEOUT_MS = 120000;
export const schema = {
action: z
@@ -44,26 +57,23 @@ export const metadata: ToolMetadata = {
},
};
-interface MarketplaceExtension {
- name?: string;
- summary?: string;
- description?: string;
- author?: string;
- version?: string;
-}
-
export default async function localstackExtensions({
action,
name,
source,
}: InferSchema) {
return withToolAnalytics("localstack-extensions", { action, name, source }, async () => {
- const checks = [
- requireAuthToken(),
- requireLocalStackCli(),
- requireLocalStackRunning(),
- requireProFeature(ProFeature.EXTENSIONS),
- ];
+ // `available` only needs the platform API; the container-touching actions need a
+ // running LocalStack (the extension manager runs inside it) + the Docker daemon.
+ const checks =
+ action === "available"
+ ? [requireAuthToken()]
+ : [
+ requireAuthToken(),
+ requireDockerDaemon(),
+ requireLocalStackRunning(),
+ requireProFeature(ProFeature.EXTENSIONS),
+ ];
const preflightError = await runPreflights(checks);
if (preflightError) return preflightError;
@@ -83,40 +93,71 @@ export default async function localstackExtensions({
});
}
-function cleanOutput(stdout: string, stderr: string) {
- return {
- stdout: stripAnsiCodes(stdout || "").trim(),
- stderr: stripAnsiCodes(stderr || "").trim(),
- };
+/**
+ * Run the extension manager module inside the running LocalStack container. The
+ * manager pip-installs into the extensions venv on /var/lib/localstack — the exact
+ * mechanism the `localstack extensions` CLI used, minus the throwaway container.
+ * DEBUG=0 keeps the JSON-lines event stream free of runtime debug logging.
+ */
+async function runExtensionManager(args: string[], { repairVenv = false } = {}) {
+ const docker = new DockerApiClient();
+ const containerId = await docker.findLocalStackContainer();
+ if (repairVenv) {
+ await docker.executeInContainer(
+ containerId,
+ ["sh", "-c", EXTENSIONS_VENV_REPAIR_SCRIPT],
+ undefined,
+ {
+ env: ["DEBUG=0"],
+ timeoutMs: EXTENSION_EXEC_TIMEOUT_MS,
+ }
+ );
+ }
+ return await docker.executeInContainer(
+ containerId,
+ [...EXTENSIONS_MANAGER_COMMAND, ...args],
+ undefined,
+ { env: ["DEBUG=0"], timeoutMs: EXTENSION_EXEC_TIMEOUT_MS }
+ );
}
-function combineOutput(stdout: string, stderr: string): string {
- return [stdout, stderr].filter((part) => part.trim().length > 0).join("\n").trim();
+function outcomeSummaryBlock(outcome: ExtensionOutcome): string {
+ return outcome.summaryLines.length
+ ? `\n\n\`\`\`\n${outcome.summaryLines.join("\n")}\n\`\`\``
+ : "";
}
-async function handleList() {
- const cmd = await runCommand("localstack", ["extensions", "list"], {
- env: { ...process.env },
- });
- const cleaned = cleanOutput(cmd.stdout, cmd.stderr);
- const combined = combineOutput(cleaned.stdout, cleaned.stderr);
- const combinedLower = combined.toLowerCase();
+/** Activate an install/uninstall by restarting the runtime in place. */
+async function activationSuffix(): Promise {
+ const restart = await restartRuntimeInPlace();
+ if (restart.ok) {
+ return "\n\nLocalStack was restarted to activate the change. ✅";
+ }
+ return `\n\n⚠️ LocalStack restart did not confirm readiness: ${restart.detail} Please verify LocalStack status.`;
+}
- if (cmd.exitCode !== 0 && !combined) {
- return ResponseBuilder.error("List Failed", cleaned.stderr || "Failed to list installed extensions.");
+async function handleList() {
+ let result;
+ try {
+ result = await runExtensionManager(["list"]);
+ } catch (error) {
+ return ResponseBuilder.error(
+ "List Failed",
+ `Could not run the extension manager in the LocalStack container: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
}
- const looksEmpty =
- !combined ||
- combinedLower.includes("no extensions installed") ||
- combinedLower.includes("no extension installed");
- if (looksEmpty) {
- return ResponseBuilder.markdown(
- "No LocalStack extensions are currently installed.\n\nUse the `available` action to browse the marketplace."
+ const extensions = parseInstalledExtensions(result.stdout);
+ if (extensions.length === 0 && result.exitCode !== 0) {
+ return ResponseBuilder.error(
+ "List Failed",
+ result.stderr || result.stdout || "Failed to list installed extensions."
);
}
- return ResponseBuilder.markdown(`## Installed LocalStack Extensions\n\n\`\`\`\n${combined}\n\`\`\``);
+ return ResponseBuilder.markdown(formatInstalledExtensions(extensions));
}
async function handleInstall(name?: string, source?: string) {
@@ -130,60 +171,43 @@ async function handleInstall(name?: string, source?: string) {
}
const target = source || name!;
- const cmd = await runCommand("localstack", ["extensions", "install", target], {
- env: { ...process.env },
- timeout: 120000,
- });
- const cleaned = cleanOutput(cmd.stdout, cmd.stderr);
- const combined = combineOutput(cleaned.stdout, cleaned.stderr);
- const combinedLower = combined.toLowerCase();
-
- if (combinedLower.includes("could not resolve package")) {
+ let result;
+ try {
+ result = await runExtensionManager(["install", target], { repairVenv: true });
+ } catch (error) {
return ResponseBuilder.error(
- "Extension Not Found",
- `Could not resolve the extension package '${name || target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.`
+ "Install Failed",
+ `Could not run the extension manager in the LocalStack container: ${
+ error instanceof Error ? error.message : String(error)
+ }`
);
}
- if (combinedLower.includes("no module named 'localstack.pro'")) {
+ const outcome = summarizeInstall(parseExtensionEvents(result.stdout));
+
+ if (outcome.kind === "not-found") {
return ResponseBuilder.error(
- "Auth Token Required",
- "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license."
+ "Extension Not Found",
+ `Could not resolve the extension package '${target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.`
);
}
-
- if (
- combinedLower.includes("non-zero exit status") ||
- combinedLower.includes("returned non-zero")
- ) {
+ if (!outcome.success) {
return ResponseBuilder.error(
"Install Failed",
- "The extension could not be installed from the provided source. The repository may not contain valid LocalStack extension code. Run the command again with --verbose for more details, or check that the repository contains a proper LocalStack extension."
+ `${outcome.errorDetail || "Extension installation failed."}${outcomeSummaryBlock(outcome)}`
);
}
- const hasSuccessPattern = combinedLower.includes("extension successfully installed");
- if (cmd.exitCode !== 0 && !hasSuccessPattern) {
- return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed.");
- }
-
- if (hasSuccessPattern || cmd.exitCode === 0) {
- const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 });
- const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr);
- const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr);
-
- let response = `## Extension Installation Result\n\n\`\`\`\n${combined || "Extension successfully installed."}\n\`\`\`\n\n`;
- response += "LocalStack was restarted to activate the extension.";
- if (restartCombined) {
- response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``;
- }
- if (restartCmd.exitCode !== 0) {
- response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status.";
- }
- return ResponseBuilder.markdown(response);
+ if (outcome.kind === "already-installed") {
+ return ResponseBuilder.markdown(
+ `## Extension Installation Result${outcomeSummaryBlock(outcome)}\n\nThe extension is already installed — no restart needed.`
+ );
}
- return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed.");
+ const restartNote = await activationSuffix();
+ return ResponseBuilder.markdown(
+ `## Extension Installation Result${outcomeSummaryBlock(outcome)}${restartNote}`
+ );
}
async function handleUninstall(name?: string) {
@@ -194,67 +218,45 @@ async function handleUninstall(name?: string) {
);
}
- const cmd = await runCommand("localstack", ["extensions", "uninstall", name], {
- env: { ...process.env },
- timeout: 60000,
- });
- const cleaned = cleanOutput(cmd.stdout, cmd.stderr);
- const combined = combineOutput(cleaned.stdout, cleaned.stderr);
- const combinedLower = combined.toLowerCase();
-
- if (combinedLower.includes("no module named 'localstack.pro'")) {
+ let result;
+ try {
+ result = await runExtensionManager(["uninstall", name], { repairVenv: true });
+ } catch (error) {
return ResponseBuilder.error(
- "Auth Token Required",
- "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license."
+ "Uninstall Failed",
+ `Could not run the extension manager in the LocalStack container: ${
+ error instanceof Error ? error.message : String(error)
+ }`
);
}
- const hasSuccessPattern = combinedLower.includes("extension successfully uninstalled");
- if (cmd.exitCode !== 0 && !hasSuccessPattern) {
- return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed.");
- }
-
- if (hasSuccessPattern || cmd.exitCode === 0) {
- const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 });
- const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr);
- const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr);
+ const outcome = summarizeUninstall(parseExtensionEvents(result.stdout));
- let response = `## Extension Uninstall Result\n\n\`\`\`\n${combined || "Extension successfully uninstalled."}\n\`\`\`\n\n`;
- response += "LocalStack was restarted to apply extension removal.";
- if (restartCombined) {
- response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``;
- }
- if (restartCmd.exitCode !== 0) {
- response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status.";
- }
- return ResponseBuilder.markdown(response);
+ if (outcome.kind === "not-installed") {
+ return ResponseBuilder.error(
+ "Extension Not Installed",
+ outcome.errorDetail || `Extension '${name}' is not installed.`
+ );
+ }
+ if (!outcome.success) {
+ return ResponseBuilder.error(
+ "Uninstall Failed",
+ `${outcome.errorDetail || "Extension uninstallation failed."}${outcomeSummaryBlock(outcome)}`
+ );
}
- return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed.");
+ const restartNote = await activationSuffix();
+ return ResponseBuilder.markdown(
+ `## Extension Uninstall Result${outcomeSummaryBlock(outcome)}${restartNote}`
+ );
}
async function handleAvailable() {
const token = process.env.LOCALSTACK_AUTH_TOKEN!;
-
- const encoded = Buffer.from(`:${token}`).toString("base64");
- const client = new HttpClient();
+ const client = new PlatformApiClient(token);
try {
- const marketplace = await client.request(
- "https://api.localstack.cloud/v1/extensions/marketplace",
- {
- method: "GET",
- baseUrl: "",
- headers: {
- Authorization: `Basic ${encoded}`,
- Accept: "application/json",
- },
- }
- );
-
- if (!Array.isArray(marketplace)) {
- return ResponseBuilder.error("Marketplace Fetch Failed", "Unexpected marketplace response format.");
- }
+ const marketplace = await client.getExtensionsMarketplace();
const simplified = marketplace.map((item) => ({
name: item.name || "unknown-extension",
@@ -270,14 +272,9 @@ async function handleAvailable() {
return ResponseBuilder.markdown(markdown);
} catch (error) {
- if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
- return ResponseBuilder.error(
- "Authentication Failed",
- "Could not fetch the marketplace. Ensure LOCALSTACK_AUTH_TOKEN is set correctly."
- );
- }
-
- const message = error instanceof Error ? error.message : String(error);
- return ResponseBuilder.error("Marketplace Fetch Failed", message);
+ return ResponseBuilder.error(
+ "Marketplace Fetch Failed",
+ describePlatformError(error, "the extensions marketplace")
+ );
}
}
diff --git a/src/tools/localstack-iam-policy-analyzer.ts b/src/tools/localstack-iam-policy-analyzer.ts
index 8cbae0f..4926acd 100644
--- a/src/tools/localstack-iam-policy-analyzer.ts
+++ b/src/tools/localstack-iam-policy-analyzer.ts
@@ -13,7 +13,6 @@ import {
import {
runPreflights,
requireAuthToken,
- requireLocalStackCli,
requireLocalStackRunning,
requireProFeature,
} from "../core/preflight";
@@ -58,7 +57,6 @@ export default async function localstackIamPolicyAnalyzer({
return withToolAnalytics("localstack-iam-policy-analyzer", { action, mode }, async () => {
const preflightError = await runPreflights([
requireAuthToken(),
- requireLocalStackCli(),
requireLocalStackRunning(),
requireProFeature(ProFeature.IAM_ENFORCEMENT),
]);
diff --git a/src/tools/localstack-logs-analysis.ts b/src/tools/localstack-logs-analysis.ts
index 672b8a0..4cd63d7 100644
--- a/src/tools/localstack-logs-analysis.ts
+++ b/src/tools/localstack-logs-analysis.ts
@@ -1,12 +1,7 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
import { LocalStackLogRetriever, type LogEntry } from "../lib/logs/log-retriever";
-import {
- runPreflights,
- requireAuthToken,
- requireLocalStackCli,
- requireLocalStackRunning,
-} from "../core/preflight";
+import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { withToolAnalytics } from "../core/analytics";
@@ -61,11 +56,7 @@ export default async function localstackLogsAnalysis({
"localstack-logs-analysis",
{ analysisType, lines, service, operation, filter },
async () => {
- const preflightError = await runPreflights([
- requireAuthToken(),
- requireLocalStackCli(),
- requireLocalStackRunning(),
- ]);
+ const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]);
if (preflightError) return preflightError;
const retriever = new LocalStackLogRetriever();
@@ -73,7 +64,10 @@ export default async function localstackLogsAnalysis({
const logResult = await retriever.retrieveLogs(lines, retrievalFilter);
if (!logResult.success) {
- return ResponseBuilder.error("Log Retrieval Failed", logResult.errorMessage || "Unknown error");
+ return ResponseBuilder.error(
+ "Log Retrieval Failed",
+ logResult.errorMessage || "Unknown error"
+ );
}
switch (analysisType) {
diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts
index 3ba1079..c76e593 100644
--- a/src/tools/localstack-management.ts
+++ b/src/tools/localstack-management.ts
@@ -1,23 +1,27 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
import {
- detectLifecycleCli,
- type LifecycleCli,
getLocalStackStatus,
getSnowflakeEmulatorStatus,
- startRuntime,
+ launchRuntime,
+ resolveContainerName,
} from "../lib/localstack/localstack.utils";
import {
DockerApiClient,
isLocalStackContainerNotFoundError,
type ContainerMetadata,
} from "../lib/docker/docker.client";
-import { runPreflights, requireProFeature, requireAuthToken } from "../core/preflight";
+import type { VolumeResolution } from "../lib/localstack/container-spec.logic";
+import {
+ runPreflights,
+ requireProFeature,
+ requireAuthToken,
+ requireDockerDaemon,
+} from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { ProFeature } from "../lib/localstack/license-checker";
import { withToolAnalytics } from "../core/analytics";
-type ToolResponse = ReturnType;
const AWS_ALREADY_RUNNING_MESSAGE =
"⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration.";
const SNOWFLAKE_ALREADY_RUNNING_MESSAGE =
@@ -56,10 +60,15 @@ export default async function localstackManagement({
envVars,
}: InferSchema) {
return withToolAnalytics("localstack-management", { action, service, envVars }, async () => {
- // No CLI preflight: stop/restart/status drive LocalStack via the Docker API +
- // gateway, and start detects whichever lifecycle CLI is present (localstack or
- // lstk) itself — so an lstk-only host is no longer blocked here.
- const checks: Array> = [requireAuthToken()];
+ // Lifecycle runs entirely through the Docker Engine API + the LocalStack gateway;
+ // no localstack/lstk CLI is required (or used).
+ const checks: Array<
+ ReturnType | Promise>
+ > = [requireAuthToken()];
+
+ if (action === "start" || action === "restart" || action === "stop") {
+ checks.push(requireDockerDaemon());
+ }
if (service === "snowflake") {
// `start` can run when no LocalStack runtime is currently up; validate feature after startup.
@@ -87,74 +96,55 @@ export default async function localstackManagement({
});
}
+interface StartOverrides {
+ imageOverride?: string;
+ containerNameOverride?: string;
+ volumeOverride?: VolumeResolution;
+}
+
// Handle start action
async function handleStart({
envVars,
service,
- cli,
+ overrides,
}: {
envVars?: Record;
service: "aws" | "snowflake";
- cli?: LifecycleCli;
+ overrides?: StartOverrides;
}) {
if (service === "snowflake") {
- return await handleSnowflakeStart({ envVars });
- }
-
- const status = await getLocalStackStatus({ includeCliStatus: false });
- if (status.isReady || status.isRunning) {
- return ResponseBuilder.markdown(AWS_ALREADY_RUNNING_MESSAGE);
+ return await launchRuntime({
+ stack: "snowflake",
+ envVars,
+ getStatus: getSnowflakeEmulatorStatus,
+ processLabel: "Snowflake emulator",
+ alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE,
+ successTitle: "🚀 Snowflake emulator started successfully!",
+ statusHeading: "Health check",
+ timeoutMessage:
+ '❌ Snowflake emulator start timed out after 120 seconds. Health check endpoint did not return {"success": true}. If this was the first start, the image pull may still be in progress — retry in a bit.',
+ onReady: async () => await requireProFeature(ProFeature.SNOWFLAKE),
+ ...overrides,
+ });
}
- const lifecycleCli = cli || (await detectLifecycleCli());
- if (!lifecycleCli) return noLifecycleCliFoundResponse("Starting");
-
- return await startRuntime({
- cli: lifecycleCli,
- // lstk would otherwise prompt; force non-interactive when spawned headless.
- startArgs: lifecycleCli === "lstk" ? ["start", "--non-interactive"] : ["start"],
- getStatus: () => getLocalStackStatus({ includeCliStatus: false }),
+ return await launchRuntime({
+ stack: "aws",
+ envVars,
+ getStatus: getLocalStackStatus,
processLabel: "LocalStack",
alreadyRunningMessage: AWS_ALREADY_RUNNING_MESSAGE,
successTitle: "🚀 LocalStack started successfully!",
statusHeading: "Status",
timeoutMessage:
- "❌ LocalStack start timed out after 120 seconds. It may still be starting in the background.",
- envVars,
- });
-}
-
-async function handleSnowflakeStart({ envVars }: { envVars?: Record }) {
- const status = await getSnowflakeEmulatorStatus();
- if (status.isReady || status.isRunning) {
- return ResponseBuilder.markdown(SNOWFLAKE_ALREADY_RUNNING_MESSAGE);
- }
-
- // The Snowflake stack is localstack-only (`--stack snowflake` has no lstk equivalent).
- if ((await detectLifecycleCli(["localstack"])) !== "localstack") {
- return ResponseBuilder.error(
- "localstack CLI required",
- "Starting the Snowflake stack requires the Python `localstack` CLI (the `--stack snowflake` flag is localstack-only). Install it with `pip install localstack`."
- );
- }
-
- return await startRuntime({
- cli: "localstack",
- startArgs: ["start", "--stack", "snowflake"],
- getStatus: getSnowflakeEmulatorStatus,
- processLabel: "Snowflake emulator",
- alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE,
- successTitle: "🚀 Snowflake emulator started successfully!",
- statusHeading: "Health check",
- timeoutMessage:
- '❌ Snowflake emulator start timed out after 120 seconds. Health check endpoint did not return {"success": true}.',
- envVars,
- onReady: async () => await requireProFeature(ProFeature.SNOWFLAKE),
+ "❌ LocalStack start timed out after 120 seconds. It may still be starting in the background. If this was the first start, the image pull may still be in progress — retry in a bit.",
+ ...overrides,
});
}
-// Handle stop action — stop the detected container via the Docker API (no CLI needed,
-// works regardless of which CLI started it).
+// Handle stop action — stop the detected container via the Docker API. Also cleans up
+// stopped/stale containers occupying a LocalStack name, so start's conflict advice
+// ("stop it first") always has a working recovery path.
async function handleStop() {
const dockerClient = new DockerApiClient();
let containerId: string;
@@ -167,7 +157,22 @@ async function handleStop() {
`Could not inspect Docker containers: ${error instanceof Error ? error.message : String(error)}`
);
}
- const status = await getLocalStackStatus({ includeCliStatus: false });
+
+ // No RUNNING container found — check for a stale stopped one holding the name.
+ try {
+ const stale = await dockerClient.findContainerByNameAnyState(
+ resolveContainerName(process.env)
+ );
+ if (stale && !stale.running) {
+ await dockerClient.removeContainer(stale.id);
+ await dockerClient.waitForRemoval(stale.id);
+ return ResponseBuilder.markdown(`🛑 Removed stopped LocalStack container "${stale.name}".`);
+ }
+ } catch {
+ // fall through to the gateway-based reporting below
+ }
+
+ const status = await getLocalStackStatus();
if (status.isRunning) {
return ResponseBuilder.error(
"LocalStack container not found",
@@ -191,8 +196,10 @@ async function handleStop() {
}
}
-// Handle restart action — stop the running container (Docker API), then start fresh
-// (applies any new envVars). Falls through to start if nothing is running.
+// Handle restart action — stop the running container, then start fresh (applies any
+// new envVars). The recreate reuses the original container's image, name, and volume
+// so an externally-provisioned runtime (lstk's localstack-aws, custom names/images)
+// is not silently replaced by our defaults — that would strand its state.
async function handleRestart({
envVars,
service,
@@ -213,7 +220,7 @@ async function handleRestart({
}`
);
}
- const status = await getLocalStackStatus({ includeCliStatus: false });
+ const status = await getLocalStackStatus();
if (status.isRunning) {
return ResponseBuilder.error(
"LocalStack container not found",
@@ -224,18 +231,16 @@ async function handleRestart({
return await handleStart({ envVars, service });
}
- const cli = await detectCliForRestart(dockerClient, containerId, service);
- if (!cli) {
- return service === "snowflake"
- ? ResponseBuilder.error(
- "localstack CLI required",
- "Restarting the Snowflake stack requires the Python `localstack` CLI on PATH."
- )
- : noLifecycleCliFoundResponse("Restarting");
+ let metadata: ContainerMetadata | undefined;
+ try {
+ metadata = await dockerClient.inspectContainer(containerId);
+ } catch {
+ metadata = undefined;
}
try {
await dockerClient.stopContainer(containerId);
+ await dockerClient.waitForRemoval(containerId);
} catch (error) {
return ResponseBuilder.error(
"Failed to stop LocalStack",
@@ -245,47 +250,37 @@ async function handleRestart({
);
}
- await new Promise((resolve) => setTimeout(resolve, 2000));
- return await handleStart({ envVars, service, cli });
+ return await handleStart({ envVars, service, overrides: recreateOverrides(metadata, service) });
}
-function noLifecycleCliFoundResponse(action: "Starting" | "Restarting") {
- return ResponseBuilder.error(
- "No LocalStack CLI found",
- `${action} LocalStack needs the \`localstack\` or \`lstk\` CLI on PATH, but neither was found. ` +
- "Install one (`pip install localstack`, or the `lstk` CLI), or start LocalStack yourself (e.g. `lstk start`) — " +
- "the other tools drive it via the Docker API and gateway."
- );
-}
-
-async function detectCliForRestart(
- dockerClient: DockerApiClient,
- containerId: string,
+/**
+ * Derive start overrides from the container we just stopped. Reuse only applies when
+ * the previous image matches the requested stack — restarting with service=snowflake
+ * while the AWS stack was running deliberately switches stacks (previous behavior).
+ */
+function recreateOverrides(
+ metadata: ContainerMetadata | undefined,
service: "aws" | "snowflake"
-): Promise {
- if (service === "snowflake") return await detectLifecycleCli(["localstack"]);
-
- let metadata: ContainerMetadata | undefined;
- try {
- metadata = await dockerClient.inspectContainer(containerId);
- } catch {
- metadata = undefined;
- }
-
- return await detectLifecycleCli(preferredCliOrder(metadata));
-}
-
-function preferredCliOrder(metadata?: ContainerMetadata): LifecycleCli[] {
- const name = metadata?.name || "";
- const mainContainerName = (metadata?.env || [])
- .find((entry) => entry.startsWith("MAIN_CONTAINER_NAME="))
- ?.slice("MAIN_CONTAINER_NAME=".length);
-
- if (name === "localstack-aws" || mainContainerName === "localstack-aws") {
- return ["lstk", "localstack"];
+): StartOverrides | undefined {
+ if (!metadata?.image) return undefined;
+ const previousStack = metadata.image.includes("/snowflake") ? "snowflake" : "aws";
+ if (previousStack !== service) return undefined;
+
+ const overrides: StartOverrides = {
+ imageOverride: metadata.image,
+ containerNameOverride: metadata.name,
+ };
+
+ const volumeMount = (metadata.mounts || []).find(
+ (mount) => mount.destination === "/var/lib/localstack"
+ );
+ if (volumeMount?.type === "bind" && volumeMount.source) {
+ overrides.volumeOverride = { type: "bind", source: volumeMount.source };
+ } else if (volumeMount?.type === "volume" && volumeMount.name) {
+ overrides.volumeOverride = { type: "volume", name: volumeMount.name };
}
- return ["localstack", "lstk"];
+ return overrides;
}
// Handle status action
diff --git a/src/tools/localstack-snowflake-client.ts b/src/tools/localstack-snowflake-client.ts
index d31e2fb..a2939f9 100644
--- a/src/tools/localstack-snowflake-client.ts
+++ b/src/tools/localstack-snowflake-client.ts
@@ -51,10 +51,7 @@ async function requireSnowflakeConnectionProfile() {
const listResult = await runCommand("snow", ["connection", "list"], { env: { ...process.env } });
const listCombined = `${listResult.stdout || ""}\n${listResult.stderr || ""}`.toLowerCase();
- if (
- listResult.exitCode === 0 &&
- listCombined.includes(SNOWFLAKE_CONNECTION_NAME.toLowerCase())
- ) {
+ if (listResult.exitCode === 0 && listCombined.includes(SNOWFLAKE_CONNECTION_NAME.toLowerCase())) {
return null;
}
@@ -135,7 +132,10 @@ export default async function localstackSnowflakeClient({
return ResponseBuilder.markdown(result.stdout || "");
}
- return ResponseBuilder.error("Connection Check Failed", (result.stderr || result.stdout || "").trim());
+ return ResponseBuilder.error(
+ "Connection Check Failed",
+ (result.stderr || result.stdout || "").trim()
+ );
}
const hasQuery = !!query;
@@ -160,7 +160,12 @@ export default async function localstackSnowflakeClient({
return ResponseBuilder.markdown(result.stdout || "");
}
- const rawError = (result.stderr || result.stdout || result.error?.message || "Unknown error").trim();
+ const rawError = (
+ result.stderr ||
+ result.stdout ||
+ result.error?.message ||
+ "Unknown error"
+ ).trim();
return ResponseBuilder.error(
"Command Failed",
`${rawError}\n\nCheck Snowflake feature coverage: https://docs.localstack.cloud/snowflake/feature-coverage/`
diff --git a/tests/docker/validate-image.mjs b/tests/docker/validate-image.mjs
index bae04d3..4d587aa 100644
--- a/tests/docker/validate-image.mjs
+++ b/tests/docker/validate-image.mjs
@@ -13,8 +13,6 @@
* node tests/docker/validate-image.mjs -- \
* docker run -i --rm \
* -v /var/run/docker.sock:/var/run/docker.sock \
- * -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
- * -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
* --add-host host.docker.internal:host-gateway \
* --add-host s3.host.docker.internal:host-gateway \
* --add-host snowflake.localhost.localstack.cloud:host-gateway \
@@ -22,10 +20,9 @@
* -v "$PWD/data:/work/data" \
* localstack/localstack-mcp-server:dev
*
- * The cache bind mount + XDG_CACHE_HOME are required for the management tool's
- * `start` action under Docker-out-of-Docker: `localstack start` asks the HOST
- * daemon to bind-mount its license/machine/volume files, whose source paths must
- * exist at an identical path on the host (see docs/DOCKER.md).
+ * The server creates the LocalStack sibling container itself through the mounted
+ * Docker socket; state lives in a named Docker volume by default (set
+ * LOCALSTACK_VOLUME_DIR to a HOST path to use a bind mount instead).
*
* Env knobs:
* HARNESS_DEPLOY_DIR In-container path to the terraform sample (default /work/data/sample-terraform)
@@ -52,7 +49,12 @@ const DEPLOY_DIR = process.env.HARNESS_DEPLOY_DIR || "/work/data/sample-terrafor
const CDK_DIR = process.env.HARNESS_CDK_DIR || "/work/data/sample-cdk";
const SQL_FILE = process.env.HARNESS_SQL_FILE || "/work/data/sample-sql/snowflake_test.sql";
const TOKEN_REAL = process.env.HARNESS_TOKEN_REAL === "1";
-const SKIP = new Set((process.env.HARNESS_SKIP || "").split(",").map((s) => s.trim()).filter(Boolean));
+const SKIP = new Set(
+ (process.env.HARNESS_SKIP || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+);
const NO_CLEANUP = process.env.HARNESS_NO_CLEANUP === "1";
const RUN_REMOTE = process.env.HARNESS_RUN_REMOTE === "1";
const RUN_EPHEMERAL = RUN_REMOTE || process.env.HARNESS_RUN_EPHEMERAL === "1";
@@ -60,11 +62,20 @@ const RUN_REPLICATOR_START = process.env.HARNESS_RUN_REPLICATOR_START === "1";
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const EXPECTED_TOOLS = [
- "localstack-management", "localstack-deployer", "localstack-logs-analysis",
- "localstack-iam-policy-analyzer", "localstack-chaos-injector", "localstack-cloud-pods",
- "localstack-state-management", "localstack-extensions", "localstack-snowflake-client",
- "localstack-ephemeral-instances", "localstack-aws-client", "localstack-aws-replicator",
- "localstack-docs", "localstack-app-inspector",
+ "localstack-management",
+ "localstack-deployer",
+ "localstack-logs-analysis",
+ "localstack-iam-policy-analyzer",
+ "localstack-chaos-injector",
+ "localstack-cloud-pods",
+ "localstack-state-management",
+ "localstack-extensions",
+ "localstack-snowflake-client",
+ "localstack-ephemeral-instances",
+ "localstack-aws-client",
+ "localstack-aws-replicator",
+ "localstack-docs",
+ "localstack-app-inspector",
];
// ---- JSON-RPC over stdio ----------------------------------------------------
@@ -82,7 +93,12 @@ child.stdout.on("data", (chunk) => {
buf = buf.slice(nl + 1);
if (!line) continue;
let msg;
- try { msg = JSON.parse(line); } catch { serverLog.push(`[stdout-nonjson] ${line}`); continue; }
+ try {
+ msg = JSON.parse(line);
+ } catch {
+ serverLog.push(`[stdout-nonjson] ${line}`);
+ continue;
+ }
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
@@ -93,7 +109,8 @@ child.stdout.on("data", (chunk) => {
});
child.stderr.on("data", (c) => serverLog.push(`[stderr] ${c.toString().trimEnd()}`));
child.on("exit", (code, sig) => {
- for (const { reject } of pending.values()) reject(new Error(`server exited (code=${code} sig=${sig})`));
+ for (const { reject } of pending.values())
+ reject(new Error(`server exited (code=${code} sig=${sig})`));
pending.clear();
});
@@ -106,8 +123,14 @@ function rpc(method, params, timeoutMs = 300000) {
reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}`));
}, timeoutMs);
pending.set(id, {
- resolve: (r) => { clearTimeout(timer); resolve(r); },
- reject: (e) => { clearTimeout(timer); reject(e); },
+ resolve: (r) => {
+ clearTimeout(timer);
+ resolve(r);
+ },
+ reject: (e) => {
+ clearTimeout(timer);
+ reject(e);
+ },
});
child.stdin.write(payload);
});
@@ -125,11 +148,18 @@ async function getPrompt(name, args, timeoutMs = 60000) {
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Call a tool, retrying until `ok(result)` (default: not an error) or attempts run out.
-async function callToolUntil(name, args, { attempts = 6, delayMs = 5000, timeoutMs = 60000, ok } = {}) {
+async function callToolUntil(
+ name,
+ args,
+ { attempts = 6, delayMs = 5000, timeoutMs = 60000, ok } = {}
+) {
let last;
for (let i = 0; i < attempts; i++) {
- try { last = await callTool(name, args, timeoutMs); }
- catch (e) { last = { text: String(e.message), isError: true }; }
+ try {
+ last = await callTool(name, args, timeoutMs);
+ } catch (e) {
+ last = { text: String(e.message), isError: true };
+ }
if (ok ? ok(last) : !last.isError) return last;
if (i < attempts - 1) await sleep(delayMs);
}
@@ -146,20 +176,24 @@ function record(key, name, ok, detail, note) {
if (note) console.log(" ↳ " + note);
}
const snip = (t, n = 400) => (t || "").replace(/\s+/g, " ").trim().slice(0, n);
-const hasAwsCreds = () => Boolean(
- process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID ||
- process.env.AWS_ACCESS_KEY_ID
-) && Boolean(
- process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY ||
- process.env.AWS_SECRET_ACCESS_KEY
-) && Boolean(
- process.env.AWS_REPLICATOR_SOURCE_REGION_NAME ||
- process.env.AWS_DEFAULT_REGION ||
- process.env.AWS_REGION
-);
+const hasAwsCreds = () =>
+ Boolean(process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID) &&
+ Boolean(
+ process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY
+ ) &&
+ Boolean(
+ process.env.AWS_REPLICATOR_SOURCE_REGION_NAME ||
+ process.env.AWS_DEFAULT_REGION ||
+ process.env.AWS_REGION
+ );
function gracefulProGate(result) {
- return result.isError && /(Authentication|Auth Token|Feature Not Available|license|not seem to include|requires a LocalStack license)/i.test(result.text);
+ return (
+ result.isError &&
+ /(Authentication|Auth Token|Feature Not Available|license|not seem to include|requires a LocalStack license)/i.test(
+ result.text
+ )
+ );
}
function recordToolResult(key, name, result, predicate = (r) => !r.isError, note) {
@@ -180,10 +214,17 @@ function recordToolResult(key, name, result, predicate = (r) => !r.isError, note
function firstMarkdownTableValue(text) {
for (const line of (text || "").split("\n")) {
const trimmed = line.trim();
- if (!trimmed.startsWith("|") || /^[-|:\s]+$/.test(trimmed) || /Trace ID|Span ID|Event ID/.test(trimmed)) {
+ if (
+ !trimmed.startsWith("|") ||
+ /^[-|:\s]+$/.test(trimmed) ||
+ /Trace ID|Span ID|Event ID/.test(trimmed)
+ ) {
continue;
}
- const cells = trimmed.split("|").map((cell) => cell.trim()).filter(Boolean);
+ const cells = trimmed
+ .split("|")
+ .map((cell) => cell.trim())
+ .filter(Boolean);
if (cells[0] && cells[0] !== "-") return cells[0].replace(/^`|`$/g, "");
}
return undefined;
@@ -191,27 +232,42 @@ function firstMarkdownTableValue(text) {
async function main() {
// 1. Handshake
- const init = await rpc("initialize", {
- protocolVersion: "2024-11-05",
- capabilities: {},
- clientInfo: { name: "ls-mcp-docker-validator", version: "1.0.0" },
- }, 60000);
+ const init = await rpc(
+ "initialize",
+ {
+ protocolVersion: "2024-11-05",
+ capabilities: {},
+ clientInfo: { name: "ls-mcp-docker-validator", version: "1.0.0" },
+ },
+ 60000
+ );
notify("notifications/initialized", {});
- console.log(`Connected. Server: ${init?.serverInfo?.name ?? "?"} ${init?.serverInfo?.version ?? ""}`);
+ console.log(
+ `Connected. Server: ${init?.serverInfo?.name ?? "?"} ${init?.serverInfo?.version ?? ""}`
+ );
// 2. tools/list
const toolsRes = await rpc("tools/list", {}, 60000);
const toolNames = (toolsRes?.tools || []).map((t) => t.name);
const missing = EXPECTED_TOOLS.filter((t) => !toolNames.includes(t));
- record("tools", "tools/list exposes all 14 tools", missing.length === 0,
- `found ${toolNames.length} tools`, missing.length ? `MISSING: ${missing.join(", ")}` : undefined);
+ record(
+ "tools",
+ "tools/list exposes all 14 tools",
+ missing.length === 0,
+ `found ${toolNames.length} tools`,
+ missing.length ? `MISSING: ${missing.join(", ")}` : undefined
+ );
// 2b. prompts/get
if (!SKIP.has("prompt")) {
try {
const prompts = await rpc("prompts/list", {}, 60000);
- const hasPrompt = (prompts?.prompts || []).some((prompt) => prompt.name === "infrastructure-tester");
- const prompt = await getPrompt("infrastructure-tester", { iac_path: "/work/data/sample-terraform" });
+ const hasPrompt = (prompts?.prompts || []).some(
+ (prompt) => prompt.name === "infrastructure-tester"
+ );
+ const prompt = await getPrompt("infrastructure-tester", {
+ iac_path: "/work/data/sample-terraform",
+ });
const text = (prompt?.messages || []).map((msg) => msg?.content?.text || "").join("\n");
record(
"prompt",
@@ -220,7 +276,9 @@ async function main() {
snip(text, 400),
hasPrompt ? undefined : "prompt missing from prompts/list"
);
- } catch (e) { record("prompt", "infrastructure-tester prompt", false, String(e.message)); }
+ } catch (e) {
+ record("prompt", "infrastructure-tester prompt", false, String(e.message));
+ }
}
// 3. docs (token-only; calls an external API, so retry once for transient blips)
@@ -229,18 +287,32 @@ async function main() {
const r = await callToolUntil(
"localstack-docs",
{ query: "how to start localstack and configure auth token", limit: 2 },
- { attempts: 2, delayMs: 3000, timeoutMs: 60000, ok: (x) => !x.isError && /LocalStack Docs/i.test(x.text) }
+ {
+ attempts: 2,
+ delayMs: 3000,
+ timeoutMs: 60000,
+ ok: (x) => !x.isError && /LocalStack Docs/i.test(x.text),
+ }
);
- record("docs", "localstack-docs returns snippets", !r.isError && /LocalStack Docs/i.test(r.text), snip(r.text));
- } catch (e) { record("docs", "localstack-docs", false, String(e.message)); }
+ record(
+ "docs",
+ "localstack-docs returns snippets",
+ !r.isError && /LocalStack Docs/i.test(r.text),
+ snip(r.text)
+ );
+ } catch (e) {
+ record("docs", "localstack-docs", false, String(e.message));
+ }
}
- // 4. management status (pre-start) — validates CLI + docker socket reachability
+ // 4. management status (pre-start) — validates gateway probe + docker socket reachability
if (!SKIP.has("status")) {
try {
const r = await callTool("localstack-management", { action: "status" }, 60000);
record("status", "localstack-management status (pre-start)", !r.isError, snip(r.text));
- } catch (e) { record("status", "management status", false, String(e.message)); }
+ } catch (e) {
+ record("status", "management status", false, String(e.message));
+ }
}
// 5. management start
@@ -249,7 +321,9 @@ async function main() {
const r = await callTool("localstack-management", { action: "start" }, 240000);
const ok = !r.isError && /(started successfully|already running)/i.test(r.text);
record("start", "localstack-management start", ok, snip(r.text, 500));
- } catch (e) { record("start", "management start", false, String(e.message)); }
+ } catch (e) {
+ record("start", "management start", false, String(e.message));
+ }
}
// 5b. Readiness gate — after a cold start the container reports "running" before
@@ -261,31 +335,72 @@ async function main() {
{ command: "sts get-caller-identity" },
{ attempts: 24, delayMs: 5000, timeoutMs: 30000 }
);
- console.log(`\n[readiness] LocalStack services ${ready.isError ? "NOT ready after wait" : "ready"}`);
+ console.log(
+ `\n[readiness] LocalStack services ${ready.isError ? "NOT ready after wait" : "ready"}`
+ );
}
// 6. aws-client — validates docker exec of awslocal inside the LS container
if (!SKIP.has("aws")) {
try {
- const mb = await callTool("localstack-aws-client", { command: "s3 mb s3://harness-test-bucket" }, 60000);
+ const mb = await callTool(
+ "localstack-aws-client",
+ { command: "s3 mb s3://harness-test-bucket" },
+ 60000
+ );
const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
const ok = !mb.isError && !ls.isError && /harness-test-bucket/.test(ls.text);
- record("aws", "localstack-aws-client (awslocal s3 mb + ls)", ok, `mb: ${snip(mb.text, 120)} | ls: ${snip(ls.text, 200)}`);
- } catch (e) { record("aws", "aws-client", false, String(e.message)); }
+ record(
+ "aws",
+ "localstack-aws-client (awslocal s3 mb + ls)",
+ ok,
+ `mb: ${snip(mb.text, 120)} | ls: ${snip(ls.text, 200)}`
+ );
+ } catch (e) {
+ record("aws", "aws-client", false, String(e.message));
+ }
}
// 6b. logs-analysis — validates docker log access to the sibling LocalStack container.
if (!SKIP.has("logs")) {
try {
- await callTool("localstack-aws-client", { command: "s3api head-bucket --bucket definitely-missing-harness-bucket" }, 60000).catch(() => {});
- const summary = await callTool("localstack-logs-analysis", { analysisType: "summary", lines: 1000 }, 60000);
- const errors = await callTool("localstack-logs-analysis", { analysisType: "errors", lines: 1000, service: "s3" }, 60000);
- const requests = await callTool("localstack-logs-analysis", { analysisType: "requests", lines: 1000, service: "s3", operation: "CreateBucket" }, 60000);
- const raw = await callTool("localstack-logs-analysis", { analysisType: "logs", lines: 1000, filter: "harness-test-bucket" }, 60000);
- const ok = [summary, errors, requests, raw].every((r) => !r.isError) && /LocalStack Summary|Summary/i.test(summary.text);
- record("logs", "localstack-logs-analysis summary/errors/requests/logs", ok,
- `summary: ${snip(summary.text, 180)} | errors: ${snip(errors.text, 120)} | requests: ${snip(requests.text, 120)} | raw: ${snip(raw.text, 120)}`);
- } catch (e) { record("logs", "logs-analysis", false, String(e.message)); }
+ await callTool(
+ "localstack-aws-client",
+ { command: "s3api head-bucket --bucket definitely-missing-harness-bucket" },
+ 60000
+ ).catch(() => {});
+ const summary = await callTool(
+ "localstack-logs-analysis",
+ { analysisType: "summary", lines: 1000 },
+ 60000
+ );
+ const errors = await callTool(
+ "localstack-logs-analysis",
+ { analysisType: "errors", lines: 1000, service: "s3" },
+ 60000
+ );
+ const requests = await callTool(
+ "localstack-logs-analysis",
+ { analysisType: "requests", lines: 1000, service: "s3", operation: "CreateBucket" },
+ 60000
+ );
+ const raw = await callTool(
+ "localstack-logs-analysis",
+ { analysisType: "logs", lines: 1000, filter: "harness-test-bucket" },
+ 60000
+ );
+ const ok =
+ [summary, errors, requests, raw].every((r) => !r.isError) &&
+ /LocalStack Summary|Summary/i.test(summary.text);
+ record(
+ "logs",
+ "localstack-logs-analysis summary/errors/requests/logs",
+ ok,
+ `summary: ${snip(summary.text, 180)} | errors: ${snip(errors.text, 120)} | requests: ${snip(requests.text, 120)} | raw: ${snip(raw.text, 120)}`
+ );
+ } catch (e) {
+ record("logs", "logs-analysis", false, String(e.message));
+ }
}
// 6c. state-management — export local state to a mounted path, reset, import, inspect.
@@ -294,15 +409,47 @@ async function main() {
const statePath = `/work/data/harness-state-${RUN_ID}.zip`;
try {
await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000);
- const exported = await callTool("localstack-state-management", { action: "export", file_path: statePath, services: ["s3"] }, 120000);
- const inspected = await callTool("localstack-state-management", { action: "inspect", services: "s3" }, 60000);
- const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000);
+ const exported = await callTool(
+ "localstack-state-management",
+ { action: "export", file_path: statePath, services: ["s3"] },
+ 120000
+ );
+ const inspected = await callTool(
+ "localstack-state-management",
+ { action: "inspect", services: "s3" },
+ 60000
+ );
+ const reset = await callTool(
+ "localstack-state-management",
+ { action: "reset", services: ["s3"] },
+ 120000
+ );
const afterReset = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const imported = await callTool("localstack-state-management", { action: "import", file_path: statePath }, 120000);
+ const imported = await callTool(
+ "localstack-state-management",
+ { action: "import", file_path: statePath },
+ 120000
+ );
const afterImport = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const ok = !exported.isError && !inspected.isError && !reset.isError && !imported.isError && /State Exported/i.test(exported.text) && afterImport.text.includes(bucket);
- recordToolResult("state", "localstack-state-management export/inspect/reset/import", { text: `export: ${exported.text}\ninspect: ${inspected.text}\nreset: ${reset.text}\nimport: ${imported.text}\nafter reset: ${afterReset.text}\nafter import: ${afterImport.text}`, isError: !ok }, () => ok);
- } catch (e) { record("state", "state-management", false, String(e.message)); }
+ const ok =
+ !exported.isError &&
+ !inspected.isError &&
+ !reset.isError &&
+ !imported.isError &&
+ /State Exported/i.test(exported.text) &&
+ afterImport.text.includes(bucket);
+ recordToolResult(
+ "state",
+ "localstack-state-management export/inspect/reset/import",
+ {
+ text: `export: ${exported.text}\ninspect: ${inspected.text}\nreset: ${reset.text}\nimport: ${imported.text}\nafter reset: ${afterReset.text}\nafter import: ${afterImport.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
+ } catch (e) {
+ record("state", "state-management", false, String(e.message));
+ }
}
// 6d. cloud-pods — remote/cloud-backed snapshot. Opt in because it creates account resources.
@@ -310,20 +457,61 @@ async function main() {
const podName = `mcp-harness-${RUN_ID}`.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
const bucket = `harness-pod-${RUN_ID}`;
if (!RUN_REMOTE) {
- record("cloudpods", "localstack-cloud-pods save/load/delete", "warn", "skipped remote Cloud Pod create/load/delete", "set HARNESS_RUN_REMOTE=1 to exercise remote Cloud Pods");
+ record(
+ "cloudpods",
+ "localstack-cloud-pods save/load/delete",
+ "warn",
+ "skipped remote Cloud Pod create/load/delete",
+ "set HARNESS_RUN_REMOTE=1 to exercise remote Cloud Pods"
+ );
} else {
try {
- await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000).catch(() => {});
+ await callTool(
+ "localstack-cloud-pods",
+ { action: "delete", pod_name: podName },
+ 120000
+ ).catch(() => {});
await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000);
- const save = await callTool("localstack-cloud-pods", { action: "save", pod_name: podName }, 300000);
- const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000);
- const load = await callTool("localstack-cloud-pods", { action: "load", pod_name: podName }, 300000);
+ const save = await callTool(
+ "localstack-cloud-pods",
+ { action: "save", pod_name: podName },
+ 300000
+ );
+ const reset = await callTool(
+ "localstack-state-management",
+ { action: "reset", services: ["s3"] },
+ 120000
+ );
+ const load = await callTool(
+ "localstack-cloud-pods",
+ { action: "load", pod_name: podName },
+ 300000
+ );
const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const del = await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000);
- const ok = !save.isError && !reset.isError && !load.isError && !del.isError && ls.text.includes(bucket);
- recordToolResult("cloudpods", "localstack-cloud-pods save/reset/load/delete", { text: `save: ${save.text}\nload: ${load.text}\ndelete: ${del.text}\nls: ${ls.text}`, isError: !ok }, () => ok);
+ const del = await callTool(
+ "localstack-cloud-pods",
+ { action: "delete", pod_name: podName },
+ 120000
+ );
+ const ok =
+ !save.isError &&
+ !reset.isError &&
+ !load.isError &&
+ !del.isError &&
+ ls.text.includes(bucket);
+ recordToolResult(
+ "cloudpods",
+ "localstack-cloud-pods save/reset/load/delete",
+ {
+ text: `save: ${save.text}\nload: ${load.text}\ndelete: ${del.text}\nls: ${ls.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
} catch (e) {
- try { await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000); } catch {}
+ try {
+ await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000);
+ } catch {}
record("cloudpods", "cloud-pods", false, String(e.message));
}
}
@@ -332,13 +520,26 @@ async function main() {
// 6e. app-inspector — enable, generate traffic, list traces and drill into spans/events when present.
if (!SKIP.has("appinspector")) {
try {
- const enable = await callTool("localstack-app-inspector", { action: "set-status", status: "enabled" }, 60000);
- await callTool("localstack-aws-client", { command: `s3 mb s3://harness-ai-${RUN_ID}` }, 60000);
+ const enable = await callTool(
+ "localstack-app-inspector",
+ { action: "set-status", status: "enabled" },
+ 60000
+ );
+ await callTool(
+ "localstack-aws-client",
+ { command: `s3 mb s3://harness-ai-${RUN_ID}` },
+ 60000
+ );
await sleep(2000);
const traces = await callToolUntil(
"localstack-app-inspector",
{ action: "list-traces", limit: 10 },
- { attempts: 6, delayMs: 3000, timeoutMs: 60000, ok: (r) => !r.isError && !/No traces found/i.test(r.text) }
+ {
+ attempts: 6,
+ delayMs: 3000,
+ timeoutMs: 60000,
+ ok: (r) => !r.isError && !/No traces found/i.test(r.text),
+ }
);
const traceId = firstMarkdownTableValue(traces.text);
let trace = { text: "", isError: false };
@@ -346,17 +547,50 @@ async function main() {
let events = { text: "", isError: false };
let iamEvents = { text: "", isError: false };
if (traceId) {
- trace = await callTool("localstack-app-inspector", { action: "get-trace", trace_id: traceId }, 60000);
- spans = await callTool("localstack-app-inspector", { action: "list-spans", trace_id: traceId, limit: 10 }, 60000);
+ trace = await callTool(
+ "localstack-app-inspector",
+ { action: "get-trace", trace_id: traceId },
+ 60000
+ );
+ spans = await callTool(
+ "localstack-app-inspector",
+ { action: "list-spans", trace_id: traceId, limit: 10 },
+ 60000
+ );
const spanId = firstMarkdownTableValue(spans.text);
if (spanId) {
- events = await callTool("localstack-app-inspector", { action: "list-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000);
- iamEvents = await callTool("localstack-app-inspector", { action: "list-iam-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000);
+ events = await callTool(
+ "localstack-app-inspector",
+ { action: "list-events", trace_id: traceId, span_id: spanId, limit: 10 },
+ 60000
+ );
+ iamEvents = await callTool(
+ "localstack-app-inspector",
+ { action: "list-iam-events", trace_id: traceId, span_id: spanId, limit: 10 },
+ 60000
+ );
}
}
- const ok = !enable.isError && !traces.isError && Boolean(traceId) && !trace.isError && !spans.isError && !events.isError && !iamEvents.isError;
- recordToolResult("appinspector", "localstack-app-inspector enable/list/get/spans/events", { text: `enable: ${enable.text}\ntraces: ${traces.text}\ntrace: ${trace.text}\nspans: ${spans.text}\nevents: ${events.text}\niam: ${iamEvents.text}`, isError: !ok }, () => ok);
- } catch (e) { record("appinspector", "app-inspector", false, String(e.message)); }
+ const ok =
+ !enable.isError &&
+ !traces.isError &&
+ Boolean(traceId) &&
+ !trace.isError &&
+ !spans.isError &&
+ !events.isError &&
+ !iamEvents.isError;
+ recordToolResult(
+ "appinspector",
+ "localstack-app-inspector enable/list/get/spans/events",
+ {
+ text: `enable: ${enable.text}\ntraces: ${traces.text}\ntrace: ${trace.text}\nspans: ${spans.text}\nevents: ${events.text}\niam: ${iamEvents.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
+ } catch (e) {
+ record("appinspector", "app-inspector", false, String(e.message));
+ }
}
// 6f. chaos-injector — add a deterministic S3 ListBuckets fault, observe it, then clear faults/latency.
@@ -369,18 +603,57 @@ async function main() {
error: { statusCode: 503, code: "ServiceUnavailable" },
};
try {
- const add = await callTool("localstack-chaos-injector", { action: "add-fault-rule", rules: [rule] }, 60000);
+ const add = await callTool(
+ "localstack-chaos-injector",
+ { action: "add-fault-rule", rules: [rule] },
+ 60000
+ );
const faults = await callTool("localstack-chaos-injector", { action: "get-faults" }, 60000);
const affected = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const latency = await callTool("localstack-chaos-injector", { action: "inject-latency", latency_ms: 25 }, 60000);
- const getLatency = await callTool("localstack-chaos-injector", { action: "get-latency" }, 60000);
- const clearLatency = await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000);
- const clear = await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000);
- const ok = !add.isError && !faults.isError && affected.isError && !latency.isError && !getLatency.isError && !clearLatency.isError && !clear.isError;
- recordToolResult("chaos", "localstack-chaos-injector add/get/effect/clear", { text: `add: ${add.text}\nfaults: ${faults.text}\naffected: ${affected.text}\nlatency: ${latency.text}\nget latency: ${getLatency.text}\nclear latency: ${clearLatency.text}\nclear: ${clear.text}`, isError: !ok }, () => ok);
+ const latency = await callTool(
+ "localstack-chaos-injector",
+ { action: "inject-latency", latency_ms: 25 },
+ 60000
+ );
+ const getLatency = await callTool(
+ "localstack-chaos-injector",
+ { action: "get-latency" },
+ 60000
+ );
+ const clearLatency = await callTool(
+ "localstack-chaos-injector",
+ { action: "clear-latency" },
+ 60000
+ );
+ const clear = await callTool(
+ "localstack-chaos-injector",
+ { action: "clear-all-faults" },
+ 60000
+ );
+ const ok =
+ !add.isError &&
+ !faults.isError &&
+ affected.isError &&
+ !latency.isError &&
+ !getLatency.isError &&
+ !clearLatency.isError &&
+ !clear.isError;
+ recordToolResult(
+ "chaos",
+ "localstack-chaos-injector add/get/effect/clear",
+ {
+ text: `add: ${add.text}\nfaults: ${faults.text}\naffected: ${affected.text}\nlatency: ${latency.text}\nget latency: ${getLatency.text}\nclear latency: ${clearLatency.text}\nclear: ${clear.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
} catch (e) {
- try { await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000); } catch {}
- try { await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000); } catch {}
+ try {
+ await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000);
+ } catch {}
+ try {
+ await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000);
+ } catch {}
record("chaos", "chaos-injector", false, String(e.message));
}
}
@@ -388,15 +661,45 @@ async function main() {
// 6g. IAM policy analyzer — mode transitions plus log analysis, then restore disabled.
if (!SKIP.has("iam")) {
try {
- const status = await callTool("localstack-iam-policy-analyzer", { action: "get-status" }, 60000);
- const soft = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "SOFT_MODE" }, 60000);
+ const status = await callTool(
+ "localstack-iam-policy-analyzer",
+ { action: "get-status" },
+ 60000
+ );
+ const soft = await callTool(
+ "localstack-iam-policy-analyzer",
+ { action: "set-mode", mode: "SOFT_MODE" },
+ 60000
+ );
await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const analyze = await callTool("localstack-iam-policy-analyzer", { action: "analyze-policies" }, 60000);
- const disabled = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000);
+ const analyze = await callTool(
+ "localstack-iam-policy-analyzer",
+ { action: "analyze-policies" },
+ 60000
+ );
+ const disabled = await callTool(
+ "localstack-iam-policy-analyzer",
+ { action: "set-mode", mode: "DISABLED" },
+ 60000
+ );
const ok = !status.isError && !soft.isError && !analyze.isError && !disabled.isError;
- recordToolResult("iam", "localstack-iam-policy-analyzer status/set/analyze/restore", { text: `status: ${status.text}\nsoft: ${soft.text}\nanalyze: ${analyze.text}\ndisabled: ${disabled.text}`, isError: !ok }, () => ok);
+ recordToolResult(
+ "iam",
+ "localstack-iam-policy-analyzer status/set/analyze/restore",
+ {
+ text: `status: ${status.text}\nsoft: ${soft.text}\nanalyze: ${analyze.text}\ndisabled: ${disabled.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
} catch (e) {
- try { await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000); } catch {}
+ try {
+ await callTool(
+ "localstack-iam-policy-analyzer",
+ { action: "set-mode", mode: "DISABLED" },
+ 60000
+ );
+ } catch {}
record("iam", "iam-policy-analyzer", false, String(e.message));
}
}
@@ -404,21 +707,47 @@ async function main() {
// 6h. aws-replicator — list endpoints always; start a job only when source AWS creds are explicitly available.
if (!SKIP.has("replicator")) {
try {
- const resources = await callTool("localstack-aws-replicator", { action: "list-resources" }, 120000);
+ const resources = await callTool(
+ "localstack-aws-replicator",
+ { action: "list-resources" },
+ 120000
+ );
const jobs = await callTool("localstack-aws-replicator", { action: "list" }, 120000);
- let start = { text: "start skipped: no HARNESS_RUN_REPLICATOR_START=1 or source AWS credentials", isError: false };
+ let start = {
+ text: "start skipped: no HARNESS_RUN_REPLICATOR_START=1 or source AWS credentials",
+ isError: false,
+ };
if (RUN_REPLICATOR_START && hasAwsCreds()) {
- start = await callTool("localstack-aws-replicator", {
- action: "start",
- replication_type: "SINGLE_RESOURCE",
- resource_type: process.env.HARNESS_REPLICATOR_RESOURCE_TYPE || "AWS::SSM::Parameter",
- resource_identifier: process.env.HARNESS_REPLICATOR_RESOURCE_IDENTIFIER || "/localstack-mcp-harness",
- }, 300000);
+ start = await callTool(
+ "localstack-aws-replicator",
+ {
+ action: "start",
+ replication_type: "SINGLE_RESOURCE",
+ resource_type: process.env.HARNESS_REPLICATOR_RESOURCE_TYPE || "AWS::SSM::Parameter",
+ resource_identifier:
+ process.env.HARNESS_REPLICATOR_RESOURCE_IDENTIFIER || "/localstack-mcp-harness",
+ },
+ 300000
+ );
}
const ok = !resources.isError && !jobs.isError && !start.isError;
- const note = RUN_REPLICATOR_START && hasAwsCreds() ? undefined : "replication job start skipped unless HARNESS_RUN_REPLICATOR_START=1 and source AWS creds are set";
- recordToolResult("replicator", "localstack-aws-replicator list-resources/list/start-if-configured", { text: `resources: ${resources.text}\njobs: ${jobs.text}\nstart: ${start.text}`, isError: !ok }, () => ok, note);
- } catch (e) { record("replicator", "aws-replicator", false, String(e.message)); }
+ const note =
+ RUN_REPLICATOR_START && hasAwsCreds()
+ ? undefined
+ : "replication job start skipped unless HARNESS_RUN_REPLICATOR_START=1 and source AWS creds are set";
+ recordToolResult(
+ "replicator",
+ "localstack-aws-replicator list-resources/list/start-if-configured",
+ {
+ text: `resources: ${resources.text}\njobs: ${jobs.text}\nstart: ${start.text}`,
+ isError: !ok,
+ },
+ () => ok,
+ note
+ );
+ } catch (e) {
+ record("replicator", "aws-replicator", false, String(e.message));
+ }
}
// 6i. ephemeral instances — list always; create/logs/delete only with explicit opt-in.
@@ -427,9 +756,19 @@ async function main() {
try {
const list = await callTool("localstack-ephemeral-instances", { action: "list" }, 120000);
if (!RUN_EPHEMERAL) {
- recordToolResult("ephemeral", "localstack-ephemeral-instances list", list, (r) => !r.isError, "create/logs/delete skipped; set HARNESS_RUN_EPHEMERAL=1 to provision a short-lived cloud instance");
+ recordToolResult(
+ "ephemeral",
+ "localstack-ephemeral-instances list",
+ list,
+ (r) => !r.isError,
+ "create/logs/delete skipped; set HARNESS_RUN_EPHEMERAL=1 to provision a short-lived cloud instance"
+ );
} else {
- const create = await callTool("localstack-ephemeral-instances", { action: "create", name: instanceName, lifetime: 10 }, 240000);
+ const create = await callTool(
+ "localstack-ephemeral-instances",
+ { action: "create", name: instanceName, lifetime: 10 },
+ 240000
+ );
if (create.isError && /compute\.resource_exhausted|quota|limit/i.test(create.text)) {
record(
"ephemeral",
@@ -439,15 +778,37 @@ async function main() {
"platform quota exhausted; create/logs/delete could not be completed"
);
} else {
- const logs = await callTool("localstack-ephemeral-instances", { action: "logs", name: instanceName }, 180000);
- const del = await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000);
+ const logs = await callTool(
+ "localstack-ephemeral-instances",
+ { action: "logs", name: instanceName },
+ 180000
+ );
+ const del = await callTool(
+ "localstack-ephemeral-instances",
+ { action: "delete", name: instanceName },
+ 120000
+ );
const ok = !list.isError && !create.isError && !logs.isError && !del.isError;
- recordToolResult("ephemeral", "localstack-ephemeral-instances list/create/logs/delete", { text: `list: ${list.text}\ncreate: ${create.text}\nlogs: ${logs.text}\ndelete: ${del.text}`, isError: !ok }, () => ok);
+ recordToolResult(
+ "ephemeral",
+ "localstack-ephemeral-instances list/create/logs/delete",
+ {
+ text: `list: ${list.text}\ncreate: ${create.text}\nlogs: ${logs.text}\ndelete: ${del.text}`,
+ isError: !ok,
+ },
+ () => ok
+ );
}
}
} catch (e) {
if (RUN_EPHEMERAL) {
- try { await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000); } catch {}
+ try {
+ await callTool(
+ "localstack-ephemeral-instances",
+ { action: "delete", name: instanceName },
+ 120000
+ );
+ } catch {}
}
record("ephemeral", "ephemeral-instances", false, String(e.message));
}
@@ -456,36 +817,84 @@ async function main() {
// 7. deployer terraform
if (!SKIP.has("deploy")) {
try {
- const r = await callTool("localstack-deployer", { action: "deploy", projectType: "terraform", directory: DEPLOY_DIR }, 300000);
- const ok = !r.isError && /(completed successfully|Apply complete|bucket_name|Terraform Outputs)/i.test(r.text);
- record("deploy", "localstack-deployer terraform deploy (tflocal -> LS)", ok, snip(r.text, 600));
- } catch (e) { record("deploy", "deployer terraform", false, String(e.message)); }
+ const r = await callTool(
+ "localstack-deployer",
+ { action: "deploy", projectType: "terraform", directory: DEPLOY_DIR },
+ 300000
+ );
+ const ok =
+ !r.isError &&
+ /(completed successfully|Apply complete|bucket_name|Terraform Outputs)/i.test(r.text);
+ record(
+ "deploy",
+ "localstack-deployer terraform deploy (tflocal -> LS)",
+ ok,
+ snip(r.text, 600)
+ );
+ } catch (e) {
+ record("deploy", "deployer terraform", false, String(e.message));
+ }
}
// 7b. deployer CDK — validates cdklocal endpoint injection and path-style S3 asset uploads.
if (!SKIP.has("deploy-cdk")) {
try {
- const r = await callTool("localstack-deployer", { action: "deploy", projectType: "cdk", directory: CDK_DIR }, 300000);
+ const r = await callTool(
+ "localstack-deployer",
+ { action: "deploy", projectType: "cdk", directory: CDK_DIR },
+ 300000
+ );
const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
- const ok = !r.isError && !ls.isError && /CDK stack deployed successfully/i.test(r.text) && ls.text.includes("mcp-cdk-sample-bucket") && !/Error during `cdklocal/i.test(r.text);
- record("deploy-cdk", "localstack-deployer CDK deploy (cdklocal -> LS)", ok, `deploy: ${snip(r.text, 500)} | s3 ls: ${snip(ls.text, 160)}`);
- } catch (e) { record("deploy-cdk", "deployer CDK", false, String(e.message)); }
+ const ok =
+ !r.isError &&
+ !ls.isError &&
+ /CDK stack deployed successfully/i.test(r.text) &&
+ ls.text.includes("mcp-cdk-sample-bucket") &&
+ !/Error during `cdklocal/i.test(r.text);
+ record(
+ "deploy-cdk",
+ "localstack-deployer CDK deploy (cdklocal -> LS)",
+ ok,
+ `deploy: ${snip(r.text, 500)} | s3 ls: ${snip(ls.text, 160)}`
+ );
+ } catch (e) {
+ record("deploy-cdk", "deployer CDK", false, String(e.message));
+ }
}
// 8. extensions — Pro-gated (needs valid token + marketplace API)
if (!SKIP.has("extensions")) {
try {
const r = await callTool("localstack-extensions", { action: "available" }, 60000);
- recordToolResult("extensions", "localstack-extensions available (Pro)", r, (x) => !x.isError && /Marketplace|extensions available/i.test(x.text));
- } catch (e) { record("extensions", "extensions", false, String(e.message)); }
+ recordToolResult(
+ "extensions",
+ "localstack-extensions available (Pro)",
+ r,
+ (x) => !x.isError && /Marketplace|extensions available/i.test(x.text)
+ );
+ } catch (e) {
+ record("extensions", "extensions", false, String(e.message));
+ }
}
// 9. cleanup and remaining management lifecycle coverage
if (!NO_CLEANUP && !SKIP.has("deploy")) {
- try { await callTool("localstack-deployer", { action: "destroy", projectType: "terraform", directory: DEPLOY_DIR }, 180000); } catch {}
+ try {
+ await callTool(
+ "localstack-deployer",
+ { action: "destroy", projectType: "terraform", directory: DEPLOY_DIR },
+ 180000
+ );
+ } catch {}
}
if (!NO_CLEANUP && !SKIP.has("deploy-cdk")) {
- try { await callTool("localstack-deployer", { action: "destroy", projectType: "cdk", directory: CDK_DIR }, 180000); } catch {}
+ try {
+ await callTool(
+ "localstack-deployer",
+ { action: "destroy", projectType: "cdk", directory: CDK_DIR },
+ 180000
+ );
+ } catch {}
}
if (!SKIP.has("restart")) {
@@ -496,29 +905,63 @@ async function main() {
{ command: "sts get-caller-identity" },
{ attempts: 12, delayMs: 5000, timeoutMs: 30000 }
);
- record("restart", "localstack-management restart", !r.isError && !ready.isError, `restart: ${snip(r.text, 300)} | readiness: ${snip(ready.text, 160)}`);
- } catch (e) { record("restart", "management restart", false, String(e.message)); }
+ record(
+ "restart",
+ "localstack-management restart",
+ !r.isError && !ready.isError,
+ `restart: ${snip(r.text, 300)} | readiness: ${snip(ready.text, 160)}`
+ );
+ } catch (e) {
+ record("restart", "management restart", false, String(e.message));
+ }
}
if (!SKIP.has("stop")) {
try {
const r = await callTool("localstack-management", { action: "stop" }, 60000);
- record("stop", "localstack-management stop", !r.isError && /(stopped|stop command executed)/i.test(r.text), snip(r.text, 300));
- } catch (e) { record("stop", "management stop", false, String(e.message)); }
+ record(
+ "stop",
+ "localstack-management stop",
+ !r.isError && /(stopped|stop command executed)/i.test(r.text),
+ snip(r.text, 300)
+ );
+ } catch (e) {
+ record("stop", "management stop", false, String(e.message));
+ }
}
// 10. Snowflake stack — starts a separate runtime flavor after the AWS stack is stopped.
if (!SKIP.has("snowflake")) {
try {
- const start = await callTool("localstack-management", { action: "start", service: "snowflake" }, 240000);
- const check = await callTool("localstack-snowflake-client", { action: "check-connection" }, 120000);
- const exec = await callTool("localstack-snowflake-client", { action: "execute", file_path: SQL_FILE }, 180000);
+ const start = await callTool(
+ "localstack-management",
+ { action: "start", service: "snowflake" },
+ 240000
+ );
+ const check = await callTool(
+ "localstack-snowflake-client",
+ { action: "check-connection" },
+ 120000
+ );
+ const exec = await callTool(
+ "localstack-snowflake-client",
+ { action: "execute", file_path: SQL_FILE },
+ 180000
+ );
const ok = !start.isError && !check.isError && !exec.isError;
- recordToolResult("snowflake", "localstack-snowflake-client check-connection/execute file", { text: `start: ${start.text}\ncheck: ${check.text}\nexecute: ${exec.text}`, isError: !ok }, () => ok);
- } catch (e) { record("snowflake", "snowflake-client", false, String(e.message)); }
- finally {
+ recordToolResult(
+ "snowflake",
+ "localstack-snowflake-client check-connection/execute file",
+ { text: `start: ${start.text}\ncheck: ${check.text}\nexecute: ${exec.text}`, isError: !ok },
+ () => ok
+ );
+ } catch (e) {
+ record("snowflake", "snowflake-client", false, String(e.message));
+ } finally {
if (!NO_CLEANUP) {
- try { await callTool("localstack-management", { action: "stop" }, 60000); } catch {}
+ try {
+ await callTool("localstack-management", { action: "stop" }, 60000);
+ } catch {}
}
}
}
@@ -526,11 +969,18 @@ async function main() {
main()
.then(() => finish())
- .catch((e) => { console.error("\nHARNESS ERROR:", e.message); finish(1); });
+ .catch((e) => {
+ console.error("\nHARNESS ERROR:", e.message);
+ finish(1);
+ });
function finish(forceCode) {
- try { child.stdin.end(); } catch {}
- try { child.kill("SIGTERM"); } catch {}
+ try {
+ child.stdin.end();
+ } catch {}
+ try {
+ child.kill("SIGTERM");
+ } catch {}
const hard = results.filter((r) => r.ok === false);
const warn = results.filter((r) => r.ok === "warn");
const pass = results.filter((r) => r.ok === true);