From b22b94b13a71d3ab13b387476bc85937a9855763 Mon Sep 17 00:00:00 2001 From: Felix Schneider Date: Thu, 14 Aug 2025 15:00:32 +0200 Subject: [PATCH 1/2] test: verify fs file operations --- backend/src/services/export.ts | 18 +- backend/src/services/file.test.ts | 42 +++ backend/src/services/file.ts | 457 +++++------------------------- backend/src/services/package.ts | 14 +- 4 files changed, 127 insertions(+), 404 deletions(-) create mode 100644 backend/src/services/file.test.ts diff --git a/backend/src/services/export.ts b/backend/src/services/export.ts index 2c88728..66607dd 100644 --- a/backend/src/services/export.ts +++ b/backend/src/services/export.ts @@ -5,37 +5,29 @@ import { promisify } from "util"; const execAsync = promisify(exec); -export async function exportContainerCode( - containerId: string -): Promise { - const tempDir = `/tmp/export-${containerId}-${Date.now()}`; +export async function exportProjectCode(projectPath: string): Promise { + const tempDir = `/tmp/export-${Date.now()}`; const zipPath = `${tempDir}.zip`; try { + await execAsync("pnpm build", { cwd: projectPath }); await fs.mkdir(tempDir, { recursive: true }); - - const copyCommand = `docker cp ${containerId}:/app/my-nextjs-app/. ${tempDir}/`; - await execAsync(copyCommand); + await execAsync(`cp -R ${projectPath}/. ${tempDir}/`); const nodeModulesPath = path.join(tempDir, "node_modules"); const nextPath = path.join(tempDir, ".next"); - try { await fs.rm(nodeModulesPath, { recursive: true, force: true }); } catch {} - try { await fs.rm(nextPath, { recursive: true, force: true }); } catch {} - const zipCommand = `cd ${tempDir} && zip -r ${zipPath} . -x "*.DS_Store"`; - await execAsync(zipCommand); - + await execAsync(`zip -r ${zipPath} . -x "*.DS_Store"`, { cwd: tempDir }); const zipBuffer = await fs.readFile(zipPath); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(zipPath, { force: true }); - return zipBuffer; } catch (error) { try { diff --git a/backend/src/services/file.test.ts b/backend/src/services/file.test.ts new file mode 100644 index 0000000..61d9385 --- /dev/null +++ b/backend/src/services/file.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { + getFileTree, + getFileContentTree, + writeFile, + readFile, + renameFile, + removeFile, +} from "./file"; + +test("file operations without Docker", async () => { + const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "project-")); + + try { + await writeFile(projectPath, "dir/sample.txt", "hello"); + const content = await readFile(projectPath, "dir/sample.txt"); + expect(content).toBe("hello"); + + await renameFile(projectPath, "dir/sample.txt", "dir/renamed.txt"); + const renamed = await readFile(projectPath, "dir/renamed.txt"); + expect(renamed).toBe("hello"); + + const tree = await getFileTree(projectPath); + expect(tree.length).toBe(1); + expect(tree[0].name).toBe("dir"); + expect(tree[0].children?.[0].name).toBe("renamed.txt"); + + const contentTree = await getFileContentTree(projectPath); + const fileItem = contentTree[0].children?.[0]; + expect(fileItem?.content).toBe("hello"); + + await removeFile(projectPath, "dir/renamed.txt"); + const treeAfter = await getFileTree(projectPath); + expect(treeAfter[0].children?.length).toBe(0); + } finally { + await fs.rm(projectPath, { recursive: true, force: true }); + } +}); diff --git a/backend/src/services/file.ts b/backend/src/services/file.ts index 588c2c9..64fd104 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -1,15 +1,5 @@ -import { exec } from "child_process"; -import Docker from "dockerode"; import fs from "fs/promises"; -import { promisify } from "util"; -import { v4 as uuidv4 } from "uuid"; - -const execAsync = promisify(exec); -const BASE_PATH = "/app/my-nextjs-app"; - -function getAbsolutePath(filePath: string): string { - return filePath.startsWith("/") ? filePath : `${BASE_PATH}/${filePath}`; -} +import path from "path"; export interface FileItem { name: string; @@ -27,417 +17,114 @@ export interface FileContentItem { children?: FileContentItem[]; } -export async function getFileTree( - docker: Docker, - containerId: string, - containerPath: string = BASE_PATH -): Promise { - const container = docker.getContainer(containerId); - - const findCommand = [ - "sh", - "-c", - `find ${containerPath} \\( -name node_modules -o -name .next \\) -prune -o -type f -o -type d | grep -v -E "(node_modules|\\.next)" | sort`, - ]; - - const exec = await container.exec({ - Cmd: findCommand, - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await exec.start({ Detach: false, Tty: false }); - const output = await new Promise((resolve, reject) => { - let data = ""; - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); - stream.on("end", () => resolve(data)); - stream.on("error", reject); - }); - - const paths = output - .trim() - .split("\n") - .filter((p) => p && p !== containerPath); - const fileTree: Map = new Map(); - - fileTree.set(containerPath, { - name: "root", - path: containerPath, - type: "directory", - children: [], - }); - - for (const filePath of paths) { - const stat = await getFileStat(container, filePath); - const relativePath = filePath.replace(containerPath + "/", ""); - const parts = relativePath.split("/"); - const fileName = parts[parts.length - 1] || ""; - - const fileItem: FileItem = { - name: fileName, - path: filePath, - type: stat.isDirectory ? "directory" : "file", - }; - - if (stat.isDirectory) { - fileItem.children = []; - } - - fileTree.set(filePath, fileItem); - - const parentPath = filePath.substring(0, filePath.lastIndexOf("/")); - const parent = fileTree.get(parentPath || containerPath); - if (parent && parent.children) { - parent.children.push(fileItem); - } - } - - const root = fileTree.get(containerPath); - return root?.children || []; +function resolvePath(projectPath: string, filePath: string): string { + return path.isAbsolute(filePath) + ? filePath + : path.join(projectPath, filePath); } -export async function getFileContentTree( - docker: Docker, - containerId: string, - containerPath: string = BASE_PATH -): Promise { - const container = docker.getContainer(containerId); - - const findCommand = [ - "sh", - "-c", - `find ${containerPath} \\( -name node_modules -o -name .next -o -path "*/components/ui" \\) -prune -o -type f -o -type d | grep -v -E "(node_modules|\\.next|components/ui|bun\\.lock|components\\.json|next-env\\.d\\.ts|package-lock\\.json|postcss\\.config\\.mjs|favicon\\.ico|\\.gitignore)" | sort`, - ]; - - const exec = await container.exec({ - Cmd: findCommand, - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await exec.start({ Detach: false, Tty: false }); - const output = await new Promise((resolve, reject) => { - let data = ""; - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); - stream.on("end", () => resolve(data)); - stream.on("error", reject); - }); - - const paths = output - .trim() - .split("\n") - .filter((p) => p && p !== containerPath); - - const fileTree: Map = new Map(); - - fileTree.set(containerPath, { - name: "root", - path: containerPath, - type: "directory", - children: [], - }); - - const filesToRead: string[] = []; - const pathToItemMap: Map = new Map(); - - for (const filePath of paths) { - const stat = await getFileStat(container, filePath); - const relativePath = filePath.replace(containerPath + "/", ""); - const parts = relativePath.split("/"); - const fileName = parts[parts.length - 1] || ""; - - const fileItem: FileContentItem = { - name: fileName, - path: filePath, - type: stat.isDirectory ? "directory" : "file", - }; - - if (stat.isDirectory) { - fileItem.children = []; +async function walk( + dir: string, + includeContent: boolean +): Promise<(FileItem | FileContentItem)[]> { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results: (FileItem | FileContentItem)[] = []; + + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".next") continue; + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const children = await walk(fullPath, includeContent); + results.push({ + name: entry.name, + path: fullPath, + type: "directory", + children, + }); } else { - filesToRead.push(filePath); - } - - pathToItemMap.set(filePath, fileItem); - fileTree.set(filePath, fileItem); - } - - const fileContents = await readFilesBatch(docker, containerId, filesToRead); - - for (const [filePath, content] of fileContents) { - const fileItem = pathToItemMap.get(filePath); - if (fileItem) { - fileItem.content = content; - } - } - - for (const fileItem of pathToItemMap.values()) { - const parentPath = fileItem.path.substring( - 0, - fileItem.path.lastIndexOf("/") - ); - const parent = fileTree.get(parentPath || containerPath); - if (parent && parent.children) { - parent.children.push(fileItem); - } - } - - const root = fileTree.get(containerPath); - return root?.children || []; -} - -async function readFilesBatch( - docker: Docker, - containerId: string, - filePaths: string[] -): Promise> { - const results = new Map(); - const batchSize = 50; - - for (let i = 0; i < filePaths.length; i += batchSize) { - const batch = filePaths.slice(i, i + batchSize); - const batchPromises = batch.map(async (filePath) => { - try { - const content = await readFile(docker, containerId, filePath); - return [filePath, content] as [string, string]; - } catch (error) { - return [ - filePath, - `Error reading file: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ] as [string, string]; + const item: any = { + name: entry.name, + path: fullPath, + type: "file", + }; + if (includeContent) { + item.content = await fs.readFile(fullPath, "utf8"); } - }); - - const batchResults = await Promise.all(batchPromises); - for (const [filePath, content] of batchResults) { - results.set(filePath, content); + results.push(item); } } return results; } -async function getFileStat( - container: Docker.Container, - filePath: string -): Promise<{ isDirectory: boolean }> { - const exec = await container.exec({ - Cmd: ["stat", "-c", "%F", filePath], - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await exec.start({ Detach: false, Tty: false }); - const output = await new Promise((resolve, reject) => { - let data = ""; - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); - stream.on("end", () => resolve(data)); - stream.on("error", reject); - }); +export async function getFileTree(projectPath: string): Promise { + return (await walk(projectPath, false)) as FileItem[]; +} - return { - isDirectory: output.trim().includes("directory"), - }; +export async function getFileContentTree( + projectPath: string +): Promise { + return (await walk(projectPath, true)) as FileContentItem[]; } export async function readFile( - docker: Docker, - containerId: string, + projectPath: string, filePath: string ): Promise { - const container = docker.getContainer(containerId); - - const exec = await container.exec({ - Cmd: ["sh", "-c", `cat "${filePath}" | head -c 10000000`], - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await exec.start({ Detach: false, Tty: false }); - - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let stderr = ""; - - stream.on("data", (chunk: Buffer) => { - if (chunk.length > 8) { - const header = chunk.slice(0, 8); - const streamType = header[0]; - - if (streamType === 1) { - chunks.push(chunk.slice(8)); - } else if (streamType === 2) { - stderr += chunk.slice(8).toString("utf8"); - } - } else { - chunks.push(chunk); - } - }); - - stream.on("end", () => { - if (stderr && stderr.trim() !== "exec /bin/sh: invalid argument") { - console.error("File read stderr:", stderr); - } - - const buffer = Buffer.concat(chunks); - const content = buffer.toString("utf8"); - - const cleanContent = content.replace(/^\uFEFF/, ""); - resolve(cleanContent); - }); - - stream.on("error", (error) => { - console.error("Stream error:", error); - reject(error); - }); - }); + const absolute = resolvePath(projectPath, filePath); + const content = await fs.readFile(absolute, "utf8"); + return content.replace(/^\uFEFF/, ""); } export async function listFiles( - docker: Docker, - containerId: string, - containerPath: string = BASE_PATH + projectPath: string, + dirPath: string = projectPath ): Promise { - const container = docker.getContainer(containerId); - const exec = await container.exec({ - Cmd: ["ls", "-la", containerPath], - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await exec.start({ Detach: false, Tty: false }); - const output = await new Promise((resolve, reject) => { - let data = ""; - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); + const absolute = resolvePath(projectPath, dirPath); + const entries = await fs.readdir(absolute, { withFileTypes: true }); + const results = []; + + for (const entry of entries) { + const fullPath = path.join(absolute, entry.name); + const stat = await fs.stat(fullPath); + results.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : "file", + permissions: (stat.mode & 0o777).toString(8), + size: stat.size.toString(), + modified: stat.mtime.toISOString(), }); - stream.on("end", () => resolve(data)); - stream.on("error", reject); - }); - - const lines = output.trim().split("\n"); - return lines - .slice(1) - .map((line) => { - const parts = line.trim().split(/\s+/); - const permissions = parts[0]; - const isDirectory = permissions!.startsWith("d"); - const name = parts.slice(8).join(" "); + } - return { - name, - type: isDirectory ? "directory" : "file", - permissions, - size: parts[4], - modified: `${parts[5]} ${parts[6]} ${parts[7]}`, - }; - }) - .filter((item) => item.name !== "." && item.name !== ".."); + return results; } export async function writeFile( - containerId: string, + projectPath: string, filePath: string, content: string ): Promise { - console.log(`Writing file: ${filePath} (${content.length} characters)`); - - const tempFile = `/tmp/file-${uuidv4()}`; - - try { - await fs.writeFile(tempFile, content, "utf8"); - console.log(`Temporary file created: ${tempFile}`); - - const absolutePath = getAbsolutePath(filePath); - console.log(`Target path: ${absolutePath}`); - - try { - const copyCommand = `docker cp "${tempFile}" "${containerId}:${absolutePath}"`; - console.log(`Executing: ${copyCommand}`); - const { stdout, stderr } = await execAsync(copyCommand); - - if (stderr) { - console.log(`Copy stderr: ${stderr}`); - } - if (stdout) { - console.log(`Copy stdout: ${stdout}`); - } - - console.log("File copied successfully"); - } catch (copyError) { - console.log("Copy failed, trying to create directory first:", copyError); - - const dirPath = absolutePath.substring(0, absolutePath.lastIndexOf("/")); - const createDirCommand = `docker exec "${containerId}" mkdir -p "${dirPath}"`; - console.log(`Executing: ${createDirCommand}`); - - await execAsync(createDirCommand); - console.log("Directory created"); - - const retryCommand = `docker cp "${tempFile}" "${containerId}:${absolutePath}"`; - console.log(`Retrying: ${retryCommand}`); - - const { stdout, stderr } = await execAsync(retryCommand); - if (stderr) { - console.log(`Retry stderr: ${stderr}`); - } - if (stdout) { - console.log(`Retry stdout: ${stdout}`); - } - - console.log("File copied successfully on retry"); - } - - try { - const verifyCommand = `docker exec "${containerId}" head -n 5 "${absolutePath}"`; - const { stdout: verifyOutput } = await execAsync(verifyCommand); - console.log(`File verification (first 5 lines):\n${verifyOutput}`); - } catch (verifyError) { - console.log("Could not verify file content:", verifyError); - } - - await fs.unlink(tempFile); - console.log("Temporary file cleaned up"); - } catch (error) { - console.error("Write file error:", error); - try { - await fs.unlink(tempFile); - } catch (unlinkError) { - console.error("Failed to clean up temp file:", unlinkError); - } - throw error; - } + const absolute = resolvePath(projectPath, filePath); + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await fs.writeFile(absolute, content, "utf8"); } export async function renameFile( - containerId: string, + projectPath: string, oldPath: string, newPath: string ): Promise { - const absoluteOldPath = getAbsolutePath(oldPath); - const absoluteNewPath = getAbsolutePath(newPath); - - const newDir = absoluteNewPath.substring(0, absoluteNewPath.lastIndexOf("/")); - const createDirCommand = `docker exec "${containerId}" mkdir -p "${newDir}"`; - await execAsync(createDirCommand); - - const moveCommand = `docker exec "${containerId}" mv "${absoluteOldPath}" "${absoluteNewPath}"`; - await execAsync(moveCommand); + const absOld = resolvePath(projectPath, oldPath); + const absNew = resolvePath(projectPath, newPath); + await fs.mkdir(path.dirname(absNew), { recursive: true }); + await fs.rename(absOld, absNew); } export async function removeFile( - containerId: string, + projectPath: string, filePath: string ): Promise { - const absolutePath = getAbsolutePath(filePath); - const removeCommand = `docker exec "${containerId}" rm -rf "${absolutePath}"`; - await execAsync(removeCommand); + const absolute = resolvePath(projectPath, filePath); + await fs.rm(absolute, { recursive: true, force: true }); } diff --git a/backend/src/services/package.ts b/backend/src/services/package.ts index 17572fd..63157e4 100644 --- a/backend/src/services/package.ts +++ b/backend/src/services/package.ts @@ -2,17 +2,19 @@ import { exec } from "child_process"; import { promisify } from "util"; const execAsync = promisify(exec); -const BASE_PATH = "/app/my-nextjs-app"; export async function addDependency( - containerId: string, + projectPath: string, packageName: string, isDev: boolean = false ): Promise { - const devFlag = isDev ? "--dev" : ""; - const addCommand = - `docker exec -w ${BASE_PATH} ${containerId} bun add ${packageName} ${devFlag}`.trim(); + const devFlag = isDev ? "--save-dev" : ""; + const addCommand = `pnpm add ${packageName} ${devFlag}`.trim(); + const { stdout, stderr } = await execAsync(addCommand, { cwd: projectPath }); + return stdout || stderr; +} - const { stdout, stderr } = await execAsync(addCommand); +export async function buildProject(projectPath: string): Promise { + const { stdout, stderr } = await execAsync("pnpm build", { cwd: projectPath }); return stdout || stderr; } From cb66d4fd1b72c5519b20a424d404be29b1e9b3b2 Mon Sep 17 00:00:00 2001 From: Felix Schneider Date: Thu, 14 Aug 2025 16:44:27 +0200 Subject: [PATCH 2/2] refactor: use process-managed filesystem --- backend/src/services/export.ts | 30 +++--- backend/src/services/file.test.ts | 42 +++++---- backend/src/services/file.ts | 147 ++++++++++++++++-------------- backend/src/services/package.ts | 20 +++- backend/src/services/process.ts | 132 +++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 103 deletions(-) create mode 100644 backend/src/services/process.ts diff --git a/backend/src/services/export.ts b/backend/src/services/export.ts index 66607dd..073c4ed 100644 --- a/backend/src/services/export.ts +++ b/backend/src/services/export.ts @@ -2,26 +2,30 @@ import { exec } from "child_process"; import fs from "fs/promises"; import path from "path"; import { promisify } from "util"; +import * as processService from "./process"; const execAsync = promisify(exec); -export async function exportProjectCode(projectPath: string): Promise { - const tempDir = `/tmp/export-${Date.now()}`; +export async function exportContainerCode(id: string): Promise { + const projectPath = processService.getProjectPath(id); + if (!projectPath) throw new Error("Prozess nicht gefunden"); + + const tempDir = `/tmp/export-${id}-${Date.now()}`; const zipPath = `${tempDir}.zip`; try { await execAsync("pnpm build", { cwd: projectPath }); await fs.mkdir(tempDir, { recursive: true }); - await execAsync(`cp -R ${projectPath}/. ${tempDir}/`); + await fs.cp(projectPath, tempDir, { recursive: true }); - const nodeModulesPath = path.join(tempDir, "node_modules"); - const nextPath = path.join(tempDir, ".next"); - try { - await fs.rm(nodeModulesPath, { recursive: true, force: true }); - } catch {} - try { - await fs.rm(nextPath, { recursive: true, force: true }); - } catch {} + await fs.rm(path.join(tempDir, "node_modules"), { + recursive: true, + force: true, + }); + await fs.rm(path.join(tempDir, ".next"), { + recursive: true, + force: true, + }); await execAsync(`zip -r ${zipPath} . -x "*.DS_Store"`, { cwd: tempDir }); const zipBuffer = await fs.readFile(zipPath); @@ -34,11 +38,11 @@ export async function exportProjectCode(projectPath: string): Promise { await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(zipPath, { force: true }); } catch {} - throw new Error( - `Export failed: ${ + `Export fehlgeschlagen: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } + diff --git a/backend/src/services/file.test.ts b/backend/src/services/file.test.ts index 61d9385..750c2ba 100644 --- a/backend/src/services/file.test.ts +++ b/backend/src/services/file.test.ts @@ -1,42 +1,48 @@ -import { expect, test } from "bun:test"; +import { expect, test, mock } from "bun:test"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import { - getFileTree, - getFileContentTree, - writeFile, - readFile, - renameFile, - removeFile, -} from "./file"; - test("file operations without Docker", async () => { const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "project-")); + const id = "test-id"; + + mock.module("./process", () => ({ + getProjectPath: () => projectPath, + })); + + const { + getFileTree, + getFileContentTree, + writeFile, + readFile, + renameFile, + removeFile, + } = await import("./file"); try { - await writeFile(projectPath, "dir/sample.txt", "hello"); - const content = await readFile(projectPath, "dir/sample.txt"); + await writeFile(id, "dir/sample.txt", "hello"); + const content = await readFile(id, "dir/sample.txt"); expect(content).toBe("hello"); - await renameFile(projectPath, "dir/sample.txt", "dir/renamed.txt"); - const renamed = await readFile(projectPath, "dir/renamed.txt"); + await renameFile(id, "dir/sample.txt", "dir/renamed.txt"); + const renamed = await readFile(id, "dir/renamed.txt"); expect(renamed).toBe("hello"); - const tree = await getFileTree(projectPath); + const tree = await getFileTree(id); expect(tree.length).toBe(1); expect(tree[0].name).toBe("dir"); expect(tree[0].children?.[0].name).toBe("renamed.txt"); - const contentTree = await getFileContentTree(projectPath); + const contentTree = await getFileContentTree(id); const fileItem = contentTree[0].children?.[0]; expect(fileItem?.content).toBe("hello"); - await removeFile(projectPath, "dir/renamed.txt"); - const treeAfter = await getFileTree(projectPath); + await removeFile(id, "dir/renamed.txt"); + const treeAfter = await getFileTree(id); expect(treeAfter[0].children?.length).toBe(0); } finally { + mock.restore(); await fs.rm(projectPath, { recursive: true, force: true }); } }); diff --git a/backend/src/services/file.ts b/backend/src/services/file.ts index 64fd104..d1ef92f 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -1,5 +1,8 @@ import fs from "fs/promises"; import path from "path"; +import * as processService from "./process"; + +const IGNORED = ["node_modules", ".next"]; export interface FileItem { name: string; @@ -17,114 +20,124 @@ export interface FileContentItem { children?: FileContentItem[]; } -function resolvePath(projectPath: string, filePath: string): string { - return path.isAbsolute(filePath) - ? filePath - : path.join(projectPath, filePath); +function getBasePath(id: string): string { + const cwd = processService.getProjectPath(id); + if (!cwd) throw new Error("Prozess nicht gefunden"); + return cwd; } -async function walk( - dir: string, - includeContent: boolean -): Promise<(FileItem | FileContentItem)[]> { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const results: (FileItem | FileContentItem)[] = []; - +export async function listFiles( + id: string, + dir: string = "." +): Promise { + const abs = path.join(getBasePath(id), dir); + const entries = await fs.readdir(abs, { withFileTypes: true }); + const result: any[] = []; for (const entry of entries) { - if (entry.name === "node_modules" || entry.name === ".next") continue; - const fullPath = path.join(dir, entry.name); + if (IGNORED.includes(entry.name)) continue; + const stat = await fs.stat(path.join(abs, entry.name)); + result.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : "file", + size: stat.size, + modified: stat.mtime.toISOString(), + }); + } + return result; +} +async function walk(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const items: FileItem[] = []; + for (const entry of entries) { + if (IGNORED.includes(entry.name)) continue; + const full = path.join(dir, entry.name); if (entry.isDirectory()) { - const children = await walk(fullPath, includeContent); - results.push({ + items.push({ name: entry.name, - path: fullPath, + path: full, type: "directory", - children, + children: await walk(full), }); } else { - const item: any = { - name: entry.name, - path: fullPath, - type: "file", - }; - if (includeContent) { - item.content = await fs.readFile(fullPath, "utf8"); - } - results.push(item); + items.push({ name: entry.name, path: full, type: "file" }); } } + return items; +} - return results; +export async function getFileTree( + id: string, + dir: string = "." +): Promise { + const abs = path.join(getBasePath(id), dir); + return await walk(abs); } -export async function getFileTree(projectPath: string): Promise { - return (await walk(projectPath, false)) as FileItem[]; +async function walkWithContent(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const items: FileContentItem[] = []; + for (const entry of entries) { + if (IGNORED.includes(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + items.push({ + name: entry.name, + path: full, + type: "directory", + children: await walkWithContent(full), + }); + } else { + const content = await fs.readFile(full, "utf8"); + items.push({ name: entry.name, path: full, type: "file", content }); + } + } + return items; } export async function getFileContentTree( - projectPath: string + id: string, + dir: string = "." ): Promise { - return (await walk(projectPath, true)) as FileContentItem[]; + const abs = path.join(getBasePath(id), dir); + return await walkWithContent(abs); } export async function readFile( - projectPath: string, + id: string, filePath: string ): Promise { - const absolute = resolvePath(projectPath, filePath); - const content = await fs.readFile(absolute, "utf8"); + const abs = path.join(getBasePath(id), filePath); + const content = await fs.readFile(abs, "utf8"); return content.replace(/^\uFEFF/, ""); } -export async function listFiles( - projectPath: string, - dirPath: string = projectPath -): Promise { - const absolute = resolvePath(projectPath, dirPath); - const entries = await fs.readdir(absolute, { withFileTypes: true }); - const results = []; - - for (const entry of entries) { - const fullPath = path.join(absolute, entry.name); - const stat = await fs.stat(fullPath); - results.push({ - name: entry.name, - type: entry.isDirectory() ? "directory" : "file", - permissions: (stat.mode & 0o777).toString(8), - size: stat.size.toString(), - modified: stat.mtime.toISOString(), - }); - } - - return results; -} - export async function writeFile( - projectPath: string, + id: string, filePath: string, content: string ): Promise { - const absolute = resolvePath(projectPath, filePath); - await fs.mkdir(path.dirname(absolute), { recursive: true }); - await fs.writeFile(absolute, content, "utf8"); + const abs = path.join(getBasePath(id), filePath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content, "utf8"); } export async function renameFile( - projectPath: string, + id: string, oldPath: string, newPath: string ): Promise { - const absOld = resolvePath(projectPath, oldPath); - const absNew = resolvePath(projectPath, newPath); + const absOld = path.join(getBasePath(id), oldPath); + const absNew = path.join(getBasePath(id), newPath); await fs.mkdir(path.dirname(absNew), { recursive: true }); await fs.rename(absOld, absNew); } export async function removeFile( - projectPath: string, + id: string, filePath: string ): Promise { - const absolute = resolvePath(projectPath, filePath); - await fs.rm(absolute, { recursive: true, force: true }); + const abs = path.join(getBasePath(id), filePath); + await fs.rm(abs, { recursive: true, force: true }); } + diff --git a/backend/src/services/package.ts b/backend/src/services/package.ts index 63157e4..8afef28 100644 --- a/backend/src/services/package.ts +++ b/backend/src/services/package.ts @@ -1,20 +1,30 @@ import { exec } from "child_process"; import { promisify } from "util"; +import * as processService from "./process"; const execAsync = promisify(exec); +function getCwd(id: string): string { + const cwd = processService.getProjectPath(id); + if (!cwd) throw new Error("Prozess nicht gefunden"); + return cwd; +} + export async function addDependency( - projectPath: string, + id: string, packageName: string, isDev: boolean = false ): Promise { + const cwd = getCwd(id); const devFlag = isDev ? "--save-dev" : ""; - const addCommand = `pnpm add ${packageName} ${devFlag}`.trim(); - const { stdout, stderr } = await execAsync(addCommand, { cwd: projectPath }); + const cmd = `pnpm add ${packageName} ${devFlag}`.trim(); + const { stdout, stderr } = await execAsync(cmd, { cwd }); return stdout || stderr; } -export async function buildProject(projectPath: string): Promise { - const { stdout, stderr } = await execAsync("pnpm build", { cwd: projectPath }); +export async function buildProject(id: string): Promise { + const cwd = getCwd(id); + const { stdout, stderr } = await execAsync("pnpm build", { cwd }); return stdout || stderr; } + diff --git a/backend/src/services/process.ts b/backend/src/services/process.ts new file mode 100644 index 0000000..7a1922e --- /dev/null +++ b/backend/src/services/process.ts @@ -0,0 +1,132 @@ +import { spawn, ChildProcess } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import getPort from "get-port"; + +interface ProcessInfo { + id: string; + cwd: string; + port: number; + proc?: ChildProcess; + status: "running" | "stopped"; + createdAt: string; +} + +const processes = new Map(); +const BASE_DIR = path.join("/tmp", "process-apps"); + +async function runCommand(cmd: string, args: string[], cwd: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { cwd, stdio: "inherit" }); + child.on("exit", (code) => { + code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +export async function createProcess(id: string): Promise<{ port: number }> { + const cwd = path.join(BASE_DIR, id); + await fs.mkdir(cwd, { recursive: true }); + + // Clone template project + await runCommand("git", [ + "clone", + "https://github.com/ntegrals/december-nextjs-template.git", + cwd, + ], process.cwd()); + + // Install dependencies + await runCommand("pnpm", ["install"], cwd); + + const port = await getPort(); + const proc = spawn("pnpm", ["dev"], { + cwd, + env: { ...process.env, PORT: String(port) }, + stdio: "inherit", + }); + + processes.set(id, { + id, + cwd, + port, + proc, + status: "running", + createdAt: new Date().toISOString(), + }); + + proc.on("exit", () => { + const info = processes.get(id); + if (info) { + info.status = "stopped"; + info.proc = undefined; + } + }); + + return { port }; +} + +export async function startProcess(id: string): Promise<{ port: number }> { + const info = processes.get(id); + if (!info) throw new Error("Prozess nicht gefunden"); + if (info.status === "running" && info.proc) return { port: info.port }; + + const port = await getPort(); + const proc = spawn("pnpm", ["dev"], { + cwd: info.cwd, + env: { ...process.env, PORT: String(port) }, + stdio: "inherit", + }); + + info.port = port; + info.proc = proc; + info.status = "running"; + + proc.on("exit", () => { + info.status = "stopped"; + info.proc = undefined; + }); + + return { port }; +} + +export async function stopProcess(id: string): Promise { + const info = processes.get(id); + if (!info || !info.proc) throw new Error("Prozess nicht gefunden"); + info.proc.kill(); + info.status = "stopped"; + info.proc = undefined; +} + +export async function deleteProcess(id: string): Promise { + const info = processes.get(id); + if (!info) throw new Error("Prozess nicht gefunden"); + if (info.proc) { + info.proc.kill(); + } + await fs.rm(info.cwd, { recursive: true, force: true }); + processes.delete(id); +} + +export function getProcessInfo(id: string): ProcessInfo | undefined { + return processes.get(id); +} + +export function getProjectPath(id: string): string | undefined { + return processes.get(id)?.cwd; +} + +export async function listProjectProcesses(): Promise { + const list: any[] = []; + for (const info of processes.values()) { + list.push({ + id: info.id, + status: info.status, + port: info.port, + url: `http://localhost:${info.port}`, + pid: info.proc?.pid, + createdAt: info.createdAt, + }); + } + return list; +}