@@ -2,7 +2,10 @@ import { z } from "zod";
22import { type ToolMetadata , type InferSchema } from "xmcp" ;
33import { runCommand , stripAnsiCodes } from "../core/command-runner" ;
44import path from "path" ;
5+ import fs from "fs" ;
56import { ensureLocalStackCli } from "../lib/localstack/localstack.utils" ;
7+ import { runPreflights , requireLocalStackRunning } from "../core/preflight" ;
8+ import { DockerApiClient } from "../lib/docker/docker.client" ;
69import {
710 checkDependencies ,
811 inferProjectType ,
@@ -18,18 +21,19 @@ import { ResponseBuilder } from "../core/response-builder";
1821// Define the schema for tool parameters
1922export const schema = {
2023 action : z
21- . enum ( [ "deploy" , "destroy" ] )
24+ . enum ( [ "deploy" , "destroy" , "create-stack" , "delete-stack" ] )
2225 . describe (
23- "The deployment action to perform: 'deploy' to create/update resources , or 'destroy' to remove them ."
26+ "The action to perform: 'deploy'/'destroy' for CDK/Terraform , or 'create-stack'/'delete-stack' for CloudFormation ."
2427 ) ,
2528 projectType : z
26- . enum ( [ "cdk" , "terraform" , "auto" ] )
29+ . enum ( [ "cdk" , "terraform" , "auto" ] )
2730 . default ( "auto" )
2831 . describe (
2932 "The type of project. 'auto' (default) infers from files. Specify 'cdk' or 'terraform' to override."
3033 ) ,
3134 directory : z
3235 . string ( )
36+ . optional ( )
3337 . describe (
3438 "The required path to the project directory containing your infrastructure-as-code files."
3539 ) ,
@@ -39,6 +43,14 @@ export const schema = {
3943 . describe (
4044 "Key-value pairs for parameterization. Used for Terraform variables (-var) or CDK context (-c)."
4145 ) ,
46+ stackName : z
47+ . string ( )
48+ . optional ( )
49+ . describe ( "The name of the CloudFormation stack. Required for 'create-stack' and 'delete-stack'." ) ,
50+ templatePath : z
51+ . string ( )
52+ . optional ( )
53+ . describe ( "The local file path to the CloudFormation template. Required for 'create-stack' if not discoverable from 'directory'." ) ,
4254} ;
4355
4456// Define tool metadata
@@ -59,17 +71,174 @@ export default async function localstackDeployer({
5971 projectType,
6072 directory,
6173 variables,
74+ stackName,
75+ templatePath,
6276} : InferSchema < typeof schema > ) {
63- // Check if LocalStack CLI is available first
64- const cliError = await ensureLocalStackCli ( ) ;
65- if ( cliError ) return cliError ;
77+ if ( action === "deploy" || action === "destroy" ) {
78+ const cliError = await ensureLocalStackCli ( ) ;
79+ if ( cliError ) return cliError ;
80+ } else {
81+ const preflightError = await runPreflights ( [ requireLocalStackRunning ( ) ] ) ;
82+ if ( preflightError ) return preflightError ;
83+ }
84+
85+ if ( action === "create-stack" ) {
86+ if ( ! stackName ) {
87+ return ResponseBuilder . error (
88+ "Missing Parameter" ,
89+ "The parameter 'stackName' is required for action 'create-stack'."
90+ ) ;
91+ }
92+ let resolvedTemplatePath = templatePath ;
93+ if ( ! resolvedTemplatePath ) {
94+ if ( ! directory ) {
95+ return ResponseBuilder . error (
96+ "Missing Parameter" ,
97+ "Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template."
98+ ) ;
99+ }
100+ try {
101+ const files = await fs . promises . readdir ( directory ) ;
102+ const yamlFiles = files . filter ( ( f ) => f . endsWith ( ".yaml" ) || f . endsWith ( ".yml" ) ) ;
103+ if ( yamlFiles . length === 0 ) {
104+ return ResponseBuilder . error (
105+ "Template Not Found" ,
106+ `No .yaml/.yml template found in directory '${ directory } '.`
107+ ) ;
108+ }
109+ if ( yamlFiles . length > 1 ) {
110+ return ResponseBuilder . error (
111+ "Multiple Templates Found" ,
112+ `Multiple .yaml/.yml templates found in '${ directory } '. Please specify 'templatePath'.\n\nFound:\n${ yamlFiles
113+ . map ( ( f ) => `- ${ f } ` )
114+ . join ( "\n" ) } `
115+ ) ;
116+ }
117+ resolvedTemplatePath = path . join ( directory , yamlFiles [ 0 ] ) ;
118+ } catch ( err ) {
119+ const message = err instanceof Error ? err . message : String ( err ) ;
120+ return ResponseBuilder . error (
121+ "Directory Read Error" ,
122+ `Failed to read directory '${ directory } '. ${ message } `
123+ ) ;
124+ }
125+ }
126+
127+ let templateBody = "" ;
128+ try {
129+ templateBody = await fs . promises . readFile ( resolvedTemplatePath , "utf-8" ) ;
130+ } catch ( err ) {
131+ const message = err instanceof Error ? err . message : String ( err ) ;
132+ return ResponseBuilder . error (
133+ "Template Read Error" ,
134+ `Failed to read template file at '${ resolvedTemplatePath } '. ${ message } `
135+ ) ;
136+ }
137+
138+ try {
139+ const dockerClient = new DockerApiClient ( ) ;
140+ const containerId = await dockerClient . findLocalStackContainer ( ) ;
141+
142+ const tempPath = `/tmp/ls-cfn-${ Date . now ( ) } .yaml` ;
143+ const writeRes = await dockerClient . executeInContainer (
144+ containerId ,
145+ [ "/bin/sh" , "-c" , `cat > ${ tempPath } ` ] ,
146+ templateBody
147+ ) ;
148+ if ( writeRes . exitCode !== 0 ) {
149+ return ResponseBuilder . error (
150+ "Template Upload Failed" ,
151+ writeRes . stderr || `Failed to write template to ${ tempPath } `
152+ ) ;
153+ }
154+
155+ const createCmd = [
156+ "awslocal" ,
157+ "cloudformation" ,
158+ "create-stack" ,
159+ "--stack-name" ,
160+ stackName ,
161+ "--template-body" ,
162+ `file://${ tempPath } ` ,
163+ ] ;
164+ const createRes = await dockerClient . executeInContainer ( containerId , createCmd ) ;
165+
166+ try {
167+ await dockerClient . executeInContainer ( containerId , [ "/bin/sh" , "-c" , `rm -f ${ tempPath } ` ] ) ;
168+ } catch { }
169+
170+ if ( createRes . exitCode === 0 ) {
171+ return ResponseBuilder . markdown (
172+ ( createRes . stdout && createRes . stdout . trim ( ) )
173+ ? createRes . stdout
174+ : `Stack '${ stackName } ' creation initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor stack status and wait for CREATE_COMPLETE.`
175+ ) ;
176+ }
177+ return ResponseBuilder . error (
178+ "CloudFormation create-stack failed" ,
179+ createRes . stderr || "Unknown error"
180+ ) ;
181+ } catch ( error ) {
182+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
183+ return ResponseBuilder . error (
184+ "CloudFormation Error" ,
185+ `An unexpected error occurred: ${ errorMessage } `
186+ ) ;
187+ }
188+ }
189+
190+ if ( action === "delete-stack" ) {
191+ if ( ! stackName ) {
192+ return ResponseBuilder . error (
193+ "Missing Parameter" ,
194+ "The parameter 'stackName' is required for action 'delete-stack'."
195+ ) ;
196+ }
197+ try {
198+ const dockerClient = new DockerApiClient ( ) ;
199+ const containerId = await dockerClient . findLocalStackContainer ( ) ;
200+ const command = [
201+ "awslocal" ,
202+ "cloudformation" ,
203+ "delete-stack" ,
204+ "--stack-name" ,
205+ stackName ,
206+ ] ;
207+ const result = await dockerClient . executeInContainer ( containerId , command ) ;
208+ if ( result . exitCode === 0 ) {
209+ return ResponseBuilder . markdown (
210+ ( result . stdout && result . stdout . trim ( ) )
211+ ? result . stdout
212+ : `Stack '${ stackName } ' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.`
213+ ) ;
214+ }
215+ return ResponseBuilder . error (
216+ "CloudFormation delete-stack failed" ,
217+ result . stderr || "Unknown error"
218+ ) ;
219+ } catch ( error ) {
220+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
221+ return ResponseBuilder . error (
222+ "CloudFormation Error" ,
223+ `An unexpected error occurred: ${ errorMessage } `
224+ ) ;
225+ }
226+ }
66227
67228 let resolvedProjectType : "cdk" | "terraform" ;
68229
69230 try {
231+ if ( ! directory ) {
232+ return ResponseBuilder . error (
233+ "Missing Parameter" ,
234+ "The parameter 'directory' is required for actions 'deploy' and 'destroy'."
235+ ) ;
236+ }
237+ const nonNullDirectory = directory as string ;
238+
70239 // Step 1: Project Type Resolution
71240 if ( projectType === "auto" ) {
72- const inferredType = await inferProjectType ( directory ) ;
241+ const inferredType = await inferProjectType ( nonNullDirectory ) ;
73242
74243 if ( inferredType === "ambiguous" ) {
75244 return ResponseBuilder . error (
@@ -121,7 +290,12 @@ Please review your variables and ensure they don't contain shell metacharacters
121290 }
122291
123292 // Execute Commands Based on Project Type and Action
124- return await executeDeploymentCommands ( resolvedProjectType , action , directory , variables ) ;
293+ return await executeDeploymentCommands (
294+ resolvedProjectType ,
295+ action ,
296+ nonNullDirectory ,
297+ variables
298+ ) ;
125299 } catch ( error ) {
126300 const errorMessage = error instanceof Error ? error . message : String ( error ) ;
127301 return ResponseBuilder . error (
0 commit comments