Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@ name: Node.js CI

on:
pull_request:
branches: [ main ]
branches: [main]
push:
branches: [ main ]
branches: [main]
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v2
with:
node-version: 20.x
- name: Use Node.js 20
uses: actions/setup-node@v2
with:
node-version: 20.x

- name: Install dependencies
run: yarn
- name: Install dependencies
run: yarn

- name: Build the project
run: yarn build
- name: Build the project
run: yarn build
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
};
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
"build": "xmcp build",
"dev": "xmcp dev",
"start": "node dist/stdio.js",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "jest"
},
"dependencies": {
"xmcp": "^0.1.7",
"zod": "3.24.4"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"eslint-config-prettier": "^10.1.8",
"jest": "^30.1.3",
"prettier": "^3.6.2",
"swc-loader": "^0.2.6"
"swc-loader": "^0.2.6",
"ts-jest": "^29.4.1"
},
"main": "./dist/stdio.js",
"files": [
Expand Down
97 changes: 97 additions & 0 deletions src/core/command-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawn, SpawnOptions } from "child_process";
import { DEFAULT_COMMAND_TIMEOUT, DEFAULT_COMMAND_MAX_BUFFER } from "./config";

export interface CommandResult {
stdout: string;
stderr: string;
error?: Error;
exitCode: number | null;
}

export interface CommandOptions extends SpawnOptions {
timeout?: number;
maxBuffer?: number;
onData?: (chunk: string, type: "stdout" | "stderr") => void;
}

export function runCommand(
command: string,
args: string[],
options: CommandOptions = {}
): Promise<CommandResult> {
return new Promise((resolve) => {
const {
timeout = DEFAULT_COMMAND_TIMEOUT,
maxBuffer = DEFAULT_COMMAND_MAX_BUFFER,
onData,
...spawnOptions
} = options;

const child = spawn(command, args, { ...spawnOptions });

let stdout = "";
let stderr = "";
let outBytes = 0;
let errBytes = 0;
let timedOut = false;
let bufferExceeded = false;
let error: Error | undefined;

const killProcess = (reason: string) => {
if (child.killed) return;
error = new Error(reason);
child.kill(spawnOptions.killSignal ?? "SIGTERM");
setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
}, 2000);
};

const timer = setTimeout(() => {
timedOut = true;
killProcess(`Command timed out after ${timeout}ms`);
}, timeout);

const onChunk = (isStdout: boolean) => (chunk: Buffer) => {
if (timedOut || bufferExceeded) return;
const len = chunk.length;
if (isStdout) {
outBytes += len;
const data = chunk.toString();
stdout += data;
if (onData) onData(data, "stdout");
if (outBytes > maxBuffer) {
bufferExceeded = true;
killProcess(`stdout exceeded maxBuffer size of ${maxBuffer} bytes`);
}
} else {
errBytes += len;
const data = chunk.toString();
stderr += data;
if (onData) onData(data, "stderr");
if (errBytes > maxBuffer) {
bufferExceeded = true;
killProcess(`stderr exceeded maxBuffer size of ${maxBuffer} bytes`);
}
}
};

child.stdout?.on("data", onChunk(true));
child.stderr?.on("data", onChunk(false));

child.on("error", (err) => {
error = err;
});

child.on("close", (code) => {
clearTimeout(timer);
if (!timedOut && !bufferExceeded && code !== 0 && !error) {
error = new Error(`Command failed with exit code ${code}: ${stderr.trim()}`);
}
resolve({ stdout, stderr, error, exitCode: code });
});
});
}

export function stripAnsiCodes(text: string): string {
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}
11 changes: 11 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const LOCALSTACK_HOSTNAME = process.env.LOCALSTACK_HOSTNAME || "localhost";
export const LOCALSTACK_PORT = process.env.LOCALSTACK_PORT || 4566;
export const LOCALSTACK_BASE_URL = `http://${LOCALSTACK_HOSTNAME}:${LOCALSTACK_PORT}`;

// Default timeout for network requests in milliseconds
export const DEFAULT_FETCH_TIMEOUT = 15000;

// Default timeouts and buffer sizes for command execution
export const DEFAULT_COMMAND_TIMEOUT = 300000; // 5 minutes
export const DEFAULT_COMMAND_MAX_BUFFER = 1024 * 1024 * 10; // 10 MB
export const IAM_CONFIG_ENDPOINT = "/_aws/iam/config";
69 changes: 69 additions & 0 deletions src/core/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { DEFAULT_FETCH_TIMEOUT, LOCALSTACK_BASE_URL } from "./config";

export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: string,
message: string
) {
super(message);
this.name = "HttpError";
}
}

interface RequestOptions extends RequestInit {
timeout?: number;
baseUrl?: string;
}

export class HttpClient {
async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const {
timeout = DEFAULT_FETCH_TIMEOUT,
baseUrl = LOCALSTACK_BASE_URL,
...fetchOptions
} = options;

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(`${baseUrl}${endpoint}`, {
...fetchOptions,
signal: controller.signal,
});

if (!response.ok) {
const responseBody = await response.text();
const errorMessage = `HTTP Error: ${response.status} ${response.statusText} for URL: ${response.url}`;
throw new HttpError(response.status, response.statusText, responseBody, errorMessage);
}

// Handle empty responses
if (response.status === 204 || response.headers.get("content-length") === "0") {
return {} as T;
}

const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return (await response.json()) as T;
} else {
return (await response.text()) as T;
}
} catch (error: any) {
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${timeout}ms`);
}
if (error.code === "ECONNREFUSED") {
throw new Error(`Connection refused at ${baseUrl}. Is LocalStack running?`);
}
throw error; // Re-throw other errors
} finally {
clearTimeout(timeoutId);
}
}
}

// Export a singleton instance for convenience
export const httpClient = new HttpClient();
24 changes: 24 additions & 0 deletions src/core/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ensureLocalStackCli } from "../lib/localstack/localstack.utils";
import { checkProFeature, ProFeature } from "../lib/localstack/license-checker";
import { ResponseBuilder } from "./response-builder";

type ToolResponse = ReturnType<typeof ResponseBuilder.error>;

export const requireLocalStackCli = async (): Promise<ToolResponse | null> => {
const cliCheck = await ensureLocalStackCli();
return cliCheck ? (cliCheck as ToolResponse) : null;
};

export const requireProFeature = async (feature: ProFeature): Promise<ToolResponse | null> => {
const licenseCheck = await checkProFeature(feature);
return !licenseCheck.isSupported
? ResponseBuilder.error("Feature Not Available", licenseCheck.errorMessage)
: null;
};

export const runPreflights = async (
checks: Array<Promise<ToolResponse | null>>
): Promise<ToolResponse | null> => {
const results = await Promise.all(checks);
return results.find((r) => r !== null) || null;
};
27 changes: 27 additions & 0 deletions src/core/response-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
interface ToolResponse {
content: Array<{ type: "text"; text: string }>;
}

export class ResponseBuilder {
public static success(message: string): ToolResponse {
return {
content: [{ type: "text", text: `✅ ${message}` }],
};
}

public static error(title: string, details?: string): ToolResponse {
let text = `❌ **${title}**`;
if (details) {
text += `\n\n${details}`;
}
return {
content: [{ type: "text", text }],
};
}

public static markdown(content: string): ToolResponse {
return {
content: [{ type: "text", text: content }],
};
}
}
31 changes: 31 additions & 0 deletions src/lib/deployment/deployment-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DeploymentEvent } from "./deployment-utils";

export function formatDeploymentReport(baseTitle: string, events: DeploymentEvent[]): string {
let report = `# ${baseTitle}\n\n`;

for (const event of events) {
switch (event.type) {
case "header":
report += `## ${event.title}\n\n`;
break;
case "command":
report += `**Executing:** \`${event.content}\`\n\n`;
break;
case "output":
if (event.content.trim()) {
report += `\`\`\`\n${event.content.trim()}\n\`\`\`\n\n`;
}
break;
case "warning":
report += `**⚠️ Message:**\n\`\`\`\n${event.content.trim()}\n\`\`\`\n\n`;
break;
case "error":
report += `❌ **${event.title || "Error"}**\n\n\`\`\`\n${event.content.trim()}\n\`\`\`\n`;
break;
case "success":
report += `✅ **${event.content}**\n`;
break;
}
}
return report;
}
38 changes: 38 additions & 0 deletions src/lib/deployment/deployment-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { parseCdkOutputs, parseTerraformOutputs, validateVariables } from "./deployment-utils";

describe("deployment-utils", () => {
describe("validateVariables", () => {
it("should allow valid variables", () => {
const errors = validateVariables({ key: "value", ANOTHER_KEY: "some-value-123" });
expect(errors).toHaveLength(0);
});
it("should reject variables with shell metacharacters", () => {
const errors = validateVariables({ key: "value; ls -la" });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain("contains forbidden character: ;");
});
});

describe("parseCdkOutputs", () => {
it("should correctly parse CDK deploy output", () => {
const stdout = `
Stack ARN:
arn:aws:cloudformation:us-east-1:000000000000:stack/MyStack/abc-def

Outputs:
MyStack.MyBucketName = my-cdk-bucket
MyStack.MyLambdaArn = arn:aws:lambda:us-east-1:000:function:MyLambda
`;
const result = parseCdkOutputs(stdout);
expect(result).toContain("| **MyStack.MyBucketName** | `my-cdk-bucket` |");
});
});

describe("parseTerraformOutputs", () => {
it("should handle empty outputs gracefully", () => {
const json = JSON.stringify({});
const result = parseTerraformOutputs(json);
expect(result).toContain("No outputs defined");
});
});
});
Loading