From 800d47845f7d1ce8ecc2999ca8e753fec2ceaf9d Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Feb 2026 19:45:45 -0500 Subject: [PATCH] refactor: migrate to directory-based stack structure - Updated ComposeService to run docker commands from stack-specific directories, ensuring relative paths resolve correctly. - Refactored FileSystemService to manage stacks as directories, including methods for creating, updating, and deleting stacks. - Implemented automatic migration of existing flat-file stacks to the new directory structure on server startup. - Adjusted API routes to use stack names instead of filenames, simplifying stack management. - Modified frontend components to align with the new stack naming conventions and removed unnecessary file extensions. --- backend/src/index.ts | 107 +++++---- backend/src/services/ComposeService.ts | 40 +++- backend/src/services/FileSystemService.ts | 250 ++++++++++++++++++---- docker-compose.yml | 18 +- frontend/src/components/EditorLayout.tsx | 18 +- frontend/src/components/HomeDashboard.tsx | 7 +- 6 files changed, 321 insertions(+), 119 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index ba8037b6..be7cc283 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,9 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-producti // ConfigService for persistent auth storage const configService = new ConfigService(); +// FileSystemService for stack management +const fileSystemService = new FileSystemService(); + // Cookie settings const COOKIE_NAME = 'sencho_token'; const COOKIE_OPTIONS = { @@ -249,67 +252,67 @@ app.get('/api/containers', async (req: Request, res: Response) => { } }); -const fileSystemService = new FileSystemService(); +// Stack Routes - Updated to use stackName (directory name) instead of filename app.get('/api/stacks', async (req: Request, res: Response) => { try { - const files = await fileSystemService.getStackFiles(); - res.json(files); + const stacks = await fileSystemService.getStacks(); + res.json(stacks); } catch (error) { - res.status(500).json({ error: 'Failed to fetch stack files' }); + res.status(500).json({ error: 'Failed to fetch stacks' }); } }); -app.get('/api/stacks/:filename', async (req: Request, res: Response) => { +app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - const content = await fileSystemService.getStackContent(filename); + const stackName = req.params.stackName as string; + const content = await fileSystemService.getStackContent(stackName); res.send(content); } catch (error) { - res.status(500).json({ error: 'Failed to read file' }); + res.status(500).json({ error: 'Failed to read stack' }); } }); -app.put('/api/stacks/:filename', async (req: Request, res: Response) => { +app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; + const stackName = req.params.stackName as string; const { content } = req.body; - console.log('PUT /api/stacks/:filename', { filename, contentType: typeof content, contentLength: content?.length }); + console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { console.error('Content is not a string:', content); return res.status(400).json({ error: 'Content must be a string' }); } - await fileSystemService.saveStackContent(filename, content); - console.log('File saved successfully:', filename); - res.json({ message: 'File saved successfully' }); + await fileSystemService.saveStackContent(stackName, content); + console.log('Stack saved successfully:', stackName); + res.json({ message: 'Stack saved successfully' }); } catch (error) { - console.error('Failed to save file:', error); - res.status(500).json({ error: 'Failed to save file' }); + console.error('Failed to save stack:', error); + res.status(500).json({ error: 'Failed to save stack' }); } }); -app.get('/api/stacks/:filename/env', async (req: Request, res: Response) => { +app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - const exists = await fileSystemService.envExists(filename); + const stackName = req.params.stackName as string; + const exists = await fileSystemService.envExists(stackName); if (!exists) { return res.status(404).json({ error: 'Env file not found' }); } - const content = await fileSystemService.getEnvContent(filename); + const content = await fileSystemService.getEnvContent(stackName); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read env file' }); } }); -app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => { +app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; + const stackName = req.params.stackName as string; const { content } = req.body; if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); } - await fileSystemService.saveEnvContent(filename, content); + await fileSystemService.saveEnvContent(stackName, content); res.json({ message: 'Env file saved successfully' }); } catch (error) { console.error('Failed to save env file:', error); @@ -319,11 +322,11 @@ app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => { app.post('/api/stacks', async (req: Request, res: Response) => { try { - const { filename } = req.body; - if (!filename || typeof filename !== 'string') { - return res.status(400).json({ error: 'Filename is required and must be a string' }); + const { stackName } = req.body; + if (!stackName || typeof stackName !== 'string') { + return res.status(400).json({ error: 'Stack name is required and must be a string' }); } - await fileSystemService.createStack(filename); + await fileSystemService.createStack(stackName); res.json({ message: 'Stack created successfully' }); } catch (error) { console.error('Failed to create stack:', error); @@ -331,10 +334,10 @@ app.post('/api/stacks', async (req: Request, res: Response) => { } }); -app.delete('/api/stacks/:filename', async (req: Request, res: Response) => { +app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - await fileSystemService.deleteStack(filename); + const stackName = req.params.stackName as string; + await fileSystemService.deleteStack(stackName); res.json({ message: 'Stack deleted successfully' }); } catch (error) { console.error('Failed to delete stack:', error); @@ -342,10 +345,9 @@ app.delete('/api/stacks/:filename', async (req: Request, res: Response) => { } }); -app.get('/api/stacks/:filename/containers', async (req: Request, res: Response) => { +app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - const stackName = filename.replace(/\.yml$/, ''); + const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(); const containers = await dockerController.getContainersByStack(stackName); res.json(containers); @@ -389,20 +391,20 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { const composeService = new ComposeService(); -app.post('/api/stacks/:filename/up', async (req: Request, res: Response) => { +app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - composeService.runCommand(filename, 'up', terminalWs || undefined); + const stackName = req.params.stackName as string; + composeService.runCommand(stackName, 'up', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); } }); -app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => { +app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; - composeService.runCommand(filename, 'down', terminalWs || undefined); + const stackName = req.params.stackName as string; + composeService.runCommand(stackName, 'down', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); @@ -410,11 +412,11 @@ app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => { }); // Update stack: pull images and recreate containers -app.post('/api/stacks/:filename/update', async (req: Request, res: Response) => { +app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { try { - const filename = req.params.filename as string; + const stackName = req.params.stackName as string; // Run update asynchronously, don't wait for completion - composeService.updateStack(filename, terminalWs || undefined).catch(error => { + composeService.updateStack(stackName, terminalWs || undefined).catch(error => { console.error('Update stack error:', error); }); res.json({ status: 'Update started' }); @@ -505,6 +507,21 @@ if (process.env.NODE_ENV === 'production') { }); } -server.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); -}); +// Start server with migration +async function startServer() { + try { + // Run migration before starting server + console.log('Running stack migration check...'); + await fileSystemService.migrateFlatToDirectory(); + console.log('Migration check completed'); + } catch (error) { + console.error('Migration failed:', error); + // Continue starting server even if migration fails + } + + server.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +} + +startServer(); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index aee46435..4d9c70b9 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -9,11 +9,24 @@ export class ComposeService { this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose'); } - runCommand(filename: string, action: 'up' | 'down', ws?: WebSocket) { - const filePath = path.join(this.baseDir, filename); - const args = action === 'up' ? ['compose', '-f', filePath, 'up', '-d'] : ['compose', '-f', filePath, 'down']; + /** + * Run docker compose up or down command + * CRITICAL: cwd is set to the stack directory so relative paths in compose files + * resolve correctly inside the isolated stack folder + */ + runCommand(stackName: string, action: 'up' | 'down', ws?: WebSocket) { + const stackDir = path.join(this.baseDir, stackName); + + // Run docker compose from within the stack directory + // This ensures relative paths (e.g., ./data:/config) resolve correctly + const args = action === 'up' + ? ['compose', 'up', '-d'] + : ['compose', 'down']; - const child = spawn('docker', args, { shell: true }); + const child = spawn('docker', args, { + cwd: stackDir, // CRITICAL: Set working directory to stack folder + shell: true + }); if (ws) { child.stdout.on('data', (data: Buffer) => { @@ -34,9 +47,12 @@ export class ComposeService { } } - // Update command: pull images first, then recreate containers - async updateStack(filename: string, ws?: WebSocket): Promise { - const filePath = path.join(this.baseDir, filename); + /** + * Update stack: pull images first, then recreate containers + * CRITICAL: cwd is set to the stack directory so relative paths resolve correctly + */ + async updateStack(stackName: string, ws?: WebSocket): Promise { + const stackDir = path.join(this.baseDir, stackName); const sendOutput = (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) { @@ -47,7 +63,10 @@ export class ComposeService { // Step 1: Pull images sendOutput('=== Pulling latest images ===\n'); await new Promise((resolve, reject) => { - const pullProcess = spawn('docker', ['compose', '-f', filePath, 'pull'], { shell: true }); + const pullProcess = spawn('docker', ['compose', 'pull'], { + cwd: stackDir, + shell: true + }); pullProcess.stdout.on('data', (data: Buffer) => { sendOutput(data.toString()); @@ -76,7 +95,10 @@ export class ComposeService { // Step 2: Recreate containers with new images sendOutput('=== Recreating containers ===\n'); await new Promise((resolve, reject) => { - const upProcess = spawn('docker', ['compose', '-f', filePath, 'up', '-d'], { shell: true }); + const upProcess = spawn('docker', ['compose', 'up', '-d'], { + cwd: stackDir, + shell: true + }); upProcess.stdout.on('data', (data: Buffer) => { sendOutput(data.toString()); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 7eba0822..1bb56dd0 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,4 +1,4 @@ -import { promises as fs } from 'fs'; +import { promises as fs, Dirent } from 'fs'; import path from 'path'; export class FileSystemService { @@ -8,41 +8,109 @@ export class FileSystemService { this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose'); } - async getStackFiles(): Promise { + /** + * Check if a directory contains a valid compose file + */ + private async hasComposeFile(dir: string): Promise { + const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; + + for (const file of composeFiles) { + try { + await fs.access(path.join(dir, file)); + return true; + } catch { + // Continue checking other options + } + } + + return false; + } + + /** + * Get the path to the compose file for a stack + * Throws if no compose file is found + */ + private async getComposeFilePath(stackName: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; + + for (const file of composeFiles) { + const filePath = path.join(stackDir, file); + try { + await fs.access(filePath); + return filePath; + } catch { + // Continue checking other options + } + } + + throw new Error(`No compose file found for stack: ${stackName}`); + } + + /** + * Get all stacks (directories containing compose files) + * Returns array of stack names (directory names) + */ + async getStacks(): Promise { try { - const files = await fs.readdir(this.baseDir); - return files.filter(file => file.endsWith('.yml') || file.endsWith('.yaml')); + const items = await fs.readdir(this.baseDir, { withFileTypes: true }); + const stackNames: string[] = []; + + for (const item of items) { + if (!item.isDirectory()) continue; + + const stackDir = path.join(this.baseDir, item.name); + const hasCompose = await this.hasComposeFile(stackDir); + + if (hasCompose) { + stackNames.push(item.name); + } + } + + return stackNames; } catch (error) { - console.error('Error reading stack files:', error); + console.error('Error reading stacks:', error); return []; } } - async getStackContent(filename: string): Promise { - const filePath = path.join(this.baseDir, filename); + /** + * Get the content of a stack's compose file + */ + async getStackContent(stackName: string): Promise { try { + const filePath = await this.getComposeFilePath(stackName); return await fs.readFile(filePath, 'utf-8'); } catch (error) { - console.error('Error reading file:', error); - throw new Error(`Failed to read file: ${filename}`); + console.error('Error reading stack content:', error); + throw new Error(`Failed to read stack: ${stackName}`); } } - async saveStackContent(filename: string, content: string): Promise { - const filePath = path.join(this.baseDir, filename); + /** + * Save content to a stack's compose file + * Always writes to compose.yaml (standardizing on this filename) + */ + async saveStackContent(stackName: string, content: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + const filePath = path.join(stackDir, 'compose.yaml'); + console.log('Saving to path:', filePath); + try { await fs.writeFile(filePath, content, 'utf-8'); console.log('File written successfully'); } catch (error) { console.error('Error writing file:', error); - throw new Error(`Failed to save file: ${filename}`); + throw new Error(`Failed to save stack: ${stackName}`); } } - async envExists(filename: string): Promise { - const envFilename = filename.replace(/\.yml$/, '.env'); - const envPath = path.join(this.baseDir, envFilename); + /** + * Check if a stack has an .env file + */ + async envExists(stackName: string): Promise { + const envPath = path.join(this.baseDir, stackName, '.env'); try { await fs.access(envPath); return true; @@ -51,66 +119,156 @@ export class FileSystemService { } } - async getEnvContent(filename: string): Promise { - const envFilename = filename.replace(/\.yml$/, '.env'); - const envPath = path.join(this.baseDir, envFilename); + /** + * Get the content of a stack's .env file + */ + async getEnvContent(stackName: string): Promise { + const envPath = path.join(this.baseDir, stackName, '.env'); try { return await fs.readFile(envPath, 'utf-8'); } catch (error) { console.error('Error reading env file:', error); - throw new Error(`Failed to read env file for: ${filename}`); + throw new Error(`Failed to read env file for stack: ${stackName}`); } } - async saveEnvContent(filename: string, content: string): Promise { - const envFilename = filename.replace(/\.yml$/, '.env'); - const envPath = path.join(this.baseDir, envFilename); + /** + * Save content to a stack's .env file + */ + async saveEnvContent(stackName: string, content: string): Promise { + const envPath = path.join(this.baseDir, stackName, '.env'); console.log('Saving env to path:', envPath); + try { await fs.writeFile(envPath, content, 'utf-8'); console.log('Env file written successfully'); } catch (error) { console.error('Error writing env file:', error); - throw new Error(`Failed to save env file for: ${filename}`); + throw new Error(`Failed to save env file for stack: ${stackName}`); } } - async createStack(filename: string): Promise { - if (!filename.endsWith('.yml')) { - throw new Error('Filename must end with .yml'); + /** + * Create a new stack (directory with boilerplate compose.yaml) + */ + async createStack(stackName: string): Promise { + // Validate stack name (no special characters, not empty) + if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { + throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens'); } - const filePath = path.join(this.baseDir, filename); - const boilerplate = `version: '3.8' - -services: + + const stackDir = path.join(this.baseDir, stackName); + + // Check if directory already exists + try { + await fs.access(stackDir); + throw new Error(`Stack "${stackName}" already exists`); + } catch (error: any) { + if (error.message.includes('already exists')) { + throw error; + } + // Directory doesn't exist, proceed + } + + // Create the directory + await fs.mkdir(stackDir, { recursive: true }); + + // Write boilerplate compose.yaml + const composePath = path.join(stackDir, 'compose.yaml'); + const boilerplate = `services: # Add your services here `; try { - await fs.writeFile(filePath, boilerplate, 'utf-8'); - console.log('Stack file created successfully:', filename); + await fs.writeFile(composePath, boilerplate, 'utf-8'); + console.log('Stack created successfully:', stackName); } catch (error) { - console.error('Error creating stack file:', error); - throw new Error(`Failed to create stack file: ${filename}`); + console.error('Error creating stack:', error); + throw new Error(`Failed to create stack: ${stackName}`); } } - async deleteStack(filename: string): Promise { - const filePath = path.join(this.baseDir, filename); - const envFilename = filename.replace(/\.yml$/, '.env'); - const envPath = path.join(this.baseDir, envFilename); + /** + * Delete a stack (entire directory and its contents) + */ + async deleteStack(stackName: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + try { - await fs.unlink(filePath); - console.log('Stack file deleted successfully:', filename); - // Try to delete env file if it exists + await fs.rm(stackDir, { recursive: true, force: true }); + console.log('Stack deleted successfully:', stackName); + } catch (error) { + console.error('Error deleting stack:', error); + throw new Error(`Failed to delete stack: ${stackName}`); + } + } + + /** + * Get the base directory path for stacks + */ + getBaseDir(): string { + return this.baseDir; + } + + /** + * Migrate existing flat-file stacks to directory-based structure + * This runs automatically on server startup + */ + async migrateFlatToDirectory(): Promise { + try { + // Ensure base directory exists try { - await fs.unlink(envPath); - console.log('Associated env file deleted:', envFilename); + await fs.access(this.baseDir); } catch { - // Env file doesn't exist, ignore + console.log('Creating compose directory:', this.baseDir); + await fs.mkdir(this.baseDir, { recursive: true }); + return; // No files to migrate in a new directory + } + + const items = await fs.readdir(this.baseDir, { withFileTypes: true }); + + for (const item of items) { + // Only process .yml/.yaml files (skip directories and other files) + if (!item.isFile()) continue; + if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue; + + const stackName = item.name.replace(/\.(yml|yaml)$/, ''); + const stackDir = path.join(this.baseDir, stackName); + + // Check if target directory already exists + try { + await fs.access(stackDir); + console.log(`Skipping migration for "${stackName}": directory already exists`); + continue; + } catch { + // Directory doesn't exist, proceed with migration + } + + console.log(`Migrating stack: ${stackName}`); + + // Create the stack directory + await fs.mkdir(stackDir, { recursive: true }); + + // Move compose file to new location (standardize on compose.yaml) + const oldComposePath = path.join(this.baseDir, item.name); + const newComposePath = path.join(stackDir, 'compose.yaml'); + await fs.rename(oldComposePath, newComposePath); + + // Move env file if it exists (old pattern: stackname.env) + const oldEnvPath = path.join(this.baseDir, `${stackName}.env`); + const newEnvPath = path.join(stackDir, '.env'); + try { + await fs.access(oldEnvPath); + await fs.rename(oldEnvPath, newEnvPath); + console.log(`Migrated env file for: ${stackName}`); + } catch { + // No env file to migrate, that's fine + } + + console.log(`Successfully migrated stack: ${stackName}`); } } catch (error) { - console.error('Error deleting stack file:', error); - throw new Error(`Failed to delete stack file: ${filename}`); + console.error('Migration error:', error); + // Don't throw - allow the server to start even if migration fails } } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 50b659af..c1dcaf0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,23 @@ services: sencho: + image: saelix/sencho:latest build: . container_name: sencho + restart: unless-stopped ports: - "3000:3000" volumes: - # Docker socket for managing containers - /var/run/docker.sock:/var/run/docker.sock - # Compose files directory - - ${COMPOSE_DIR:-./mock_data/docker/compose}:/app/compose - # Persistent data directory for auth config - ./data:/app/data + + # Stacks Directory + # Left Side = Path on your host server (Change this to your actual folder) + # Right Side = Path inside Sencho (Do NOT change) + - /path/to/your/docker/compose:/app/compose + environment: - - NODE_ENV=production - - JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production} + # Tell Sencho's backend where to look inside the container - COMPOSE_DIR=/app/compose - DATA_DIR=/app/data - restart: unless-stopped + # Generate a secure random string for this in production + - JWT_SECRET=change-me-in-production diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1d412fd0..73a77028 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -356,11 +356,12 @@ export default function EditorLayout() { const handleCreateStack = async () => { if (!newStackName.trim()) return; - const filename = newStackName.endsWith('.yml') ? newStackName : newStackName + '.yml'; + // Send stackName directly (no .yml extension - backend creates directory) + const stackName = newStackName.trim(); try { const response = await apiFetch('/stacks', { method: 'POST', - body: JSON.stringify({ filename }), + body: JSON.stringify({ stackName }), }); if (!response.ok) throw new Error('Failed to create stack'); setCreateDialogOpen(false); @@ -388,18 +389,17 @@ export default function EditorLayout() { const safeContent = content || ''; const safeEnvContent = envContent || ''; - // Get stack name without extension - const stackName = selectedFile ? selectedFile.replace('.yml', '').replace('.yaml', '') : ''; + // Stack name is now the same as selectedFile (no extension to strip) + const stackName = selectedFile || ''; // Filter files based on search query const filteredFiles = files.filter(file => { - const nameWithoutExt = file.replace('.yml', '').replace('.yaml', '').toLowerCase(); - return nameWithoutExt.includes(searchQuery.toLowerCase()); + return file.toLowerCase().includes(searchQuery.toLowerCase()); }); - // Get display name for stack (without extension) - const getDisplayName = (filename: string) => { - return filename.replace('.yml', '').replace('.yaml', ''); + // Get display name for stack (now just returns the name as-is since no extension) + const getDisplayName = (stackName: string) => { + return stackName; }; return ( diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 7ed760ee..87499149 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -104,17 +104,18 @@ export default function HomeDashboard() { const handleCreateStack = async () => { if (!newStackName.trim() || !convertedYaml) return; - const filename = newStackName.endsWith('.yml') ? newStackName : newStackName + '.yml'; + // Send stackName directly (no .yml extension - backend creates directory) + const stackName = newStackName.trim(); try { // Create the stack const createResponse = await apiFetch('/stacks', { method: 'POST', - body: JSON.stringify({ filename }), + body: JSON.stringify({ stackName }), }); if (!createResponse.ok) throw new Error('Failed to create stack'); // Save the converted YAML content - const saveResponse = await apiFetch(`/stacks/${filename}`, { + const saveResponse = await apiFetch(`/stacks/${stackName}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), });