From 763efcd8e2383d5d35b8a2896592c323d3d4305a Mon Sep 17 00:00:00 2001 From: Maxim Kasyanenko Date: Wed, 24 Jun 2026 11:28:17 -0400 Subject: [PATCH 1/5] fix: make LocalStack lifecycle CLI-agnostic (support lstk; Docker-API stop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #49 (gap #3). start/stop/restart hardcoded the Python `localstack` binary and the management preflight hard-required it, so an `lstk`-only host (no Python CLI) couldn't start/stop/restart through the MCP even though its LocalStack was running. - stop: stop the already-detected container via the Docker API — provenance- agnostic and CLI-free (works for localstack / lstk / compose / raw `docker run`). - start: detect whichever lifecycle CLI is present (`localstack`, else `lstk`) rather than hardcoding `localstack`; clear error if neither is installed. - restart: Docker-API stop + start (still applies new envVars). - drop `requireLocalStackCli` from the management preflight (status/stop/restart no longer need it; start detects a CLI itself). Snowflake start still requires `localstack` — `--stack snowflake` is localstack-only. Also (gap #6, for the lifecycle spawn): set PYTHONIOENCODING=utf-8 / PYTHONUTF8=1 on the start subprocess so the Python CLI's emoji output doesn't crash under the Windows cp1252 code page. Adds DockerApiClient.stopContainer and detectLifecycleCli with unit tests, plus lifecycle tool-boundary tests. Also restores the management status tests that were dropped from main during the #49 squash merge. Co-Authored-By: Claude Opus 4.8 --- src/lib/docker/docker.client.test.ts | 19 ++- src/lib/docker/docker.client.ts | 11 ++ src/lib/localstack/localstack.utils.test.ts | 30 +++++ src/lib/localstack/localstack.utils.ts | 34 ++++- src/localstack-management.tool.test.ts | 139 ++++++++++++++++++++ src/tools/localstack-management.ts | 81 ++++++++---- 6 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 src/localstack-management.tool.test.ts diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts index 0ae7e27..9747ddb 100644 --- a/src/lib/docker/docker.client.test.ts +++ b/src/lib/docker/docker.client.test.ts @@ -10,12 +10,13 @@ jest.mock("dockerode", () => { 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 getContainer = jest.fn(() => ({ exec, stop })); const __state = { demuxTarget: "stdout" as "stdout" | "stderr" }; class DockerMock { - static __mocks = { listContainers, getContainer, exec, start, execInspect, __state }; + static __mocks = { listContainers, getContainer, exec, start, execInspect, stop, __state }; modem: any; constructor() { this.modem = { @@ -52,9 +53,10 @@ describe("DockerApiClient", () => { mocks.exec.mockReset(); mocks.start.mockReset(); mocks.execInspect.mockReset(); + mocks.stop.mockReset(); // Restore default implementations after reset - mocks.getContainer.mockImplementation(() => ({ exec: mocks.exec })); + mocks.getContainer.mockImplementation(() => ({ exec: mocks.exec, stop: mocks.stop })); mocks.exec.mockImplementation(async () => ({ start: mocks.start, inspect: mocks.execInspect })); mocks.__state.demuxTarget = "stdout"; delete process.env.MAIN_CONTAINER_NAME; @@ -151,6 +153,17 @@ describe("DockerApiClient", () => { ); }); + test("stopContainer stops the container via the Docker API", async () => { + const mocks = getDockerMocks(); + mocks.stop.mockResolvedValueOnce(undefined); + + const client = new DockerApiClient(); + await client.stopContainer("abc123", 5); + + expect(mocks.getContainer).toHaveBeenCalledWith("abc123"); + expect(mocks.stop).toHaveBeenCalledWith({ t: 5 }); + }); + test("executeInContainer returns stdout on success", async () => { const mocks = getDockerMocks(); mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]); diff --git a/src/lib/docker/docker.client.ts b/src/lib/docker/docker.client.ts index 5d957f9..11689dd 100644 --- a/src/lib/docker/docker.client.ts +++ b/src/lib/docker/docker.client.ts @@ -77,6 +77,17 @@ export class DockerApiClient { ); } + /** + * 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. LocalStack containers run with + * `--rm`, so stopping also removes them, matching `localstack stop`. + */ + async stopContainer(containerId: string, timeoutSeconds = 10): Promise { + const container = this.docker.getContainer(containerId); + await container.stop({ t: timeoutSeconds }); + } + 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..9dc1543 100644 --- a/src/lib/localstack/localstack.utils.test.ts +++ b/src/lib/localstack/localstack.utils.test.ts @@ -1,4 +1,5 @@ import { + detectLifecycleCli, getGatewayHealth, getLocalStackStatus, getSnowflakeEmulatorStatus, @@ -144,6 +145,35 @@ 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("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("getSnowflakeEmulatorStatus", () => { test("marks emulator healthy on success payload", async () => { mockedRunCommand.mockResolvedValueOnce({ diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index 31df821..e470d50 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -47,6 +47,31 @@ After installation, make sure the 'localstack' command is available in your PATH } } +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"]); + 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(): Promise { + if (await cliAvailable("localstack")) return "localstack"; + if (await cliAvailable("lstk")) return "lstk"; + return null; +} + /** * Check if Snowflake CLI is installed and available in the system PATH * @returns Promise with availability status, version (if available), and error message (if not available) @@ -303,6 +328,7 @@ export async function getSnowflakeEmulatorStatus(): Promise Promise; processLabel: string; @@ -336,9 +364,13 @@ 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 (harmless for `lstk`). + 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"], }); diff --git a/src/localstack-management.tool.test.ts b/src/localstack-management.tool.test.ts new file mode 100644 index 0000000..6df05ac --- /dev/null +++ b/src/localstack-management.tool.test.ts @@ -0,0 +1,139 @@ +// NOTE: lives at src/ root (not src/tools/) on purpose — xmcp discovers every file +// under src/tools/ as a tool and bundles it into the server, so a *.test.ts there +// would be loaded at runtime and crash the server with "jest is not defined". +import localstackManagement from "./tools/localstack-management"; +import { runCommand } from "./core/command-runner"; +import { httpClient } from "./core/http-client"; + +jest.mock("./core/command-runner", () => ({ runCommand: jest.fn() })); +jest.mock("./core/http-client", () => ({ + httpClient: { request: jest.fn() }, + HttpError: class HttpError extends Error {}, +})); +// Run the tool body directly without the analytics wrapper / network. +jest.mock("./core/analytics", () => ({ + withToolAnalytics: (_name: string, _args: unknown, fn: () => unknown) => fn(), +})); + +const mockFindContainer = jest.fn(); +const mockStopContainer = jest.fn(); +jest.mock("./lib/docker/docker.client", () => ({ + DockerApiClient: jest.fn().mockImplementation(() => ({ + findLocalStackContainer: mockFindContainer, + stopContainer: mockStopContainer, + })), +})); + +const mockedRunCommand = runCommand as jest.MockedFunction; +const mockedRequest = httpClient.request as jest.MockedFunction; + +const callStatus = () => + localstackManagement({ action: "status", service: "aws" } as never) as Promise<{ + content: Array<{ text: string }>; + }>; +const textOf = (res: { content: Array<{ text: string }> }) => + res.content.map((c) => c.text).join("\n"); + +describe("localstack-management status (pre-start)", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + mockedRequest.mockReset(); + mockFindContainer.mockReset(); + mockStopContainer.mockReset(); + process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; + delete process.env.MAIN_CONTAINER_NAME; + }); + + test("renders an informational 'not running' status, never an ❌ error, when the gateway is down and `localstack status` yields nothing", async () => { + mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); + mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => { + if (cmd === "localstack" && args[0] === "status") { + return { stdout: "", stderr: "", exitCode: 1, error: new Error("exit 1") } as never; + } + return { stdout: "LocalStack CLI 4.0.0", stderr: "", exitCode: 0 } as never; + }); + + const text = textOf(await callStatus()); + expect(text.trimStart().startsWith("❌")).toBe(false); + expect(text).toMatch(/not currently running|not running/i); + }); + + test("status does not require the Python LocalStack CLI when gateway health is reachable", async () => { + mockedRequest.mockResolvedValueOnce({ + services: { s3: "available" }, + edition: "pro", + version: "4.0.0", + }); + mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => { + if (cmd === "localstack" && args[0] === "--help") { + return { + stdout: "", + stderr: "command not found: localstack", + exitCode: null, + error: new Error("spawn localstack ENOENT"), + } as never; + } + return { stdout: "", stderr: "", exitCode: 0 } as never; + }); + + const text = textOf(await callStatus()); + expect(text).toContain("LocalStack gateway is reachable"); + expect(text).toContain("ready to accept requests"); + expect(mockedRunCommand).not.toHaveBeenCalledWith("localstack", ["--help"]); + }); +}); + +describe("localstack-management lifecycle (provenance-agnostic)", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + mockedRequest.mockReset(); + mockFindContainer.mockReset(); + mockStopContainer.mockReset(); + process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; + delete process.env.MAIN_CONTAINER_NAME; + }); + + test("start reports a clear error when neither the localstack nor lstk CLI is present", async () => { + // Both CLI presence probes (`--version`) fail → no lifecycle CLI available. + mockedRunCommand.mockImplementation(async (_cmd: string, args: string[]) => { + if (args[0] === "--version") { + return { stdout: "", stderr: "not found", exitCode: null, error: new Error("ENOENT") } as never; + } + return { stdout: "", stderr: "", exitCode: 0 } as never; + }); + + const res = (await localstackManagement({ action: "start", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toContain("No LocalStack CLI found"); + expect(text).toMatch(/lstk/); + }); + + test("stop stops the detected container via the Docker API (no CLI)", async () => { + mockFindContainer.mockResolvedValue("abc123"); + mockStopContainer.mockResolvedValue(undefined); + + const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text.trimStart().startsWith("❌")).toBe(false); + expect(text).toMatch(/stopped successfully/i); + expect(mockStopContainer).toHaveBeenCalledWith("abc123"); + // Stop must not shell out to the `localstack` CLI anymore. + expect(mockedRunCommand).not.toHaveBeenCalled(); + }); + + test("stop reports not-running (no error) when no container is found", async () => { + mockFindContainer.mockRejectedValue(new Error("no container")); + + const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text.trimStart().startsWith("❌")).toBe(false); + expect(text).toMatch(/not running/i); + expect(mockStopContainer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts index 7edc18c..ae95486 100644 --- a/src/tools/localstack-management.ts +++ b/src/tools/localstack-management.ts @@ -1,17 +1,13 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { + detectLifecycleCli, getLocalStackStatus, getSnowflakeEmulatorStatus, startRuntime, } from "../lib/localstack/localstack.utils"; -import { runCommand } from "../core/command-runner"; -import { - runPreflights, - requireLocalStackCli, - requireProFeature, - requireAuthToken, -} from "../core/preflight"; +import { DockerApiClient } 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"; @@ -51,8 +47,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. @@ -92,8 +90,20 @@ async function handleStart({ return await handleSnowflakeStart({ envVars }); } + const cli = await detectLifecycleCli(); + if (!cli) { + return ResponseBuilder.error( + "No LocalStack CLI found", + "Starting 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." + ); + } + return await startRuntime({ - startArgs: ["start"], + cli, + // lstk would otherwise prompt; force non-interactive when spawned headless. + startArgs: cli === "lstk" ? ["start", "--non-interactive"] : ["start"], getStatus: () => getLocalStackStatus({ includeCliStatus: false }), processLabel: "LocalStack", alreadyRunningMessage: @@ -107,7 +117,16 @@ async function handleStart({ } async function handleSnowflakeStart({ envVars }: { envVars?: Record }) { + // The Snowflake stack is localstack-only (`--stack snowflake` has no lstk equivalent). + if ((await detectLifecycleCli()) !== "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", @@ -122,29 +141,29 @@ async function handleSnowflakeStart({ envVars }: { envVars?: Record; service: "aws" | "snowflake"; }) { - await runCommand("localstack", ["stop"], { timeout: 60000 }); - await new Promise((resolve) => setTimeout(resolve, 2000)); + const dockerClient = new DockerApiClient(); + try { + const containerId = await dockerClient.findLocalStackContainer(); + await dockerClient.stopContainer(containerId); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch { + // Nothing running to stop — proceed to start. + } return await handleStart({ envVars, service }); } From 2812c1fc3cb2fc3c3c33738a788e34f09bdbfed8 Mon Sep 17 00:00:00 2001 From: HarshCasper Date: Thu, 2 Jul 2026 10:12:54 -0700 Subject: [PATCH 2/5] improve the code --- README.md | 28 +-- docs/DOCKER.md | 37 ++-- src/cli/init.ts | 2 +- src/lib/docker/docker.client.test.ts | 191 +++++++++++++++++- src/lib/docker/docker.client.ts | 136 +++++++++++-- src/lib/localstack/localstack.utils.test.ts | 116 +++++++++++ src/lib/localstack/localstack.utils.ts | 210 +++++++++++++++----- src/lib/logs/log-retriever.test.ts | 28 +++ src/lib/logs/log-retriever.ts | 14 ++ src/lib/wizard/prereqs.ts | 15 +- src/localstack-management.tool.test.ts | 104 +++++++++- src/tools/localstack-management.ts | 152 +++++++++++--- 12 files changed, 894 insertions(+), 139 deletions(-) create mode 100644 src/lib/logs/log-retriever.test.ts 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 9747ddb..ca099bf 100644 --- a/src/lib/docker/docker.client.test.ts +++ b/src/lib/docker/docker.client.test.ts @@ -4,6 +4,7 @@ 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)); @@ -11,12 +12,23 @@ jest.mock("dockerode", () => { }); const exec = jest.fn(async () => ({ start, inspect: execInspect })); const stop = jest.fn(); - const getContainer = jest.fn(() => ({ exec, stop })); + 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, stop, __state }; + static __mocks = { + listContainers, + getContainer, + exec, + start, + execInspect, + containerInspect, + stop, + remove, + __state, + }; modem: any; constructor() { this.modem = { @@ -53,14 +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, stop: mocks.stop })); + 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 () => { @@ -75,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"; @@ -105,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([ { @@ -116,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" }], }, ]); @@ -125,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"; @@ -156,17 +282,64 @@ 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 }); @@ -197,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 11689dd..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,34 +120,82 @@ 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. LocalStack containers run with - * `--rm`, so stopping also removes them, matching `localstack stop`. + * 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): Promise { + async stopContainer( + containerId: string, + timeoutSeconds = 10, + requestTimeoutMs = 60000 + ): Promise { const container = this.docker.getContainer(containerId); - await container.stop({ t: timeoutSeconds }); + 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( diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts index 9dc1543..ccbc913 100644 --- a/src/lib/localstack/localstack.utils.test.ts +++ b/src/lib/localstack/localstack.utils.test.ts @@ -1,11 +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(), @@ -16,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({ @@ -33,6 +51,7 @@ describe("localstack.utils", () => { beforeEach(() => { mockedRunCommand.mockReset(); mockedRequest.mockReset(); + mockedSpawn.mockReset(); }); describe("getGatewayHealth", () => { @@ -66,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", () => { @@ -163,6 +190,21 @@ describe("localstack.utils", () => { 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: "", @@ -174,6 +216,21 @@ describe("localstack.utils", () => { }); }); + 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({ @@ -200,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 e470d50..87fb259 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -16,23 +16,20 @@ export interface SnowflakeCliCheckResult { errorMessage?: string; } -/** - * 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 CLI_CHECK_TIMEOUT = 5000; - return { - isAvailable: true, - version: version.trim(), - }; - } catch (error) { - return { - isAvailable: false, - errorMessage: `❌ LocalStack CLI is not installed or not available in PATH. +function cliSpawnOptions() { + return { + timeout: CLI_CHECK_TIMEOUT, + 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/ @@ -42,8 +39,32 @@ Installation options: - 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.`, +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)); } } @@ -55,7 +76,7 @@ export type LifecycleCli = "localstack" | "lstk"; * binary isn't on PATH. */ async function cliAvailable(bin: string): Promise { - const { error, exitCode } = await runCommand(bin, ["--version"]); + const { error, exitCode } = await runCommand(bin, ["--version"], cliSpawnOptions()); return !error && exitCode === 0; } @@ -66,9 +87,12 @@ async function cliAvailable(bin: string): Promise { * still be detected and driven via the gateway + Docker API, but *creating* one needs a * CLI. */ -export async function detectLifecycleCli(): Promise { - if (await cliAvailable("localstack")) return "localstack"; - if (await cliAvailable("lstk")) return "lstk"; +export async function detectLifecycleCli( + preferredOrder: LifecycleCli[] = ["localstack", "lstk"] +): Promise { + for (const cli of preferredOrder) { + if (await cliAvailable(cli)) return cli; + } return null; } @@ -78,8 +102,19 @@ export async function detectLifecycleCli(): Promise { */ export async function checkSnowflakeCli(): Promise { try { - await runCommand("snow", ["--help"]); - const { stdout: version } = await runCommand("snow", ["--version"]); + const help = await runCommand("snow", ["--help"], cliSpawnOptions()); + if (help.error || help.exitCode !== 0) { + throw help.error || new Error(help.stderr || `snow --help exited with code ${help.exitCode}`); + } + const { + stdout: version, + error, + exitCode, + stderr, + } = await runCommand("snow", ["--version"], cliSpawnOptions()); + if (error || exitCode !== 0) { + throw error || new Error(stderr || `snow --version exited with code ${exitCode}`); + } return { isAvailable: true, @@ -175,7 +210,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)); @@ -350,7 +389,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); @@ -365,32 +404,76 @@ export async function startRuntime({ 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 (harmless for `lstk`). - if (!environment.PYTHONIOENCODING) environment.PYTHONIOENCODING = "utf-8"; - if (!environment.PYTHONUTF8) environment.PYTHONUTF8 = "1"; + // 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(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 @@ -399,10 +482,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()}` ) ); } @@ -414,26 +518,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/localstack-management.tool.test.ts b/src/localstack-management.tool.test.ts index 6df05ac..05b7cb9 100644 --- a/src/localstack-management.tool.test.ts +++ b/src/localstack-management.tool.test.ts @@ -4,6 +4,7 @@ import localstackManagement from "./tools/localstack-management"; import { runCommand } from "./core/command-runner"; import { httpClient } from "./core/http-client"; +import { LocalStackContainerNotFoundError } from "./lib/docker/docker.client"; jest.mock("./core/command-runner", () => ({ runCommand: jest.fn() })); jest.mock("./core/http-client", () => ({ @@ -17,10 +18,20 @@ jest.mock("./core/analytics", () => ({ const mockFindContainer = jest.fn(); const mockStopContainer = jest.fn(); +const mockInspectContainer = jest.fn(); jest.mock("./lib/docker/docker.client", () => ({ + LocalStackContainerNotFoundError: class LocalStackContainerNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "LocalStackContainerNotFoundError"; + } + }, + isLocalStackContainerNotFoundError: (error: unknown) => + error instanceof Error && error.name === "LocalStackContainerNotFoundError", DockerApiClient: jest.fn().mockImplementation(() => ({ findLocalStackContainer: mockFindContainer, stopContainer: mockStopContainer, + inspectContainer: mockInspectContainer, })), })); @@ -40,6 +51,7 @@ describe("localstack-management status (pre-start)", () => { mockedRequest.mockReset(); mockFindContainer.mockReset(); mockStopContainer.mockReset(); + mockInspectContainer.mockReset(); process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; delete process.env.MAIN_CONTAINER_NAME; }); @@ -89,15 +101,22 @@ describe("localstack-management lifecycle (provenance-agnostic)", () => { mockedRequest.mockReset(); mockFindContainer.mockReset(); mockStopContainer.mockReset(); + mockInspectContainer.mockReset(); process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; delete process.env.MAIN_CONTAINER_NAME; }); test("start reports a clear error when neither the localstack nor lstk CLI is present", async () => { + mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); // Both CLI presence probes (`--version`) fail → no lifecycle CLI available. mockedRunCommand.mockImplementation(async (_cmd: string, args: string[]) => { if (args[0] === "--version") { - return { stdout: "", stderr: "not found", exitCode: null, error: new Error("ENOENT") } as never; + return { + stdout: "", + stderr: "not found", + exitCode: null, + error: new Error("ENOENT"), + } as never; } return { stdout: "", stderr: "", exitCode: 0 } as never; }); @@ -110,6 +129,27 @@ describe("localstack-management lifecycle (provenance-agnostic)", () => { expect(text).toMatch(/lstk/); }); + test("start reports already-running before requiring a lifecycle CLI", async () => { + mockedRequest.mockResolvedValueOnce({ + services: { s3: "available" }, + edition: "pro", + version: "4.0.0", + }); + mockedRunCommand.mockResolvedValue({ + stdout: "", + stderr: "not found", + exitCode: null, + error: new Error("ENOENT"), + } as never); + + const res = (await localstackManagement({ action: "start", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toMatch(/already running/i); + expect(mockedRunCommand).not.toHaveBeenCalled(); + }); + test("stop stops the detected container via the Docker API (no CLI)", async () => { mockFindContainer.mockResolvedValue("abc123"); mockStopContainer.mockResolvedValue(undefined); @@ -126,7 +166,8 @@ describe("localstack-management lifecycle (provenance-agnostic)", () => { }); test("stop reports not-running (no error) when no container is found", async () => { - mockFindContainer.mockRejectedValue(new Error("no container")); + mockFindContainer.mockRejectedValue(new LocalStackContainerNotFoundError("no container")); + mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { content: Array<{ text: string }>; @@ -136,4 +177,63 @@ describe("localstack-management lifecycle (provenance-agnostic)", () => { expect(text).toMatch(/not running/i); expect(mockStopContainer).not.toHaveBeenCalled(); }); + + test("stop reports Docker lookup failures instead of pretending LocalStack is stopped", async () => { + mockFindContainer.mockRejectedValue(new Error("connect ECONNREFUSED /var/run/docker.sock")); + + const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toMatch(/Docker lookup failed/i); + expect(mockStopContainer).not.toHaveBeenCalled(); + }); + + test("stop reports Docker stop failures as structured errors", async () => { + mockFindContainer.mockResolvedValue("abc123"); + mockStopContainer.mockRejectedValue(new Error("permission denied")); + + const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toMatch(/Failed to stop LocalStack/i); + expect(text).toMatch(/permission denied/i); + }); + + test("restart does not stop the running container when no start CLI is available", async () => { + mockFindContainer.mockResolvedValue("abc123"); + mockInspectContainer.mockResolvedValue({ name: "localstack-aws", env: [] }); + mockedRunCommand.mockResolvedValue({ + stdout: "", + stderr: "not found", + exitCode: null, + error: new Error("ENOENT"), + } as never); + + const res = (await localstackManagement({ action: "restart", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toContain("No LocalStack CLI found"); + expect(mockStopContainer).not.toHaveBeenCalled(); + }); + + test("restart aborts when Docker stop fails", async () => { + mockFindContainer.mockResolvedValue("abc123"); + mockInspectContainer.mockResolvedValue({ name: "localstack-main", env: [] }); + mockStopContainer.mockRejectedValue(new Error("permission denied")); + mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => + cmd === "localstack" && args[0] === "--version" + ? ({ stdout: "LocalStack CLI 4.0.0", stderr: "", exitCode: 0 } as never) + : ({ stdout: "", stderr: "", exitCode: 0 } as never) + ); + + const res = (await localstackManagement({ action: "restart", service: "aws" } as never)) as { + content: Array<{ text: string }>; + }; + const text = textOf(res); + expect(text).toMatch(/Failed to stop LocalStack/i); + expect(text).toMatch(/permission denied/i); + }); }); diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts index ae95486..3ba1079 100644 --- a/src/tools/localstack-management.ts +++ b/src/tools/localstack-management.ts @@ -2,17 +2,26 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { detectLifecycleCli, + type LifecycleCli, getLocalStackStatus, getSnowflakeEmulatorStatus, startRuntime, } from "../lib/localstack/localstack.utils"; -import { DockerApiClient } from "../lib/docker/docker.client"; +import { + 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 @@ -82,32 +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 cli = await detectLifecycleCli(); - if (!cli) { - return ResponseBuilder.error( - "No LocalStack CLI found", - "Starting 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." - ); + 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({ - cli, + cli: lifecycleCli, // lstk would otherwise prompt; force non-interactive when spawned headless. - startArgs: cli === "lstk" ? ["start", "--non-interactive"] : ["start"], + 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: @@ -117,8 +125,13 @@ 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") { + 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`." @@ -130,8 +143,7 @@ async function handleSnowflakeStart({ envVars }: { envVars?: Record setTimeout(resolve, 2000)); + } 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, 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 { - // Nothing running to stop — proceed to start. + 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 await handleStart({ envVars, service }); + + return ["localstack", "lstk"]; } // Handle status action From 415d32fd4304c7a37b0277ca787f2572a5a05bbe Mon Sep 17 00:00:00 2001 From: HarshCasper Date: Thu, 2 Jul 2026 17:19:17 -0700 Subject: [PATCH 3/5] fix the snowflake cli timeout --- src/lib/localstack/localstack.utils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index 87fb259..acbcc98 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -17,10 +17,11 @@ export interface SnowflakeCliCheckResult { } const CLI_CHECK_TIMEOUT = 5000; +const SNOWFLAKE_CLI_CHECK_TIMEOUT = 30000; -function cliSpawnOptions() { +function cliSpawnOptions(timeoutMs: number = CLI_CHECK_TIMEOUT) { return { - timeout: CLI_CHECK_TIMEOUT, + timeout: timeoutMs, shell: process.platform === "win32", }; } @@ -102,7 +103,7 @@ export async function detectLifecycleCli( */ export async function checkSnowflakeCli(): Promise { try { - const help = await runCommand("snow", ["--help"], cliSpawnOptions()); + const help = await runCommand("snow", ["--help"], cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)); if (help.error || help.exitCode !== 0) { throw help.error || new Error(help.stderr || `snow --help exited with code ${help.exitCode}`); } @@ -111,7 +112,7 @@ export async function checkSnowflakeCli(): Promise { error, exitCode, stderr, - } = await runCommand("snow", ["--version"], cliSpawnOptions()); + } = await runCommand("snow", ["--version"], cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)); if (error || exitCode !== 0) { throw error || new Error(stderr || `snow --version exited with code ${exitCode}`); } From 35f9201593ce954461729edf6c9479fc8134fe94 Mon Sep 17 00:00:00 2001 From: HarshCasper Date: Fri, 3 Jul 2026 23:01:15 -0700 Subject: [PATCH 4/5] fix the docker test --- src/lib/localstack/localstack.utils.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index acbcc98..e73fee5 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -103,25 +103,21 @@ export async function detectLifecycleCli( */ export async function checkSnowflakeCli(): Promise { try { - const help = await runCommand("snow", ["--help"], cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)); - if (help.error || help.exitCode !== 0) { - throw help.error || new Error(help.stderr || `snow --help exited with code ${help.exitCode}`); - } - const { - stdout: version, - error, - exitCode, - stderr, - } = await runCommand("snow", ["--version"], cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT)); + 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. @@ -133,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()}` : "" + }`, }; } } From 400433c5ae55742378ffccb5250c46813eb38f16 Mon Sep 17 00:00:00 2001 From: HarshCasper Date: Fri, 3 Jul 2026 23:19:08 -0700 Subject: [PATCH 5/5] remove the test file (to be setup later) --- src/localstack-management.tool.test.ts | 239 ------------------------- 1 file changed, 239 deletions(-) delete mode 100644 src/localstack-management.tool.test.ts diff --git a/src/localstack-management.tool.test.ts b/src/localstack-management.tool.test.ts deleted file mode 100644 index 05b7cb9..0000000 --- a/src/localstack-management.tool.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -// NOTE: lives at src/ root (not src/tools/) on purpose — xmcp discovers every file -// under src/tools/ as a tool and bundles it into the server, so a *.test.ts there -// would be loaded at runtime and crash the server with "jest is not defined". -import localstackManagement from "./tools/localstack-management"; -import { runCommand } from "./core/command-runner"; -import { httpClient } from "./core/http-client"; -import { LocalStackContainerNotFoundError } from "./lib/docker/docker.client"; - -jest.mock("./core/command-runner", () => ({ runCommand: jest.fn() })); -jest.mock("./core/http-client", () => ({ - httpClient: { request: jest.fn() }, - HttpError: class HttpError extends Error {}, -})); -// Run the tool body directly without the analytics wrapper / network. -jest.mock("./core/analytics", () => ({ - withToolAnalytics: (_name: string, _args: unknown, fn: () => unknown) => fn(), -})); - -const mockFindContainer = jest.fn(); -const mockStopContainer = jest.fn(); -const mockInspectContainer = jest.fn(); -jest.mock("./lib/docker/docker.client", () => ({ - LocalStackContainerNotFoundError: class LocalStackContainerNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "LocalStackContainerNotFoundError"; - } - }, - isLocalStackContainerNotFoundError: (error: unknown) => - error instanceof Error && error.name === "LocalStackContainerNotFoundError", - DockerApiClient: jest.fn().mockImplementation(() => ({ - findLocalStackContainer: mockFindContainer, - stopContainer: mockStopContainer, - inspectContainer: mockInspectContainer, - })), -})); - -const mockedRunCommand = runCommand as jest.MockedFunction; -const mockedRequest = httpClient.request as jest.MockedFunction; - -const callStatus = () => - localstackManagement({ action: "status", service: "aws" } as never) as Promise<{ - content: Array<{ text: string }>; - }>; -const textOf = (res: { content: Array<{ text: string }> }) => - res.content.map((c) => c.text).join("\n"); - -describe("localstack-management status (pre-start)", () => { - beforeEach(() => { - mockedRunCommand.mockReset(); - mockedRequest.mockReset(); - mockFindContainer.mockReset(); - mockStopContainer.mockReset(); - mockInspectContainer.mockReset(); - process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; - delete process.env.MAIN_CONTAINER_NAME; - }); - - test("renders an informational 'not running' status, never an ❌ error, when the gateway is down and `localstack status` yields nothing", async () => { - mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); - mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => { - if (cmd === "localstack" && args[0] === "status") { - return { stdout: "", stderr: "", exitCode: 1, error: new Error("exit 1") } as never; - } - return { stdout: "LocalStack CLI 4.0.0", stderr: "", exitCode: 0 } as never; - }); - - const text = textOf(await callStatus()); - expect(text.trimStart().startsWith("❌")).toBe(false); - expect(text).toMatch(/not currently running|not running/i); - }); - - test("status does not require the Python LocalStack CLI when gateway health is reachable", async () => { - mockedRequest.mockResolvedValueOnce({ - services: { s3: "available" }, - edition: "pro", - version: "4.0.0", - }); - mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => { - if (cmd === "localstack" && args[0] === "--help") { - return { - stdout: "", - stderr: "command not found: localstack", - exitCode: null, - error: new Error("spawn localstack ENOENT"), - } as never; - } - return { stdout: "", stderr: "", exitCode: 0 } as never; - }); - - const text = textOf(await callStatus()); - expect(text).toContain("LocalStack gateway is reachable"); - expect(text).toContain("ready to accept requests"); - expect(mockedRunCommand).not.toHaveBeenCalledWith("localstack", ["--help"]); - }); -}); - -describe("localstack-management lifecycle (provenance-agnostic)", () => { - beforeEach(() => { - mockedRunCommand.mockReset(); - mockedRequest.mockReset(); - mockFindContainer.mockReset(); - mockStopContainer.mockReset(); - mockInspectContainer.mockReset(); - process.env.LOCALSTACK_AUTH_TOKEN = "test-token"; - delete process.env.MAIN_CONTAINER_NAME; - }); - - test("start reports a clear error when neither the localstack nor lstk CLI is present", async () => { - mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); - // Both CLI presence probes (`--version`) fail → no lifecycle CLI available. - mockedRunCommand.mockImplementation(async (_cmd: string, args: string[]) => { - if (args[0] === "--version") { - return { - stdout: "", - stderr: "not found", - exitCode: null, - error: new Error("ENOENT"), - } as never; - } - return { stdout: "", stderr: "", exitCode: 0 } as never; - }); - - const res = (await localstackManagement({ action: "start", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toContain("No LocalStack CLI found"); - expect(text).toMatch(/lstk/); - }); - - test("start reports already-running before requiring a lifecycle CLI", async () => { - mockedRequest.mockResolvedValueOnce({ - services: { s3: "available" }, - edition: "pro", - version: "4.0.0", - }); - mockedRunCommand.mockResolvedValue({ - stdout: "", - stderr: "not found", - exitCode: null, - error: new Error("ENOENT"), - } as never); - - const res = (await localstackManagement({ action: "start", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toMatch(/already running/i); - expect(mockedRunCommand).not.toHaveBeenCalled(); - }); - - test("stop stops the detected container via the Docker API (no CLI)", async () => { - mockFindContainer.mockResolvedValue("abc123"); - mockStopContainer.mockResolvedValue(undefined); - - const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text.trimStart().startsWith("❌")).toBe(false); - expect(text).toMatch(/stopped successfully/i); - expect(mockStopContainer).toHaveBeenCalledWith("abc123"); - // Stop must not shell out to the `localstack` CLI anymore. - expect(mockedRunCommand).not.toHaveBeenCalled(); - }); - - test("stop reports not-running (no error) when no container is found", async () => { - mockFindContainer.mockRejectedValue(new LocalStackContainerNotFoundError("no container")); - mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); - - const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text.trimStart().startsWith("❌")).toBe(false); - expect(text).toMatch(/not running/i); - expect(mockStopContainer).not.toHaveBeenCalled(); - }); - - test("stop reports Docker lookup failures instead of pretending LocalStack is stopped", async () => { - mockFindContainer.mockRejectedValue(new Error("connect ECONNREFUSED /var/run/docker.sock")); - - const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toMatch(/Docker lookup failed/i); - expect(mockStopContainer).not.toHaveBeenCalled(); - }); - - test("stop reports Docker stop failures as structured errors", async () => { - mockFindContainer.mockResolvedValue("abc123"); - mockStopContainer.mockRejectedValue(new Error("permission denied")); - - const res = (await localstackManagement({ action: "stop", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toMatch(/Failed to stop LocalStack/i); - expect(text).toMatch(/permission denied/i); - }); - - test("restart does not stop the running container when no start CLI is available", async () => { - mockFindContainer.mockResolvedValue("abc123"); - mockInspectContainer.mockResolvedValue({ name: "localstack-aws", env: [] }); - mockedRunCommand.mockResolvedValue({ - stdout: "", - stderr: "not found", - exitCode: null, - error: new Error("ENOENT"), - } as never); - - const res = (await localstackManagement({ action: "restart", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toContain("No LocalStack CLI found"); - expect(mockStopContainer).not.toHaveBeenCalled(); - }); - - test("restart aborts when Docker stop fails", async () => { - mockFindContainer.mockResolvedValue("abc123"); - mockInspectContainer.mockResolvedValue({ name: "localstack-main", env: [] }); - mockStopContainer.mockRejectedValue(new Error("permission denied")); - mockedRunCommand.mockImplementation(async (cmd: string, args: string[]) => - cmd === "localstack" && args[0] === "--version" - ? ({ stdout: "LocalStack CLI 4.0.0", stderr: "", exitCode: 0 } as never) - : ({ stdout: "", stderr: "", exitCode: 0 } as never) - ); - - const res = (await localstackManagement({ action: "restart", service: "aws" } as never)) as { - content: Array<{ text: string }>; - }; - const text = textOf(res); - expect(text).toMatch(/Failed to stop LocalStack/i); - expect(text).toMatch(/permission denied/i); - }); -});