diff --git a/.gitignore b/.gitignore index 1e01783..b6fd557 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ terraform.tfstate* .terraform.lock.hcl .mcp-test-results/ .DS_Store +cdk.out/ diff --git a/README.md b/README.md index e2a7e0f..c7b8129 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,41 @@ Prompts are user-selected workflow templates exposed by MCP clients as slash com ## Installation -| Editor | Installation | -| :--------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cursor** | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=localstack-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IEBsb2NhbHN0YWNrL2xvY2Fsc3RhY2stbWNwLXNlcnZlciJ9) | +### Quick Setup (Wizard) + +The fastest way to install the MCP server is the interactive setup wizard: + +```bash +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, +- 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. + +It can also run fully non-interactively, e.g. in dotfiles or scripts: + +```bash +npx -y @localstack/localstack-mcp-server init --method npx --client cursor,claude-code --yes +``` + +To remove the server from your clients again: + +```bash +npx -y @localstack/localstack-mcp-server remove +``` + +Run `npx -y @localstack/localstack-mcp-server init --help` for all options. + +### Manual Setup + +| Editor | Installation | +| :--------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cursor** | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=localstack&config=eyJjb21tYW5kIjoibnB4IC15IEBsb2NhbHN0YWNrL2xvY2Fsc3RhY2stbWNwLXNlcnZlciJ9) | For other MCP Clients, refer to the [configuration guide](#configuration). @@ -65,7 +97,7 @@ For other MCP Clients, refer to the [configuration guide](#configuration). - [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) and Docker installed in your system path - [`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 for running infrastructure deployment tooling - A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**) -- [Node.js v22.x](https://nodejs.org/en/download/) or higher installed in your system path +- [Node.js v20](https://nodejs.org/en/download/) or higher installed in your system path ### Configuration @@ -74,7 +106,7 @@ Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp. ```json { "mcpServers": { - "localstack-mcp-server": { + "localstack": { "command": "npx", "args": ["-y", "@localstack/localstack-mcp-server"], "env": { @@ -92,7 +124,7 @@ If you installed from source, change `command` and `args` to point to your local ```json { "mcpServers": { - "localstack-mcp-server": { + "localstack": { "command": "node", "args": ["/path/to/your/localstack-mcp-server/dist/stdio.js"], "env": { diff --git a/package.json b/package.json index b3550d2..8eb647d 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "node": ">=20.0.0" }, "scripts": { - "build": "xmcp build", + "build": "xmcp build && yarn build:cli", + "build:cli": "esbuild src/cli/index.ts --bundle --platform=node --format=cjs --target=node20 --tsconfig=tsconfig.cli.json --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --outfile=dist/cli.js --external:./stdio.js \"--banner:js=#!/usr/bin/env node\" --log-level=warning", "dev": "xmcp dev", "start": "node dist/stdio.js", + "prepack": "yarn build", "format": "prettier --write .", "test": "jest", "test:mcp:direct": "yarn build && playwright test -c playwright.config.mjs tests/mcp/direct.spec.mjs", @@ -23,12 +25,15 @@ "zod": "4.3.6" }, "devDependencies": { + "@clack/prompts": "^1.5.1", "@gleanwork/mcp-server-tester": "1.0.0-beta.6", "@playwright/test": "^1.58.2", "@types/dockerode": "^3.3.43", "@types/jest": "^30.0.0", + "esbuild": "^0.28.0", "eslint-config-prettier": "^10.1.8", "jest": "^30.1.3", + "jsonc-parser": "^3.3.1", "prettier": "^3.6.2", "swc-loader": "^0.2.6", "ts-jest": "^29.4.1", @@ -39,7 +44,7 @@ "dist" ], "bin": { - "localstack-mcp-server": "./dist/stdio.js" + "localstack-mcp-server": "./dist/cli.js" }, "author": "@localstack", "license": "Apache-2.0", diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..cbde579 --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,41 @@ +import { ALL_CLIENT_IDS } from "../lib/wizard/clients/registry"; + +export const HELP_TEXT = `LocalStack MCP Server + +Usage: + npx -y @localstack/localstack-mcp-server Start the MCP server (stdio) + npx -y @localstack/localstack-mcp-server init Set up the server in your MCP clients + npx -y @localstack/localstack-mcp-server remove Remove the server from your MCP clients + +init options: + --method How the MCP server should run (default: npx) + --client MCP clients to configure, comma-separated or repeated. + Valid: ${ALL_CLIENT_IDS.join(", ")} + --token LocalStack Auth Token (default: $LOCALSTACK_AUTH_TOKEN) + --config Extra LocalStack config vars, e.g. "DEBUG=1,PERSISTENCE=1" + --cache-dir [docker] State/cache dir mounted into the container + (default: ~/.localstack-mcp) + --workspace [docker] Workspace dir to mount for IaC deployments + (default: current directory; pass "" to skip) + --image-tag [docker] Image tag for localstack/localstack-mcp-server + (default: latest) + --force Overwrite an existing "localstack" entry without asking + -y, --yes Accept defaults for everything not provided via flags; + existing entries are kept unless --force is also given + -h, --help Show this help + +remove options: + --client Clients to remove "localstack" from (default: all with an entry) + --force, -y, --yes Don't ask for confirmation + +Examples: + npx -y @localstack/localstack-mcp-server init + npx -y @localstack/localstack-mcp-server init --method npx --client cursor,claude-code + npx -y @localstack/localstack-mcp-server init --method docker --client cursor --yes + npx -y @localstack/localstack-mcp-server remove --client cursor + +The auth token is read from $LOCALSTACK_AUTH_TOKEN when --token is not given. +Get yours at https://app.localstack.cloud/workspace/auth-tokens + +The wizard writes and removes only the MCP server entry named "localstack". +`; diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..26cb106 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,65 @@ +/** + * Launcher for the published bin. With no recognized subcommand it starts the + * MCP server exactly as before (dist/stdio.js), so every existing client + * config keeps working. `init`/`remove` run the setup wizard. + */ +import * as fs from "fs"; +import * as path from "path"; + +function getVersion(): string { + try { + const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") + ); + return packageJson.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +async function main(): Promise { + const command = process.argv[2]; + + switch (command) { + case "init": { + const { runInit } = await import("./init"); + process.exit(await runInit(process.argv.slice(3))); + break; + } + case "remove": { + const { runRemove } = await import("./remove"); + process.exit(await runRemove(process.argv.slice(3))); + break; + } + case "help": + case "--help": + case "-h": { + const { HELP_TEXT } = await import("./help"); + console.log(HELP_TEXT); + process.exit(0); + break; + } + case "version": + case "--version": + case "-v": { + console.log(getVersion()); + process.exit(0); + break; + } + default: + // Anything else (including no args) starts the MCP server, matching the + // pre-wizard behavior for MCP clients that launch this bin. A stderr + // hint covers typos like "innit" — MCP hosts ignore stderr. + if (command !== undefined) { + console.error( + `Unknown command "${command}" — starting the MCP server. Did you mean "init"? See --help for setup commands.` + ); + } + require("./stdio.js"); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 0000000..a021de9 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,520 @@ +import * as p from "@clack/prompts"; +import * as os from "os"; +import * as path from "path"; +import { parseInitFlags } from "../lib/wizard/cli-args.logic"; +import { CLIENT_ADAPTERS } from "../lib/wizard/clients/registry"; +import { ClientAdapter, ClientContext, DetectResult } from "../lib/wizard/clients/types"; +import { parseEnvVarsInput } from "../lib/wizard/env-parser.logic"; +import { checkPrereqs } from "../lib/wizard/prereqs"; +import { buildDockerServerSpec, buildNpxServerSpec } from "../lib/wizard/server-config.logic"; +import { + AUTH_TOKEN_ENV, + ClientId, + DockerOptions, + InstallMethod, + ServerSpec, + WizardAnswers, +} from "../lib/wizard/types"; +import { HELP_TEXT } from "./help"; +import { + ClientOutcome, + createProgress, + ensureAnswer, + EXIT_CANCELLED, + EXIT_ERROR, + EXIT_OK, + formatOutcomeLines, + isInteractive, + printPrereqResults, +} from "./ui"; + +function buildContext(): ClientContext { + return { platform: process.platform, homeDir: os.homedir(), env: process.env }; +} + +function expandPath(input: string, homeDir: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + const expanded = trimmed.replace(/^~(?=$|[\\/])/, homeDir); + return path.resolve(expanded); +} + +function preWriteAnswer(value: T | symbol): T { + return ensureAnswer(value, "Cancelled — nothing was written."); +} + +interface DetectedClient { + adapter: ClientAdapter; + detection: DetectResult; +} + +async function detectClients( + ctx: ClientContext, + clientIds: ClientId[] | undefined = undefined +): Promise { + const selected = clientIds + ? CLIENT_ADAPTERS.filter((adapter) => clientIds.includes(adapter.id)) + : CLIENT_ADAPTERS; + return Promise.all( + selected.map(async (adapter) => ({ adapter, detection: await adapter.detect(ctx) })) + ); +} + +function formatDetectionSummary(detected: DetectedClient[], allClients: boolean): string { + if (!allClients) { + return `Checked selected clients: ${detected.map((entry) => entry.adapter.label).join(", ")}`; + } + const installed = detected + .filter((entry) => entry.detection.installed) + .map((entry) => entry.adapter.label); + return installed.length > 0 ? `Detected: ${installed.join(", ")}` : "No clients detected"; +} + +async function resolveMethod( + flagMethod: InstallMethod | undefined, + interactive: boolean, + yes: boolean, + ctx: ClientContext +): Promise { + let method = flagMethod; + if (!method && interactive && !yes) { + method = preWriteAnswer( + await p.select({ + message: "Where should the MCP server run?", + options: [ + { + value: "npx", + label: "npx (Node on this machine)", + hint: "uses your local Node 20+, LocalStack CLI, and Docker", + }, + { + value: "docker", + label: "Docker (self-contained image)", + hint: + ctx.platform === "win32" + ? "not yet supported on Windows" + : "only Docker required on the host", + }, + ], + initialValue: "npx", + }) + ); + } + method = method ?? "npx"; + + if (method === "docker" && ctx.platform === "win32") { + p.log.error( + "The Docker install method isn't supported on Windows yet — use npx, or run the wizard inside WSL." + ); + return null; + } + return method; +} + +async function resolveToken( + flagToken: string | undefined, + interactive: boolean +): Promise { + if (flagToken?.trim()) return flagToken.trim(); + + const fromEnv = process.env[AUTH_TOKEN_ENV]?.trim(); + if (fromEnv) { + p.log.info(`Using ${AUTH_TOKEN_ENV} from this shell environment.`); + return fromEnv; + } + + if (!interactive) { + p.log.error( + `No LocalStack Auth Token found. Set ${AUTH_TOKEN_ENV} or pass --token. Create one at https://app.localstack.cloud/workspace/auth-tokens` + ); + return null; + } + + const token = preWriteAnswer( + await p.password({ + message: `Paste your ${AUTH_TOKEN_ENV}`, + validate: (value) => (value && value.trim() ? undefined : "An auth token is required"), + }) + ).trim(); + + if (!token.startsWith("ls-")) { + p.log.warn( + 'This token does not start with "ls-". The wizard will save it, but double-check it if tools fail later.' + ); + } + return token; +} + +interface DockerFlagInputs { + cacheDir?: string; + workspace?: string; + imageTag?: string; +} + +async function resolveDockerOptions( + flags: DockerFlagInputs, + interactive: boolean, + yes: boolean, + ctx: ClientContext +): Promise { + const defaults = { + cacheDir: path.join(ctx.homeDir, ".localstack-mcp"), + workspace: process.cwd(), + imageTag: "latest", + }; + + const prompts = interactive && !yes; + + let cacheDir = defaults.cacheDir; + if (flags.cacheDir?.trim()) { + cacheDir = expandPath(flags.cacheDir, ctx.homeDir); + } else if (prompts) { + const answer = preWriteAnswer( + await p.text({ + message: "State/cache directory for Docker runs", + initialValue: defaults.cacheDir, + validate: (value) => (value?.trim() ? undefined : "A directory is required"), + }) + ); + cacheDir = expandPath(answer, ctx.homeDir); + } + + // flags.workspace === "" is meaningful: skip the workspace mount. + let workspaceRaw = flags.workspace ?? defaults.workspace; + if (flags.workspace === undefined && prompts) { + workspaceRaw = preWriteAnswer( + await p.text({ + message: "Workspace directory to mount (submit empty to skip)", + initialValue: defaults.workspace, + }) + ); + } + const workspace = expandPath(workspaceRaw, ctx.homeDir); + + let imageTag = defaults.imageTag; + if (flags.imageTag?.trim()) { + imageTag = flags.imageTag.trim(); + } else if (prompts) { + imageTag = preWriteAnswer( + await p.text({ + message: "Docker image tag", + initialValue: defaults.imageTag, + validate: (value) => (value?.trim() ? undefined : "A tag is required"), + }) + ).trim(); + } + + return { cacheDir, workspaceDir: workspace || undefined, imageTag }; +} + +async function resolveExtraEnv( + flagConfig: string | undefined, + method: InstallMethod, + interactive: boolean, + yes: boolean +): Promise | null> { + let input = flagConfig; + if (input === undefined && interactive && !yes) { + input = preWriteAnswer( + await p.text({ + message: "Extra LocalStack environment variables (optional)", + defaultValue: "", + placeholder: "KEY=value,KEY2=value2", + validate: (value) => { + if (!value?.trim()) return undefined; + const { errors } = parseEnvVarsInput(value); + return errors.length > 0 ? errors.join("; ") : undefined; + }, + }) + ); + } + if (!input?.trim()) return {}; + + const { env, errors } = parseEnvVarsInput(input); + if (errors.length > 0) { + p.log.error(`Invalid --config: ${errors.join("; ")}`); + return null; + } + if (method === "docker" && env.LOCALSTACK_HOSTNAME) { + p.log.warn( + "LOCALSTACK_HOSTNAME is already set in Docker mode. The generated Docker config uses host.docker.internal; override it only if you know your network setup needs it." + ); + } + return env; +} + +async function resolveClients( + flagClients: ClientId[] | undefined, + detected: DetectedClient[], + interactive: boolean, + yes: boolean +): Promise { + const byId = new Map(detected.map((entry) => [entry.adapter.id, entry])); + + if (flagClients && flagClients.length > 0) { + for (const id of flagClients) { + const entry = byId.get(id); + if (entry?.detection.unsupportedReason) { + p.log.error(`${entry.adapter.label}: ${entry.detection.unsupportedReason}`); + return null; + } + if (id === "codex" && !entry?.detection.installed) { + p.log.error("Codex is managed through its CLI, but `codex` was not found on PATH."); + return null; + } + if (id === "claude-code" && !entry?.detection.installed) { + p.log.error("Claude Code is managed through its CLI, but `claude` was not found on PATH."); + return null; + } + } + return flagClients; + } + + const detectedIds = detected + .filter((entry) => entry.detection.installed) + .map((entry) => entry.adapter.id); + + if (!interactive || yes) { + if (detectedIds.length === 0) { + p.log.error( + "No supported MCP clients were detected. Re-run with --client to choose one explicitly." + ); + return null; + } + p.log.info(`Configuring detected clients: ${detectedIds.join(", ")}`); + return detectedIds; + } + + const options = detected + // Codex is CLI-managed: only offer it when the binary is present. + .filter((entry) => entry.adapter.id !== "codex" || entry.detection.installed) + .filter((entry) => !entry.detection.unsupportedReason) + .map((entry) => ({ + value: entry.adapter.id, + label: entry.adapter.label, + hint: entry.detection.installed ? "detected" : undefined, + })); + + const selection = preWriteAnswer( + await p.multiselect({ + message: "Choose the MCP clients to configure", + options, + initialValues: detectedIds, + required: true, + }) + ); + return selection; +} + +async function installIntoClients( + clients: ClientId[], + detected: DetectedClient[], + spec: ServerSpec, + ctx: ClientContext, + force: boolean, + interactive: boolean, + yes: boolean +): Promise { + const byId = new Map(detected.map((entry) => [entry.adapter.id, entry.adapter])); + const outcomes: ClientOutcome[] = []; + const pushOutcome = ( + clientId: ClientId, + outcome: ClientOutcome["outcome"], + restartNote?: string + ) => { + const adapter = byId.get(clientId)!; + outcomes.push({ clientId, label: adapter.label, outcome, restartNote }); + }; + const skipRemaining = (startIndex: number, detail: string) => { + for (const remaining of clients.slice(startIndex)) { + pushOutcome(remaining, { status: "skipped", detail }); + } + }; + + for (const [index, clientId] of clients.entries()) { + const adapter = byId.get(clientId)!; + const existing = await adapter.getExisting(ctx); + + for (const warning of existing.warnings ?? []) { + p.log.warn(warning); + } + + if (existing.error) { + pushOutcome(clientId, { status: "failed", detail: existing.error }); + continue; + } + + if (existing.entries.length > 0 && !force) { + const summary = existing.entries + .map((entry) => `"${entry.key}" (${entry.method})`) + .join(" and "); + // --yes means "don't ask": keep existing entries, like non-interactive + // runs. Overwriting without a prompt requires the explicit --force. + if (!interactive || yes) { + pushOutcome(clientId, { + status: "skipped", + detail: `existing ${summary} entry — re-run with --force to overwrite`, + }); + continue; + } + const overwrite = await p.confirm({ + message: `${adapter.label} already has ${summary}. Overwrite with the new config?`, + initialValue: true, + }); + if (p.isCancel(overwrite)) { + // Earlier clients in this loop may already be configured — report + // what happened instead of pretending nothing was written. + skipRemaining(index, "cancelled"); + p.log.warn("Cancelled — see the summary below for what was already configured."); + return outcomes; + } + if (!overwrite) { + pushOutcome(clientId, { status: "skipped", detail: "kept the existing entry" }); + continue; + } + } + + const progress = createProgress(); + progress.start(`Configuring ${adapter.label}…`); + const outcome = await adapter.install(spec, ctx); + progress.stop( + `${adapter.label}: ${outcome.status === "installed" ? "configured" : outcome.status}` + ); + pushOutcome(clientId, outcome, adapter.restartNote); + } + + return outcomes; +} + +function printSummary(outcomes: ClientOutcome[], answers: WizardAnswers): void { + const lines = [formatOutcomeLines(outcomes)]; + + const restartNotes = outcomes + .filter((entry) => entry.outcome.status === "installed" && entry.restartNote) + .map((entry) => `• ${entry.restartNote}`); + if (restartNotes.length > 0) { + lines.push("", "Next steps:", ...restartNotes); + } + if ( + answers.method === "docker" && + outcomes.some((entry) => entry.outcome.status === "installed") + ) { + lines.push( + "", + "The first run pulls the localstack/localstack-mcp-server image (~1.7 GB) — give it a minute." + ); + } + p.note(lines.join("\n"), "Setup summary"); +} + +export async function runInit(argv: string[]): Promise { + const { flags, errors } = parseInitFlags(argv); + if (!flags) { + for (const error of errors) console.error(`Error: ${error}`); + console.error('Run "init --help" for usage.'); + return EXIT_ERROR; + } + if (flags.help) { + console.log(HELP_TEXT); + return EXIT_OK; + } + + const ctx = buildContext(); + const interactive = isInteractive(); + let detectedPromise: Promise | undefined; + + if (!interactive && !flags.yes && (!flags.method || !flags.clients)) { + console.error( + "Error: non-interactive runs need --method and --client (or --yes to use detected defaults)." + ); + return EXIT_ERROR; + } + + p.intro("LocalStack MCP Server setup"); + + const startDetectingClients = () => { + detectedPromise ??= detectClients(ctx, flags.clients); + return detectedPromise; + }; + + // Close the clack frame on every abort so output never looks truncated. + const abort = (): number => { + p.cancel("Setup aborted — nothing was written."); + return EXIT_ERROR; + }; + + const method = await resolveMethod(flags.method, interactive, flags.yes, ctx); + if (!method) return abort(); + + const needsClientDetection = !flags.clients || flags.clients.length === 0; + startDetectingClients(); + + const prereqProgress = createProgress(); + prereqProgress.start("Checking prerequisites (Docker can take a moment)…"); + const prereqs = await checkPrereqs(method); + prereqProgress.stop("Prerequisite checks"); + const { fatal } = printPrereqResults(prereqs); + if (fatal) { + p.cancel("Fix the failed prerequisite above and re-run the wizard."); + return EXIT_ERROR; + } + + const token = await resolveToken(flags.token, interactive); + if (token === null) return abort(); + + const docker = + method === "docker" + ? await resolveDockerOptions( + { cacheDir: flags.cacheDir, workspace: flags.workspace, imageTag: flags.imageTag }, + interactive, + flags.yes, + ctx + ) + : undefined; + + const extraEnv = await resolveExtraEnv(flags.config, method, interactive, flags.yes); + if (extraEnv === null) return abort(); + + const detectProgress = createProgress(); + detectProgress.start( + needsClientDetection ? "Finishing MCP client detection…" : "Checking selected MCP clients…" + ); + const detected = await startDetectingClients(); + detectProgress.stop(formatDetectionSummary(detected, needsClientDetection)); + + const clients = await resolveClients(flags.clients, detected, interactive, flags.yes); + if (!clients) return abort(); + + const spec = + method === "docker" + ? buildDockerServerSpec(token, extraEnv, docker!) + : buildNpxServerSpec(token, extraEnv); + + const answers: WizardAnswers = { method, token, extraEnv, docker, clients, force: flags.force }; + const outcomes = await installIntoClients( + clients, + detected, + spec, + ctx, + flags.force, + interactive, + flags.yes + ); + + printSummary(outcomes, answers); + + const failed = outcomes.filter((entry) => entry.outcome.status === "failed"); + const installed = outcomes.filter((entry) => entry.outcome.status === "installed"); + if (failed.length > 0) { + p.outro(`Done with errors — ${failed.length} client(s) failed (see above).`); + return EXIT_ERROR; + } + if (installed.length === 0) { + p.outro("Nothing was changed — re-run with --force to overwrite existing entries."); + return EXIT_OK; + } + p.outro("✅ All set! Restart or open your MCP client, then ask it to start LocalStack."); + return EXIT_OK; +} + +export { EXIT_CANCELLED }; diff --git a/src/cli/remove.ts b/src/cli/remove.ts new file mode 100644 index 0000000..fe4b41d --- /dev/null +++ b/src/cli/remove.ts @@ -0,0 +1,140 @@ +import * as p from "@clack/prompts"; +import * as os from "os"; +import { parseRemoveFlags } from "../lib/wizard/cli-args.logic"; +import { CLIENT_ADAPTERS, getClientAdapter } from "../lib/wizard/clients/registry"; +import { ClientContext } from "../lib/wizard/clients/types"; +import { ClientId } from "../lib/wizard/types"; +import { HELP_TEXT } from "./help"; +import { + ClientOutcome, + createProgress, + ensureAnswer, + EXIT_ERROR, + EXIT_OK, + formatOutcomeLines, + isInteractive, +} from "./ui"; + +function preWriteAnswer(value: T | symbol): T { + return ensureAnswer(value, "Cancelled — nothing was removed."); +} + +export async function runRemove(argv: string[]): Promise { + const { flags, errors } = parseRemoveFlags(argv); + if (!flags) { + for (const error of errors) console.error(`Error: ${error}`); + console.error('Run "remove --help" for usage.'); + return EXIT_ERROR; + } + if (flags.help) { + console.log(HELP_TEXT); + return EXIT_OK; + } + + const ctx: ClientContext = { + platform: process.platform, + homeDir: os.homedir(), + env: process.env, + }; + const interactive = isInteractive(); + + if (!interactive && (!flags.clients || flags.clients.length === 0) && !flags.force) { + console.error("Error: non-interactive runs need --client or --force."); + return EXIT_ERROR; + } + + p.intro("LocalStack MCP Server removal"); + + let targets: ClientId[]; + if (flags.clients && flags.clients.length > 0) { + targets = flags.clients; + } else { + const spin = createProgress(); + spin.start("Looking for LocalStack entries…"); + const withEntries: { id: ClientId; label: string; keys: string[] }[] = []; + const uninspectable: string[] = []; + const inspected = await Promise.all( + CLIENT_ADAPTERS.map(async (adapter) => ({ + adapter, + existing: await adapter.getExisting(ctx), + })) + ); + for (const { adapter, existing } of inspected) { + if (existing.error) { + uninspectable.push(`${adapter.label}: ${existing.error}`); + continue; + } + if (existing.entries.length > 0) { + withEntries.push({ + id: adapter.id, + label: adapter.label, + keys: existing.entries.map((entry) => entry.key), + }); + } + } + spin.stop( + withEntries.length > 0 + ? `Found entries in: ${withEntries.map((entry) => entry.label).join(", ")}` + : "No wizard-managed LocalStack entries found." + ); + for (const warning of uninspectable) { + p.log.warn(`Could not inspect ${warning} — target it explicitly with --client if needed.`); + } + if (withEntries.length === 0) { + p.outro("Nothing to remove. The wizard only manages entries named `localstack`."); + return EXIT_OK; + } + + if (interactive && !flags.force) { + targets = preWriteAnswer( + await p.multiselect({ + message: "Remove the `localstack` MCP server from:", + options: withEntries.map((entry) => ({ + value: entry.id, + label: entry.label, + hint: entry.keys.join(", "), + })), + initialValues: withEntries.map((entry) => entry.id), + required: true, + }) + ); + } else { + targets = withEntries.map((entry) => entry.id); + } + } + + if (interactive && !flags.force) { + const confirmed = preWriteAnswer( + await p.confirm({ + message: `Remove the LocalStack entry from ${targets.length} client(s)?`, + initialValue: true, + }) + ); + if (!confirmed) { + p.cancel("Nothing was removed."); + return EXIT_OK; + } + } + + const outcomes: ClientOutcome[] = []; + for (const clientId of targets) { + const adapter = getClientAdapter(clientId); + const progress = createProgress(); + progress.start(`Updating ${adapter.label}…`); + const outcome = await adapter.remove(ctx); + progress.stop( + `${adapter.label}: ${outcome.status === "installed" ? "removed" : outcome.status}` + ); + outcomes.push({ clientId, label: adapter.label, outcome }); + } + + p.note(formatOutcomeLines(outcomes), "Removal summary"); + + const failed = outcomes.filter((entry) => entry.outcome.status === "failed"); + if (failed.length > 0) { + p.outro(`Done with errors — ${failed.length} client(s) failed (see above).`); + return EXIT_ERROR; + } + p.outro("Done. Restart the affected clients to apply the change."); + return EXIT_OK; +} diff --git a/src/cli/ui.ts b/src/cli/ui.ts new file mode 100644 index 0000000..9845dd2 --- /dev/null +++ b/src/cli/ui.ts @@ -0,0 +1,66 @@ +import * as p from "@clack/prompts"; +import { PrereqResult } from "../lib/wizard/prereqs"; +import { ClientId, InstallOutcome } from "../lib/wizard/types"; + +export const EXIT_OK = 0; +export const EXIT_ERROR = 1; +export const EXIT_CANCELLED = 130; + +/** Unwraps a clack prompt result, exiting cleanly when the user hits Ctrl+C/Esc. */ +export function ensureAnswer(value: T | symbol, cancelMessage = "Cancelled."): T { + if (p.isCancel(value)) { + p.cancel(cancelMessage); + process.exit(EXIT_CANCELLED); + } + return value as T; +} + +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +export interface Progress { + start(message: string): void; + stop(message: string): void; +} + +/** A clack spinner on TTYs; plain log lines when output is piped (CI, logs). */ +export function createProgress(): Progress { + if (process.stdout.isTTY) { + const spin = p.spinner(); + return { start: (message) => spin.start(message), stop: (message) => spin.stop(message) }; + } + return { start: () => {}, stop: (message) => p.log.step(message) }; +} + +export function printPrereqResults(results: PrereqResult[]): { fatal: boolean } { + let fatal = false; + for (const result of results) { + if (result.ok) { + p.log.success(`${result.name} ✓`); + } else if (result.fatal) { + fatal = true; + p.log.error(`${result.name} ✗ — ${result.hint ?? "required"}`); + } else { + p.log.warn(`${result.name} ✗ — ${result.hint ?? "continuing anyway"}`); + } + } + return { fatal }; +} + +export interface ClientOutcome { + clientId: ClientId; + label: string; + outcome: InstallOutcome; + restartNote?: string; +} + +export function formatOutcomeLines(outcomes: ClientOutcome[]): string { + return outcomes + .map(({ label, outcome }) => { + const symbol = + outcome.status === "installed" ? "✓" : outcome.status === "skipped" ? "−" : "✗"; + return `${symbol} ${label}: ${outcome.detail}`; + }) + .join("\n"); +} diff --git a/src/lib/wizard/cli-args.logic.test.ts b/src/lib/wizard/cli-args.logic.test.ts new file mode 100644 index 0000000..75323b1 --- /dev/null +++ b/src/lib/wizard/cli-args.logic.test.ts @@ -0,0 +1,74 @@ +import { parseInitFlags, parseRemoveFlags } from "./cli-args.logic"; + +describe("parseInitFlags", () => { + it("parses a full non-interactive invocation", () => { + const { flags, errors } = parseInitFlags([ + "--method", + "docker", + "--client", + "cursor,claude-code", + "--token", + "ls-abc", + "--config", + "DEBUG=1", + "--cache-dir", + "/tmp/cache", + "--workspace", + "/tmp/proj", + "--image-tag", + "0.5.0", + "--force", + "--yes", + ]); + expect(errors).toEqual([]); + expect(flags).toMatchObject({ + method: "docker", + clients: ["cursor", "claude-code"], + token: "ls-abc", + config: "DEBUG=1", + cacheDir: "/tmp/cache", + workspace: "/tmp/proj", + imageTag: "0.5.0", + force: true, + yes: true, + }); + }); + + it("supports repeatable --client flags and dedupes", () => { + const { flags } = parseInitFlags(["--client", "cursor", "--client", "vscode,cursor"]); + expect(flags?.clients).toEqual(["cursor", "vscode"]); + }); + + it("rejects unknown methods and clients", () => { + expect(parseInitFlags(["--method", "brew"]).errors[0]).toContain("--method"); + expect(parseInitFlags(["--client", "zed"]).errors[0]).toContain('Unknown client "zed"'); + }); + + it("rejects unknown flags", () => { + expect(parseInitFlags(["--bogus"]).errors.length).toBeGreaterThan(0); + }); + + it("rejects --client with no usable ids instead of falling back to auto-detect", () => { + expect(parseInitFlags(["--client", ""]).errors[0]).toContain("--client was given"); + expect(parseInitFlags(["--client", ","]).errors[0]).toContain("--client was given"); + }); + + it("defaults booleans to false with no flags", () => { + const { flags, errors } = parseInitFlags([]); + expect(errors).toEqual([]); + expect(flags).toMatchObject({ force: false, yes: false, help: false }); + expect(flags?.method).toBeUndefined(); + expect(flags?.clients).toBeUndefined(); + }); +}); + +describe("parseRemoveFlags", () => { + it("parses clients and force", () => { + const { flags } = parseRemoveFlags(["--client", "cursor", "--force"]); + expect(flags).toMatchObject({ clients: ["cursor"], force: true }); + }); + + it("treats --yes as --force", () => { + expect(parseRemoveFlags(["--yes"]).flags?.force).toBe(true); + }); +}); diff --git a/src/lib/wizard/cli-args.logic.ts b/src/lib/wizard/cli-args.logic.ts new file mode 100644 index 0000000..ceffb23 --- /dev/null +++ b/src/lib/wizard/cli-args.logic.ts @@ -0,0 +1,134 @@ +import { parseArgs } from "util"; +import { ALL_CLIENT_IDS } from "./clients/registry"; +import { ClientId, InstallMethod } from "./types"; + +export interface InitFlags { + method?: InstallMethod; + clients?: ClientId[]; + token?: string; + config?: string; + cacheDir?: string; + workspace?: string; + imageTag?: string; + force: boolean; + yes: boolean; + help: boolean; +} + +export interface RemoveFlags { + clients?: ClientId[]; + force: boolean; + help: boolean; +} + +export interface ParsedFlags { + flags?: T; + errors: string[]; +} + +function parseClientList(values: string[]): { clients: ClientId[]; errors: string[] } { + const errors: string[] = []; + const clients: ClientId[] = []; + for (const value of values.flatMap((entry) => entry.split(","))) { + const id = value.trim(); + if (!id) continue; + if ((ALL_CLIENT_IDS as string[]).includes(id)) { + if (!clients.includes(id as ClientId)) clients.push(id as ClientId); + } else { + errors.push(`Unknown client "${id}". Valid clients: ${ALL_CLIENT_IDS.join(", ")}`); + } + } + if (clients.length === 0 && errors.length === 0) { + errors.push( + `--client was given but no client ids were provided. Valid clients: ${ALL_CLIENT_IDS.join(", ")}` + ); + } + return { clients, errors }; +} + +export function parseInitFlags(argv: string[]): ParsedFlags { + try { + const { values } = parseArgs({ + args: argv, + options: { + method: { type: "string" }, + client: { type: "string", multiple: true }, + token: { type: "string" }, + config: { type: "string" }, + "cache-dir": { type: "string" }, + workspace: { type: "string" }, + "image-tag": { type: "string" }, + force: { type: "boolean", default: false }, + yes: { type: "boolean", short: "y", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: false, + }); + + const errors: string[] = []; + if (values.method && values.method !== "npx" && values.method !== "docker") { + errors.push(`Invalid --method "${values.method}". Use "npx" or "docker".`); + } + + let clients: ClientId[] | undefined; + if (values.client && values.client.length > 0) { + const parsed = parseClientList(values.client); + errors.push(...parsed.errors); + clients = parsed.clients; + } + + if (errors.length > 0) return { errors }; + return { + errors: [], + flags: { + method: values.method as InstallMethod | undefined, + clients, + token: values.token, + config: values.config, + cacheDir: values["cache-dir"], + workspace: values.workspace, + imageTag: values["image-tag"], + force: values.force ?? false, + yes: values.yes ?? false, + help: values.help ?? false, + }, + }; + } catch (error) { + return { errors: [error instanceof Error ? error.message : String(error)] }; + } +} + +export function parseRemoveFlags(argv: string[]): ParsedFlags { + try { + const { values } = parseArgs({ + args: argv, + options: { + client: { type: "string", multiple: true }, + force: { type: "boolean", default: false }, + yes: { type: "boolean", short: "y", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: false, + }); + + let clients: ClientId[] | undefined; + const errors: string[] = []; + if (values.client && values.client.length > 0) { + const parsed = parseClientList(values.client); + errors.push(...parsed.errors); + clients = parsed.clients; + } + + if (errors.length > 0) return { errors }; + return { + errors: [], + flags: { + clients, + force: (values.force || values.yes) ?? false, + help: values.help ?? false, + }, + }; + } catch (error) { + return { errors: [error instanceof Error ? error.message : String(error)] }; + } +} diff --git a/src/lib/wizard/clients/claude-code.ts b/src/lib/wizard/clients/claude-code.ts new file mode 100644 index 0000000..4e86e07 --- /dev/null +++ b/src/lib/wizard/clients/claude-code.ts @@ -0,0 +1,106 @@ +import * as fs from "fs"; +import * as jsonc from "jsonc-parser"; +import { detectExistingEntries } from "../json-config.logic"; +import { claudeCodeUserConfigPath } from "../paths.logic"; +import { windowsSpawnSafeSpec } from "../server-config.logic"; +import { InstallOutcome, SERVER_NAME, ServerSpec } from "../types"; +import { cliAvailable, describeCliFailure, redactValues, runClientCli } from "./cli-utils"; +import { ClientAdapter, ClientContext, ExistingState } from "./types"; + +/** + * Local-scope servers (the `claude mcp add` default) live under + * projects..mcpServers in the same ~/.claude.json and shadow user scope + * inside those projects. We manage user scope only, but must surface local + * entries — and must pass --scope user explicitly, because an unscoped + * `claude mcp remove` exits 1 when the name exists in multiple scopes. + */ +function findLocalScopeProjects(configText: string): string[] { + const root = jsonc.parse(configText, [], { allowTrailingComma: true }); + const projects = root?.projects; + if (typeof projects !== "object" || projects === null) return []; + return Object.entries(projects as Record }>) + .filter(([, project]) => SERVER_NAME in (project?.mcpServers ?? {})) + .map(([projectPath]) => projectPath); +} + +export const claudeCodeAdapter: ClientAdapter = { + id: "claude-code", + label: "Claude Code", + restartNote: "Restart any open Claude Code sessions to load the server.", + + async detect(ctx: ClientContext) { + return { installed: await cliAvailable("claude", ctx) }; + }, + + async getExisting(ctx: ClientContext): Promise { + const configPath = claudeCodeUserConfigPath(ctx); + if (!fs.existsSync(configPath)) return { entries: [] }; + try { + const text = fs.readFileSync(configPath, "utf8"); + const localProjects = findLocalScopeProjects(text); + const warnings = + localProjects.length > 0 + ? [ + `Claude Code also has project-local LocalStack entries in: ${localProjects.join(", ")} — those take precedence there; remove them with \`claude mcp remove\` inside each project.`, + ] + : undefined; + return { entries: detectExistingEntries(text, ["mcpServers"]), warnings }; + } catch { + return { entries: [] }; + } + }, + + async install(rawSpec: ServerSpec, ctx: ClientContext): Promise { + const spec = ctx.platform === "win32" ? windowsSpawnSafeSpec(rawSpec) : rawSpec; + const secrets = Object.values(spec.env); + const { entries } = await this.getExisting(ctx); + for (const entry of entries) { + // Scoped to user: that's the scope we detected and the scope we write. + await runClientCli("claude", ["mcp", "remove", entry.key, "--scope", "user"], ctx); + } + + const args = ["mcp", "add", SERVER_NAME, "--scope", "user"]; + for (const [key, value] of Object.entries(spec.env)) { + args.push("--env", `${key}=${value}`); + } + args.push("--", spec.command, ...spec.args); + + const result = await runClientCli("claude", args, ctx); + if (result.exitCode === 0) { + return { + status: "installed", + detail: `added via \`claude mcp add\` (user scope in ${claudeCodeUserConfigPath(ctx)})`, + }; + } + return { status: "failed", detail: describeCliFailure(result, "claude", secrets) }; + }, + + async remove(ctx: ClientContext): Promise { + const { entries, warnings } = await this.getExisting(ctx); + if (entries.length === 0) { + return { + status: "skipped", + detail: warnings?.[0] ?? "no LocalStack entry found", + }; + } + const failures: string[] = []; + for (const entry of entries) { + const result = await runClientCli( + "claude", + ["mcp", "remove", entry.key, "--scope", "user"], + ctx + ); + if (result.exitCode !== 0) { + failures.push(`${entry.key}: ${describeCliFailure(result, "claude")}`); + } + } + if (failures.length > 0) { + return { status: "failed", detail: redactValues(failures.join("; "), []) }; + } + const removedDetail = `removed ${entries.map((entry) => entry.key).join(", ")} from user scope in ${claudeCodeUserConfigPath(ctx)}`; + return { + status: "installed", + detail: warnings?.length ? `${removedDetail} (${warnings[0]})` : removedDetail, + }; + }, +}; diff --git a/src/lib/wizard/clients/cli-adapters.test.ts b/src/lib/wizard/clients/cli-adapters.test.ts new file mode 100644 index 0000000..d9a4d92 --- /dev/null +++ b/src/lib/wizard/clients/cli-adapters.test.ts @@ -0,0 +1,200 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { runCommand, CommandResult } from "../../../core/command-runner"; +import { buildNpxServerSpec } from "../server-config.logic"; +import { claudeCodeAdapter } from "./claude-code"; +import { codexAdapter } from "./codex"; +import { ClientContext } from "./types"; + +jest.mock("../../../core/command-runner", () => ({ + runCommand: jest.fn(), +})); + +const mockedRunCommand = runCommand as jest.MockedFunction; + +function result(overrides: Partial = {}): CommandResult { + return { stdout: "", stderr: "", exitCode: 0, ...overrides }; +} + +function tempHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "localstack-mcp-cli-client-")); +} + +function testContext(homeDir: string, platform: NodeJS.Platform = "linux"): ClientContext { + return { platform, homeDir, env: {} }; +} + +describe("codexAdapter", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + }); + + it("detects only the wizard-managed localstack entry from codex mcp list --json", async () => { + mockedRunCommand.mockResolvedValue( + result({ + stdout: JSON.stringify([ + { name: "localstack", transport: { command: "docker" } }, + { name: "localstack-mcp-server", transport: { command: "npx" } }, + { name: "other", transport: { command: "node" } }, + ]), + }) + ); + + const existing = await codexAdapter.getExisting(testContext(tempHome())); + + expect(existing.entries).toEqual([{ key: "localstack", method: "docker" }]); + expect(mockedRunCommand).toHaveBeenCalledWith( + "codex", + ["mcp", "list", "--json"], + expect.objectContaining({ timeout: 60_000, shell: false }) + ); + }); + + it("adds the canonical server without mutating localstack-mcp-server", async () => { + mockedRunCommand.mockResolvedValue(result()); + + const home = tempHome(); + const outcome = await codexAdapter.install(buildNpxServerSpec("ls-token"), testContext(home)); + + expect(outcome).toEqual({ + status: "installed", + detail: `added via \`codex mcp add\` (${path.join(home, ".codex", "config.toml")})`, + }); + expect(mockedRunCommand).toHaveBeenNthCalledWith( + 1, + "codex", + [ + "mcp", + "add", + "localstack", + "--env", + "LOCALSTACK_AUTH_TOKEN=ls-token", + "--", + "npx", + "-y", + "@localstack/localstack-mcp-server", + ], + expect.any(Object) + ); + }); + + it("redacts token values from CLI failure output", async () => { + mockedRunCommand.mockResolvedValueOnce(result({ exitCode: 1, stderr: "bad token ls-secret" })); + + const outcome = await codexAdapter.install( + buildNpxServerSpec("ls-secret"), + testContext(tempHome()) + ); + + expect(outcome).toEqual({ status: "failed", detail: "bad token ***" }); + }); +}); + +describe("claudeCodeAdapter", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + }); + + it("surfaces project-local entries as warnings without managing them", async () => { + const home = tempHome(); + fs.writeFileSync( + path.join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + localstack: { command: "npx" }, + }, + projects: { + "/repo": { + mcpServers: { + localstack: { command: "docker" }, + }, + }, + }, + }) + ); + + const existing = await claudeCodeAdapter.getExisting(testContext(home)); + + expect(existing.entries).toEqual([{ key: "localstack", method: "npx" }]); + expect(existing.warnings?.[0]).toContain("/repo"); + expect(existing.warnings?.[0]).toContain("project-local LocalStack entries"); + }); + + it("removes existing user-scope localstack before adding the canonical server", async () => { + const home = tempHome(); + fs.writeFileSync( + path.join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + localstack: { command: "docker" }, + "localstack-mcp-server": { command: "npx" }, + }, + }) + ); + mockedRunCommand.mockResolvedValue(result()); + + const outcome = await claudeCodeAdapter.install( + buildNpxServerSpec("ls-token"), + testContext(home) + ); + + expect(outcome).toEqual({ + status: "installed", + detail: `added via \`claude mcp add\` (user scope in ${path.join(home, ".claude.json")})`, + }); + expect(mockedRunCommand).toHaveBeenNthCalledWith( + 1, + "claude", + ["mcp", "remove", "localstack", "--scope", "user"], + expect.any(Object) + ); + expect(mockedRunCommand).toHaveBeenNthCalledWith( + 2, + "claude", + [ + "mcp", + "add", + "localstack", + "--scope", + "user", + "--env", + "LOCALSTACK_AUTH_TOKEN=ls-token", + "--", + "npx", + "-y", + "@localstack/localstack-mcp-server", + ], + expect.any(Object) + ); + }); + + it("wraps npx for Windows shell-less Claude Code spawns", async () => { + mockedRunCommand.mockResolvedValue(result()); + + await claudeCodeAdapter.install( + buildNpxServerSpec("ls-token"), + testContext(tempHome(), "win32") + ); + + expect(mockedRunCommand).toHaveBeenLastCalledWith( + "claude", + [ + "mcp", + "add", + "localstack", + "--scope", + "user", + "--env", + "LOCALSTACK_AUTH_TOKEN=ls-token", + "--", + "cmd", + "/c", + "npx", + "-y", + "@localstack/localstack-mcp-server", + ], + expect.objectContaining({ shell: true }) + ); + }); +}); diff --git a/src/lib/wizard/clients/cli-utils.ts b/src/lib/wizard/clients/cli-utils.ts new file mode 100644 index 0000000..0a6e678 --- /dev/null +++ b/src/lib/wizard/clients/cli-utils.ts @@ -0,0 +1,77 @@ +import { runCommand, CommandResult } from "../../../core/command-runner"; +import { ClientContext } from "./types"; + +const VERSION_CHECK_TIMEOUT = 15_000; +const MUTATION_TIMEOUT = 60_000; + +/** + * npm/cargo-installed CLIs are .cmd/.exe shims on Windows; spawn needs a shell + * to resolve them there. cmd.exe quoting cannot be done safely for arbitrary + * strings (backslash-escaping doesn't exist, %VAR% expands even inside + * quotes), so on Windows we refuse args containing cmd metacharacters instead + * of trying to escape them — our generated args are all safe; only + * user-supplied env values/tokens can trip this. + */ +const WINDOWS_SAFE_ARG = /^[A-Za-z0-9_\-=.:/@,+~ ]+$/; + +function spawnOptions(ctx: ClientContext, timeout: number) { + return { timeout, shell: ctx.platform === "win32" }; +} + +function findUnsafeWindowsArg(args: string[], ctx: ClientContext): string | undefined { + if (ctx.platform !== "win32") return undefined; + return args.find((arg) => !WINDOWS_SAFE_ARG.test(arg)); +} + +function quoteForWindowsShell(args: string[], ctx: ClientContext): string[] { + if (ctx.platform !== "win32") return args; + return args.map((arg) => (arg.includes(" ") ? `"${arg}"` : arg)); +} + +export async function cliAvailable(binary: string, ctx: ClientContext): Promise { + const result = await runCommand(binary, ["--version"], spawnOptions(ctx, VERSION_CHECK_TIMEOUT)); + return result.exitCode === 0; +} + +/** Returns the CLI's --version output, or null when it isn't runnable. */ +export async function cliVersionOutput(binary: string, ctx: ClientContext): Promise { + const result = await runCommand(binary, ["--version"], spawnOptions(ctx, VERSION_CHECK_TIMEOUT)); + if (result.exitCode !== 0) return null; + return `${result.stdout} ${result.stderr}`.trim(); +} + +export async function runClientCli( + binary: string, + args: string[], + ctx: ClientContext +): Promise { + const unsafeArg = findUnsafeWindowsArg(args, ctx); + if (unsafeArg !== undefined) { + const message = `refusing to pass an argument with shell-special characters to ${binary} on Windows: "${unsafeArg}" — use a value without quotes, %, or &|<>^ characters, or configure this client manually`; + return { stdout: "", stderr: message, exitCode: 1, error: new Error(message) }; + } + return runCommand(binary, quoteForWindowsShell(args, ctx), spawnOptions(ctx, MUTATION_TIMEOUT)); +} + +/** Replaces any of the given secret values with *** in CLI output. */ +export function redactValues(text: string, secrets: string[]): string { + let result = text; + for (const secret of secrets) { + if (secret) result = result.split(secret).join("***"); + } + return result; +} + +export function describeCliFailure( + result: CommandResult, + binary: string, + secrets: string[] = [] +): string { + if (result.error?.message.includes("ENOENT")) { + return `\`${binary}\` was not found on your PATH — install it first, then re-run the wizard`; + } + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); + const detail = stderr || stdout || result.error?.message || `exit code ${result.exitCode}`; + return redactValues(detail, secrets); +} diff --git a/src/lib/wizard/clients/codex.ts b/src/lib/wizard/clients/codex.ts new file mode 100644 index 0000000..5c40e4d --- /dev/null +++ b/src/lib/wizard/clients/codex.ts @@ -0,0 +1,85 @@ +import { codexConfigPath } from "../paths.logic"; +import { windowsSpawnSafeSpec } from "../server-config.logic"; +import { InstallMethod, InstallOutcome, SERVER_NAME, ServerSpec } from "../types"; +import { cliAvailable, describeCliFailure, runClientCli } from "./cli-utils"; +import { ClientAdapter, ClientContext, ExistingState } from "./types"; + +function classifyCommand(command: unknown): InstallMethod | "unknown" { + if (command === "docker") return "docker"; + if (command === "npx") return "npx"; + return "unknown"; +} + +/** + * Codex is managed exclusively through its CLI (the wizard only offers it when + * `codex` is on PATH). `codex mcp add` overwrites existing entries silently; + * `codex mcp remove` is idempotent (exit 0 when missing). + */ +export const codexAdapter: ClientAdapter = { + id: "codex", + label: "Codex", + restartNote: "Restart any open Codex sessions to load the server.", + + async detect(ctx: ClientContext) { + return { installed: await cliAvailable("codex", ctx) }; + }, + + async getExisting(ctx: ClientContext): Promise { + const result = await runClientCli("codex", ["mcp", "list", "--json"], ctx); + if (result.exitCode !== 0) return { entries: [] }; + try { + const servers: Array<{ name?: string; transport?: { command?: string } }> = JSON.parse( + result.stdout + ); + return { + entries: servers + .filter((server) => server.name === SERVER_NAME) + .map((server) => ({ + key: server.name as string, + method: classifyCommand(server.transport?.command), + })), + }; + } catch { + return { entries: [] }; + } + }, + + async install(rawSpec: ServerSpec, ctx: ClientContext): Promise { + const spec = ctx.platform === "win32" ? windowsSpawnSafeSpec(rawSpec) : rawSpec; + const secrets = Object.values(spec.env); + + const args = ["mcp", "add", SERVER_NAME]; + for (const [key, value] of Object.entries(spec.env)) { + args.push("--env", `${key}=${value}`); + } + args.push("--", spec.command, ...spec.args); + + const result = await runClientCli("codex", args, ctx); + if (result.exitCode === 0) { + return { + status: "installed", + detail: `added via \`codex mcp add\` (${codexConfigPath(ctx)})`, + }; + } + return { status: "failed", detail: describeCliFailure(result, "codex", secrets) }; + }, + + async remove(ctx: ClientContext): Promise { + const { entries } = await this.getExisting(ctx); + if (entries.length === 0) { + return { status: "skipped", detail: "no LocalStack entry found" }; + } + const failures: string[] = []; + for (const entry of entries) { + const result = await runClientCli("codex", ["mcp", "remove", entry.key], ctx); + if (result.exitCode !== 0) { + failures.push(`${entry.key}: ${describeCliFailure(result, "codex")}`); + } + } + if (failures.length > 0) return { status: "failed", detail: failures.join("; ") }; + return { + status: "installed", + detail: `removed ${entries.map((entry) => entry.key).join(", ")} from ${codexConfigPath(ctx)}`, + }; + }, +}; diff --git a/src/lib/wizard/clients/file-client.test.ts b/src/lib/wizard/clients/file-client.test.ts new file mode 100644 index 0000000..7b4d78b --- /dev/null +++ b/src/lib/wizard/clients/file-client.test.ts @@ -0,0 +1,124 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { buildNpxServerSpec } from "../server-config.logic"; +import { ServerSpec } from "../types"; +import { createFileClientAdapter } from "./file-client"; +import { ClientContext } from "./types"; + +function tempHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "localstack-mcp-file-client-")); +} + +function testContext(homeDir: string): ClientContext { + return { platform: "linux", homeDir, env: {} }; +} + +function createAdapter(configPath: string) { + return createFileClientAdapter({ + id: "cursor", + label: "Cursor", + restartNote: "restart", + configPath: () => configPath, + detectInstalled: async () => true, + rootPath: ["mcpServers"], + buildEntry: (spec: ServerSpec) => ({ command: spec.command, args: spec.args, env: spec.env }), + }); +} + +describe("createFileClientAdapter", () => { + it("creates parent directories and a 0600 config file on fresh install", async () => { + const home = tempHome(); + const configPath = path.join(home, ".cursor", "mcp.json"); + const adapter = createAdapter(configPath); + + const outcome = await adapter.install(buildNpxServerSpec("ls-token"), testContext(home)); + + expect(outcome).toEqual({ status: "installed", detail: configPath }); + const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(parsed.mcpServers.localstack.env.LOCALSTACK_AUTH_TOKEN).toBe("ls-token"); + expect((fs.statSync(configPath).mode & 0o777).toString(8)).toBe("600"); + }); + + it("preserves localstack-mcp-server while writing the wizard-managed entry", async () => { + const home = tempHome(); + const configPath = path.join(home, ".cursor", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + "localstack-mcp-server": { command: "docker" }, + other: { command: "node" }, + }, + }) + ); + const adapter = createAdapter(configPath); + + const existing = await adapter.getExisting(testContext(home)); + const outcome = await adapter.install(buildNpxServerSpec("ls-token"), testContext(home)); + const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); + + expect(existing.entries).toEqual([]); + expect(outcome.status).toBe("installed"); + expect(parsed.mcpServers["localstack-mcp-server"]).toEqual({ command: "docker" }); + expect(parsed.mcpServers.localstack.command).toBe("npx"); + expect(parsed.mcpServers.other).toEqual({ command: "node" }); + }); + + it("removes only localstack and leaves localstack-mcp-server alone", async () => { + const home = tempHome(); + const configPath = path.join(home, ".cursor", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + localstack: { command: "npx" }, + "localstack-mcp-server": { command: "docker" }, + other: { command: "node" }, + }, + }) + ); + const adapter = createAdapter(configPath); + + const outcome = await adapter.remove(testContext(home)); + const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); + + expect(outcome.status).toBe("installed"); + expect(outcome.detail).toContain("removed localstack"); + expect(parsed.mcpServers).toEqual({ + "localstack-mcp-server": { command: "docker" }, + other: { command: "node" }, + }); + }); + + it("shows the exact path when remove finds no config file", async () => { + const home = tempHome(); + const configPath = path.join(home, ".cursor", "mcp.json"); + const adapter = createAdapter(configPath); + + await expect(adapter.remove(testContext(home))).resolves.toEqual({ + status: "skipped", + detail: `no config file found at ${configPath}`, + }); + }); + + it("reports invalid JSON instead of overwriting the file", async () => { + const home = tempHome(); + const configPath = path.join(home, ".cursor", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, "{ nope"); + const adapter = createAdapter(configPath); + + const existing = await adapter.getExisting(testContext(home)); + const outcome = await adapter.install(buildNpxServerSpec("ls-token"), testContext(home)); + + expect(existing.error).toContain("invalid JSON"); + expect(outcome).toEqual({ + status: "failed", + detail: `${configPath}: file contains invalid JSON (InvalidSymbol at offset 2) — fix it manually and re-run`, + }); + expect(fs.readFileSync(configPath, "utf8")).toBe("{ nope"); + }); +}); diff --git a/src/lib/wizard/clients/file-client.ts b/src/lib/wizard/clients/file-client.ts new file mode 100644 index 0000000..dc02c99 --- /dev/null +++ b/src/lib/wizard/clients/file-client.ts @@ -0,0 +1,125 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + applyServerEntry, + detectExistingEntries, + removeServerEntries, + validateConfigText, +} from "../json-config.logic"; +import { ClientId, InstallOutcome, ServerSpec } from "../types"; +import { ClientAdapter, ClientContext, DetectResult, ExistingState } from "./types"; + +export interface FileClientConfig { + id: ClientId; + label: string; + restartNote: string; + /** Null means the client does not exist on this platform. */ + configPath(ctx: ClientContext): string | null; + unsupportedReason?(ctx: ClientContext): string | undefined; + detectInstalled(ctx: ClientContext): Promise; + rootPath: string[]; + buildEntry(spec: ServerSpec, ctx: ClientContext): unknown; + /** Initial file content when the config does not exist yet. */ + newFileSeed?: string; +} + +export function createFileClientAdapter(config: FileClientConfig): ClientAdapter { + /** null = file absent; { error } = present but unreadable. */ + const readConfigText = (ctx: ClientContext): string | null | { error: string } => { + const configPath = config.configPath(ctx); + if (!configPath || !fs.existsSync(configPath)) return null; + try { + return fs.readFileSync(configPath, "utf8"); + } catch (error) { + return { error: `cannot read ${configPath}: ${String(error)}` }; + } + }; + + return { + id: config.id, + label: config.label, + restartNote: config.restartNote, + + async detect(ctx: ClientContext): Promise { + const unsupportedReason = config.unsupportedReason?.(ctx); + if (unsupportedReason || config.configPath(ctx) === null) { + return { + installed: false, + unsupportedReason: unsupportedReason ?? `not available on ${ctx.platform}`, + }; + } + return { installed: await config.detectInstalled(ctx) }; + }, + + async getExisting(ctx: ClientContext): Promise { + const text = readConfigText(ctx); + if (text === null) return { entries: [] }; + if (typeof text !== "string") return { entries: [], error: text.error }; + const validationError = validateConfigText(text); + if (validationError) return { entries: [], error: validationError }; + return { entries: detectExistingEntries(text, config.rootPath) }; + }, + + async install(spec: ServerSpec, ctx: ClientContext): Promise { + const configPath = config.configPath(ctx); + if (!configPath) { + return { status: "failed", detail: `${config.label} is not available on this platform` }; + } + + const existingText = readConfigText(ctx); + if (existingText !== null && typeof existingText !== "string") { + return { status: "failed", detail: existingText.error }; + } + const text = existingText ?? config.newFileSeed ?? "{}"; + const validationError = validateConfigText(text); + if (validationError) { + return { + status: "failed", + detail: `${configPath}: ${validationError} — fix it manually and re-run`, + }; + } + + try { + const updated = applyServerEntry(text, config.rootPath, config.buildEntry(spec, ctx)); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + // mode applies only when the file is created — it carries the auth + // token, so don't leave fresh files world-readable. + fs.writeFileSync(configPath, updated.endsWith("\n") ? updated : `${updated}\n`, { + mode: 0o600, + }); + return { status: "installed", detail: configPath }; + } catch (error) { + return { status: "failed", detail: `could not write ${configPath}: ${String(error)}` }; + } + }, + + async remove(ctx: ClientContext): Promise { + const configPath = config.configPath(ctx); + const text = readConfigText(ctx); + if (!configPath) { + return { status: "skipped", detail: `${config.label} is not available on this platform` }; + } + if (text === null) { + return { status: "skipped", detail: `no config file found at ${configPath}` }; + } + if (typeof text !== "string") { + return { status: "failed", detail: text.error }; + } + const validationError = validateConfigText(text); + if (validationError) { + return { status: "failed", detail: `${configPath}: ${validationError}` }; + } + + try { + const { text: updated, removed } = removeServerEntries(text, config.rootPath); + if (removed.length === 0) { + return { status: "skipped", detail: "no LocalStack entry found" }; + } + fs.writeFileSync(configPath, updated); + return { status: "installed", detail: `removed ${removed.join(", ")} from ${configPath}` }; + } catch (error) { + return { status: "failed", detail: `could not update ${configPath}: ${String(error)}` }; + } + }, + }; +} diff --git a/src/lib/wizard/clients/registry.ts b/src/lib/wizard/clients/registry.ts new file mode 100644 index 0000000..3368144 --- /dev/null +++ b/src/lib/wizard/clients/registry.ts @@ -0,0 +1,116 @@ +import * as fs from "fs"; +import * as path from "path"; +import { opencodeEntry, standardEntry, vscodeEntry } from "../entry-builders.logic"; +import { + amazonQConfigPath, + claudeDesktopConfigPath, + cursorConfigPath, + opencodeConfigDir, + opencodeConfigPath, + vscodeConfigPath, + vscodeUserDir, +} from "../paths.logic"; +import { ClientId } from "../types"; +import { claudeCodeAdapter } from "./claude-code"; +import { cliAvailable, cliVersionOutput } from "./cli-utils"; +import { codexAdapter } from "./codex"; +import { createFileClientAdapter } from "./file-client"; +import { ClientAdapter } from "./types"; + +const exists = (candidate: string) => fs.existsSync(candidate); + +const cursorAdapter = createFileClientAdapter({ + id: "cursor", + label: "Cursor", + restartNote: "Restart Cursor — the server appears under Cursor Settings > MCP.", + configPath: (ctx) => cursorConfigPath(ctx), + detectInstalled: async (ctx) => exists(path.join(ctx.homeDir, ".cursor")), + rootPath: ["mcpServers"], + buildEntry: standardEntry, +}); + +const claudeDesktopAdapter = createFileClientAdapter({ + id: "claude-desktop", + label: "Claude Desktop", + restartNote: "Restart the Claude desktop app to load the server.", + configPath: claudeDesktopConfigPath, + unsupportedReason: (ctx) => + ctx.platform !== "darwin" && ctx.platform !== "win32" + ? "Claude Desktop is only available on macOS and Windows" + : undefined, + detectInstalled: async (ctx) => { + const configPath = claudeDesktopConfigPath(ctx); + return configPath !== null && exists(path.dirname(configPath)); + }, + rootPath: ["mcpServers"], + buildEntry: standardEntry, +}); + +const vscodeAdapter = createFileClientAdapter({ + id: "vscode", + label: "VS Code", + restartNote: "Restart VS Code — MCP requires the GitHub Copilot Chat extension.", + configPath: (ctx) => vscodeConfigPath(ctx), + detectInstalled: async (ctx) => exists(vscodeUserDir(ctx)), + rootPath: ["servers"], + buildEntry: vscodeEntry, +}); + +const opencodeAdapter = createFileClientAdapter({ + id: "opencode", + label: "OpenCode", + restartNote: "Run `opencode mcp list` to verify the server is loaded.", + configPath: (ctx) => opencodeConfigPath(ctx, exists), + detectInstalled: async (ctx) => exists(opencodeConfigDir(ctx)) || cliAvailable("opencode", ctx), + rootPath: ["mcp"], + buildEntry: opencodeEntry, + newFileSeed: '{\n "$schema": "https://opencode.ai/config.json"\n}\n', +}); + +const amazonQAdapter = createFileClientAdapter({ + id: "amazon-q", + label: "Amazon Q CLI (Kiro)", + restartNote: "Start a new `q chat` / `kiro-cli chat` session to load the server.", + configPath: (ctx) => amazonQConfigPath(ctx, exists), + unsupportedReason: (ctx) => + ctx.platform === "win32" + ? "not configurable from native Windows yet — run the wizard inside WSL (Kiro CLI 2.0+ users can add the entry to ~/.kiro/settings/mcp.json manually)" + : undefined, + detectInstalled: async (ctx) => { + if ( + exists(path.join(ctx.homeDir, ".kiro")) || + exists(path.join(ctx.homeDir, ".aws", "amazonq")) || + (await cliAvailable("kiro-cli", ctx)) + ) { + return true; + } + // `q` is a heavily overloaded binary name (harelba/q etc.) — only count + // it when its --version output actually looks like Amazon Q / Kiro. + const version = await cliVersionOutput("q", ctx); + return version !== null && /amazon|kiro|^q \d/i.test(version); + }, + rootPath: ["mcpServers"], + buildEntry: standardEntry, +}); + +/** + * Display order for the wizard's client picker. Codex is CLI-managed and only + * offered when its binary is detected (see init flow). + */ +export const CLIENT_ADAPTERS: ClientAdapter[] = [ + cursorAdapter, + claudeCodeAdapter, + claudeDesktopAdapter, + vscodeAdapter, + codexAdapter, + opencodeAdapter, + amazonQAdapter, +]; + +export function getClientAdapter(id: ClientId): ClientAdapter { + const adapter = CLIENT_ADAPTERS.find((candidate) => candidate.id === id); + if (!adapter) throw new Error(`Unknown MCP client: ${id}`); + return adapter; +} + +export const ALL_CLIENT_IDS = CLIENT_ADAPTERS.map((adapter) => adapter.id); diff --git a/src/lib/wizard/clients/types.ts b/src/lib/wizard/clients/types.ts new file mode 100644 index 0000000..2a57811 --- /dev/null +++ b/src/lib/wizard/clients/types.ts @@ -0,0 +1,32 @@ +import { ClientId, ExistingEntrySummary, InstallOutcome, ServerSpec } from "../types"; + +export interface ClientContext { + platform: NodeJS.Platform; + homeDir: string; + env: NodeJS.ProcessEnv; +} + +export interface DetectResult { + installed: boolean; + /** Set when the client cannot be configured on this platform at all. */ + unsupportedReason?: string; +} + +export interface ExistingState { + entries: ExistingEntrySummary[]; + /** Set when the client's config exists but cannot be safely edited. */ + error?: string; + /** Non-blocking caveats the user should see (e.g. shadowing entries). */ + warnings?: string[]; +} + +export interface ClientAdapter { + id: ClientId; + label: string; + /** Shown after a successful install, e.g. "Restart Cursor to load the server." */ + restartNote: string; + detect(ctx: ClientContext): Promise; + getExisting(ctx: ClientContext): Promise; + install(spec: ServerSpec, ctx: ClientContext): Promise; + remove(ctx: ClientContext): Promise; +} diff --git a/src/lib/wizard/entry-builders.logic.test.ts b/src/lib/wizard/entry-builders.logic.test.ts new file mode 100644 index 0000000..a27c031 --- /dev/null +++ b/src/lib/wizard/entry-builders.logic.test.ts @@ -0,0 +1,31 @@ +import { opencodeEntry, standardEntry, vscodeEntry } from "./entry-builders.logic"; +import { ServerSpec } from "./types"; + +const spec: ServerSpec = { + command: "npx", + args: ["-y", "@localstack/localstack-mcp-server"], + env: { LOCALSTACK_AUTH_TOKEN: "tok", DEBUG: "1" }, +}; + +describe("entry builders", () => { + it("builds the standard mcpServers entry", () => { + expect(standardEntry(spec)).toEqual({ + command: "npx", + args: ["-y", "@localstack/localstack-mcp-server"], + env: { LOCALSTACK_AUTH_TOKEN: "tok", DEBUG: "1" }, + }); + }); + + it("adds type: stdio for VS Code", () => { + expect(vscodeEntry(spec)).toMatchObject({ type: "stdio", command: "npx" }); + }); + + it("builds OpenCode's array-command/environment shape", () => { + expect(opencodeEntry(spec)).toEqual({ + type: "local", + command: ["npx", "-y", "@localstack/localstack-mcp-server"], + environment: { LOCALSTACK_AUTH_TOKEN: "tok", DEBUG: "1" }, + enabled: true, + }); + }); +}); diff --git a/src/lib/wizard/entry-builders.logic.ts b/src/lib/wizard/entry-builders.logic.ts new file mode 100644 index 0000000..f1b8f99 --- /dev/null +++ b/src/lib/wizard/entry-builders.logic.ts @@ -0,0 +1,24 @@ +import { ServerSpec } from "./types"; + +/** The {command, args, env} shape shared by Cursor, Claude Desktop, and Amazon Q. */ +export function standardEntry(spec: ServerSpec): Record { + return { command: spec.command, args: spec.args, env: spec.env }; +} + +/** VS Code uses a top-level "servers" key and documents "type": "stdio" as required. */ +export function vscodeEntry(spec: ServerSpec): Record { + return { type: "stdio", ...standardEntry(spec) }; +} + +/** + * OpenCode's schema differs: the command line is a single array including the + * binary, and the env key is "environment" (not "env"). + */ +export function opencodeEntry(spec: ServerSpec): Record { + return { + type: "local", + command: [spec.command, ...spec.args], + environment: spec.env, + enabled: true, + }; +} diff --git a/src/lib/wizard/env-parser.logic.test.ts b/src/lib/wizard/env-parser.logic.test.ts new file mode 100644 index 0000000..e1d5364 --- /dev/null +++ b/src/lib/wizard/env-parser.logic.test.ts @@ -0,0 +1,38 @@ +import { parseEnvVarsInput } from "./env-parser.logic"; + +describe("parseEnvVarsInput", () => { + it("parses comma-separated KEY=value pairs", () => { + const { env, errors } = parseEnvVarsInput( + "DEBUG=1,IMAGE_NAME=localstack/localstack-pro:latest" + ); + expect(errors).toEqual([]); + expect(env).toEqual({ DEBUG: "1", IMAGE_NAME: "localstack/localstack-pro:latest" }); + }); + + it("tolerates whitespace and empty segments", () => { + const { env, errors } = parseEnvVarsInput(" DEBUG = 1 , , PERSISTENCE=1 "); + expect(errors).toEqual([]); + expect(env).toEqual({ DEBUG: "1", PERSISTENCE: "1" }); + }); + + it("keeps '=' characters inside values", () => { + const { env } = parseEnvVarsInput("EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:3000?a=b"); + expect(env.EXTRA_CORS_ALLOWED_ORIGINS).toBe("http://localhost:3000?a=b"); + }); + + it("rejects malformed pairs without dropping valid ones", () => { + const { env, errors } = parseEnvVarsInput("DEBUG=1,nonsense,2BAD=x,EMPTY="); + expect(env).toEqual({ DEBUG: "1" }); + expect(errors).toHaveLength(3); + }); + + it("rejects LOCALSTACK_AUTH_TOKEN", () => { + const { env, errors } = parseEnvVarsInput("LOCALSTACK_AUTH_TOKEN=ls-abc"); + expect(env).toEqual({}); + expect(errors[0]).toContain("LOCALSTACK_AUTH_TOKEN"); + }); + + it("returns empty results for empty input", () => { + expect(parseEnvVarsInput("")).toEqual({ env: {}, errors: [] }); + }); +}); diff --git a/src/lib/wizard/env-parser.logic.ts b/src/lib/wizard/env-parser.logic.ts new file mode 100644 index 0000000..4071018 --- /dev/null +++ b/src/lib/wizard/env-parser.logic.ts @@ -0,0 +1,48 @@ +import { AUTH_TOKEN_ENV } from "./types"; + +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export interface ParsedEnvVars { + env: Record; + errors: string[]; +} + +/** + * Parses the wizard's free-text extra-config input, e.g. "DEBUG=1,IMAGE_NAME=xxx". + * Pairs are comma-separated; values therefore cannot contain commas. + */ +export function parseEnvVarsInput(input: string): ParsedEnvVars { + const env: Record = {}; + const errors: string[] = []; + + for (const rawPair of input.split(",")) { + const pair = rawPair.trim(); + if (!pair) continue; + + const separatorIndex = pair.indexOf("="); + if (separatorIndex <= 0) { + errors.push(`"${pair}" is not in KEY=value format`); + continue; + } + + const key = pair.slice(0, separatorIndex).trim(); + const value = pair.slice(separatorIndex + 1).trim(); + + if (!ENV_KEY_PATTERN.test(key)) { + errors.push(`"${key}" is not a valid environment variable name`); + continue; + } + if (key === AUTH_TOKEN_ENV) { + errors.push(`${AUTH_TOKEN_ENV} is set via the token step, not here`); + continue; + } + if (!value) { + errors.push(`"${key}" has an empty value`); + continue; + } + + env[key] = value; + } + + return { env, errors }; +} diff --git a/src/lib/wizard/json-config.logic.test.ts b/src/lib/wizard/json-config.logic.test.ts new file mode 100644 index 0000000..43f073f --- /dev/null +++ b/src/lib/wizard/json-config.logic.test.ts @@ -0,0 +1,113 @@ +import { + applyServerEntry, + detectExistingEntries, + removeServerEntries, + validateConfigText, +} from "./json-config.logic"; + +const NPX_ENTRY = { + command: "npx", + args: ["-y", "@localstack/localstack-mcp-server"], + env: { LOCALSTACK_AUTH_TOKEN: "tok" }, +}; + +describe("validateConfigText", () => { + it("accepts empty files, objects, and JSONC comments", () => { + expect(validateConfigText("")).toBeNull(); + expect(validateConfigText("{}")).toBeNull(); + expect(validateConfigText('{\n // comment\n "a": 1,\n}')).toBeNull(); + }); + + it("rejects broken JSON and non-objects", () => { + expect(validateConfigText("{ nope")).not.toBeNull(); + expect(validateConfigText("[1, 2]")).not.toBeNull(); + }); +}); + +describe("detectExistingEntries", () => { + it("finds the localstack entry and classifies its method", () => { + const text = JSON.stringify({ mcpServers: { localstack: { command: "docker", args: [] } } }); + expect(detectExistingEntries(text, ["mcpServers"])).toEqual([ + { key: "localstack", method: "docker" }, + ]); + }); + + it("ignores localstack-mcp-server entries because the wizard only manages localstack", () => { + const text = JSON.stringify({ + mcpServers: { "localstack-mcp-server": { command: "npx", args: [] } }, + }); + expect(detectExistingEntries(text, ["mcpServers"])).toEqual([]); + }); + + it("classifies OpenCode array commands", () => { + const text = JSON.stringify({ + mcp: { localstack: { type: "local", command: ["docker", "run"] } }, + }); + expect(detectExistingEntries(text, ["mcp"])).toEqual([{ key: "localstack", method: "docker" }]); + }); + + it("returns nothing for unrelated servers or missing files", () => { + expect(detectExistingEntries('{"mcpServers":{"other":{}}}', ["mcpServers"])).toEqual([]); + expect(detectExistingEntries("", ["mcpServers"])).toEqual([]); + }); +}); + +describe("applyServerEntry", () => { + it("creates the entry in an empty config", () => { + const result = applyServerEntry("", ["mcpServers"], NPX_ENTRY); + expect(JSON.parse(result)).toEqual({ mcpServers: { localstack: NPX_ENTRY } }); + }); + + it("preserves other servers and comments", () => { + const text = '{\n // keep me\n "mcpServers": {\n "other": { "command": "foo" }\n }\n}'; + const result = applyServerEntry(text, ["mcpServers"], NPX_ENTRY); + expect(result).toContain("// keep me"); + const parsed = JSON.parse(result.replace("// keep me", "")); + expect(parsed.mcpServers.other).toEqual({ command: "foo" }); + expect(parsed.mcpServers.localstack).toEqual(NPX_ENTRY); + }); + + it("preserves localstack-mcp-server entries when writing localstack", () => { + const text = JSON.stringify({ + mcpServers: { + "localstack-mcp-server": { command: "npx", args: [] }, + other: { command: "foo" }, + }, + }); + const parsed = JSON.parse(applyServerEntry(text, ["mcpServers"], NPX_ENTRY)); + expect(parsed.mcpServers["localstack-mcp-server"]).toEqual({ command: "npx", args: [] }); + expect(parsed.mcpServers.localstack).toEqual(NPX_ENTRY); + expect(parsed.mcpServers.other).toEqual({ command: "foo" }); + }); + + it("works with VS Code's top-level servers key", () => { + const result = applyServerEntry("{}", ["servers"], { type: "stdio", ...NPX_ENTRY }); + expect(JSON.parse(result).servers.localstack.type).toBe("stdio"); + }); +}); + +describe("removeServerEntries", () => { + it("removes only the wizard-managed localstack key", () => { + const text = JSON.stringify({ + mcpServers: { + localstack: { command: "npx" }, + "localstack-mcp-server": { command: "npx" }, + other: { command: "foo" }, + }, + }); + const { text: result, removed } = removeServerEntries(text, ["mcpServers"]); + expect(removed).toEqual(["localstack"]); + const parsed = JSON.parse(result); + expect(parsed.mcpServers).toEqual({ + "localstack-mcp-server": { command: "npx" }, + other: { command: "foo" }, + }); + }); + + it("is a no-op when nothing is installed", () => { + const text = '{"mcpServers":{"other":{}}}'; + const { text: result, removed } = removeServerEntries(text, ["mcpServers"]); + expect(removed).toEqual([]); + expect(result).toBe(text); + }); +}); diff --git a/src/lib/wizard/json-config.logic.ts b/src/lib/wizard/json-config.logic.ts new file mode 100644 index 0000000..cb77b69 --- /dev/null +++ b/src/lib/wizard/json-config.logic.ts @@ -0,0 +1,74 @@ +import * as jsonc from "jsonc-parser"; +import { ExistingEntrySummary, InstallMethod, SERVER_NAME } from "./types"; + +const MODIFY_OPTIONS: jsonc.ModificationOptions = { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, +}; + +function parseConfig(text: string): { root: any; errors: jsonc.ParseError[] } { + const errors: jsonc.ParseError[] = []; + const root = jsonc.parse(text || "{}", errors, { allowTrailingComma: true }); + return { root, errors }; +} + +/** Returns an error message when the text is not valid JSON(C), else null. */ +export function validateConfigText(text: string): string | null { + const { root, errors } = parseConfig(text); + if (errors.length > 0) { + return `file contains invalid JSON (${jsonc.printParseErrorCode(errors[0].error)} at offset ${errors[0].offset})`; + } + if (text.trim() && (typeof root !== "object" || root === null || Array.isArray(root))) { + return "file does not contain a JSON object"; + } + return null; +} + +function classifyMethod(entry: unknown): InstallMethod | "unknown" { + if (typeof entry !== "object" || entry === null) return "unknown"; + const command = (entry as Record).command; + // OpenCode stores the full command line as an array. + const binary = Array.isArray(command) ? command[0] : command; + if (binary === "docker") return "docker"; + if (binary === "npx") return "npx"; + return "unknown"; +} + +function getServersObject(text: string, rootPath: string[]): Record { + const { root } = parseConfig(text); + let node: unknown = root; + for (const segment of rootPath) { + if (typeof node !== "object" || node === null) return {}; + node = (node as Record)[segment]; + } + return typeof node === "object" && node !== null ? (node as Record) : {}; +} + +/** Finds the wizard-managed `localstack` entry under rootPath. */ +export function detectExistingEntries(text: string, rootPath: string[]): ExistingEntrySummary[] { + const servers = getServersObject(text, rootPath); + if (!(SERVER_NAME in servers)) return []; + return [{ key: SERVER_NAME, method: classifyMethod(servers[SERVER_NAME]) }]; +} + +/** Writes the `localstack` entry under rootPath. Preserves comments and formatting. */ +export function applyServerEntry(text: string, rootPath: string[], entry: unknown): string { + let result = text.trim() ? text : "{}"; + const edits = jsonc.modify(result, [...rootPath, SERVER_NAME], entry, MODIFY_OPTIONS); + result = jsonc.applyEdits(result, edits); + return result; +} + +/** Deletes the wizard-managed `localstack` entry under rootPath. */ +export function removeServerEntries( + text: string, + rootPath: string[] +): { text: string; removed: string[] } { + let result = text; + const removed: string[] = []; + if (SERVER_NAME in getServersObject(result, rootPath)) { + const edits = jsonc.modify(result, [...rootPath, SERVER_NAME], undefined, MODIFY_OPTIONS); + result = jsonc.applyEdits(result, edits); + removed.push(SERVER_NAME); + } + return { text: result, removed }; +} diff --git a/src/lib/wizard/paths.logic.test.ts b/src/lib/wizard/paths.logic.test.ts new file mode 100644 index 0000000..2a00197 --- /dev/null +++ b/src/lib/wizard/paths.logic.test.ts @@ -0,0 +1,69 @@ +import { + amazonQConfigPath, + claudeCodeUserConfigPath, + claudeDesktopConfigPath, + codexConfigPath, + cursorConfigPath, + opencodeConfigPath, + vscodeConfigPath, +} from "./paths.logic"; +import { ClientContext } from "./clients/types"; + +const mac: ClientContext = { platform: "darwin", homeDir: "/Users/dev", env: {} }; +const linux: ClientContext = { platform: "linux", homeDir: "/home/dev", env: {} }; +const win: ClientContext = { + platform: "win32", + homeDir: "C:\\Users\\dev", + env: { APPDATA: "C:\\Users\\dev\\AppData\\Roaming" }, +}; + +describe("client config paths", () => { + it("resolves Cursor's global config in the home directory", () => { + expect(cursorConfigPath(mac)).toBe("/Users/dev/.cursor/mcp.json"); + expect(cursorConfigPath(linux)).toBe("/home/dev/.cursor/mcp.json"); + }); + + it("resolves Claude Desktop on macOS and Windows, null on Linux", () => { + expect(claudeDesktopConfigPath(mac)).toBe( + "/Users/dev/Library/Application Support/Claude/claude_desktop_config.json" + ); + expect(claudeDesktopConfigPath(win)).toContain("Claude"); + expect(claudeDesktopConfigPath(win)).toContain("AppData"); + expect(claudeDesktopConfigPath(linux)).toBeNull(); + }); + + it("resolves VS Code's per-platform user mcp.json", () => { + expect(vscodeConfigPath(mac)).toBe("/Users/dev/Library/Application Support/Code/User/mcp.json"); + expect(vscodeConfigPath(linux)).toBe("/home/dev/.config/Code/User/mcp.json"); + expect(vscodeConfigPath(win)).toContain("Code"); + const xdg: ClientContext = { ...linux, env: { XDG_CONFIG_HOME: "/custom/config" } }; + expect(vscodeConfigPath(xdg)).toBe("/custom/config/Code/User/mcp.json"); + }); + + it("resolves OpenCode config respecting XDG_CONFIG_HOME and existing .jsonc", () => { + expect(opencodeConfigPath(mac, () => false)).toBe("/Users/dev/.config/opencode/opencode.json"); + const xdg: ClientContext = { ...linux, env: { XDG_CONFIG_HOME: "/custom/config" } }; + expect(opencodeConfigPath(xdg, () => false)).toBe("/custom/config/opencode/opencode.json"); + expect(opencodeConfigPath(mac, (p) => p.endsWith("opencode.jsonc"))).toBe( + "/Users/dev/.config/opencode/opencode.jsonc" + ); + }); + + it("prefers the Kiro tree for Amazon Q when present", () => { + expect(amazonQConfigPath(mac, (p) => p === "/Users/dev/.kiro")).toBe( + "/Users/dev/.kiro/settings/mcp.json" + ); + expect(amazonQConfigPath(mac, () => false)).toBe("/Users/dev/.aws/amazonq/mcp.json"); + }); + + it("resolves Claude Code's user config", () => { + expect(claudeCodeUserConfigPath(mac)).toBe("/Users/dev/.claude.json"); + }); + + it("resolves Codex config with CODEX_HOME support", () => { + expect(codexConfigPath(mac)).toBe("/Users/dev/.codex/config.toml"); + expect(codexConfigPath({ ...mac, env: { CODEX_HOME: "/tmp/codex" } })).toBe( + "/tmp/codex/config.toml" + ); + }); +}); diff --git a/src/lib/wizard/paths.logic.ts b/src/lib/wizard/paths.logic.ts new file mode 100644 index 0000000..bbe4afd --- /dev/null +++ b/src/lib/wizard/paths.logic.ts @@ -0,0 +1,79 @@ +import * as path from "path"; +import { ClientContext } from "./clients/types"; + +export type ExistsFn = (candidate: string) => boolean; + +export function cursorConfigPath(ctx: ClientContext): string { + return path.join(ctx.homeDir, ".cursor", "mcp.json"); +} + +/** Claude Desktop ships for macOS and Windows only — null elsewhere. */ +export function claudeDesktopConfigPath(ctx: ClientContext): string | null { + if (ctx.platform === "darwin") { + return path.join( + ctx.homeDir, + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ); + } + if (ctx.platform === "win32") { + const appData = ctx.env.APPDATA || path.join(ctx.homeDir, "AppData", "Roaming"); + return path.join(appData, "Claude", "claude_desktop_config.json"); + } + return null; +} + +export function vscodeUserDir(ctx: ClientContext): string { + if (ctx.platform === "darwin") { + return path.join(ctx.homeDir, "Library", "Application Support", "Code", "User"); + } + if (ctx.platform === "win32") { + const appData = ctx.env.APPDATA || path.join(ctx.homeDir, "AppData", "Roaming"); + return path.join(appData, "Code", "User"); + } + const configBase = ctx.env.XDG_CONFIG_HOME || path.join(ctx.homeDir, ".config"); + return path.join(configBase, "Code", "User"); +} + +export function vscodeConfigPath(ctx: ClientContext): string { + return path.join(vscodeUserDir(ctx), "mcp.json"); +} + +export function opencodeConfigDir(ctx: ClientContext): string { + const xdgConfigHome = ctx.env.XDG_CONFIG_HOME; + const base = xdgConfigHome || path.join(ctx.homeDir, ".config"); + return path.join(base, "opencode"); +} + +/** Prefers an existing opencode.jsonc over opencode.json. */ +export function opencodeConfigPath(ctx: ClientContext, exists: ExistsFn): string { + const dir = opencodeConfigDir(ctx); + const jsoncPath = path.join(dir, "opencode.jsonc"); + if (exists(jsoncPath)) return jsoncPath; + return path.join(dir, "opencode.json"); +} + +/** + * Amazon Q Developer CLI was renamed to Kiro CLI (Nov 2025); config moved from + * ~/.aws/amazonq/mcp.json to ~/.kiro/settings/mcp.json. Write to whichever + * tree exists, preferring the new one. + */ +export function amazonQConfigPath(ctx: ClientContext, exists: ExistsFn): string { + const kiroPath = path.join(ctx.homeDir, ".kiro", "settings", "mcp.json"); + const legacyPath = path.join(ctx.homeDir, ".aws", "amazonq", "mcp.json"); + if (exists(path.join(ctx.homeDir, ".kiro"))) return kiroPath; + return legacyPath; +} + +/** Claude Code stores user-scope MCP servers at the top level of ~/.claude.json. */ +export function claudeCodeUserConfigPath(ctx: ClientContext): string { + return path.join(ctx.homeDir, ".claude.json"); +} + +/** Codex stores MCP servers under CODEX_HOME when set, else ~/.codex. */ +export function codexConfigPath(ctx: ClientContext): string { + const codexHome = ctx.env.CODEX_HOME || path.join(ctx.homeDir, ".codex"); + return path.join(codexHome, "config.toml"); +} diff --git a/src/lib/wizard/prereqs.ts b/src/lib/wizard/prereqs.ts new file mode 100644 index 0000000..525197b --- /dev/null +++ b/src/lib/wizard/prereqs.ts @@ -0,0 +1,86 @@ +import { runCommand } from "../../core/command-runner"; +import { InstallMethod } from "./types"; + +const VERSION_CHECK_TIMEOUT = 5_000; +const DOCKER_INFO_TIMEOUT = 5_000; + +export interface PrereqResult { + name: string; + ok: boolean; + /** Fatal failures block the wizard; the rest warn and continue. */ + fatal: boolean; + hint?: string; +} + +async function commandWorks( + command: string, + args: string[], + timeout = VERSION_CHECK_TIMEOUT +): Promise { + const result = await runCommand(command, args, { + timeout, + shell: process.platform === "win32", + }); + return result.exitCode === 0; +} + +async function dockerStatus(): Promise<{ installed: boolean; running: boolean }> { + const [installed, infoOk] = await Promise.all([ + commandWorks("docker", ["--version"]), + commandWorks("docker", ["info"], DOCKER_INFO_TIMEOUT), + ]); + return { + installed, + running: installed && infoOk, + }; +} + +export function checkNodeVersion(versionString: string = process.version): PrereqResult { + const major = Number(versionString.replace(/^v/, "").split(".")[0]); + return { + name: `Node.js ${versionString}`, + ok: major >= 20, + fatal: false, + hint: major >= 20 ? undefined : "the MCP server requires Node.js 20+ — upgrade from nodejs.org", + }; +} + +export async function checkPrereqs(method: InstallMethod): Promise { + const results: PrereqResult[] = []; + + const dockerPromise = dockerStatus(); + const localstackPromise = + method === "npx" ? commandWorks("localstack", ["--version"]) : Promise.resolve(false); + + if (method === "npx") { + results.push(checkNodeVersion()); + results.push({ + name: "LocalStack CLI", + ok: await localstackPromise, + fatal: false, + hint: "install it with `brew install localstack/tap/localstack-cli` or `pip install localstack` — needed by the lifecycle tools", + }); + } + + const { installed: dockerInstalled, running: dockerRunning } = await dockerPromise; + + results.push({ + name: "Docker CLI", + ok: dockerInstalled, + // Without Docker the docker-run config can never work; npx setups only + // need it later, at container start. + fatal: method === "docker", + hint: "install Docker from https://docs.docker.com/get-docker/", + }); + + if (dockerInstalled) { + results.push({ + name: "Docker daemon", + ok: dockerRunning, + fatal: false, + hint: "Docker is installed but not running — start it before using the MCP server", + }); + } + + return results; +} diff --git a/src/lib/wizard/server-config.logic.test.ts b/src/lib/wizard/server-config.logic.test.ts new file mode 100644 index 0000000..85acba0 --- /dev/null +++ b/src/lib/wizard/server-config.logic.test.ts @@ -0,0 +1,95 @@ +import { + buildDockerServerSpec, + buildNpxServerSpec, + windowsSpawnSafeSpec, +} from "./server-config.logic"; + +describe("buildNpxServerSpec", () => { + it("builds the documented npx config", () => { + const spec = buildNpxServerSpec("ls-token"); + expect(spec.command).toBe("npx"); + expect(spec.args).toEqual(["-y", "@localstack/localstack-mcp-server"]); + expect(spec.env).toEqual({ LOCALSTACK_AUTH_TOKEN: "ls-token" }); + }); + + it("merges extra env vars after the token", () => { + const spec = buildNpxServerSpec("ls-token", { DEBUG: "1", PERSISTENCE: "1" }); + expect(spec.env).toEqual({ + LOCALSTACK_AUTH_TOKEN: "ls-token", + DEBUG: "1", + PERSISTENCE: "1", + }); + }); +}); + +describe("buildDockerServerSpec", () => { + const options = { + cacheDir: "/Users/you/.localstack-mcp", + workspaceDir: "/Users/you/projects", + imageTag: "latest", + }; + + it("builds the full docker run recipe", () => { + const spec = buildDockerServerSpec("ls-token", {}, options); + expect(spec.command).toBe("docker"); + expect(spec.args).toEqual([ + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "-v", + "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp", + "-e", + "XDG_CACHE_HOME=/Users/you/.localstack-mcp", + "--add-host", + "host.docker.internal:host-gateway", + "--add-host", + "s3.host.docker.internal:host-gateway", + "--add-host", + "snowflake.localhost.localstack.cloud:host-gateway", + "-e", + "LOCALSTACK_AUTH_TOKEN", + "-e", + "LOCALSTACK_HOSTNAME=host.docker.internal", + "-v", + "/Users/you/projects:/Users/you/projects", + "localstack/localstack-mcp-server:latest", + ]); + expect(spec.env).toEqual({ LOCALSTACK_AUTH_TOKEN: "ls-token" }); + }); + + it("omits the workspace mount when not provided", () => { + const spec = buildDockerServerSpec("t", {}, { ...options, workspaceDir: undefined }); + expect(spec.args).not.toContain("/Users/you/projects:/Users/you/projects"); + expect(spec.args[spec.args.length - 1]).toBe("localstack/localstack-mcp-server:latest"); + }); + + it("forwards extra env vars by key only in args, with values in env", () => { + const spec = buildDockerServerSpec("t", { DEBUG: "1" }, options); + const debugFlagIndex = spec.args.indexOf("DEBUG"); + expect(debugFlagIndex).toBeGreaterThan(-1); + expect(spec.args[debugFlagIndex - 1]).toBe("-e"); + expect(spec.args).not.toContain("DEBUG=1"); + expect(spec.env.DEBUG).toBe("1"); + }); + + it("respects a custom image tag", () => { + const spec = buildDockerServerSpec("t", {}, { ...options, imageTag: "0.5.0" }); + expect(spec.args[spec.args.length - 1]).toBe("localstack/localstack-mcp-server:0.5.0"); + }); +}); + +describe("windowsSpawnSafeSpec", () => { + it("wraps npx in cmd /c for shell-less Windows spawns", () => { + const spec = windowsSpawnSafeSpec(buildNpxServerSpec("tok")); + expect(spec.command).toBe("cmd"); + expect(spec.args).toEqual(["/c", "npx", "-y", "@localstack/localstack-mcp-server"]); + expect(spec.env).toEqual({ LOCALSTACK_AUTH_TOKEN: "tok" }); + }); + + it("leaves non-npx commands untouched", () => { + const docker = buildDockerServerSpec("tok", {}, { cacheDir: "/c", imageTag: "latest" }); + expect(windowsSpawnSafeSpec(docker)).toBe(docker); + }); +}); diff --git a/src/lib/wizard/server-config.logic.ts b/src/lib/wizard/server-config.logic.ts new file mode 100644 index 0000000..4e37e28 --- /dev/null +++ b/src/lib/wizard/server-config.logic.ts @@ -0,0 +1,69 @@ +import { AUTH_TOKEN_ENV, DOCKER_IMAGE, DockerOptions, NPM_PACKAGE, ServerSpec } from "./types"; + +/** + * Clients that spawn the server without a shell (Claude Code, Codex) can't + * resolve the npx.cmd shim on native Windows — wrap in `cmd /c` there. + * File-based clients (Cursor, VS Code, Claude Desktop) resolve npx themselves + * and must keep the plain command. + */ +export function windowsSpawnSafeSpec(spec: ServerSpec): ServerSpec { + if (spec.command !== "npx") return spec; + return { command: "cmd", args: ["/c", spec.command, ...spec.args], env: spec.env }; +} + +export function buildNpxServerSpec( + token: string, + extraEnv: Record = {} +): ServerSpec { + return { + command: "npx", + args: ["-y", NPM_PACKAGE], + env: { [AUTH_TOKEN_ENV]: token, ...extraEnv }, + }; +} + +export function buildDockerServerSpec( + token: string, + extraEnv: Record, + options: DockerOptions +): ServerSpec { + const args = [ + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "-v", + `${options.cacheDir}:${options.cacheDir}`, + "-e", + `XDG_CACHE_HOME=${options.cacheDir}`, + "--add-host", + "host.docker.internal:host-gateway", + "--add-host", + "s3.host.docker.internal:host-gateway", + "--add-host", + "snowflake.localhost.localstack.cloud:host-gateway", + "-e", + AUTH_TOKEN_ENV, + "-e", + "LOCALSTACK_HOSTNAME=host.docker.internal", + ]; + + // Extra config vars are forwarded into the server container from the env + // block; values stay out of the args so client UIs don't display them. + for (const key of Object.keys(extraEnv)) { + args.push("-e", key); + } + + if (options.workspaceDir) { + args.push("-v", `${options.workspaceDir}:${options.workspaceDir}`); + } + + args.push(`${DOCKER_IMAGE}:${options.imageTag}`); + + return { + command: "docker", + args, + env: { [AUTH_TOKEN_ENV]: token, ...extraEnv }, + }; +} diff --git a/src/lib/wizard/types.ts b/src/lib/wizard/types.ts new file mode 100644 index 0000000..f13e673 --- /dev/null +++ b/src/lib/wizard/types.ts @@ -0,0 +1,46 @@ +export type InstallMethod = "npx" | "docker"; + +export type ClientId = + | "cursor" + | "claude-code" + | "claude-desktop" + | "vscode" + | "codex" + | "opencode" + | "amazon-q"; + +export const SERVER_NAME = "localstack"; +export const NPM_PACKAGE = "@localstack/localstack-mcp-server"; +export const DOCKER_IMAGE = "localstack/localstack-mcp-server"; +export const AUTH_TOKEN_ENV = "LOCALSTACK_AUTH_TOKEN"; + +export interface ServerSpec { + command: string; + args: string[]; + env: Record; +} + +export interface DockerOptions { + cacheDir: string; + workspaceDir?: string; + imageTag: string; +} + +export interface WizardAnswers { + method: InstallMethod; + token: string; + extraEnv: Record; + docker?: DockerOptions; + clients: ClientId[]; + force: boolean; +} + +export interface ExistingEntrySummary { + key: string; + method: InstallMethod | "unknown"; +} + +export type InstallOutcome = + | { status: "installed"; detail: string } + | { status: "skipped"; detail: string } + | { status: "failed"; detail: string }; diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index 42d8cc0..c7e7dcf 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -1,4 +1,8 @@ import { expect, test } from "@gleanwork/mcp-server-tester/fixtures/mcp"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const EXPECTED_TOOLS = [ "localstack-management", @@ -67,3 +71,73 @@ test("docs tool returns useful documentation snippets", async ({ mcp }) => { expect(result).not.toBeToolError(); expect(result).toContainToolText("LocalStack Docs"); }); + +test("wizard: init --help prints usage without starting the server", () => { + const output = execFileSync("node", ["dist/cli.js", "init", "--help"], { encoding: "utf8" }); + expect(output).toContain("init"); + expect(output).toContain("--method "); + expect(output).toContain("--client "); +}); + +test("wizard: non-interactive init writes a Cursor config", () => { + const home = mkdtempSync(join(tmpdir(), "ls-wizard-test-")); + mkdirSync(join(home, ".cursor"), { recursive: true }); + + execFileSync( + "node", + [ + "dist/cli.js", + "init", + "--method", + "npx", + "--client", + "cursor", + "--token", + "ls-test-token", + "--config", + "DEBUG=1", + "--force", + ], + { encoding: "utf8", env: { ...process.env, HOME: home, USERPROFILE: home } } + ); + + const config = JSON.parse(readFileSync(join(home, ".cursor", "mcp.json"), "utf8")); + expect(config.mcpServers.localstack.command).toBe("npx"); + expect(config.mcpServers.localstack.args).toEqual(["-y", "@localstack/localstack-mcp-server"]); + expect(config.mcpServers.localstack.env.LOCALSTACK_AUTH_TOKEN).toBe("ls-test-token"); + expect(config.mcpServers.localstack.env.DEBUG).toBe("1"); +}); + +test("wizard: no-arg dist/cli.js still serves MCP over stdio", async () => { + const child = spawn("node", ["dist/cli.js"], { stdio: ["pipe", "pipe", "pipe"] }); + try { + const response = await new Promise((resolve, reject) => { + let buffer = ""; + const timer = setTimeout(() => reject(new Error("no MCP response within 30s")), 30000); + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex !== -1) { + clearTimeout(timer); + resolve(JSON.parse(buffer.slice(0, newlineIndex))); + } + }); + child.on("error", reject); + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "wizard-regression-test", version: "0.0.0" }, + }, + }) + "\n" + ); + }); + expect(response.result.capabilities).toBeDefined(); + } finally { + child.kill(); + } +}); diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 0000000..15fcae5 --- /dev/null +++ b/tsconfig.cli.json @@ -0,0 +1,10 @@ +{ + // Used only by `yarn build:cli` (esbuild). The base tsconfig maps + // @clack/prompts to its .d.mts for type-checking; esbuild must NOT inherit + // that mapping or it would bundle the type declarations instead of the + // real module. + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/tsconfig.json b/tsconfig.json index 674932f..a979fac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,14 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, - "rootDir": "./src" + "rootDir": "./src", + "baseUrl": ".", + "paths": { + // @clack/prompts is ESM-only (.d.mts types); this project's node10 + // resolution can't see them. esbuild bundles the real module at build + // time — this mapping is for type-checking only. + "@clack/prompts": ["node_modules/@clack/prompts/dist/index.d.mts"] + } }, "include": ["xmcp-env.d.ts", "src/**/*.ts"] } diff --git a/yarn.lock b/yarn.lock index c5bdbc7..3903cd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -465,6 +465,24 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@clack/core@1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@clack/core/-/core-1.4.1.tgz#07e2271654fc2ab5719f7e6a45132dd504e69979" + integrity sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw== + dependencies: + fast-wrap-ansi "^0.2.0" + sisteransi "^1.0.5" + +"@clack/prompts@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@clack/prompts/-/prompts-1.5.1.tgz#fa135a6d1c408de8c16f95b50680ca9ca046c1be" + integrity sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw== + dependencies: + "@clack/core" "1.4.1" + fast-string-width "^3.0.2" + fast-wrap-ansi "^0.2.0" + sisteransi "^1.0.5" + "@emnapi/core@^1.4.3": version "1.5.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0" @@ -502,6 +520,136 @@ dependencies: tslib "^2.4.0" +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== + "@gleanwork/mcp-server-tester@1.0.0-beta.6": version "1.0.0-beta.6" resolved "https://registry.yarnpkg.com/@gleanwork/mcp-server-tester/-/mcp-server-tester-1.0.0-beta.6.tgz#dfc8f58fe2b41152de2d951a55688eddacab25c7" @@ -2353,6 +2501,38 @@ es-toolkit@^1.22.0: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== +esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" @@ -2483,11 +2663,30 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-string-truncated-width@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz#23afe0da67d752ca0727538f1e6967759728ce49" + integrity sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g== + +fast-string-width@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/fast-string-width/-/fast-string-width-3.0.2.tgz#16dbabb491ce5585b5ecb675b65c165d71688eeb" + integrity sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg== + dependencies: + fast-string-truncated-width "^3.0.2" + fast-uri@^3.0.1: version "3.1.0" resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz" integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== +fast-wrap-ansi@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz#95e952a0145bce3f59ad56e179f84c48d4072935" + integrity sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q== + dependencies: + fast-string-width "^3.0.2" + fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -3497,6 +3696,11 @@ json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jwa@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" @@ -4248,6 +4452,11 @@ signal-exit@^4.0.1: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + slash@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"