diff --git a/README.md b/README.md
index e0e397a..f6cb41c 100644
--- a/README.md
+++ b/README.md
@@ -77,14 +77,14 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| [`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 |
| [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime |
-| [`localstack-snowflake-client`](./src/tools/localstack-snowflake-client.ts) | Runs SQL against the LocalStack Snowflake emulator through the `snow` CLI | - Execute SELECT, DDL (CREATE/DROP), DML (INSERT/UPDATE/DELETE), and SHOW/DESCRIBE statements from a query string or a `.sql` file
- Check the Snowflake connection before running queries
- Set optional database, schema, warehouse, and role context per query
- Requires the Snowflake CLI (`snow`) and a valid LocalStack Auth Token |
+| [`localstack-snowflake-client`](./src/tools/localstack-snowflake-client.ts) | Runs SQL against the LocalStack Snowflake emulator through the `snow` CLI | - Execute SELECT, DDL (CREATE/DROP), DML (INSERT/UPDATE/DELETE), and SHOW/DESCRIBE statements from a query string or a `.sql` file
- Check the Snowflake connection before running queries
- Set optional database, schema, warehouse, and role context per query
- Requires the Snowflake CLI (`snow`) and a valid LocalStack Auth Token |
## Prompts
Prompts are user-selected workflow templates exposed by MCP clients as slash commands or quick actions. They frame multi-step LocalStack tasks so the assistant follows the same phases, evidence requirements, and reporting format every time.
-| Prompt Name | Description | Arguments |
-| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------ |
+| Prompt Name | Description | Arguments |
+| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------- |
| `infrastructure-tester` | Deploys an IaC project to LocalStack, validates declared resources with live AWS probes and App Inspector evidence, then writes and runs deterministic integration tests. | `iac_path` (required), `iac_type`, `test_language`, `test_framework`, `mode`, `services_focus`, `user_focus` |
## Installation
@@ -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, LocalStack CLI, Docker) and tells you how to fix anything missing,
+- checks the prerequisites (Node.js, a LocalStack lifecycle CLI, 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, 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
-- [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) and Docker installed in your system path
+- 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
- [`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**)
@@ -199,15 +199,15 @@ 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 name of the LocalStack container to use for the MCP server | `localstack-main` |
-| `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 |
+| `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 |
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/docs/DOCKER.md b/docs/DOCKER.md
index d0ff98a..352184e 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -10,10 +10,11 @@ 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, `localstack
-start` launches a **sibling** `localstack-main` container on the host (not nested
-inside the MCP container). 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, 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.
```
MCP client ── stdio ──► docker run … (MCP server)
@@ -52,15 +53,15 @@ 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 drive the host Docker daemon (start/stop the sibling, `awslocal` exec). |
+| 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. |
+| `--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
@@ -120,12 +121,12 @@ alias covers bootstrap asset uploads.
## 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). |
-| `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you renamed it. |
+| 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. |
## Validating an image yourself
diff --git a/src/cli/init.ts b/src/cli/init.ts
index 5339ca9..5e2ff5a 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 CLI, and Docker",
+ hint: "uses your local Node 20+, localstack or lstk, and Docker",
},
{
value: "docker",
diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts
index 0ae7e27..ca099bf 100644
--- a/src/lib/docker/docker.client.test.ts
+++ b/src/lib/docker/docker.client.test.ts
@@ -4,18 +4,31 @@ import { PassThrough } from "stream";
jest.mock("dockerode", () => {
const listContainers = jest.fn();
const execInspect = jest.fn();
+ const containerInspect = jest.fn();
const start = jest.fn((opts: any, cb: any) => {
const stream = new PassThrough();
setImmediate(() => cb(null, stream));
return undefined as unknown as NodeJS.ReadableStream;
});
const exec = jest.fn(async () => ({ start, inspect: execInspect }));
- const getContainer = jest.fn(() => ({ exec }));
+ const stop = jest.fn();
+ const remove = jest.fn();
+ const getContainer = jest.fn(() => ({ exec, stop, remove, inspect: containerInspect }));
const __state = { demuxTarget: "stdout" as "stdout" | "stderr" };
class DockerMock {
- static __mocks = { listContainers, getContainer, exec, start, execInspect, __state };
+ static __mocks = {
+ listContainers,
+ getContainer,
+ exec,
+ start,
+ execInspect,
+ containerInspect,
+ stop,
+ remove,
+ __state,
+ };
modem: any;
constructor() {
this.modem = {
@@ -52,13 +65,22 @@ describe("DockerApiClient", () => {
mocks.exec.mockReset();
mocks.start.mockReset();
mocks.execInspect.mockReset();
+ mocks.containerInspect.mockReset();
+ mocks.stop.mockReset();
+ mocks.remove.mockReset();
// Restore default implementations after reset
- mocks.getContainer.mockImplementation(() => ({ exec: mocks.exec }));
+ mocks.getContainer.mockImplementation(() => ({
+ exec: mocks.exec,
+ stop: mocks.stop,
+ remove: mocks.remove,
+ inspect: mocks.containerInspect,
+ }));
mocks.exec.mockImplementation(async () => ({ start: mocks.start, inspect: mocks.execInspect }));
mocks.__state.demuxTarget = "stdout";
delete process.env.MAIN_CONTAINER_NAME;
delete process.env.LOCALSTACK_MAIN_CONTAINER_NAME;
+ delete process.env.LOCALSTACK_PORT;
});
test("findLocalStackContainer throws when none found", async () => {
@@ -73,12 +95,38 @@ describe("DockerApiClient", () => {
test("findLocalStackContainer returns id when found", async () => {
const mocks = getDockerMocks();
- mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]);
+ mocks.listContainers.mockResolvedValueOnce([
+ { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" },
+ ]);
const client = new DockerApiClient();
await expect(client.findLocalStackContainer()).resolves.toBe("abc123");
});
+ test("findLocalStackContainer matches known runtime names for non-standard LocalStack product images", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ { Id: "snow123", Names: ["/localstack-main"], Image: "localstack/snowflake:latest" },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).resolves.toBe("snow123");
+ });
+
+ test("findLocalStackContainer matches lstk's known runtime name for Azure emulator images", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ {
+ Id: "azure123",
+ Names: ["/localstack-aws"],
+ Image: "localstack/localstack-azure-alpha:latest",
+ },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).resolves.toBe("azure123");
+ });
+
test("findLocalStackContainer matches MAIN_CONTAINER_NAME when configured", async () => {
process.env.MAIN_CONTAINER_NAME = "my-custom-localstack";
@@ -103,7 +151,22 @@ describe("DockerApiClient", () => {
await expect(client.findLocalStackContainer()).resolves.toBe("lstk123");
});
- test("findLocalStackContainer prefers the container publishing the configured gateway port over image metadata", async () => {
+ test("findLocalStackContainer detects mirrored LocalStack images by gateway port", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ {
+ Id: "mirror123",
+ Names: ["/gateway-runtime"],
+ Image: "registry.example.com/localstack/localstack-pro:latest",
+ Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }],
+ },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).resolves.toBe("mirror123");
+ });
+
+ test("findLocalStackContainer prefers the LocalStack image publishing the configured gateway port over other LocalStack images", async () => {
const mocks = getDockerMocks();
mocks.listContainers.mockResolvedValueOnce([
{
@@ -114,7 +177,7 @@ describe("DockerApiClient", () => {
{
Id: "port123",
Names: ["/gateway-runtime"],
- Image: "example/custom-runtime:latest",
+ Image: "localstack/localstack:latest",
Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }],
},
]);
@@ -123,6 +186,71 @@ describe("DockerApiClient", () => {
await expect(client.findLocalStackContainer()).resolves.toBe("port123");
});
+ test("findLocalStackContainer does not match a non-LocalStack container by port alone", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ {
+ Id: "not-localstack",
+ Names: ["/plain-node"],
+ Image: "node:22-bookworm-slim",
+ Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }],
+ },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).rejects.toThrow(
+ /Could not find a running LocalStack container/i
+ );
+ });
+
+ test("findLocalStackContainer does not match non-runtime LocalStack namespace images", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ {
+ Id: "mcp-dev",
+ Names: ["/localstack-mcp-dev"],
+ Image: "localstack/localstack-mcp-server:latest",
+ Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }],
+ },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).rejects.toThrow(
+ /Could not find a running LocalStack container/i
+ );
+ });
+
+ test("findLocalStackContainer honors an explicit LOCALSTACK_PORT before falling back to image-only matches", async () => {
+ process.env.LOCALSTACK_PORT = "4567";
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ {
+ Id: "default-runtime",
+ Names: ["/gateway-runtime"],
+ Image: "localstack/localstack-pro:latest",
+ Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }],
+ },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).rejects.toThrow(
+ /none publishes the configured gateway port 4567/i
+ );
+ });
+
+ test("findLocalStackContainer rejects ambiguous LocalStack image matches without a port or explicit name", async () => {
+ const mocks = getDockerMocks();
+ mocks.listContainers.mockResolvedValueOnce([
+ { Id: "ls1", Names: ["/localstack-sidecar-1"], Image: "localstack/localstack:latest" },
+ { Id: "ls2", Names: ["/localstack-sidecar-2"], Image: "localstack/localstack-pro:latest" },
+ ]);
+
+ const client = new DockerApiClient();
+ await expect(client.findLocalStackContainer()).rejects.toThrow(
+ /Found multiple running LocalStack containers/i
+ );
+ });
+
test("findLocalStackContainer honors explicit MAIN_CONTAINER_NAME over metadata fallback", async () => {
process.env.MAIN_CONTAINER_NAME = "my-custom-localstack";
@@ -151,9 +279,67 @@ describe("DockerApiClient", () => {
);
});
+ test("stopContainer stops the container via the Docker API", async () => {
+ const mocks = getDockerMocks();
+ mocks.stop.mockResolvedValueOnce(undefined);
+ mocks.remove.mockResolvedValueOnce(undefined);
+
+ const client = new DockerApiClient();
+ await client.stopContainer("abc123", 5);
+
+ expect(mocks.getContainer).toHaveBeenCalledWith("abc123");
+ expect(mocks.stop).toHaveBeenCalledWith({ t: 5 });
+ expect(mocks.remove).toHaveBeenCalledWith();
+ });
+
+ test("stopContainer ignores remove errors when --rm already removed the container", async () => {
+ const mocks = getDockerMocks();
+ mocks.stop.mockResolvedValueOnce(undefined);
+ mocks.remove.mockRejectedValueOnce(
+ Object.assign(new Error("No such container"), { statusCode: 404 })
+ );
+
+ const client = new DockerApiClient();
+ await expect(client.stopContainer("abc123", 5)).resolves.toBeUndefined();
+ });
+
+ test("stopContainer ignores remove conflicts when Docker auto-removal is already in progress", async () => {
+ const mocks = getDockerMocks();
+ mocks.stop.mockResolvedValueOnce(undefined);
+ mocks.remove.mockRejectedValueOnce(
+ Object.assign(new Error("removal of container abc123 is already in progress"), {
+ statusCode: 409,
+ })
+ );
+
+ const client = new DockerApiClient();
+ await expect(client.stopContainer("abc123", 5)).resolves.toBeUndefined();
+ });
+
+ test("inspectContainer returns normalized container metadata", async () => {
+ const mocks = getDockerMocks();
+ mocks.containerInspect.mockResolvedValueOnce({
+ Name: "/localstack-aws",
+ Config: {
+ Image: "localstack/localstack-pro:latest",
+ Env: ["MAIN_CONTAINER_NAME=localstack-aws"],
+ },
+ });
+
+ const client = new DockerApiClient();
+ await expect(client.inspectContainer("abc123")).resolves.toEqual({
+ id: "abc123",
+ name: "localstack-aws",
+ image: "localstack/localstack-pro:latest",
+ env: ["MAIN_CONTAINER_NAME=localstack-aws"],
+ });
+ });
+
test("executeInContainer returns stdout on success", async () => {
const mocks = getDockerMocks();
- mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]);
+ mocks.listContainers.mockResolvedValueOnce([
+ { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" },
+ ]);
// prepare exec.inspect to return 0
mocks.execInspect.mockResolvedValueOnce({ ExitCode: 0 });
@@ -184,7 +370,9 @@ describe("DockerApiClient", () => {
test("executeInContainer returns stderr on failure", async () => {
const mocks = getDockerMocks();
- mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]);
+ mocks.listContainers.mockResolvedValueOnce([
+ { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" },
+ ]);
mocks.execInspect.mockResolvedValueOnce({ ExitCode: 2 });
const client = new DockerApiClient();
diff --git a/src/lib/docker/docker.client.ts b/src/lib/docker/docker.client.ts
index 5d957f9..4394181 100644
--- a/src/lib/docker/docker.client.ts
+++ b/src/lib/docker/docker.client.ts
@@ -7,6 +7,27 @@ export interface ContainerExecResult {
exitCode: number;
}
+export interface ContainerMetadata {
+ id: string;
+ name?: string;
+ image?: string;
+ env?: string[];
+}
+
+export class LocalStackContainerNotFoundError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "LocalStackContainerNotFoundError";
+ }
+}
+
+export function isLocalStackContainerNotFoundError(error: unknown): boolean {
+ return (
+ error instanceof LocalStackContainerNotFoundError ||
+ (error instanceof Error && error.name === "LocalStackContainerNotFoundError")
+ );
+}
+
export class DockerApiClient {
private docker: any;
@@ -38,7 +59,48 @@ export class DockerApiClient {
}
private hasLocalStackImage(container: { Image?: string }): boolean {
- return /^localstack\/localstack(?:-pro)?(?::|@|$)/.test(container.Image || "");
+ return /^(?:[^/]+\/)?localstack\/(?:localstack(?:-pro)?|snowflake|localstack-azure-alpha)(?::|@|$)/.test(
+ container.Image || ""
+ );
+ }
+
+ private async withDockerRequestTimeout(
+ operation: Promise,
+ timeoutMs: number,
+ description: string
+ ): Promise {
+ let timer: NodeJS.Timeout;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error(`${description} timed out after ${timeoutMs}ms`)),
+ timeoutMs
+ );
+ });
+ try {
+ return await Promise.race([operation, timeout]);
+ } finally {
+ clearTimeout(timer!);
+ }
+ }
+
+ private isContainerAlreadyGone(error: unknown): boolean {
+ if (!error || typeof error !== "object") return false;
+ const dockerError = error as { statusCode?: number; reason?: string; message?: string };
+ const text = `${dockerError.reason || ""} ${dockerError.message || ""}`.toLowerCase();
+ return (
+ dockerError.statusCode === 404 ||
+ text.includes("no such container") ||
+ ((dockerError.statusCode === 409 || text.includes("removal of container")) &&
+ text.includes("already in progress"))
+ );
+ }
+
+ private findByKnownLocalStackName(
+ containers: T[]
+ ): T | undefined {
+ return ["localstack-main", "localstack-aws"]
+ .map((name) => containers.find((c) => this.matchesConfiguredContainerName(c, name)))
+ .find(Boolean);
}
async findLocalStackContainer(): Promise {
@@ -58,25 +120,84 @@ export class DockerApiClient {
).trim();
const configuredName = explicitName || "localstack-main";
- const byConfiguredName = (running || []).find((c) =>
- this.matchesConfiguredContainerName(c, configuredName)
- );
- if (byConfiguredName) return byConfiguredName.Id as string;
+ if (explicitName) {
+ const byConfiguredName = (running || []).find((c) =>
+ this.matchesConfiguredContainerName(c, configuredName)
+ );
+ if (byConfiguredName) return byConfiguredName.Id as string;
+ }
if (!explicitName) {
- const byGatewayPort = (running || []).find((c) => this.publishesConfiguredGatewayPort(c));
+ const byKnownName = this.findByKnownLocalStackName(running || []);
+ if (byKnownName) return byKnownName.Id as string;
+
+ const localstackImages = (running || []).filter((c) => this.hasLocalStackImage(c));
+ const byGatewayPort = localstackImages.find((c) => this.publishesConfiguredGatewayPort(c));
if (byGatewayPort) return byGatewayPort.Id as string;
- const byLocalStackImage = (running || []).find((c) => this.hasLocalStackImage(c));
- if (byLocalStackImage) return byLocalStackImage.Id as string;
+ const explicitPort = Boolean(process.env.LOCALSTACK_PORT?.trim());
+ if (explicitPort && localstackImages.length > 0) {
+ throw new LocalStackContainerNotFoundError(
+ `Found running LocalStack containers, but none publishes the configured gateway port ${process.env.LOCALSTACK_PORT}. ` +
+ `Set MAIN_CONTAINER_NAME to the container name to use.`
+ );
+ }
+
+ if (localstackImages.length === 1) return localstackImages[0].Id as string;
+ if (localstackImages.length > 1) {
+ throw new LocalStackContainerNotFoundError(
+ `Found multiple running LocalStack containers but none publishes the configured gateway port ${process.env.LOCALSTACK_PORT || LOCALSTACK_PORT}. ` +
+ `Set MAIN_CONTAINER_NAME to the container name to use.`
+ );
+ }
}
- throw new Error(
+ throw new LocalStackContainerNotFoundError(
`Could not find a running LocalStack container named "${configuredName}". ` +
`Set MAIN_CONTAINER_NAME to your container name if it is custom.`
);
}
+ async inspectContainer(containerId: string): Promise {
+ const container = this.docker.getContainer(containerId);
+ const inspect = await container.inspect();
+ return {
+ id: containerId,
+ name: this.normalizeContainerName(inspect?.Name),
+ image: inspect?.Config?.Image,
+ env: inspect?.Config?.Env,
+ };
+ }
+
+ /**
+ * Stop a container via the Docker API (graceful SIGTERM, then SIGKILL after the
+ * timeout). Provenance-agnostic — works regardless of which CLI started it (or none)
+ * and needs no host-side `localstack`/`lstk` binary. Remove after stop so `lstk`
+ * containers, which are not started with `--rm`, do not linger.
+ */
+ async stopContainer(
+ containerId: string,
+ timeoutSeconds = 10,
+ 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.remove(),
+ requestTimeoutMs,
+ "Docker container remove"
+ );
+ } catch (error) {
+ if (!this.isContainerAlreadyGone(error)) throw error;
+ }
+ }
+
async executeInContainer(
containerId: string,
command: string[],
diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts
index abdaa0e..ccbc913 100644
--- a/src/lib/localstack/localstack.utils.test.ts
+++ b/src/lib/localstack/localstack.utils.test.ts
@@ -1,10 +1,16 @@
import {
+ checkLocalStackCli,
+ detectLifecycleCli,
getGatewayHealth,
getLocalStackStatus,
getSnowflakeEmulatorStatus,
+ startRuntime,
} from "./localstack.utils";
import { runCommand } from "../../core/command-runner";
import { httpClient } from "../../core/http-client";
+import { spawn } from "child_process";
+import { EventEmitter } from "events";
+import { PassThrough } from "stream";
jest.mock("../../core/command-runner", () => ({
runCommand: jest.fn(),
@@ -15,8 +21,21 @@ jest.mock("../../core/http-client", () => ({
HttpError: class HttpError extends Error {},
}));
+jest.mock("child_process", () => ({
+ spawn: 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 cliUnavailable = () =>
mockedRunCommand.mockResolvedValue({
@@ -32,6 +51,7 @@ describe("localstack.utils", () => {
beforeEach(() => {
mockedRunCommand.mockReset();
mockedRequest.mockReset();
+ mockedSpawn.mockReset();
});
describe("getGatewayHealth", () => {
@@ -65,6 +85,14 @@ describe("localstack.utils", () => {
expect(health.reachable).toBe(false);
expect(health.ready).toBe(false);
});
+
+ test("does not treat arbitrary text responses as a LocalStack gateway", async () => {
+ mockedRequest.mockResolvedValueOnce("not localstack" as any);
+
+ const health = await getGatewayHealth();
+ expect(health.reachable).toBe(false);
+ expect(health.ready).toBe(false);
+ });
});
describe("getLocalStackStatus", () => {
@@ -144,6 +172,65 @@ describe("localstack.utils", () => {
});
});
+ 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");
+ });
+
+ test("falls back to lstk when only lstk is present", async () => {
+ onlyAvailable("lstk");
+ expect(await detectLifecycleCli()).toBe("lstk");
+ });
+
+ 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 })
+ );
+ });
+
+ 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();
+ });
+ });
+
+ 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);
+ });
+ });
+
describe("getSnowflakeEmulatorStatus", () => {
test("marks emulator healthy on success payload", async () => {
mockedRunCommand.mockResolvedValueOnce({
@@ -170,4 +257,63 @@ describe("localstack.utils", () => {
expect(result.isReady).toBe(false);
});
});
+
+ describe("startRuntime", () => {
+ test("reports lstk zero-exit failures immediately with stdout when readiness is absent", async () => {
+ const child = mockChildProcess();
+ const getStatus = jest
+ .fn()
+ .mockResolvedValueOnce({ isRunning: false, isReady: false })
+ .mockResolvedValueOnce({ isRunning: false, isReady: false });
+
+ const resultPromise = startRuntime({
+ cli: "lstk",
+ startArgs: ["start", "--non-interactive"],
+ getStatus,
+ processLabel: "LocalStack",
+ alreadyRunningMessage: "already running",
+ successTitle: "started",
+ statusHeading: "Status",
+ timeoutMessage: "timed out",
+ });
+
+ await Promise.resolve();
+ child.stdout.write("Error: Port 4566 already in use\n");
+ child.emit("close", 0);
+
+ const text = (await resultPromise).content[0].text;
+ expect(text).toMatch(/exited before it became ready/i);
+ expect(text).toContain("Port 4566 already in use");
+ });
+
+ 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",
+ });
+
+ await Promise.resolve();
+ child.emit("close", 0);
+
+ const text = (await resultPromise).content[0].text;
+ expect(text).toContain("started");
+ expect(text).toContain("LocalStack gateway is reachable");
+ });
+ });
});
diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts
index 31df821..e73fee5 100644
--- a/src/lib/localstack/localstack.utils.ts
+++ b/src/lib/localstack/localstack.utils.ts
@@ -16,35 +16,85 @@ 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",
+ };
+}
+
+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 {
- await runCommand("localstack", ["--help"]);
- const { stdout: version } = await runCommand("localstack", ["--version"]);
+ 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: version.trim(),
+ version: versionResult.stdout.trim(),
};
} catch (error) {
- return {
- isAvailable: false,
- errorMessage: `❌ LocalStack CLI is not installed or not available in PATH.
+ return localStackCliUnavailable(error instanceof Error ? error.message : String(error));
+ }
+}
-Please install LocalStack by following the official documentation:
-https://docs.localstack.cloud/aws/getting-started/installation/
+export type LifecycleCli = "localstack" | "lstk";
-Installation options:
-- Using pip: pip install localstack
-- Using Docker: Use the LocalStack Docker image
-- Using Homebrew (macOS): brew install localstack/tap/localstack-cli
+/**
+ * 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;
+}
-After installation, make sure the 'localstack' command is available in your PATH.`,
- };
+/**
+ * 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;
}
/**
@@ -53,14 +103,21 @@ After installation, make sure the 'localstack' command is available in your PATH
*/
export async function checkSnowflakeCli(): Promise {
try {
- await runCommand("snow", ["--help"]);
- const { stdout: version } = await runCommand("snow", ["--version"]);
+ const { stdout, error, exitCode, stderr } = await runCommand(
+ "snow",
+ ["--version"],
+ cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)
+ );
+ if (error || exitCode !== 0) {
+ throw error || new Error(stderr || `snow --version exited with code ${exitCode}`);
+ }
return {
isAvailable: true,
- version: version.trim(),
+ version: stdout.trim(),
};
} catch (error) {
+ const details = error instanceof Error ? error.message : String(error);
return {
isAvailable: false,
errorMessage: `❌ Snowflake CLI (snow) is not installed or not available in PATH.
@@ -72,7 +129,9 @@ Installation options:
- Using pip: pip install snowflake-cli-labs
- Using Homebrew (macOS): brew install snowflake-cli
-After installation, make sure the 'snow' command is available in your PATH.`,
+After installation, make sure the 'snow' command is available in your PATH.${
+ details.trim() ? `\n\nDetails: ${details.trim()}` : ""
+ }`,
};
}
}
@@ -150,7 +209,11 @@ export async function getGatewayHealth(): Promise {
version?: string;
}>("/_localstack/health", { method: "GET", timeout: GATEWAY_HEALTH_TIMEOUT });
- const services = data && typeof data === "object" && data.services ? data.services : undefined;
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
+ return { reachable: false, ready: false };
+ }
+
+ const services = data.services ? data.services : undefined;
const ready = Object.values(services || {}).some((state) => READY_SERVICE_STATES.has(state));
@@ -303,6 +366,7 @@ export async function getSnowflakeEmulatorStatus(): Promise Promise;
processLabel: string;
@@ -322,7 +388,7 @@ export async function startRuntime({
timeoutMessage: string;
envVars?: Record;
onReady?: () => Promise | null>;
-}) {
+}): Promise> {
const statusCheck = await getStatus();
if (statusCheck.isReady || statusCheck.isRunning) {
return ResponseBuilder.markdown(alreadyRunningMessage);
@@ -336,29 +402,77 @@ export async function startRuntime({
if (process.env.LOCALSTACK_AUTH_TOKEN) {
environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN;
}
+ // 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";
+ }
return new Promise((resolve) => {
- const child = spawn("localstack", startArgs, {
+ const child = spawn(cli, startArgs, {
env: environment,
- stdio: ["ignore", "ignore", "pipe"],
+ stdio: ["ignore", "pipe", "pipe"],
+ shell: process.platform === "win32",
});
+ let stdout = "";
let stderr = "";
- child.stderr.on("data", (data) => {
+ child.stdout?.on("data", (data) => {
+ stdout += data.toString();
+ });
+ child.stderr?.on("data", (data) => {
stderr += data.toString();
});
let earlyExit = false;
let poll: NodeJS.Timeout;
+ let resolved = false;
+ const finish = (response: ReturnType) => {
+ if (resolved) return;
+ resolved = true;
+ if (poll) clearInterval(poll);
+ 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 successResponse = (status: RuntimeStatus) => {
+ let resultMessage = `${successTitle}\n\n`;
+ if (envVars)
+ resultMessage += `✅ Custom environment variables passed to lifecycle CLI: ${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;
+
+ if (onReady) {
+ const preflight = await onReady();
+ if (preflight) {
+ finish(preflight);
+ return true;
+ }
+ }
+
+ finish(successResponse(status));
+ return true;
+ };
+
child.on("error", (err) => {
earlyExit = true;
- if (poll) clearInterval(poll);
- resolve(
+ finish(
ResponseBuilder.markdown(`❌ Failed to start ${processLabel} process: ${err.message}`)
);
});
- child.on("close", (code) => {
+ 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
@@ -367,10 +481,31 @@ export async function startRuntime({
// polling so readiness is still detected and the promise resolves — clearing
// the interval here would leave the start call hanging forever.
if (code !== 0) {
- if (poll) clearInterval(poll);
- resolve(
+ 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 unexpectedly with code ${code}.\n\nStderr:\n${stderr}`
+ `❌ ${processLabel} process exited before it became ready.${failureDetails()}`
)
);
}
@@ -382,26 +517,22 @@ export async function startRuntime({
poll = setInterval(async () => {
timeWaited += pollInterval;
- const status = await getStatus();
- if (status.isReady || status.isRunning) {
- if (onReady) {
- const preflight = await onReady();
- if (preflight) {
- clearInterval(poll);
- resolve(preflight);
- return;
- }
- }
+ try {
+ if (await finishIfReady()) return;
+ } catch (error) {
+ finish(
+ ResponseBuilder.markdown(
+ `❌ Failed to check ${processLabel} status: ${
+ error instanceof Error ? error.message : String(error)
+ }${failureDetails()}`
+ )
+ );
+ return;
+ }
- clearInterval(poll);
- let resultMessage = `${successTitle}\n\n`;
- if (envVars)
- resultMessage += `✅ Custom environment variables applied: ${Object.keys(envVars).join(", ")}\n`;
- if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`;
- resolve(ResponseBuilder.markdown(resultMessage));
- } else if (timeWaited >= maxWaitTime) {
- clearInterval(poll);
- resolve(ResponseBuilder.markdown(timeoutMessage));
+ if (timeWaited >= maxWaitTime) {
+ const details = failureDetails();
+ finish(ResponseBuilder.markdown(details ? `${timeoutMessage}${details}` : timeoutMessage));
}
}, pollInterval);
});
diff --git a/src/lib/logs/log-retriever.test.ts b/src/lib/logs/log-retriever.test.ts
new file mode 100644
index 0000000..afa5156
--- /dev/null
+++ b/src/lib/logs/log-retriever.test.ts
@@ -0,0 +1,28 @@
+import { runCommand } from "../../core/command-runner";
+import { LocalStackLogRetriever } from "./log-retriever";
+
+jest.mock("../../core/command-runner", () => ({
+ runCommand: jest.fn(),
+}));
+
+const mockedRunCommand = runCommand as jest.MockedFunction;
+
+describe("LocalStackLogRetriever", () => {
+ beforeEach(() => {
+ mockedRunCommand.mockReset();
+ });
+
+ 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);
+
+ 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);
+ });
+});
diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts
index 14e0dca..bcfe9b4 100644
--- a/src/lib/logs/log-retriever.ts
+++ b/src/lib/logs/log-retriever.ts
@@ -39,6 +39,20 @@ export class LocalStackLogRetriever {
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) {
return {
success: false,
diff --git a/src/lib/wizard/prereqs.ts b/src/lib/wizard/prereqs.ts
index 525197b..2c257f7 100644
--- a/src/lib/wizard/prereqs.ts
+++ b/src/lib/wizard/prereqs.ts
@@ -49,16 +49,21 @@ export async function checkPrereqs(method: InstallMethod): Promise localstackOk || lstkOk)
+ : Promise.resolve(false);
if (method === "npx") {
results.push(checkNodeVersion());
results.push({
- name: "LocalStack CLI",
- ok: await localstackPromise,
+ name: "LocalStack lifecycle CLI",
+ ok: await lifecycleCliPromise,
fatal: false,
- hint: "install it with `brew install localstack/tap/localstack-cli` or `pip install localstack` — needed by the lifecycle tools",
+ 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",
});
}
diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts
index 7edc18c..3ba1079 100644
--- a/src/tools/localstack-management.ts
+++ b/src/tools/localstack-management.ts
@@ -1,22 +1,27 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
import {
+ detectLifecycleCli,
+ type LifecycleCli,
getLocalStackStatus,
getSnowflakeEmulatorStatus,
startRuntime,
} from "../lib/localstack/localstack.utils";
-import { runCommand } from "../core/command-runner";
import {
- runPreflights,
- requireLocalStackCli,
- requireProFeature,
- requireAuthToken,
-} from "../core/preflight";
+ DockerApiClient,
+ isLocalStackContainerNotFoundError,
+ type ContainerMetadata,
+} from "../lib/docker/docker.client";
+import { runPreflights, requireProFeature, requireAuthToken } 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 =
+ "⚠️ Snowflake emulator is already running. Use 'restart' if you want to apply new configuration.";
export const schema = {
action: z
@@ -51,8 +56,10 @@ 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()];
- if (action !== "status") checks.push(requireLocalStackCli());
if (service === "snowflake") {
// `start` can run when no LocalStack runtime is currently up; validate feature after startup.
@@ -84,20 +91,31 @@ export default async function localstackManagement({
async function handleStart({
envVars,
service,
+ cli,
}: {
envVars?: Record;
service: "aws" | "snowflake";
+ cli?: LifecycleCli;
}) {
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);
+ }
+
+ const lifecycleCli = cli || (await detectLifecycleCli());
+ if (!lifecycleCli) return noLifecycleCliFoundResponse("Starting");
+
return await startRuntime({
- startArgs: ["start"],
+ cli: lifecycleCli,
+ // lstk would otherwise prompt; force non-interactive when spawned headless.
+ startArgs: lifecycleCli === "lstk" ? ["start", "--non-interactive"] : ["start"],
getStatus: () => getLocalStackStatus({ includeCliStatus: false }),
processLabel: "LocalStack",
- alreadyRunningMessage:
- "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration.",
+ alreadyRunningMessage: AWS_ALREADY_RUNNING_MESSAGE,
successTitle: "🚀 LocalStack started successfully!",
statusHeading: "Status",
timeoutMessage:
@@ -107,12 +125,25 @@ async function handleStart({
}
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 emulator is already running. Use 'restart' if you want to apply new configuration.",
+ alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE,
successTitle: "🚀 Snowflake emulator started successfully!",
statusHeading: "Health check",
timeoutMessage:
@@ -122,29 +153,46 @@ async function handleSnowflakeStart({ envVars }: { envVars?: Record;
service: "aws" | "snowflake";
}) {
- await runCommand("localstack", ["stop"], { timeout: 60000 });
+ const dockerClient = new DockerApiClient();
+ let containerId: string;
+ try {
+ containerId = await dockerClient.findLocalStackContainer();
+ } catch (error) {
+ if (!isLocalStackContainerNotFoundError(error)) {
+ return ResponseBuilder.error(
+ "Docker lookup failed",
+ `Could not inspect Docker containers before restart: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+ const status = await getLocalStackStatus({ includeCliStatus: false });
+ if (status.isRunning) {
+ return ResponseBuilder.error(
+ "LocalStack container not found",
+ "The LocalStack gateway is reachable, but no matching Docker container could be identified for restart. " +
+ "Set MAIN_CONTAINER_NAME to the LocalStack container name, or restart the runtime outside the MCP server."
+ );
+ }
+ 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");
+ }
+
+ try {
+ await dockerClient.stopContainer(containerId);
+ } catch (error) {
+ return ResponseBuilder.error(
+ "Failed to stop LocalStack",
+ `Restart aborted because the running LocalStack container could not be stopped: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+
await new Promise((resolve) => setTimeout(resolve, 2000));
- return await handleStart({ envVars, service });
+ return await handleStart({ envVars, service, cli });
+}
+
+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,
+ 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"];
+ }
+
+ return ["localstack", "lstk"];
}
// Handle status action