diff --git a/backend/src/services/export.ts b/backend/src/services/export.ts index 2c88728..073c4ed 100644 --- a/backend/src/services/export.ts +++ b/backend/src/services/export.ts @@ -2,51 +2,47 @@ 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 exportContainerCode( - containerId: string -): Promise { - const tempDir = `/tmp/export-${containerId}-${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 }); - - const copyCommand = `docker cp ${containerId}:/app/my-nextjs-app/. ${tempDir}/`; - await execAsync(copyCommand); - - 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 fs.cp(projectPath, tempDir, { recursive: true }); + + 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); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(zipPath, { force: true }); - return zipBuffer; } catch (error) { try { 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 new file mode 100644 index 0000000..750c2ba --- /dev/null +++ b/backend/src/services/file.test.ts @@ -0,0 +1,48 @@ +import { expect, test, mock } from "bun:test"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +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(id, "dir/sample.txt", "hello"); + const content = await readFile(id, "dir/sample.txt"); + expect(content).toBe("hello"); + + 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(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(id); + const fileItem = contentTree[0].children?.[0]; + expect(fileItem?.content).toBe("hello"); + + 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 588c2c9..d1ef92f 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -1,15 +1,8 @@ -import { exec } from "child_process"; -import Docker from "dockerode"; import fs from "fs/promises"; -import { promisify } from "util"; -import { v4 as uuidv4 } from "uuid"; +import path from "path"; +import * as processService from "./process"; -const execAsync = promisify(exec); -const BASE_PATH = "/app/my-nextjs-app"; - -function getAbsolutePath(filePath: string): string { - return filePath.startsWith("/") ? filePath : `${BASE_PATH}/${filePath}`; -} +const IGNORED = ["node_modules", ".next"]; export interface FileItem { name: string; @@ -27,417 +20,124 @@ 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, - }); +function getBasePath(id: string): string { + const cwd = processService.getProjectPath(id); + if (!cwd) throw new Error("Prozess nicht gefunden"); + return cwd; +} - 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(); +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 (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(), }); - 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 || []; + return result; } -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): 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()) { + items.push({ + name: entry.name, + path: full, + type: "directory", + children: await walk(full), + }); } else { - filesToRead.push(filePath); + items.push({ name: entry.name, path: full, type: "file" }); } - - 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 || []; + return items; } -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]; - } - }); +export async function getFileTree( + id: string, + dir: string = "." +): Promise { + const abs = path.join(getBasePath(id), dir); + return await walk(abs); +} - const batchResults = await Promise.all(batchPromises); - for (const [filePath, content] of batchResults) { - results.set(filePath, content); +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 results; + return items; } -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); - }); - - return { - isDirectory: output.trim().includes("directory"), - }; +export async function getFileContentTree( + id: string, + dir: string = "." +): Promise { + const abs = path.join(getBasePath(id), dir); + return await walkWithContent(abs); } export async function readFile( - docker: Docker, - containerId: string, + id: 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); - }); - }); -} - -export async function listFiles( - docker: Docker, - containerId: string, - containerPath: string = BASE_PATH -): 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(); - }); - 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 !== ".."); + const abs = path.join(getBasePath(id), filePath); + const content = await fs.readFile(abs, "utf8"); + return content.replace(/^\uFEFF/, ""); } export async function writeFile( - containerId: string, + id: 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 abs = path.join(getBasePath(id), filePath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content, "utf8"); } export async function renameFile( - containerId: string, + id: 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 = 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( - containerId: string, + id: string, filePath: string ): Promise { - const absolutePath = getAbsolutePath(filePath); - const removeCommand = `docker exec "${containerId}" rm -rf "${absolutePath}"`; - await execAsync(removeCommand); + 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 17572fd..8afef28 100644 --- a/backend/src/services/package.ts +++ b/backend/src/services/package.ts @@ -1,18 +1,30 @@ import { exec } from "child_process"; import { promisify } from "util"; +import * as processService from "./process"; const execAsync = promisify(exec); -const BASE_PATH = "/app/my-nextjs-app"; + +function getCwd(id: string): string { + const cwd = processService.getProjectPath(id); + if (!cwd) throw new Error("Prozess nicht gefunden"); + return cwd; +} export async function addDependency( - containerId: string, + id: 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 cwd = getCwd(id); + const devFlag = isDev ? "--save-dev" : ""; + const cmd = `pnpm add ${packageName} ${devFlag}`.trim(); + const { stdout, stderr } = await execAsync(cmd, { cwd }); + return stdout || stderr; +} - const { stdout, stderr } = await execAsync(addCommand); +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; +}