Skip to content

Commit a775f08

Browse files
committed
round 2 (refactor)
1 parent 9e238ae commit a775f08

17 files changed

Lines changed: 3328 additions & 1452 deletions

jest.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
preset: "ts-jest",
3+
testEnvironment: "node",
4+
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
5+
};

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
"build": "xmcp build",
1010
"dev": "xmcp dev",
1111
"start": "node dist/stdio.js",
12-
"format": "prettier --write ."
12+
"format": "prettier --write .",
13+
"test": "jest"
1314
},
1415
"dependencies": {
1516
"xmcp": "^0.1.7",
1617
"zod": "3.24.4"
1718
},
1819
"devDependencies": {
20+
"@types/jest": "^30.0.0",
1921
"eslint-config-prettier": "^10.1.8",
22+
"jest": "^30.1.3",
2023
"prettier": "^3.6.2",
21-
"swc-loader": "^0.2.6"
24+
"swc-loader": "^0.2.6",
25+
"ts-jest": "^29.4.1"
2226
},
2327
"main": "./dist/stdio.js",
2428
"files": [

src/core/command-runner.ts

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,9 @@ export interface CommandResult {
1111
export interface CommandOptions extends SpawnOptions {
1212
timeout?: number;
1313
maxBuffer?: number;
14+
onData?: (chunk: string, type: "stdout" | "stderr") => void;
1415
}
1516

16-
/**
17-
* Executes a command securely using spawn.
18-
* @param command The command to execute (e.g., 'tflocal').
19-
* @param args An array of string arguments.
20-
* @param options Spawn options including cwd, env, timeout, etc.
21-
* @returns A promise that resolves with the command result.
22-
*/
2317
export function runCommand(
2418
command: string,
2519
args: string[],
@@ -29,45 +23,75 @@ export function runCommand(
2923
const {
3024
timeout = DEFAULT_COMMAND_TIMEOUT,
3125
maxBuffer = DEFAULT_COMMAND_MAX_BUFFER,
26+
onData,
3227
...spawnOptions
3328
} = options;
3429

35-
const child = spawn(command, args, {
36-
...spawnOptions,
37-
timeout,
38-
});
30+
const child = spawn(command, args, { ...spawnOptions });
3931

4032
let stdout = "";
4133
let stderr = "";
34+
let outBytes = 0;
35+
let errBytes = 0;
36+
let timedOut = false;
37+
let bufferExceeded = false;
38+
let error: Error | undefined;
4239

43-
child.stdout?.on("data", (data) => {
44-
stdout += data.toString();
45-
});
40+
const killProcess = (reason: string) => {
41+
if (child.killed) return;
42+
error = new Error(reason);
43+
child.kill(spawnOptions.killSignal ?? "SIGTERM");
44+
setTimeout(() => {
45+
if (!child.killed) child.kill("SIGKILL");
46+
}, 2000);
47+
};
4648

47-
child.stderr?.on("data", (data) => {
48-
stderr += data.toString();
49-
});
49+
const timer = setTimeout(() => {
50+
timedOut = true;
51+
killProcess(`Command timed out after ${timeout}ms`);
52+
}, timeout);
53+
54+
const onChunk = (isStdout: boolean) => (chunk: Buffer) => {
55+
if (timedOut || bufferExceeded) return;
56+
const len = chunk.length;
57+
if (isStdout) {
58+
outBytes += len;
59+
const data = chunk.toString();
60+
stdout += data;
61+
if (onData) onData(data, "stdout");
62+
if (outBytes > maxBuffer) {
63+
bufferExceeded = true;
64+
killProcess(`stdout exceeded maxBuffer size of ${maxBuffer} bytes`);
65+
}
66+
} else {
67+
errBytes += len;
68+
const data = chunk.toString();
69+
stderr += data;
70+
if (onData) onData(data, "stderr");
71+
if (errBytes > maxBuffer) {
72+
bufferExceeded = true;
73+
killProcess(`stderr exceeded maxBuffer size of ${maxBuffer} bytes`);
74+
}
75+
}
76+
};
77+
78+
child.stdout?.on("data", onChunk(true));
79+
child.stderr?.on("data", onChunk(false));
5080

51-
child.on("error", (error) => {
52-
resolve({ stdout, stderr, error, exitCode: child.exitCode });
81+
child.on("error", (err) => {
82+
error = err;
5383
});
5484

5585
child.on("close", (code) => {
56-
let error: Error | undefined;
57-
if (code !== 0) {
86+
clearTimeout(timer);
87+
if (!timedOut && !bufferExceeded && code !== 0 && !error) {
5888
error = new Error(`Command failed with exit code ${code}: ${stderr.trim()}`);
5989
}
6090
resolve({ stdout, stderr, error, exitCode: code });
6191
});
6292
});
6393
}
6494

65-
/**
66-
* Strip ANSI escape codes from command output for clean display.
67-
* This is the exact same function from the original deployment-utils.ts, now centralized.
68-
* @param text The text containing ANSI escape codes.
69-
* @returns Cleaned text without ANSI codes.
70-
*/
7195
export function stripAnsiCodes(text: string): string {
7296
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
7397
}

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export const DEFAULT_FETCH_TIMEOUT = 15000;
88
// Default timeouts and buffer sizes for command execution
99
export const DEFAULT_COMMAND_TIMEOUT = 300000; // 5 minutes
1010
export const DEFAULT_COMMAND_MAX_BUFFER = 1024 * 1024 * 10; // 10 MB
11+
export const IAM_CONFIG_ENDPOINT = "/_aws/iam/config";

src/core/preflight.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ensureLocalStackCli } from "../lib/localstack/localstack.utils";
2+
import { checkProFeature, ProFeature } from "../lib/localstack/license-checker";
3+
import { ResponseBuilder } from "./response-builder";
4+
5+
type ToolResponse = ReturnType<typeof ResponseBuilder.error>;
6+
7+
export const requireLocalStackCli = async (): Promise<ToolResponse | null> => {
8+
const cliCheck = await ensureLocalStackCli();
9+
return cliCheck ? (cliCheck as ToolResponse) : null;
10+
};
11+
12+
export const requireProFeature = async (feature: ProFeature): Promise<ToolResponse | null> => {
13+
const licenseCheck = await checkProFeature(feature);
14+
return !licenseCheck.isSupported
15+
? ResponseBuilder.error("Feature Not Available", licenseCheck.errorMessage)
16+
: null;
17+
};
18+
19+
export const runPreflights = async (
20+
checks: Array<Promise<ToolResponse | null>>
21+
): Promise<ToolResponse | null> => {
22+
const results = await Promise.all(checks);
23+
return results.find((r) => r !== null) || null;
24+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { DeploymentEvent } from "./deployment-utils";
2+
3+
export function formatDeploymentReport(baseTitle: string, events: DeploymentEvent[]): string {
4+
let report = `# ${baseTitle}\n\n`;
5+
6+
for (const event of events) {
7+
switch (event.type) {
8+
case "header":
9+
report += `## ${event.title}\n\n`;
10+
break;
11+
case "command":
12+
report += `**Executing:** \`${event.content}\`\n\n`;
13+
break;
14+
case "output":
15+
if (event.content.trim()) {
16+
report += `\`\`\`\n${event.content.trim()}\n\`\`\`\n\n`;
17+
}
18+
break;
19+
case "warning":
20+
report += `**⚠️ Message:**\n\`\`\`\n${event.content.trim()}\n\`\`\`\n\n`;
21+
break;
22+
case "error":
23+
report += `❌ **${event.title || "Error"}**\n\n\`\`\`\n${event.content.trim()}\n\`\`\`\n`;
24+
break;
25+
case "success":
26+
report += `✅ **${event.content}**\n`;
27+
break;
28+
}
29+
}
30+
return report;
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { parseCdkOutputs, parseTerraformOutputs, validateVariables } from "./deployment-utils";
2+
3+
describe("deployment-utils", () => {
4+
describe("validateVariables", () => {
5+
it("should allow valid variables", () => {
6+
const errors = validateVariables({ key: "value", ANOTHER_KEY: "some-value-123" });
7+
expect(errors).toHaveLength(0);
8+
});
9+
it("should reject variables with shell metacharacters", () => {
10+
const errors = validateVariables({ key: "value; ls -la" });
11+
expect(errors.length).toBeGreaterThan(0);
12+
expect(errors[0]).toContain("contains forbidden character: ;");
13+
});
14+
});
15+
16+
describe("parseCdkOutputs", () => {
17+
it("should correctly parse CDK deploy output", () => {
18+
const stdout = `
19+
Stack ARN:
20+
arn:aws:cloudformation:us-east-1:000000000000:stack/MyStack/abc-def
21+
22+
Outputs:
23+
MyStack.MyBucketName = my-cdk-bucket
24+
MyStack.MyLambdaArn = arn:aws:lambda:us-east-1:000:function:MyLambda
25+
`;
26+
const result = parseCdkOutputs(stdout);
27+
expect(result).toContain("| **MyStack.MyBucketName** | `my-cdk-bucket` |");
28+
});
29+
});
30+
31+
describe("parseTerraformOutputs", () => {
32+
it("should handle empty outputs gracefully", () => {
33+
const json = JSON.stringify({});
34+
const result = parseTerraformOutputs(json);
35+
expect(result).toContain("No outputs defined");
36+
});
37+
});
38+
});

src/lib/deployment/deployment-utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,11 @@ export function parseCdkOutputs(stdout: string): string {
222222
return `Error parsing CDK outputs: ${error instanceof Error ? error.message : String(error)}`;
223223
}
224224
}
225+
226+
export type DeploymentEventType = "header" | "command" | "output" | "error" | "success" | "warning";
227+
228+
export interface DeploymentEvent {
229+
type: DeploymentEventType;
230+
title?: string;
231+
content: string;
232+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { generateIamPolicy, deduplicatePermissions } from "./iam-policy.logic";
2+
import type { LogEntry } from "../logs/log-retriever";
3+
4+
describe("iam-policy.logic", () => {
5+
describe("generateIamPolicy", () => {
6+
it("should generate a valid identity-based policy without a Principal key", () => {
7+
const denials: LogEntry[] = [
8+
{
9+
iamPrincipal: "lambda.amazonaws.com",
10+
iamAction: "s3:GetObject",
11+
iamResource: "arn:aws:s3:::my-bucket/*",
12+
message: "",
13+
fullLine: "",
14+
isApiCall: false,
15+
isError: false,
16+
isWarning: false,
17+
},
18+
{
19+
iamPrincipal: "lambda.amazonaws.com",
20+
iamAction: "s3:PutObject",
21+
iamResource: "arn:aws:s3:::my-bucket/*",
22+
message: "",
23+
fullLine: "",
24+
isApiCall: false,
25+
isError: false,
26+
isWarning: false,
27+
},
28+
{
29+
iamPrincipal: "ecs-tasks.amazonaws.com",
30+
iamAction: "sqs:SendMessage",
31+
iamResource: "arn:aws:sqs:us-east-1:000:my-queue",
32+
message: "",
33+
fullLine: "",
34+
isApiCall: false,
35+
isError: false,
36+
isWarning: false,
37+
},
38+
] as any;
39+
40+
const permissions = deduplicatePermissions(denials);
41+
const policy = generateIamPolicy(permissions);
42+
43+
expect(policy.Version).toBe("2012-10-17");
44+
expect(policy.Statement.length).toBe(2);
45+
for (const s of policy.Statement) {
46+
expect((s as any).Principal).toBeUndefined();
47+
}
48+
49+
const s3Statement = policy.Statement.find((s: any) =>
50+
String(s.Resource).includes("s3")
51+
) as any;
52+
expect(s3Statement.Action).toEqual(["s3:GetObject", "s3:PutObject"]);
53+
});
54+
});
55+
});

0 commit comments

Comments
 (0)