diff --git a/.gitignore b/.gitignore index b24b6415..42ad7b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ # Build outputs dist/ .next/ +node_modules diff --git a/apps/app/electron/main-simplified.js b/apps/app/electron/main-simplified.js new file mode 100644 index 00000000..f15e6946 --- /dev/null +++ b/apps/app/electron/main-simplified.js @@ -0,0 +1,241 @@ +/** + * Simplified Electron main process + * + * This version spawns the backend server and uses HTTP API for most operations. + * Only native features (dialogs, shell) use IPC. + */ + +const path = require("path"); +const { spawn } = require("child_process"); + +// Load environment variables from .env file +require("dotenv").config({ path: path.join(__dirname, "../.env") }); + +const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); + +let mainWindow = null; +let serverProcess = null; +const SERVER_PORT = 3008; + +// Get icon path - works in both dev and production +function getIconPath() { + return app.isPackaged + ? path.join(process.resourcesPath, "app", "public", "logo.png") + : path.join(__dirname, "../public/logo.png"); +} + +/** + * Start the backend server + */ +async function startServer() { + const isDev = !app.isPackaged; + + // Server entry point + const serverPath = isDev + ? path.join(__dirname, "../../server/dist/index.js") + : path.join(process.resourcesPath, "server", "index.js"); + + // Set environment variables for server + const env = { + ...process.env, + PORT: SERVER_PORT.toString(), + DATA_DIR: app.getPath("userData"), + }; + + console.log("[Electron] Starting backend server..."); + + serverProcess = spawn("node", [serverPath], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + serverProcess.stdout.on("data", (data) => { + console.log(`[Server] ${data.toString().trim()}`); + }); + + serverProcess.stderr.on("data", (data) => { + console.error(`[Server Error] ${data.toString().trim()}`); + }); + + serverProcess.on("close", (code) => { + console.log(`[Server] Process exited with code ${code}`); + serverProcess = null; + }); + + // Wait for server to be ready + await waitForServer(); +} + +/** + * Wait for server to be available + */ +async function waitForServer(maxAttempts = 30) { + const http = require("http"); + + for (let i = 0; i < maxAttempts; i++) { + try { + await new Promise((resolve, reject) => { + const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + reject(new Error(`Status: ${res.statusCode}`)); + } + }); + req.on("error", reject); + req.setTimeout(1000, () => { + req.destroy(); + reject(new Error("Timeout")); + }); + }); + console.log("[Electron] Server is ready"); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + + throw new Error("Server failed to start"); +} + +/** + * Create the main window + */ +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1024, + minHeight: 700, + icon: getIconPath(), + webPreferences: { + preload: path.join(__dirname, "preload-simplified.js"), + contextIsolation: true, + nodeIntegration: false, + }, + titleBarStyle: "hiddenInset", + backgroundColor: "#0a0a0a", + }); + + // Load Next.js dev server in development or production build + const isDev = !app.isPackaged; + if (isDev) { + mainWindow.loadURL("http://localhost:3007"); + if (process.env.OPEN_DEVTOOLS === "true") { + mainWindow.webContents.openDevTools(); + } + } else { + mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html")); + } + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +// App lifecycle +app.whenReady().then(async () => { + // Set app icon (dock icon on macOS) + if (process.platform === "darwin" && app.dock) { + app.dock.setIcon(getIconPath()); + } + + try { + // Start backend server + await startServer(); + + // Create window + createWindow(); + } catch (error) { + console.error("[Electron] Failed to start:", error); + app.quit(); + } + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + // Kill server process + if (serverProcess) { + console.log("[Electron] Stopping server..."); + serverProcess.kill(); + serverProcess = null; + } +}); + +// ============================================ +// IPC Handlers - Only native features +// ============================================ + +// Native file dialogs +ipcMain.handle("dialog:openDirectory", async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory", "createDirectory"], + }); + return result; +}); + +ipcMain.handle("dialog:openFile", async (_, options = {}) => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openFile"], + ...options, + }); + return result; +}); + +ipcMain.handle("dialog:saveFile", async (_, options = {}) => { + const result = await dialog.showSaveDialog(mainWindow, options); + return result; +}); + +// Shell operations +ipcMain.handle("shell:openExternal", async (_, url) => { + try { + await shell.openExternal(url); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("shell:openPath", async (_, filePath) => { + try { + await shell.openPath(filePath); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// App info +ipcMain.handle("app:getPath", async (_, name) => { + return app.getPath(name); +}); + +ipcMain.handle("app:getVersion", async () => { + return app.getVersion(); +}); + +ipcMain.handle("app:isPackaged", async () => { + return app.isPackaged; +}); + +// Ping - for connection check +ipcMain.handle("ping", async () => { + return "pong"; +}); + +// Get server URL for HTTP client +ipcMain.handle("server:getUrl", async () => { + return `http://localhost:${SERVER_PORT}`; +}); diff --git a/apps/app/electron/preload-simplified.js b/apps/app/electron/preload-simplified.js new file mode 100644 index 00000000..289d2cd7 --- /dev/null +++ b/apps/app/electron/preload-simplified.js @@ -0,0 +1,37 @@ +/** + * Simplified Electron preload script + * + * Only exposes native features (dialogs, shell) and server URL. + * All other operations go through HTTP API. + */ + +const { contextBridge, ipcRenderer } = require("electron"); + +// Expose minimal API for native features +contextBridge.exposeInMainWorld("electronAPI", { + // Platform info + platform: process.platform, + isElectron: true, + + // Connection check + ping: () => ipcRenderer.invoke("ping"), + + // Get server URL for HTTP client + getServerUrl: () => ipcRenderer.invoke("server:getUrl"), + + // Native dialogs - better UX than prompt() + openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"), + openFile: (options) => ipcRenderer.invoke("dialog:openFile", options), + saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options), + + // Shell operations + openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url), + openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath), + + // App info + getPath: (name) => ipcRenderer.invoke("app:getPath", name), + getVersion: () => ipcRenderer.invoke("app:getVersion"), + isPackaged: () => ipcRenderer.invoke("app:isPackaged"), +}); + +console.log("[Preload] Electron API exposed (simplified mode)"); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 0f50a5b0..d2a2a6f2 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -94,7 +94,7 @@ export default function Home() { try { const api = getElectronAPI(); const result = await api.ping(); - setIpcConnected(result === "pong" || result === "pong (mock)"); + setIpcConnected(result === "pong"); } catch (error) { console.error("IPC connection failed:", error); setIpcConnected(false); @@ -193,8 +193,8 @@ export default function Home() { {/* Environment indicator */} {isMounted && !isElectron() && ( -
- Web Mode (Mock IPC) +
+ Web Mode
)} @@ -210,8 +210,8 @@ export default function Home() { {/* Environment indicator - only show after mount to prevent hydration issues */} {isMounted && !isElectron() && ( -
- Web Mode (Mock IPC) +
+ Web Mode
)} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 2c13361c..f5095351 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -413,14 +413,33 @@ export function Sidebar() { return; } - const project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - }; + // Check if project already exists (by path) to preserve theme and other settings + const existingProject = projects.find((p) => p.path === path); + + let project: Project; + if (existingProject) { + // Update existing project, preserving theme and other properties + project = { + ...existingProject, + name, // Update name in case it changed + lastOpened: new Date().toISOString(), + }; + // Update the project in the store (this will update the existing entry) + const updatedProjects = projects.map((p) => + p.id === existingProject.id ? project : p + ); + useAppStore.setState({ projects: updatedProjects }); + } else { + // Create new project + project = { + id: `project-${Date.now()}`, + name, + path, + lastOpened: new Date().toISOString(), + }; + addProject(project); + } - addProject(project); setCurrentProject(project); // Check if app_spec.txt exists @@ -455,7 +474,7 @@ export function Sidebar() { }); } } - }, [addProject, setCurrentProject]); + }, [projects, addProject, setCurrentProject]); const handleRestoreProject = useCallback( (projectId: string) => { diff --git a/apps/app/src/components/session-manager.tsx b/apps/app/src/components/session-manager.tsx index d5f87140..7cafacdc 100644 --- a/apps/app/src/components/session-manager.tsx +++ b/apps/app/src/components/session-manager.tsx @@ -4,14 +4,13 @@ import { useState, useEffect } from "react"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Input } from "@/components/ui/input"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, MessageSquare, @@ -26,7 +25,6 @@ import { import { cn } from "@/lib/utils"; import type { SessionListItem } from "@/types/electron"; import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; -import { useAppStore } from "@/store/app-store"; // Random session name generator const adjectives = [ diff --git a/apps/app/src/components/ui/description-image-dropzone.tsx b/apps/app/src/components/ui/description-image-dropzone.tsx index 7df30bf3..5b3b1a9c 100644 --- a/apps/app/src/components/ui/description-image-dropzone.tsx +++ b/apps/app/src/components/ui/description-image-dropzone.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { useState, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import { ImageIcon, X, Loader2 } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; @@ -98,7 +98,7 @@ export function DescriptionImageDropZone({ }); }; - const saveImageToTemp = async ( + const saveImageToTemp = useCallback(async ( base64Data: string, filename: string, mimeType: string @@ -107,8 +107,8 @@ export function DescriptionImageDropZone({ const api = getElectronAPI(); // Check if saveImageToTemp method exists if (!api.saveImageToTemp) { - // Fallback for mock API - return a mock path in .automaker/images - console.log("[DescriptionImageDropZone] Using mock path for image"); + // Fallback path when saveImageToTemp is not available + console.log("[DescriptionImageDropZone] Using fallback path for image"); return `.automaker/images/${Date.now()}_${filename}`; } @@ -124,7 +124,7 @@ export function DescriptionImageDropZone({ console.error("[DescriptionImageDropZone] Error saving image:", error); return null; } - }; + }, [currentProject?.path]); const processFiles = useCallback( async (files: FileList) => { @@ -193,7 +193,7 @@ export function DescriptionImageDropZone({ setIsProcessing(false); }, - [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages] + [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp] ); const handleDrop = useCallback( diff --git a/apps/app/src/components/views/agent-tools-view.tsx b/apps/app/src/components/views/agent-tools-view.tsx index b9a4fada..94cd947a 100644 --- a/apps/app/src/components/views/agent-tools-view.tsx +++ b/apps/app/src/components/views/agent-tools-view.tsx @@ -149,12 +149,12 @@ export function AgentToolsView() { setTerminalResult(null); try { - // Simulate agent requesting terminal command execution - console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`); + // Terminal command simulation for demonstration purposes + console.log(`[Agent Tool] Simulating command: ${terminalCommand}`); - // In mock mode, simulate terminal output - // In real Electron mode, this would use child_process - const mockOutputs: Record = { + // Simulated outputs for common commands (preview mode) + // In production, the agent executes commands via Claude SDK + const simulatedOutputs: Record = { ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json", pwd: currentProject?.path || "/Users/demo/project", "echo hello": "hello", @@ -168,8 +168,8 @@ export function AgentToolsView() { await new Promise((resolve) => setTimeout(resolve, 500)); const output = - mockOutputs[terminalCommand.toLowerCase()] || - `Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`; + simulatedOutputs[terminalCommand.toLowerCase()] || + `[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`; setTerminalResult({ success: true, diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 057d7c22..edd819b1 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -394,22 +394,25 @@ export function BoardView() { }, []); // Load features using features API + // IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop const loadFeatures = useCallback(async () => { if (!currentProject) return; const currentPath = currentProject.path; const previousPath = prevProjectPathRef.current; + const isProjectSwitch = previousPath !== null && currentPath !== previousPath; - // If project switched, clear features first to prevent cross-contamination - // Also treat this as an initial load for the new project - if (previousPath !== null && currentPath !== previousPath) { + // Get cached features from store (without adding to dependencies) + const cachedFeatures = useAppStore.getState().features; + + // If project switched, mark it but don't clear features yet + // We'll clear after successful API load to prevent data loss + if (isProjectSwitch) { console.log( - `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}, clearing features` + `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}` ); isSwitchingProjectRef.current = true; isInitialLoadRef.current = true; - setFeatures([]); - setPersistedCategories([]); // Also clear categories } // Update the ref to track current project @@ -424,6 +427,7 @@ export function BoardView() { const api = getElectronAPI(); if (!api.features) { console.error("[BoardView] Features API not available"); + // Keep cached features if API is unavailable return; } @@ -441,10 +445,31 @@ export function BoardView() { thinkingLevel: f.thinkingLevel || "none", }) ); + // Successfully loaded features - now safe to set them setFeatures(featuresWithIds); + + // Only clear categories on project switch AFTER successful load + if (isProjectSwitch) { + setPersistedCategories([]); + } + } else if (!result.success && result.error) { + console.error("[BoardView] API returned error:", result.error); + // If it's a new project or the error indicates no features found, + // that's expected - start with empty array + if (isProjectSwitch) { + setFeatures([]); + setPersistedCategories([]); + } + // Otherwise keep cached features } } catch (error) { console.error("Failed to load features:", error); + // On error, keep existing cached features for the current project + // Only clear on project switch if we have no features from server + if (isProjectSwitch && cachedFeatures.length === 0) { + setFeatures([]); + setPersistedCategories([]); + } } finally { setIsLoading(false); isInitialLoadRef.current = false; @@ -1475,8 +1500,14 @@ export function BoardView() { if (isRunning) { map.in_progress.push(f); } else { - // Otherwise, use the feature's status - map[f.status].push(f); + // Otherwise, use the feature's status (fallback to backlog for unknown statuses) + const status = f.status as ColumnId; + if (map[status]) { + map[status].push(f); + } else { + // Unknown status, default to backlog + map.backlog.push(f); + } } }); diff --git a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx index 288c45ef..94a49338 100644 --- a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx +++ b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx @@ -61,12 +61,14 @@ export function AuthenticationStatusDisplay({ {claudeAuthStatus.method === "oauth_token_env" ? "Using CLAUDE_CODE_OAUTH_TOKEN" : claudeAuthStatus.method === "oauth_token" - ? "Using stored OAuth token" + ? "Using stored OAuth token (claude login)" : claudeAuthStatus.method === "api_key_env" ? "Using ANTHROPIC_API_KEY" : claudeAuthStatus.method === "api_key" ? "Using stored API key" - : "Unknown method"} + : claudeAuthStatus.method === "credentials_file" + ? "Using credentials file" + : `Using ${claudeAuthStatus.method || "detected"} authentication`}
@@ -107,14 +109,16 @@ export function AuthenticationStatusDisplay({
- {codexAuthStatus.method === "cli_verified" || - codexAuthStatus.method === "cli_tokens" + {codexAuthStatus.method === "subscription" + ? "Using Codex subscription (Plus/Team)" + : codexAuthStatus.method === "cli_verified" || + codexAuthStatus.method === "cli_tokens" ? "Using CLI login (OpenAI account)" : codexAuthStatus.method === "api_key" ? "Using stored API key" : codexAuthStatus.method === "env" ? "Using OPENAI_API_KEY" - : "Unknown method"} + : `Using ${codexAuthStatus.method || "unknown"} authentication`}
diff --git a/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts b/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts index 1fae138b..3f4422c4 100644 --- a/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts +++ b/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts @@ -68,19 +68,24 @@ export function useCliStatus() { try { const result = await api.setup.getClaudeStatus(); if (result.success && result.auth) { - const auth = result.auth; - // Validate method is one of the expected values, default to "none" - const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "none"] as const; + // Cast to extended type that includes server-added fields + const auth = result.auth as typeof result.auth & { + oauthTokenValid?: boolean; + apiKeyValid?: boolean; + }; + // Map server method names to client method types + // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, none + const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "none"] as const; type AuthMethod = typeof validMethods[number]; const method: AuthMethod = validMethods.includes(auth.method as AuthMethod) ? (auth.method as AuthMethod) - : "none"; + : auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none const authStatus = { authenticated: auth.authenticated, method, hasCredentialsFile: auth.hasCredentialsFile ?? false, - oauthTokenValid: auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, - apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey, + oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, + apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey, hasEnvOAuthToken: auth.hasEnvOAuthToken, hasEnvApiKey: auth.hasEnvApiKey, }; @@ -96,27 +101,30 @@ export function useCliStatus() { try { const result = await api.setup.getCodexStatus(); if (result.success && result.auth) { - const auth = result.auth; - // Determine method - prioritize cli_verified and cli_tokens over auth_file - const method = - auth.method === "cli_verified" || auth.method === "cli_tokens" - ? auth.method === "cli_verified" - ? ("cli_verified" as const) - : ("cli_tokens" as const) - : auth.method === "auth_file" - ? ("api_key" as const) - : auth.method === "env_var" - ? ("env" as const) - : ("none" as const); + // Cast to extended type that includes server-added fields + const auth = result.auth as typeof result.auth & { + hasSubscription?: boolean; + cliLoggedIn?: boolean; + hasEnvApiKey?: boolean; + }; + // Map server method names to client method types + // Server returns: subscription, cli_verified, cli_tokens, api_key, env, none + const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const; + type CodexMethod = typeof validMethods[number]; + const method: CodexMethod = validMethods.includes(auth.method as CodexMethod) + ? (auth.method as CodexMethod) + : auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key const authStatus = { authenticated: auth.authenticated, method, - // Only set apiKeyValid for actual API key methods, not CLI login + // Only set apiKeyValid for actual API key methods, not CLI login or subscription apiKeyValid: - method === "cli_verified" || method === "cli_tokens" + method === "cli_verified" || method === "cli_tokens" || method === "subscription" ? undefined - : auth.hasAuthFile || auth.hasEnvKey, + : auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey, + hasSubscription: auth.hasSubscription, + cliLoggedIn: auth.cliLoggedIn, }; setCodexAuthStatus(authStatus); } diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 6b6e4a4e..0cdef02d 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -21,7 +21,7 @@ import { CardTitle, } from "@/components/ui/card"; import { useAppStore } from "@/store/app-store"; -import { getElectronAPI } from "@/lib/electron"; +import { getElectronAPI, type Project } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; import { FolderOpen, @@ -105,14 +105,34 @@ export function WelcomeView() { return; } - const project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - }; + // Check if project already exists (by path) to preserve theme and other settings + const existingProject = projects.find((p) => p.path === path); + + let project: Project; + if (existingProject) { + // Update existing project, preserving theme and other properties + project = { + ...existingProject, + name, // Update name in case it changed + lastOpened: new Date().toISOString(), + }; + // Update the project in the store (this will update the existing entry) + const updatedProjects = projects.map((p) => + p.id === existingProject.id ? project : p + ); + // We need to manually update projects since addProject would create a duplicate + useAppStore.setState({ projects: updatedProjects }); + } else { + // Create new project + project = { + id: `project-${Date.now()}`, + name, + path, + lastOpened: new Date().toISOString(), + }; + addProject(project); + } - addProject(project); setCurrentProject(project); // Show initialization dialog if files were created @@ -148,7 +168,7 @@ export function WelcomeView() { setIsOpening(false); } }, - [addProject, setCurrentProject, analyzeProject] + [projects, addProject, setCurrentProject, analyzeProject] ); const handleOpenProject = useCallback(async () => { diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index f27cd028..367e519e 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -443,13 +443,109 @@ export const isElectron = (): boolean => { return typeof window !== "undefined" && window.isElectron === true; }; -// Get the Electron API or a mock for web development +// Check if backend server is available +let serverAvailable: boolean | null = null; +let serverCheckPromise: Promise | null = null; + +export const checkServerAvailable = async (): Promise => { + if (serverAvailable !== null) return serverAvailable; + if (serverCheckPromise) return serverCheckPromise; + + serverCheckPromise = (async () => { + try { + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const response = await fetch(`${serverUrl}/api/health`, { + method: "GET", + signal: AbortSignal.timeout(2000), + }); + serverAvailable = response.ok; + } catch { + serverAvailable = false; + } + return serverAvailable; + })(); + + return serverCheckPromise; +}; + +// Reset server check (useful for retrying connection) +export const resetServerCheck = (): void => { + serverAvailable = null; + serverCheckPromise = null; +}; + +// Cached HTTP client instance +let httpClientInstance: ElectronAPI | null = null; + +// Check if we're in simplified Electron mode (HTTP backend instead of IPC) +const isSimplifiedElectronMode = (): boolean => { + if (typeof window === "undefined") return false; + const api = window.electronAPI as any; + // Simplified mode has isElectron flag and getServerUrl but NOT readFile + return api?.isElectron === true && + typeof api?.getServerUrl === "function" && + typeof api?.readFile !== "function"; +}; + +// Get the Electron API or HTTP client for web mode +// In simplified Electron mode and web mode, uses HTTP client export const getElectronAPI = (): ElectronAPI => { + // Check if we're in simplified Electron mode (uses HTTP backend) + if (isSimplifiedElectronMode()) { + if (typeof window !== "undefined" && !httpClientInstance) { + const { getHttpApiClient } = require("./http-api-client"); + httpClientInstance = getHttpApiClient(); + } + return httpClientInstance!; + } + + // Full Electron API with IPC if (isElectron() && window.electronAPI) { return window.electronAPI; } - // Return mock API for web development + // Web mode: use HTTP API client + if (typeof window !== "undefined") { + if (!httpClientInstance) { + const { getHttpApiClient } = require("./http-api-client"); + httpClientInstance = getHttpApiClient(); + } + return httpClientInstance!; + } + + // SSR fallback - this shouldn't be called during actual operation + throw new Error("Cannot get Electron API during SSR"); +}; + +// Async version that checks server availability first +export const getElectronAPIAsync = async (): Promise => { + // Simplified Electron mode or web mode: use HTTP client + if (isSimplifiedElectronMode() || !isElectron()) { + if (typeof window !== "undefined") { + const { getHttpApiClient } = await import("./http-api-client"); + return getHttpApiClient(); + } + } + + // Full Electron API with IPC + if (isElectron() && window.electronAPI) { + return window.electronAPI; + } + + throw new Error("Cannot get Electron API during SSR"); +}; + +// Check if backend is connected (for showing connection status in UI) +export const isBackendConnected = async (): Promise => { + // Full Electron mode: backend is built-in + if (isElectron() && !isSimplifiedElectronMode()) return true; + // Simplified Electron or web mode: check server availability + return await checkServerAvailable(); +}; + +// Mock API for development/fallback when no backend is available +const getMockElectronAPI = (): ElectronAPI => { return { ping: async () => "pong (mock)", diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts new file mode 100644 index 00000000..123709de --- /dev/null +++ b/apps/app/src/lib/http-api-client.ts @@ -0,0 +1,664 @@ +/** + * HTTP API Client for web mode + * + * This client provides the same API as the Electron IPC bridge, + * but communicates with the backend server via HTTP/WebSocket. + */ + +import type { + ElectronAPI, + FileResult, + WriteResult, + ReaddirResult, + StatResult, + DialogResult, + SaveImageResult, + AutoModeAPI, + FeaturesAPI, + SuggestionsAPI, + SpecRegenerationAPI, + AutoModeEvent, + SuggestionsEvent, + SpecRegenerationEvent, + FeatureSuggestion, + SuggestionType, +} from "./electron"; +import type { Feature } from "@/store/app-store"; +import type { + WorktreeAPI, + GitAPI, + ModelDefinition, + ProviderStatus, +} from "@/types/electron"; + +// Check if we're in simplified Electron mode (Electron with HTTP backend) +const isSimplifiedElectronMode = (): boolean => { + if (typeof window === "undefined") return false; + const api = (window as any).electronAPI; + // Simplified mode has isElectron flag but limited methods + return api?.isElectron === true && typeof api?.getServerUrl === "function"; +}; + +// Check if native Electron dialogs are available +const hasNativeDialogs = (): boolean => { + if (typeof window === "undefined") return false; + const api = (window as any).electronAPI; + return typeof api?.openDirectory === "function"; +}; + +// Server URL - configurable via environment variable or Electron +const getServerUrl = async (): Promise => { + if (typeof window !== "undefined") { + // In simplified Electron mode, get URL from main process + const api = (window as any).electronAPI; + if (api?.getServerUrl) { + try { + return await api.getServerUrl(); + } catch { + // Fall through to defaults + } + } + + // Check for environment variable + const envUrl = process.env.NEXT_PUBLIC_SERVER_URL; + if (envUrl) return envUrl; + + // Default to localhost for development + return "http://localhost:3008"; + } + return "http://localhost:3008"; +}; + +// Synchronous version for constructor (uses default, then updates) +const getServerUrlSync = (): string => { + if (typeof window !== "undefined") { + const envUrl = process.env.NEXT_PUBLIC_SERVER_URL; + if (envUrl) return envUrl; + } + return "http://localhost:3008"; +}; + +// Get API key from environment variable +const getApiKey = (): string | null => { + if (typeof window !== "undefined") { + return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null; + } + return null; +}; + +type EventType = + | "agent:stream" + | "auto-mode:event" + | "suggestions:event" + | "spec-regeneration:event"; + +type EventCallback = (payload: unknown) => void; + +/** + * HTTP API Client that implements ElectronAPI interface + */ +export class HttpApiClient implements ElectronAPI { + private serverUrl: string; + private ws: WebSocket | null = null; + private eventCallbacks: Map> = new Map(); + private reconnectTimer: NodeJS.Timeout | null = null; + private isConnecting = false; + + constructor() { + this.serverUrl = getServerUrlSync(); + // Update server URL asynchronously if in Electron + this.initServerUrl(); + this.connectWebSocket(); + } + + private async initServerUrl(): Promise { + const url = await getServerUrl(); + if (url !== this.serverUrl) { + this.serverUrl = url; + // Reconnect WebSocket with new URL + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.connectWebSocket(); + } + } + + private connectWebSocket(): void { + if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { + return; + } + + this.isConnecting = true; + + try { + const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events"; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log("[HttpApiClient] WebSocket connected"); + this.isConnecting = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const callbacks = this.eventCallbacks.get(data.type); + if (callbacks) { + callbacks.forEach((cb) => cb(data.payload)); + } + } catch (error) { + console.error("[HttpApiClient] Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = () => { + console.log("[HttpApiClient] WebSocket disconnected"); + this.isConnecting = false; + this.ws = null; + // Attempt to reconnect after 5 seconds + if (!this.reconnectTimer) { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connectWebSocket(); + }, 5000); + } + }; + + this.ws.onerror = (error) => { + console.error("[HttpApiClient] WebSocket error:", error); + this.isConnecting = false; + }; + } catch (error) { + console.error("[HttpApiClient] Failed to create WebSocket:", error); + this.isConnecting = false; + } + } + + private subscribeToEvent(type: EventType, callback: EventCallback): () => void { + if (!this.eventCallbacks.has(type)) { + this.eventCallbacks.set(type, new Set()); + } + this.eventCallbacks.get(type)!.add(callback); + + // Ensure WebSocket is connected + this.connectWebSocket(); + + return () => { + const callbacks = this.eventCallbacks.get(type); + if (callbacks) { + callbacks.delete(callback); + } + }; + } + + private getHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + const apiKey = getApiKey(); + if (apiKey) { + headers["X-API-Key"] = apiKey; + } + return headers; + } + + private async post(endpoint: string, body?: unknown): Promise { + const response = await fetch(`${this.serverUrl}${endpoint}`, { + method: "POST", + headers: this.getHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + return response.json(); + } + + private async get(endpoint: string): Promise { + const headers = this.getHeaders(); + const response = await fetch(`${this.serverUrl}${endpoint}`, { headers }); + return response.json(); + } + + // Basic operations + async ping(): Promise { + const result = await this.get<{ status: string }>("/api/health"); + return result.status === "ok" ? "pong" : "error"; + } + + async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> { + // Use native Electron shell if available (better UX) + if (hasNativeDialogs()) { + const api = (window as any).electronAPI; + if (api.openExternalLink) { + return api.openExternalLink(url); + } + } + // Web mode: open in new tab + window.open(url, "_blank", "noopener,noreferrer"); + return { success: true }; + } + + // File picker - uses native Electron dialogs when available, otherwise prompt + async openDirectory(): Promise { + // Use native Electron dialog if available + if (hasNativeDialogs()) { + const api = (window as any).electronAPI; + return api.openDirectory(); + } + + // Web mode: show a modal to let user type/paste path + const path = prompt("Enter project directory path:"); + if (!path) { + return { canceled: true, filePaths: [] }; + } + + // Validate with server + const result = await this.post<{ + success: boolean; + path?: string; + error?: string; + }>("/api/fs/validate-path", { filePath: path }); + + if (result.success && result.path) { + return { canceled: false, filePaths: [result.path] }; + } + + alert(result.error || "Invalid path"); + return { canceled: true, filePaths: [] }; + } + + async openFile(options?: object): Promise { + // Use native Electron dialog if available + if (hasNativeDialogs()) { + const api = (window as any).electronAPI; + return api.openFile(options); + } + + // Web mode: prompt for file path + const path = prompt("Enter file path:"); + if (!path) { + return { canceled: true, filePaths: [] }; + } + + const result = await this.post<{ success: boolean; exists: boolean }>( + "/api/fs/exists", + { filePath: path } + ); + + if (result.success && result.exists) { + return { canceled: false, filePaths: [path] }; + } + + alert("File does not exist"); + return { canceled: true, filePaths: [] }; + } + + // File system operations + async readFile(filePath: string): Promise { + return this.post("/api/fs/read", { filePath }); + } + + async writeFile(filePath: string, content: string): Promise { + return this.post("/api/fs/write", { filePath, content }); + } + + async mkdir(dirPath: string): Promise { + return this.post("/api/fs/mkdir", { dirPath }); + } + + async readdir(dirPath: string): Promise { + return this.post("/api/fs/readdir", { dirPath }); + } + + async exists(filePath: string): Promise { + const result = await this.post<{ success: boolean; exists: boolean }>( + "/api/fs/exists", + { filePath } + ); + return result.exists; + } + + async stat(filePath: string): Promise { + return this.post("/api/fs/stat", { filePath }); + } + + async deleteFile(filePath: string): Promise { + return this.post("/api/fs/delete", { filePath }); + } + + async trashItem(filePath: string): Promise { + // In web mode, trash is just delete + return this.deleteFile(filePath); + } + + async getPath(name: string): Promise { + // Server provides data directory + if (name === "userData") { + const result = await this.get<{ dataDir: string }>("/api/health/detailed"); + return result.dataDir || "/data"; + } + return `/data/${name}`; + } + + async saveImageToTemp( + data: string, + filename: string, + mimeType: string, + projectPath?: string + ): Promise { + return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath }); + } + + // CLI checks - server-side + async checkClaudeCli(): Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + recommendation?: string; + installCommands?: { + macos?: string; + windows?: string; + linux?: string; + npm?: string; + }; + error?: string; + }> { + return this.get("/api/setup/claude-status"); + } + + async checkCodexCli(): Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + hasApiKey?: boolean; + recommendation?: string; + installCommands?: { + macos?: string; + windows?: string; + linux?: string; + npm?: string; + }; + error?: string; + }> { + return this.get("/api/setup/codex-status"); + } + + // Model API + model = { + getAvailable: async (): Promise<{ + success: boolean; + models?: ModelDefinition[]; + error?: string; + }> => { + return this.get("/api/models/available"); + }, + checkProviders: async (): Promise<{ + success: boolean; + providers?: Record; + error?: string; + }> => { + return this.get("/api/models/providers"); + }, + }; + + async testOpenAIConnection(apiKey?: string): Promise<{ + success: boolean; + message?: string; + error?: string; + }> { + return this.post("/api/setup/test-openai", { apiKey }); + } + + // Setup API + setup = { + getClaudeStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasCredentialsFile?: boolean; + hasToken?: boolean; + hasStoredOAuthToken?: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; + hasEnvOAuthToken?: boolean; + }; + error?: string; + }> => this.get("/api/setup/claude-status"), + + getCodexStatus: (): Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasAuthFile: boolean; + hasEnvKey: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; + }; + error?: string; + }> => this.get("/api/setup/codex-status"), + + installClaude: (): Promise<{ + success: boolean; + message?: string; + error?: string; + }> => this.post("/api/setup/install-claude"), + + installCodex: (): Promise<{ + success: boolean; + message?: string; + error?: string; + }> => this.post("/api/setup/install-codex"), + + authClaude: (): Promise<{ + success: boolean; + token?: string; + requiresManualAuth?: boolean; + terminalOpened?: boolean; + command?: string; + error?: string; + message?: string; + output?: string; + }> => this.post("/api/setup/auth-claude"), + + authCodex: (apiKey?: string): Promise<{ + success: boolean; + requiresManualAuth?: boolean; + command?: string; + error?: string; + }> => this.post("/api/setup/auth-codex", { apiKey }), + + storeApiKey: (provider: string, apiKey: string): Promise<{ + success: boolean; + error?: string; + }> => this.post("/api/setup/store-api-key", { provider, apiKey }), + + getApiKeys: (): Promise<{ + success: boolean; + hasAnthropicKey: boolean; + hasOpenAIKey: boolean; + hasGoogleKey: boolean; + }> => this.get("/api/setup/api-keys"), + + configureCodexMcp: (projectPath: string): Promise<{ + success: boolean; + configPath?: string; + error?: string; + }> => this.post("/api/setup/configure-codex-mcp", { projectPath }), + + getPlatform: (): Promise<{ + success: boolean; + platform: string; + arch: string; + homeDir: string; + isWindows: boolean; + isMac: boolean; + isLinux: boolean; + }> => this.get("/api/setup/platform"), + + onInstallProgress: (callback: (progress: unknown) => void) => { + return this.subscribeToEvent("agent:stream", callback); + }, + + onAuthProgress: (callback: (progress: unknown) => void) => { + return this.subscribeToEvent("agent:stream", callback); + }, + }; + + // Features API + features: FeaturesAPI = { + getAll: (projectPath: string) => + this.post("/api/features/list", { projectPath }), + get: (projectPath: string, featureId: string) => + this.post("/api/features/get", { projectPath, featureId }), + create: (projectPath: string, feature: Feature) => + this.post("/api/features/create", { projectPath, feature }), + update: (projectPath: string, featureId: string, updates: Partial) => + this.post("/api/features/update", { projectPath, featureId, updates }), + delete: (projectPath: string, featureId: string) => + this.post("/api/features/delete", { projectPath, featureId }), + getAgentOutput: (projectPath: string, featureId: string) => + this.post("/api/features/agent-output", { projectPath, featureId }), + }; + + // Auto Mode API + autoMode: AutoModeAPI = { + start: (projectPath: string, maxConcurrency?: number) => + this.post("/api/auto-mode/start", { projectPath, maxConcurrency }), + stop: (projectPath: string) => + this.post("/api/auto-mode/stop", { projectPath }), + stopFeature: (featureId: string) => + this.post("/api/auto-mode/stop-feature", { featureId }), + status: (projectPath?: string) => + this.post("/api/auto-mode/status", { projectPath }), + runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => + this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }), + verifyFeature: (projectPath: string, featureId: string) => + this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), + resumeFeature: (projectPath: string, featureId: string) => + this.post("/api/auto-mode/resume-feature", { projectPath, featureId }), + contextExists: (projectPath: string, featureId: string) => + this.post("/api/auto-mode/context-exists", { projectPath, featureId }), + analyzeProject: (projectPath: string) => + this.post("/api/auto-mode/analyze-project", { projectPath }), + followUpFeature: ( + projectPath: string, + featureId: string, + prompt: string, + imagePaths?: string[] + ) => + this.post("/api/auto-mode/follow-up-feature", { + projectPath, + featureId, + prompt, + imagePaths, + }), + commitFeature: (projectPath: string, featureId: string) => + this.post("/api/auto-mode/commit-feature", { projectPath, featureId }), + onEvent: (callback: (event: AutoModeEvent) => void) => { + return this.subscribeToEvent("auto-mode:event", callback as EventCallback); + }, + }; + + // Worktree API + worktree: WorktreeAPI = { + revertFeature: (projectPath: string, featureId: string) => + this.post("/api/worktree/revert", { projectPath, featureId }), + mergeFeature: (projectPath: string, featureId: string, options?: object) => + this.post("/api/worktree/merge", { projectPath, featureId, options }), + getInfo: (projectPath: string, featureId: string) => + this.post("/api/worktree/info", { projectPath, featureId }), + getStatus: (projectPath: string, featureId: string) => + this.post("/api/worktree/status", { projectPath, featureId }), + list: (projectPath: string) => + this.post("/api/worktree/list", { projectPath }), + getDiffs: (projectPath: string, featureId: string) => + this.post("/api/worktree/diffs", { projectPath, featureId }), + getFileDiff: (projectPath: string, featureId: string, filePath: string) => + this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }), + }; + + // Git API + git: GitAPI = { + getDiffs: (projectPath: string) => + this.post("/api/git/diffs", { projectPath }), + getFileDiff: (projectPath: string, filePath: string) => + this.post("/api/git/file-diff", { projectPath, filePath }), + }; + + // Suggestions API + suggestions: SuggestionsAPI = { + generate: (projectPath: string, suggestionType?: SuggestionType) => + this.post("/api/suggestions/generate", { projectPath, suggestionType }), + stop: () => this.post("/api/suggestions/stop"), + status: () => this.get("/api/suggestions/status"), + onEvent: (callback: (event: SuggestionsEvent) => void) => { + return this.subscribeToEvent("suggestions:event", callback as EventCallback); + }, + }; + + // Spec Regeneration API + specRegeneration: SpecRegenerationAPI = { + create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) => + this.post("/api/spec-regeneration/create", { + projectPath, + projectOverview, + generateFeatures, + }), + generate: (projectPath: string, projectDefinition: string) => + this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }), + generateFeatures: (projectPath: string) => + this.post("/api/spec-regeneration/generate-features", { projectPath }), + stop: () => this.post("/api/spec-regeneration/stop"), + status: () => this.get("/api/spec-regeneration/status"), + onEvent: (callback: (event: SpecRegenerationEvent) => void) => { + return this.subscribeToEvent( + "spec-regeneration:event", + callback as EventCallback + ); + }, + }; + + // Running Agents API + runningAgents = { + getAll: (): Promise<{ + success: boolean; + runningAgents?: Array<{ + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; + }>; + totalCount?: number; + autoLoopRunning?: boolean; + error?: string; + }> => this.get("/api/running-agents"), + }; +} + +// Singleton instance +let httpApiClientInstance: HttpApiClient | null = null; + +export function getHttpApiClient(): HttpApiClient { + if (!httpApiClientInstance) { + httpApiClientInstance = new HttpApiClient(); + } + return httpApiClientInstance; +} diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index ba8a1130..8b35788a 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -1138,26 +1138,32 @@ export const useAppStore = create()( { name: "automaker-storage", partialize: (state) => ({ + // Project management projects: state.projects, currentProject: state.currentProject, trashedProjects: state.trashedProjects, projectHistory: state.projectHistory, projectHistoryIndex: state.projectHistoryIndex, + // Features - cached locally for faster hydration (authoritative source is server) + features: state.features, + // UI state currentView: state.currentView, theme: state.theme, sidebarOpen: state.sidebarOpen, - apiKeys: state.apiKeys, - chatSessions: state.chatSessions, chatHistoryOpen: state.chatHistoryOpen, + kanbanCardDetailLevel: state.kanbanCardDetailLevel, + // Settings + apiKeys: state.apiKeys, maxConcurrency: state.maxConcurrency, autoModeByProject: state.autoModeByProject, - kanbanCardDetailLevel: state.kanbanCardDetailLevel, defaultSkipTests: state.defaultSkipTests, useWorktrees: state.useWorktrees, showProfilesOnly: state.showProfilesOnly, keyboardShortcuts: state.keyboardShortcuts, muteDoneSound: state.muteDoneSound, + // Profiles and sessions aiProfiles: state.aiProfiles, + chatSessions: state.chatSessions, lastSelectedSessionByProject: state.lastSelectedSessionByProject, }), } diff --git a/apps/app/src/store/setup-store.ts b/apps/app/src/store/setup-store.ts index 8609d339..0265e64b 100644 --- a/apps/app/src/store/setup-store.ts +++ b/apps/app/src/store/setup-store.ts @@ -10,10 +10,19 @@ export interface CliStatus { error?: string; } +// Claude Auth Method - all possible authentication sources +export type ClaudeAuthMethod = + | "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable + | "oauth_token" // Stored OAuth token from claude login + | "api_key_env" // ANTHROPIC_API_KEY environment variable + | "api_key" // Manually stored API key + | "credentials_file" // Generic credentials file detection + | "none"; + // Claude Auth Status export interface ClaudeAuthStatus { authenticated: boolean; - method: "oauth_token_env" | "oauth_token" | "api_key" | "api_key_env" | "none"; + method: ClaudeAuthMethod; hasCredentialsFile?: boolean; oauthTokenValid?: boolean; apiKeyValid?: boolean; @@ -22,12 +31,23 @@ export interface ClaudeAuthStatus { error?: string; } +// Codex Auth Method - all possible authentication sources +export type CodexAuthMethod = + | "subscription" // Codex/OpenAI Plus or Team subscription + | "cli_verified" // CLI logged in with OpenAI account + | "cli_tokens" // CLI with stored access tokens + | "api_key" // Manually stored API key + | "env" // OPENAI_API_KEY environment variable + | "none"; + // Codex Auth Status export interface CodexAuthStatus { authenticated: boolean; - method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none"; + method: CodexAuthMethod; apiKeyValid?: boolean; mcpConfigured?: boolean; + hasSubscription?: boolean; + cliLoggedIn?: boolean; error?: string; } diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 00000000..6ce580b1 --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,45 @@ +# Automaker Server Configuration +# Copy this file to .env and configure your settings + +# ============================================ +# REQUIRED +# ============================================ + +# Your Anthropic API key for Claude models +ANTHROPIC_API_KEY=sk-ant-... + +# ============================================ +# OPTIONAL - Security +# ============================================ + +# API key for authenticating requests (leave empty to disable auth) +# If set, all API requests must include X-API-Key header +AUTOMAKER_API_KEY= + +# Restrict file operations to these directories (comma-separated) +# Important for security in multi-tenant environments +ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www + +# CORS origin - which domains can access the API +# Use "*" for development, set specific origin for production +CORS_ORIGIN=* + +# ============================================ +# OPTIONAL - Server +# ============================================ + +# Port to run the server on +PORT=3008 + +# Data directory for sessions and metadata +DATA_DIR=./data + +# ============================================ +# OPTIONAL - Additional AI Providers +# ============================================ + +# OpenAI API key (for Codex CLI support) +OPENAI_API_KEY= + +# Google API key (for future Gemini support) +GOOGLE_API_KEY= diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 00000000..6f909af4 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,55 @@ +# Automaker Backend Server +# Multi-stage build for minimal production image + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY apps/server/package*.json ./apps/server/ + +# Install dependencies +RUN npm ci --workspace=apps/server + +# Copy source +COPY apps/server ./apps/server + +# Build TypeScript +RUN npm run build --workspace=apps/server + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S automaker && \ + adduser -S automaker -u 1001 + +# Copy built files and production dependencies +COPY --from=builder /app/apps/server/dist ./dist +COPY --from=builder /app/apps/server/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules + +# Create data directory +RUN mkdir -p /data && chown automaker:automaker /data + +# Switch to non-root user +USER automaker + +# Environment variables +ENV NODE_ENV=production +ENV PORT=3008 +ENV DATA_DIR=/data + +# Expose port +EXPOSE 3008 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1 + +# Start server +CMD ["node", "dist/index.js"] diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 00000000..6e16d10b --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@automaker/server", + "version": "0.1.0", + "description": "Backend server for Automaker - provides API for both web and Electron modes", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src/" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.61", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/cors": "^2.8.18", + "@types/express": "^5.0.1", + "@types/node": "^20", + "@types/ws": "^8.18.1", + "tsx": "^4.19.4", + "typescript": "^5" + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 00000000..6590d13e --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,165 @@ +/** + * Automaker Backend Server + * + * Provides HTTP/WebSocket API for both web and Electron modes. + * In Electron mode, this server runs locally. + * In web mode, this server runs on a remote host. + */ + +import express from "express"; +import cors from "cors"; +import { WebSocketServer, WebSocket } from "ws"; +import { createServer } from "http"; +import dotenv from "dotenv"; + +import { createEventEmitter, type EventEmitter } from "./lib/events.js"; +import { initAllowedPaths } from "./lib/security.js"; +import { authMiddleware, getAuthStatus } from "./lib/auth.js"; +import { createFsRoutes } from "./routes/fs.js"; +import { createHealthRoutes } from "./routes/health.js"; +import { createAgentRoutes } from "./routes/agent.js"; +import { createSessionsRoutes } from "./routes/sessions.js"; +import { createFeaturesRoutes } from "./routes/features.js"; +import { createAutoModeRoutes } from "./routes/auto-mode.js"; +import { createWorktreeRoutes } from "./routes/worktree.js"; +import { createGitRoutes } from "./routes/git.js"; +import { createSetupRoutes } from "./routes/setup.js"; +import { createSuggestionsRoutes } from "./routes/suggestions.js"; +import { createModelsRoutes } from "./routes/models.js"; +import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js"; +import { createRunningAgentsRoutes } from "./routes/running-agents.js"; +import { AgentService } from "./services/agent-service.js"; +import { FeatureLoader } from "./services/feature-loader.js"; + +// Load environment variables +dotenv.config(); + +const PORT = parseInt(process.env.PORT || "3008", 10); +const DATA_DIR = process.env.DATA_DIR || "./data"; + +// Check for required environment variables +const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; +const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + +if (!hasAnthropicKey) { + console.warn(` +╔═══════════════════════════════════════════════════════════════════════╗ +║ ⚠️ WARNING: ANTHROPIC_API_KEY not set ║ +║ ║ +║ The Claude Agent SDK requires ANTHROPIC_API_KEY to function. ║ +║ ${hasOAuthToken ? ' You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only.' : ''} +║ ║ +║ Set your API key: ║ +║ export ANTHROPIC_API_KEY="sk-ant-..." ║ +║ ║ +║ Or add to apps/server/.env: ║ +║ ANTHROPIC_API_KEY=sk-ant-... ║ +╚═══════════════════════════════════════════════════════════════════════╝ +`); +} else { + console.log("[Server] ✓ ANTHROPIC_API_KEY detected"); +} + +// Initialize security +initAllowedPaths(); + +// Create Express app +const app = express(); + +// Middleware +app.use( + cors({ + origin: process.env.CORS_ORIGIN || "*", + credentials: true, + }) +); +app.use(express.json({ limit: "50mb" })); + +// Create shared event emitter for streaming +const events: EventEmitter = createEventEmitter(); + +// Create services +const agentService = new AgentService(DATA_DIR, events); +const featureLoader = new FeatureLoader(); + +// Initialize services +(async () => { + await agentService.initialize(); + console.log("[Server] Agent service initialized"); +})(); + +// Mount API routes - health is unauthenticated for monitoring +app.use("/api/health", createHealthRoutes()); + +// Apply authentication to all other routes +app.use("/api", authMiddleware); + +app.use("/api/fs", createFsRoutes(events)); +app.use("/api/agent", createAgentRoutes(agentService, events)); +app.use("/api/sessions", createSessionsRoutes(agentService)); +app.use("/api/features", createFeaturesRoutes(featureLoader)); +app.use("/api/auto-mode", createAutoModeRoutes(events)); +app.use("/api/worktree", createWorktreeRoutes()); +app.use("/api/git", createGitRoutes()); +app.use("/api/setup", createSetupRoutes()); +app.use("/api/suggestions", createSuggestionsRoutes(events)); +app.use("/api/models", createModelsRoutes()); +app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); +app.use("/api/running-agents", createRunningAgentsRoutes()); + +// Create HTTP server +const server = createServer(app); + +// WebSocket server for streaming events +const wss = new WebSocketServer({ server, path: "/api/events" }); + +wss.on("connection", (ws: WebSocket) => { + console.log("[WebSocket] Client connected"); + + // Subscribe to all events and forward to this client + const unsubscribe = events.subscribe((type, payload) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type, payload })); + } + }); + + ws.on("close", () => { + console.log("[WebSocket] Client disconnected"); + unsubscribe(); + }); + + ws.on("error", (error) => { + console.error("[WebSocket] Error:", error); + unsubscribe(); + }); +}); + +// Start server +server.listen(PORT, () => { + console.log(` +╔═══════════════════════════════════════════════════════╗ +║ Automaker Backend Server ║ +╠═══════════════════════════════════════════════════════╣ +║ HTTP API: http://localhost:${PORT} ║ +║ WebSocket: ws://localhost:${PORT}/api/events ║ +║ Health: http://localhost:${PORT}/api/health ║ +╚═══════════════════════════════════════════════════════╝ +`); +}); + +// Graceful shutdown +process.on("SIGTERM", () => { + console.log("SIGTERM received, shutting down..."); + server.close(() => { + console.log("Server closed"); + process.exit(0); + }); +}); + +process.on("SIGINT", () => { + console.log("SIGINT received, shutting down..."); + server.close(() => { + console.log("Server closed"); + process.exit(0); + }); +}); diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts new file mode 100644 index 00000000..331af2cf --- /dev/null +++ b/apps/server/src/lib/auth.ts @@ -0,0 +1,62 @@ +/** + * Authentication middleware for API security + * + * Supports API key authentication via header or environment variable. + */ + +import type { Request, Response, NextFunction } from "express"; + +// API key from environment (optional - if not set, auth is disabled) +const API_KEY = process.env.AUTOMAKER_API_KEY; + +/** + * Authentication middleware + * + * If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header. + * If not set, allows all requests (development mode). + */ +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + // If no API key is configured, allow all requests + if (!API_KEY) { + next(); + return; + } + + // Check for API key in header + const providedKey = req.headers["x-api-key"] as string | undefined; + + if (!providedKey) { + res.status(401).json({ + success: false, + error: "Authentication required. Provide X-API-Key header.", + }); + return; + } + + if (providedKey !== API_KEY) { + res.status(403).json({ + success: false, + error: "Invalid API key.", + }); + return; + } + + next(); +} + +/** + * Check if authentication is enabled + */ +export function isAuthEnabled(): boolean { + return !!API_KEY; +} + +/** + * Get authentication status for health endpoint + */ +export function getAuthStatus(): { enabled: boolean; method: string } { + return { + enabled: !!API_KEY, + method: API_KEY ? "api_key" : "none", + }; +} diff --git a/apps/server/src/lib/events.ts b/apps/server/src/lib/events.ts new file mode 100644 index 00000000..d6f7036e --- /dev/null +++ b/apps/server/src/lib/events.ts @@ -0,0 +1,57 @@ +/** + * Event emitter for streaming events to WebSocket clients + */ + +export type EventType = + | "agent:stream" + | "auto-mode:event" + | "auto-mode:started" + | "auto-mode:stopped" + | "auto-mode:idle" + | "auto-mode:error" + | "feature:started" + | "feature:completed" + | "feature:stopped" + | "feature:error" + | "feature:progress" + | "feature:tool-use" + | "feature:follow-up-started" + | "feature:follow-up-completed" + | "feature:verified" + | "feature:committed" + | "project:analysis-started" + | "project:analysis-progress" + | "project:analysis-completed" + | "project:analysis-error" + | "suggestions:event" + | "spec-regeneration:event"; + +export type EventCallback = (type: EventType, payload: unknown) => void; + +export interface EventEmitter { + emit: (type: EventType, payload: unknown) => void; + subscribe: (callback: EventCallback) => () => void; +} + +export function createEventEmitter(): EventEmitter { + const subscribers = new Set(); + + return { + emit(type: EventType, payload: unknown) { + for (const callback of subscribers) { + try { + callback(type, payload); + } catch (error) { + console.error("Error in event subscriber:", error); + } + } + }, + + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; +} diff --git a/apps/server/src/lib/security.ts b/apps/server/src/lib/security.ts new file mode 100644 index 00000000..eac0af00 --- /dev/null +++ b/apps/server/src/lib/security.ts @@ -0,0 +1,72 @@ +/** + * Security utilities for path validation + */ + +import path from "path"; + +// Allowed project directories - loaded from environment +const allowedPaths = new Set(); + +/** + * Initialize allowed paths from environment variable + */ +export function initAllowedPaths(): void { + const dirs = process.env.ALLOWED_PROJECT_DIRS; + if (dirs) { + for (const dir of dirs.split(",")) { + const trimmed = dir.trim(); + if (trimmed) { + allowedPaths.add(path.resolve(trimmed)); + } + } + } + + // Always allow the data directory + const dataDir = process.env.DATA_DIR; + if (dataDir) { + allowedPaths.add(path.resolve(dataDir)); + } +} + +/** + * Add a path to the allowed list + */ +export function addAllowedPath(filePath: string): void { + allowedPaths.add(path.resolve(filePath)); +} + +/** + * Check if a path is allowed + */ +export function isPathAllowed(filePath: string): boolean { + const resolved = path.resolve(filePath); + + // Check if the path is under any allowed directory + for (const allowed of allowedPaths) { + if (resolved.startsWith(allowed + path.sep) || resolved === allowed) { + return true; + } + } + + return false; +} + +/** + * Validate a path and throw if not allowed + */ +export function validatePath(filePath: string): string { + const resolved = path.resolve(filePath); + + if (!isPathAllowed(resolved)) { + throw new Error(`Access denied: ${filePath} is not in an allowed directory`); + } + + return resolved; +} + +/** + * Get list of allowed paths (for debugging) + */ +export function getAllowedPaths(): string[] { + return Array.from(allowedPaths); +} diff --git a/apps/server/src/routes/agent.ts b/apps/server/src/routes/agent.ts new file mode 100644 index 00000000..966b8916 --- /dev/null +++ b/apps/server/src/routes/agent.ts @@ -0,0 +1,132 @@ +/** + * Agent routes - HTTP API for Claude agent interactions + */ + +import { Router, type Request, type Response } from "express"; +import { AgentService } from "../services/agent-service.js"; +import type { EventEmitter } from "../lib/events.js"; + +export function createAgentRoutes( + agentService: AgentService, + _events: EventEmitter +): Router { + const router = Router(); + + // Start a conversation + router.post("/start", async (req: Request, res: Response) => { + try { + const { sessionId, workingDirectory } = req.body as { + sessionId: string; + workingDirectory?: string; + }; + + if (!sessionId) { + res.status(400).json({ success: false, error: "sessionId is required" }); + return; + } + + const result = await agentService.startConversation({ + sessionId, + workingDirectory, + }); + + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Send a message + router.post("/send", async (req: Request, res: Response) => { + try { + const { sessionId, message, workingDirectory, imagePaths } = req.body as { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + }; + + if (!sessionId || !message) { + res + .status(400) + .json({ success: false, error: "sessionId and message are required" }); + return; + } + + // Start the message processing (don't await - it streams via WebSocket) + agentService + .sendMessage({ + sessionId, + message, + workingDirectory, + imagePaths, + }) + .catch((error) => { + console.error("[Agent Route] Error sending message:", error); + }); + + // Return immediately - responses come via WebSocket + res.json({ success: true, message: "Message sent" }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get conversation history + router.post("/history", async (req: Request, res: Response) => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: "sessionId is required" }); + return; + } + + const result = agentService.getHistory(sessionId); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Stop execution + router.post("/stop", async (req: Request, res: Response) => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: "sessionId is required" }); + return; + } + + const result = await agentService.stopExecution(sessionId); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Clear conversation + router.post("/clear", async (req: Request, res: Response) => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: "sessionId is required" }); + return; + } + + const result = await agentService.clearSession(sessionId); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/auto-mode.ts b/apps/server/src/routes/auto-mode.ts new file mode 100644 index 00000000..408b0d96 --- /dev/null +++ b/apps/server/src/routes/auto-mode.ts @@ -0,0 +1,263 @@ +/** + * Auto Mode routes - HTTP API for autonomous feature implementation + * + * Uses the AutoModeService for real feature execution with Claude Agent SDK + */ + +import { Router, type Request, type Response } from "express"; +import type { EventEmitter } from "../lib/events.js"; +import { AutoModeService } from "../services/auto-mode-service.js"; + +export function createAutoModeRoutes(events: EventEmitter): Router { + const router = Router(); + const autoModeService = new AutoModeService(events); + + // Start auto mode loop + router.post("/start", async (req: Request, res: Response) => { + try { + const { projectPath, maxConcurrency } = req.body as { + projectPath: string; + maxConcurrency?: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath is required" }); + return; + } + + await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3); + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Stop auto mode loop + router.post("/stop", async (req: Request, res: Response) => { + try { + const runningCount = await autoModeService.stopAutoLoop(); + res.json({ success: true, runningFeatures: runningCount }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Stop a specific feature + router.post("/stop-feature", async (req: Request, res: Response) => { + try { + const { featureId } = req.body as { featureId: string }; + + if (!featureId) { + res.status(400).json({ success: false, error: "featureId is required" }); + return; + } + + const stopped = await autoModeService.stopFeature(featureId); + res.json({ success: true, stopped }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get auto mode status + router.post("/status", async (req: Request, res: Response) => { + try { + const status = autoModeService.getStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Run a single feature + router.post("/run-feature", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + // Start execution in background + autoModeService + .executeFeature(projectPath, featureId, useWorktrees ?? true, false) + .catch((error) => { + console.error(`[AutoMode] Feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Verify a feature + router.post("/verify-feature", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const passes = await autoModeService.verifyFeature(projectPath, featureId); + res.json({ success: true, passes }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Resume a feature + router.post("/resume-feature", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + // Start resume in background + autoModeService + .resumeFeature(projectPath, featureId, useWorktrees ?? true) + .catch((error) => { + console.error(`[AutoMode] Resume feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Check if context exists for a feature + router.post("/context-exists", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const exists = await autoModeService.contextExists(projectPath, featureId); + res.json({ success: true, exists }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Analyze project + router.post("/analyze-project", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath is required" }); + return; + } + + // Start analysis in background + autoModeService.analyzeProject(projectPath).catch((error) => { + console.error(`[AutoMode] Project analysis error:`, error); + }); + + res.json({ success: true, message: "Project analysis started" }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Follow up on a feature + router.post("/follow-up-feature", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, prompt, imagePaths } = req.body as { + projectPath: string; + featureId: string; + prompt: string; + imagePaths?: string[]; + }; + + if (!projectPath || !featureId || !prompt) { + res.status(400).json({ + success: false, + error: "projectPath, featureId, and prompt are required", + }); + return; + } + + // Start follow-up in background + autoModeService + .followUpFeature(projectPath, featureId, prompt, imagePaths) + .catch((error) => { + console.error(`[AutoMode] Follow up feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Commit feature changes + router.post("/commit-feature", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const commitHash = await autoModeService.commitFeature(projectPath, featureId); + res.json({ success: true, commitHash }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/features.ts b/apps/server/src/routes/features.ts new file mode 100644 index 00000000..2878ef08 --- /dev/null +++ b/apps/server/src/routes/features.ts @@ -0,0 +1,159 @@ +/** + * Features routes - HTTP API for feature management + */ + +import { Router, type Request, type Response } from "express"; +import { FeatureLoader, type Feature } from "../services/feature-loader.js"; +import { addAllowedPath } from "../lib/security.js"; + +export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { + const router = Router(); + + // List all features for a project + router.post("/list", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath is required" }); + return; + } + + // Add project path to allowed paths + addAllowedPath(projectPath); + + const features = await featureLoader.getAll(projectPath); + res.json({ success: true, features }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get a single feature + router.post("/get", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const feature = await featureLoader.get(projectPath, featureId); + if (!feature) { + res.status(404).json({ success: false, error: "Feature not found" }); + return; + } + + res.json({ success: true, feature }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Create a new feature + router.post("/create", async (req: Request, res: Response) => { + try { + const { projectPath, feature } = req.body as { + projectPath: string; + feature: Partial; + }; + + if (!projectPath || !feature) { + res + .status(400) + .json({ success: false, error: "projectPath and feature are required" }); + return; + } + + // Add project path to allowed paths + addAllowedPath(projectPath); + + const created = await featureLoader.create(projectPath, feature); + res.json({ success: true, feature: created }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Update a feature + router.post("/update", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, updates } = req.body as { + projectPath: string; + featureId: string; + updates: Partial; + }; + + if (!projectPath || !featureId || !updates) { + res.status(400).json({ + success: false, + error: "projectPath, featureId, and updates are required", + }); + return; + } + + const updated = await featureLoader.update(projectPath, featureId, updates); + res.json({ success: true, feature: updated }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Delete a feature + router.post("/delete", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const success = await featureLoader.delete(projectPath, featureId); + res.json({ success }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get agent output for a feature + router.post("/agent-output", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId are required" }); + return; + } + + const content = await featureLoader.getAgentOutput(projectPath, featureId); + res.json({ success: true, content }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts new file mode 100644 index 00000000..581cd335 --- /dev/null +++ b/apps/server/src/routes/fs.ts @@ -0,0 +1,221 @@ +/** + * File system routes + * Provides REST API equivalents for Electron IPC file operations + */ + +import { Router, type Request, type Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; +import type { EventEmitter } from "../lib/events.js"; + +export function createFsRoutes(_events: EventEmitter): Router { + const router = Router(); + + // Read file + router.post("/read", async (req: Request, res: Response) => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + const resolvedPath = validatePath(filePath); + const content = await fs.readFile(resolvedPath, "utf-8"); + + res.json({ success: true, content }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Write file + router.post("/write", async (req: Request, res: Response) => { + try { + const { filePath, content } = req.body as { + filePath: string; + content: string; + }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + const resolvedPath = validatePath(filePath); + + // Ensure parent directory exists + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.writeFile(resolvedPath, content, "utf-8"); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Create directory + router.post("/mkdir", async (req: Request, res: Response) => { + try { + const { dirPath } = req.body as { dirPath: string }; + + if (!dirPath) { + res.status(400).json({ success: false, error: "dirPath is required" }); + return; + } + + const resolvedPath = validatePath(dirPath); + await fs.mkdir(resolvedPath, { recursive: true }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Read directory + router.post("/readdir", async (req: Request, res: Response) => { + try { + const { dirPath } = req.body as { dirPath: string }; + + if (!dirPath) { + res.status(400).json({ success: false, error: "dirPath is required" }); + return; + } + + const resolvedPath = validatePath(dirPath); + const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); + + const result = entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + })); + + res.json({ success: true, entries: result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Check if file/directory exists + router.post("/exists", async (req: Request, res: Response) => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + // For exists, we check but don't require the path to be pre-allowed + // This allows the UI to validate user-entered paths + const resolvedPath = path.resolve(filePath); + + try { + await fs.access(resolvedPath); + res.json({ success: true, exists: true }); + } catch { + res.json({ success: true, exists: false }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get file stats + router.post("/stat", async (req: Request, res: Response) => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + const resolvedPath = validatePath(filePath); + const stats = await fs.stat(resolvedPath); + + res.json({ + success: true, + stats: { + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + size: stats.size, + mtime: stats.mtime, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Delete file + router.post("/delete", async (req: Request, res: Response) => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + const resolvedPath = validatePath(filePath); + await fs.rm(resolvedPath, { recursive: true }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Validate and add path to allowed list + // This is the web equivalent of dialog:openDirectory + router.post("/validate-path", async (req: Request, res: Response) => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: "filePath is required" }); + return; + } + + const resolvedPath = path.resolve(filePath); + + // Check if path exists + try { + const stats = await fs.stat(resolvedPath); + + if (!stats.isDirectory()) { + res.status(400).json({ success: false, error: "Path is not a directory" }); + return; + } + + // Add to allowed paths + addAllowedPath(resolvedPath); + + res.json({ + success: true, + path: resolvedPath, + isAllowed: isPathAllowed(resolvedPath), + }); + } catch { + res.status(400).json({ success: false, error: "Path does not exist" }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/git.ts b/apps/server/src/routes/git.ts new file mode 100644 index 00000000..e6a65ba4 --- /dev/null +++ b/apps/server/src/routes/git.ts @@ -0,0 +1,102 @@ +/** + * Git routes - HTTP API for git operations (non-worktree) + */ + +import { Router, type Request, type Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +export function createGitRoutes(): Router { + const router = Router(); + + // Get diffs for the main project + router.post("/diffs", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + try { + const { stdout: diff } = await execAsync("git diff HEAD", { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: projectPath, + }); + + const files = status + .split("\n") + .filter(Boolean) + .map((line) => { + const statusChar = line[0]; + const filePath = line.slice(3); + const statusMap: Record = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + U: "Updated", + "?": "Untracked", + }; + return { + status: statusChar, + path: filePath, + statusText: statusMap[statusChar] || "Unknown", + }; + }); + + res.json({ + success: true, + diff, + files, + hasChanges: files.length > 0, + }); + } catch { + res.json({ success: true, diff: "", files: [], hasChanges: false }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get diff for a specific file + router.post("/file-diff", async (req: Request, res: Response) => { + try { + const { projectPath, filePath } = req.body as { + projectPath: string; + filePath: string; + }; + + if (!projectPath || !filePath) { + res + .status(400) + .json({ success: false, error: "projectPath and filePath required" }); + return; + } + + try { + const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); + + res.json({ success: true, diff, filePath }); + } catch { + res.json({ success: true, diff: "", filePath }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts new file mode 100644 index 00000000..78111004 --- /dev/null +++ b/apps/server/src/routes/health.ts @@ -0,0 +1,39 @@ +/** + * Health check routes + */ + +import { Router } from "express"; +import { getAuthStatus } from "../lib/auth.js"; + +export function createHealthRoutes(): Router { + const router = Router(); + + // Basic health check + router.get("/", (_req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "0.1.0", + }); + }); + + // Detailed health check + router.get("/detailed", (_req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "0.1.0", + uptime: process.uptime(), + memory: process.memoryUsage(), + dataDir: process.env.DATA_DIR || "./data", + auth: getAuthStatus(), + env: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }, + }); + }); + + return router; +} diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts new file mode 100644 index 00000000..529c50a6 --- /dev/null +++ b/apps/server/src/routes/models.ts @@ -0,0 +1,128 @@ +/** + * Models routes - HTTP API for model providers and availability + */ + +import { Router, type Request, type Response } from "express"; + +interface ModelDefinition { + id: string; + name: string; + provider: string; + contextWindow: number; + maxOutputTokens: number; + supportsVision: boolean; + supportsTools: boolean; +} + +interface ProviderStatus { + available: boolean; + hasApiKey: boolean; + error?: string; +} + +export function createModelsRoutes(): Router { + const router = Router(); + + // Get available models + router.get("/available", async (_req: Request, res: Response) => { + try { + const models: ModelDefinition[] = [ + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + provider: "anthropic", + contextWindow: 200000, + maxOutputTokens: 16384, + supportsVision: true, + supportsTools: true, + }, + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + provider: "anthropic", + contextWindow: 200000, + maxOutputTokens: 16384, + supportsVision: true, + supportsTools: true, + }, + { + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + provider: "anthropic", + contextWindow: 200000, + maxOutputTokens: 8192, + supportsVision: true, + supportsTools: true, + }, + { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + provider: "anthropic", + contextWindow: 200000, + maxOutputTokens: 8192, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-4o", + name: "GPT-4o", + provider: "openai", + contextWindow: 128000, + maxOutputTokens: 16384, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-4o-mini", + name: "GPT-4o Mini", + provider: "openai", + contextWindow: 128000, + maxOutputTokens: 16384, + supportsVision: true, + supportsTools: true, + }, + { + id: "o1", + name: "o1", + provider: "openai", + contextWindow: 200000, + maxOutputTokens: 100000, + supportsVision: true, + supportsTools: false, + }, + ]; + + res.json({ success: true, models }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Check provider status + router.get("/providers", async (_req: Request, res: Response) => { + try { + const providers: Record = { + anthropic: { + available: !!process.env.ANTHROPIC_API_KEY, + hasApiKey: !!process.env.ANTHROPIC_API_KEY, + }, + openai: { + available: !!process.env.OPENAI_API_KEY, + hasApiKey: !!process.env.OPENAI_API_KEY, + }, + google: { + available: !!process.env.GOOGLE_API_KEY, + hasApiKey: !!process.env.GOOGLE_API_KEY, + }, + }; + + res.json({ success: true, providers }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/running-agents.ts b/apps/server/src/routes/running-agents.ts new file mode 100644 index 00000000..57285636 --- /dev/null +++ b/apps/server/src/routes/running-agents.ts @@ -0,0 +1,70 @@ +/** + * Running Agents routes - HTTP API for tracking active agent executions + */ + +import { Router, type Request, type Response } from "express"; +import path from "path"; + +interface RunningAgent { + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; +} + +// In-memory tracking of running agents (shared with auto-mode service via reference) +const runningAgentsMap = new Map(); +let autoLoopRunning = false; + +export function createRunningAgentsRoutes(): Router { + const router = Router(); + + // Get all running agents + router.get("/", async (_req: Request, res: Response) => { + try { + const runningAgents = Array.from(runningAgentsMap.values()); + + res.json({ + success: true, + runningAgents, + totalCount: runningAgents.length, + autoLoopRunning, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} + +// Export functions to update running agents from other services +export function registerRunningAgent( + featureId: string, + projectPath: string, + isAutoMode: boolean +): void { + runningAgentsMap.set(featureId, { + featureId, + projectPath, + projectName: path.basename(projectPath), + isAutoMode, + }); +} + +export function unregisterRunningAgent(featureId: string): void { + runningAgentsMap.delete(featureId); +} + +export function setAutoLoopRunning(running: boolean): void { + autoLoopRunning = running; +} + +export function getRunningAgentsCount(): number { + return runningAgentsMap.size; +} + +export function isAgentRunning(featureId: string): boolean { + return runningAgentsMap.has(featureId); +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts new file mode 100644 index 00000000..15fce21b --- /dev/null +++ b/apps/server/src/routes/sessions.ts @@ -0,0 +1,126 @@ +/** + * Sessions routes - HTTP API for session management + */ + +import { Router, type Request, type Response } from "express"; +import { AgentService } from "../services/agent-service.js"; + +export function createSessionsRoutes(agentService: AgentService): Router { + const router = Router(); + + // List all sessions + router.get("/", async (req: Request, res: Response) => { + try { + const includeArchived = req.query.includeArchived === "true"; + const sessions = await agentService.listSessions(includeArchived); + res.json({ success: true, sessions }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Create a new session + router.post("/", async (req: Request, res: Response) => { + try { + const { name, projectPath, workingDirectory } = req.body as { + name: string; + projectPath?: string; + workingDirectory?: string; + }; + + if (!name) { + res.status(400).json({ success: false, error: "name is required" }); + return; + } + + const session = await agentService.createSession( + name, + projectPath, + workingDirectory + ); + res.json({ success: true, session }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Update a session + router.put("/:sessionId", async (req: Request, res: Response) => { + try { + const { sessionId } = req.params; + const { name, tags } = req.body as { + name?: string; + tags?: string[]; + }; + + const session = await agentService.updateSession(sessionId, { name, tags }); + if (!session) { + res.status(404).json({ success: false, error: "Session not found" }); + return; + } + + res.json({ success: true, session }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Archive a session + router.post("/:sessionId/archive", async (req: Request, res: Response) => { + try { + const { sessionId } = req.params; + const success = await agentService.archiveSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: "Session not found" }); + return; + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Unarchive a session + router.post("/:sessionId/unarchive", async (req: Request, res: Response) => { + try { + const { sessionId } = req.params; + const success = await agentService.unarchiveSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: "Session not found" }); + return; + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Delete a session + router.delete("/:sessionId", async (req: Request, res: Response) => { + try { + const { sessionId } = req.params; + const success = await agentService.deleteSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: "Session not found" }); + return; + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts new file mode 100644 index 00000000..1017db8b --- /dev/null +++ b/apps/server/src/routes/setup.ts @@ -0,0 +1,407 @@ +/** + * Setup routes - HTTP API for CLI detection, API keys, and platform info + */ + +import { Router, type Request, type Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import os from "os"; +import path from "path"; +import fs from "fs/promises"; + +const execAsync = promisify(exec); + +// Storage for API keys (in-memory for now, should be persisted) +const apiKeys: Record = {}; + +export function createSetupRoutes(): Router { + const router = Router(); + + // Get Claude CLI status + router.get("/claude-status", async (_req: Request, res: Response) => { + try { + let installed = false; + let version = ""; + let cliPath = ""; + let method = "none"; + + // Try to find Claude CLI + try { + const { stdout } = await execAsync("which claude || where claude 2>/dev/null"); + cliPath = stdout.trim(); + installed = true; + method = "path"; + + // Get version + try { + const { stdout: versionOut } = await execAsync("claude --version"); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + } catch { + // Not in PATH, try common locations + const commonPaths = [ + path.join(os.homedir(), ".local", "bin", "claude"), + "/usr/local/bin/claude", + path.join(os.homedir(), ".npm-global", "bin", "claude"), + ]; + + for (const p of commonPaths) { + try { + await fs.access(p); + cliPath = p; + installed = true; + method = "local"; + break; + } catch { + // Not found at this path + } + } + } + + // Check authentication - detect all possible auth methods + let auth = { + authenticated: false, + method: "none" as string, + hasCredentialsFile: false, + hasToken: false, + hasStoredOAuthToken: false, + hasStoredApiKey: !!apiKeys.anthropic, + hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY, + hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN, + // Additional fields for detailed status + oauthTokenValid: false, + apiKeyValid: false, + }; + + // Check for credentials file (OAuth tokens from claude login) + const credentialsPath = path.join(os.homedir(), ".claude", "credentials.json"); + try { + const credentialsContent = await fs.readFile(credentialsPath, "utf-8"); + const credentials = JSON.parse(credentialsContent); + auth.hasCredentialsFile = true; + + // Check what type of token is in credentials + if (credentials.oauth_token || credentials.access_token) { + auth.hasStoredOAuthToken = true; + auth.oauthTokenValid = true; + auth.authenticated = true; + auth.method = "oauth_token"; // Stored OAuth token from credentials file + } else if (credentials.api_key) { + auth.apiKeyValid = true; + auth.authenticated = true; + auth.method = "api_key"; // Stored API key in credentials file + } + } catch { + // No credentials file or invalid format + } + + // Environment variables override stored credentials (higher priority) + if (auth.hasEnvOAuthToken) { + auth.authenticated = true; + auth.oauthTokenValid = true; + auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var + } else if (auth.hasEnvApiKey) { + auth.authenticated = true; + auth.apiKeyValid = true; + auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var + } + + // In-memory stored API key (from settings UI) + if (!auth.authenticated && apiKeys.anthropic) { + auth.authenticated = true; + auth.method = "api_key"; // Manually stored API key + } + + res.json({ + success: true, + status: installed ? "installed" : "not_installed", + installed, + method, + version, + path: cliPath, + auth, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get Codex CLI status + router.get("/codex-status", async (_req: Request, res: Response) => { + try { + let installed = false; + let version = ""; + let cliPath = ""; + let method = "none"; + + // Try to find Codex CLI + try { + const { stdout } = await execAsync("which codex || where codex 2>/dev/null"); + cliPath = stdout.trim(); + installed = true; + method = "path"; + + try { + const { stdout: versionOut } = await execAsync("codex --version"); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + } catch { + // Not found + } + + // Check for OpenAI/Codex authentication + let auth = { + authenticated: false, + method: "none" as string, + hasAuthFile: false, + hasEnvKey: !!process.env.OPENAI_API_KEY, + hasStoredApiKey: !!apiKeys.openai, + hasEnvApiKey: !!process.env.OPENAI_API_KEY, + // Additional fields for subscription/account detection + hasSubscription: false, + cliLoggedIn: false, + }; + + // Check for OpenAI CLI auth file (~/.codex/auth.json or similar) + const codexAuthPaths = [ + path.join(os.homedir(), ".codex", "auth.json"), + path.join(os.homedir(), ".openai", "credentials"), + path.join(os.homedir(), ".config", "openai", "credentials.json"), + ]; + + for (const authPath of codexAuthPaths) { + try { + const authContent = await fs.readFile(authPath, "utf-8"); + const authData = JSON.parse(authContent); + auth.hasAuthFile = true; + + // Check for subscription/tokens + if (authData.subscription || authData.plan || authData.account_type) { + auth.hasSubscription = true; + auth.authenticated = true; + auth.method = "subscription"; // Codex subscription (Plus/Team) + } else if (authData.access_token || authData.api_key) { + auth.cliLoggedIn = true; + auth.authenticated = true; + auth.method = "cli_verified"; // CLI logged in with account + } + break; + } catch { + // Auth file not found at this path + } + } + + // Environment variable has highest priority + if (auth.hasEnvApiKey) { + auth.authenticated = true; + auth.method = "env"; // OPENAI_API_KEY environment variable + } + + // In-memory stored API key (from settings UI) + if (!auth.authenticated && apiKeys.openai) { + auth.authenticated = true; + auth.method = "api_key"; // Manually stored API key + } + + res.json({ + success: true, + status: installed ? "installed" : "not_installed", + method, + version, + path: cliPath, + auth, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Install Claude CLI + router.post("/install-claude", async (_req: Request, res: Response) => { + try { + // In web mode, we can't install CLIs directly + // Return instructions instead + res.json({ + success: false, + error: + "CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Install Codex CLI + router.post("/install-codex", async (_req: Request, res: Response) => { + try { + res.json({ + success: false, + error: + "CLI installation requires terminal access. Please install manually using: npm install -g @openai/codex", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Auth Claude + router.post("/auth-claude", async (_req: Request, res: Response) => { + try { + res.json({ + success: true, + requiresManualAuth: true, + command: "claude login", + message: "Please run 'claude login' in your terminal to authenticate", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Auth Codex + router.post("/auth-codex", async (req: Request, res: Response) => { + try { + const { apiKey } = req.body as { apiKey?: string }; + + if (apiKey) { + apiKeys.openai = apiKey; + process.env.OPENAI_API_KEY = apiKey; + res.json({ success: true }); + } else { + res.json({ + success: true, + requiresManualAuth: true, + command: "codex auth login", + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Store API key + router.post("/store-api-key", async (req: Request, res: Response) => { + try { + const { provider, apiKey } = req.body as { provider: string; apiKey: string }; + + if (!provider || !apiKey) { + res.status(400).json({ success: false, error: "provider and apiKey required" }); + return; + } + + apiKeys[provider] = apiKey; + + // Also set as environment variable + if (provider === "anthropic") { + process.env.ANTHROPIC_API_KEY = apiKey; + } else if (provider === "openai") { + process.env.OPENAI_API_KEY = apiKey; + } else if (provider === "google") { + process.env.GOOGLE_API_KEY = apiKey; + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get API keys status + router.get("/api-keys", async (_req: Request, res: Response) => { + try { + res.json({ + success: true, + hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY, + hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY, + hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Configure Codex MCP + router.post("/configure-codex-mcp", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + // Create .codex directory and config + const codexDir = path.join(projectPath, ".codex"); + await fs.mkdir(codexDir, { recursive: true }); + + const configPath = path.join(codexDir, "config.toml"); + const config = `# Codex configuration +[mcp] +enabled = true +`; + await fs.writeFile(configPath, config); + + res.json({ success: true, configPath }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get platform info + router.get("/platform", async (_req: Request, res: Response) => { + try { + const platform = os.platform(); + res.json({ + success: true, + platform, + arch: os.arch(), + homeDir: os.homedir(), + isWindows: platform === "win32", + isMac: platform === "darwin", + isLinux: platform === "linux", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Test OpenAI connection + router.post("/test-openai", async (req: Request, res: Response) => { + try { + const { apiKey } = req.body as { apiKey?: string }; + const key = apiKey || apiKeys.openai || process.env.OPENAI_API_KEY; + + if (!key) { + res.json({ success: false, error: "No OpenAI API key provided" }); + return; + } + + // Simple test - just verify the key format + if (!key.startsWith("sk-")) { + res.json({ success: false, error: "Invalid OpenAI API key format" }); + return; + } + + res.json({ success: true, message: "API key format is valid" }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/routes/spec-regeneration.ts b/apps/server/src/routes/spec-regeneration.ts new file mode 100644 index 00000000..f2409d34 --- /dev/null +++ b/apps/server/src/routes/spec-regeneration.ts @@ -0,0 +1,412 @@ +/** + * Spec Regeneration routes - HTTP API for AI-powered spec generation + */ + +import { Router, type Request, type Response } from "express"; +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import path from "path"; +import fs from "fs/promises"; +import type { EventEmitter } from "../lib/events.js"; + +let isRunning = false; +let currentAbortController: AbortController | null = null; + +export function createSpecRegenerationRoutes(events: EventEmitter): Router { + const router = Router(); + + // Create project spec from overview + router.post("/create", async (req: Request, res: Response) => { + try { + const { projectPath, projectOverview, generateFeatures } = req.body as { + projectPath: string; + projectOverview: string; + generateFeatures?: boolean; + }; + + if (!projectPath || !projectOverview) { + res.status(400).json({ + success: false, + error: "projectPath and projectOverview required", + }); + return; + } + + if (isRunning) { + res.json({ success: false, error: "Spec generation already running" }); + return; + } + + isRunning = true; + currentAbortController = new AbortController(); + + // Start generation in background + generateSpec( + projectPath, + projectOverview, + events, + currentAbortController, + generateFeatures + ) + .catch((error) => { + console.error("[SpecRegeneration] Error:", error); + events.emit("spec-regeneration:event", { + type: "spec_error", + error: error.message, + }); + }) + .finally(() => { + isRunning = false; + currentAbortController = null; + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Generate from project definition + router.post("/generate", async (req: Request, res: Response) => { + try { + const { projectPath, projectDefinition } = req.body as { + projectPath: string; + projectDefinition: string; + }; + + if (!projectPath || !projectDefinition) { + res.status(400).json({ + success: false, + error: "projectPath and projectDefinition required", + }); + return; + } + + if (isRunning) { + res.json({ success: false, error: "Spec generation already running" }); + return; + } + + isRunning = true; + currentAbortController = new AbortController(); + + generateSpec( + projectPath, + projectDefinition, + events, + currentAbortController, + false + ) + .catch((error) => { + console.error("[SpecRegeneration] Error:", error); + events.emit("spec-regeneration:event", { + type: "spec_error", + error: error.message, + }); + }) + .finally(() => { + isRunning = false; + currentAbortController = null; + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Generate features from existing spec + router.post("/generate-features", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + if (isRunning) { + res.json({ success: false, error: "Generation already running" }); + return; + } + + isRunning = true; + currentAbortController = new AbortController(); + + generateFeaturesFromSpec(projectPath, events, currentAbortController) + .catch((error) => { + console.error("[SpecRegeneration] Error:", error); + events.emit("spec-regeneration:event", { + type: "features_error", + error: error.message, + }); + }) + .finally(() => { + isRunning = false; + currentAbortController = null; + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Stop generation + router.post("/stop", async (_req: Request, res: Response) => { + try { + if (currentAbortController) { + currentAbortController.abort(); + } + isRunning = false; + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get status + router.get("/status", async (_req: Request, res: Response) => { + try { + res.json({ success: true, isRunning }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} + +async function generateSpec( + projectPath: string, + projectOverview: string, + events: EventEmitter, + abortController: AbortController, + generateFeatures?: boolean +) { + const prompt = `You are helping to define a software project specification. + +Project Overview: +${projectOverview} + +Based on this overview, analyze the project and create a comprehensive specification that includes: + +1. **Project Summary** - Brief description of what the project does +2. **Core Features** - Main functionality the project needs +3. **Technical Stack** - Recommended technologies and frameworks +4. **Architecture** - High-level system design +5. **Data Models** - Key entities and their relationships +6. **API Design** - Main endpoints/interfaces needed +7. **User Experience** - Key user flows and interactions + +${generateFeatures ? ` +Also generate a list of features to implement. For each feature provide: +- ID (lowercase-hyphenated) +- Title +- Description +- Priority (1=high, 2=medium, 3=low) +- Estimated complexity (simple, moderate, complex) +` : ""} + +Format your response as markdown. Be specific and actionable.`; + + events.emit("spec-regeneration:event", { + type: "spec_progress", + content: "Starting spec generation...\n", + }); + + const options: Options = { + model: "claude-opus-4-5-20251101", + maxTurns: 10, + cwd: projectPath, + allowedTools: ["Read", "Glob", "Grep"], + permissionMode: "acceptEdits", + abortController, + }; + + const stream = query({ prompt, options }); + let responseText = ""; + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText = block.text; + events.emit("spec-regeneration:event", { + type: "spec_progress", + content: block.text, + }); + } else if (block.type === "tool_use") { + events.emit("spec-regeneration:event", { + type: "spec_tool", + tool: block.name, + input: block.input, + }); + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + responseText = msg.result || responseText; + } + } + + // Save spec + const specDir = path.join(projectPath, ".automaker"); + const specPath = path.join(specDir, "project-spec.md"); + + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile(specPath, responseText); + + events.emit("spec-regeneration:event", { + type: "spec_complete", + specPath, + content: responseText, + }); + + // If generate features was requested, parse and create them + if (generateFeatures) { + await parseAndCreateFeatures(projectPath, responseText, events); + } +} + +async function generateFeaturesFromSpec( + projectPath: string, + events: EventEmitter, + abortController: AbortController +) { + // Read existing spec + const specPath = path.join(projectPath, ".automaker", "project-spec.md"); + let spec: string; + + try { + spec = await fs.readFile(specPath, "utf-8"); + } catch { + events.emit("spec-regeneration:event", { + type: "features_error", + error: "No project spec found. Generate spec first.", + }); + return; + } + + const prompt = `Based on this project specification: + +${spec} + +Generate a prioritized list of implementable features. For each feature provide: + +1. **id**: A unique lowercase-hyphenated identifier +2. **title**: Short descriptive title +3. **description**: What this feature does (2-3 sentences) +4. **priority**: 1 (high), 2 (medium), or 3 (low) +5. **complexity**: "simple", "moderate", or "complex" +6. **dependencies**: Array of feature IDs this depends on (can be empty) + +Format as JSON: +{ + "features": [ + { + "id": "feature-id", + "title": "Feature Title", + "description": "What it does", + "priority": 1, + "complexity": "moderate", + "dependencies": [] + } + ] +} + +Generate 5-15 features that build on each other logically.`; + + events.emit("spec-regeneration:event", { + type: "features_progress", + content: "Analyzing spec and generating features...\n", + }); + + const options: Options = { + model: "claude-sonnet-4-20250514", + maxTurns: 5, + cwd: projectPath, + allowedTools: ["Read", "Glob"], + permissionMode: "acceptEdits", + abortController, + }; + + const stream = query({ prompt, options }); + let responseText = ""; + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText = block.text; + events.emit("spec-regeneration:event", { + type: "features_progress", + content: block.text, + }); + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + responseText = msg.result || responseText; + } + } + + await parseAndCreateFeatures(projectPath, responseText, events); +} + +async function parseAndCreateFeatures( + projectPath: string, + content: string, + events: EventEmitter +) { + try { + // Extract JSON from response + const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("No valid JSON found in response"); + } + + const parsed = JSON.parse(jsonMatch[0]); + const featuresDir = path.join(projectPath, ".automaker", "features"); + await fs.mkdir(featuresDir, { recursive: true }); + + const createdFeatures: Array<{ id: string; title: string }> = []; + + for (const feature of parsed.features) { + const featureDir = path.join(featuresDir, feature.id); + await fs.mkdir(featureDir, { recursive: true }); + + const featureData = { + id: feature.id, + title: feature.title, + description: feature.description, + status: "pending", + priority: feature.priority || 2, + complexity: feature.complexity || "moderate", + dependencies: feature.dependencies || [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await fs.writeFile( + path.join(featureDir, "feature.json"), + JSON.stringify(featureData, null, 2) + ); + + createdFeatures.push({ id: feature.id, title: feature.title }); + } + + events.emit("spec-regeneration:event", { + type: "features_complete", + features: createdFeatures, + count: createdFeatures.length, + }); + } catch (error) { + events.emit("spec-regeneration:event", { + type: "features_error", + error: (error as Error).message, + }); + } +} diff --git a/apps/server/src/routes/suggestions.ts b/apps/server/src/routes/suggestions.ts new file mode 100644 index 00000000..578d1328 --- /dev/null +++ b/apps/server/src/routes/suggestions.ts @@ -0,0 +1,192 @@ +/** + * Suggestions routes - HTTP API for AI-powered feature suggestions + */ + +import { Router, type Request, type Response } from "express"; +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import type { EventEmitter } from "../lib/events.js"; + +let isRunning = false; +let currentAbortController: AbortController | null = null; + +export function createSuggestionsRoutes(events: EventEmitter): Router { + const router = Router(); + + // Generate suggestions + router.post("/generate", async (req: Request, res: Response) => { + try { + const { projectPath, suggestionType = "features" } = req.body as { + projectPath: string; + suggestionType?: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + if (isRunning) { + res.json({ success: false, error: "Suggestions generation is already running" }); + return; + } + + isRunning = true; + currentAbortController = new AbortController(); + + // Start generation in background + generateSuggestions(projectPath, suggestionType, events, currentAbortController) + .catch((error) => { + console.error("[Suggestions] Error:", error); + events.emit("suggestions:event", { + type: "suggestions_error", + error: error.message, + }); + }) + .finally(() => { + isRunning = false; + currentAbortController = null; + }); + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Stop suggestions generation + router.post("/stop", async (_req: Request, res: Response) => { + try { + if (currentAbortController) { + currentAbortController.abort(); + } + isRunning = false; + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get status + router.get("/status", async (_req: Request, res: Response) => { + try { + res.json({ success: true, isRunning }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} + +async function generateSuggestions( + projectPath: string, + suggestionType: string, + events: EventEmitter, + abortController: AbortController +) { + const typePrompts: Record = { + features: "Analyze this project and suggest new features that would add value.", + refactoring: "Analyze this project and identify refactoring opportunities.", + security: "Analyze this project for security vulnerabilities and suggest fixes.", + performance: "Analyze this project for performance issues and suggest optimizations.", + }; + + const prompt = `${typePrompts[suggestionType] || typePrompts.features} + +Look at the codebase and provide 3-5 concrete suggestions. + +For each suggestion, provide: +1. A category (e.g., "User Experience", "Security", "Performance") +2. A clear description of what to implement +3. Concrete steps to implement it +4. Priority (1=high, 2=medium, 3=low) +5. Brief reasoning for why this would help + +Format your response as JSON: +{ + "suggestions": [ + { + "id": "suggestion-123", + "category": "Category", + "description": "What to implement", + "steps": ["Step 1", "Step 2"], + "priority": 1, + "reasoning": "Why this helps" + } + ] +}`; + + events.emit("suggestions:event", { + type: "suggestions_progress", + content: `Starting ${suggestionType} analysis...\n`, + }); + + const options: Options = { + model: "claude-opus-4-5-20251101", + maxTurns: 5, + cwd: projectPath, + allowedTools: ["Read", "Glob", "Grep"], + permissionMode: "acceptEdits", + abortController, + }; + + const stream = query({ prompt, options }); + let responseText = ""; + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText = block.text; + events.emit("suggestions:event", { + type: "suggestions_progress", + content: block.text, + }); + } else if (block.type === "tool_use") { + events.emit("suggestions:event", { + type: "suggestions_tool", + tool: block.name, + input: block.input, + }); + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + responseText = msg.result || responseText; + } + } + + // Parse suggestions from response + try { + const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + events.emit("suggestions:event", { + type: "suggestions_complete", + suggestions: parsed.suggestions.map((s: Record, i: number) => ({ + ...s, + id: s.id || `suggestion-${Date.now()}-${i}`, + })), + }); + } else { + throw new Error("No valid JSON found in response"); + } + } catch (error) { + // Return generic suggestions if parsing fails + events.emit("suggestions:event", { + type: "suggestions_complete", + suggestions: [ + { + id: `suggestion-${Date.now()}-0`, + category: "Analysis", + description: "Review the AI analysis output for insights", + steps: ["Review the generated analysis"], + priority: 1, + reasoning: "The AI provided analysis but suggestions need manual review", + }, + ], + }); + } +} diff --git a/apps/server/src/routes/worktree.ts b/apps/server/src/routes/worktree.ts new file mode 100644 index 00000000..9d57e3d3 --- /dev/null +++ b/apps/server/src/routes/worktree.ts @@ -0,0 +1,355 @@ +/** + * Worktree routes - HTTP API for git worktree operations + */ + +import { Router, type Request, type Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; + +const execAsync = promisify(exec); + +export function createWorktreeRoutes(): Router { + const router = Router(); + + // Check if a path is a git repo + async function isGitRepo(repoPath: string): Promise { + try { + await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath }); + return true; + } catch { + return false; + } + } + + // Get worktree info + router.post("/info", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId required" }); + return; + } + + // Check if worktree exists + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + try { + await fs.access(worktreePath); + const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + }); + res.json({ + success: true, + worktreePath, + branchName: stdout.trim(), + }); + } catch { + res.json({ success: true, worktreePath: null, branchName: null }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get worktree status + router.post("/status", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId required" }); + return; + } + + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + + try { + await fs.access(worktreePath); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: worktreePath, + }); + const files = status + .split("\n") + .filter(Boolean) + .map((line) => line.slice(3)); + const { stdout: diffStat } = await execAsync("git diff --stat", { + cwd: worktreePath, + }); + const { stdout: logOutput } = await execAsync( + 'git log --oneline -5 --format="%h %s"', + { cwd: worktreePath } + ); + + res.json({ + success: true, + modifiedFiles: files.length, + files, + diffStat: diffStat.trim(), + recentCommits: logOutput.trim().split("\n").filter(Boolean), + }); + } catch { + res.json({ + success: true, + modifiedFiles: 0, + files: [], + diffStat: "", + recentCommits: [], + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // List all worktrees + router.post("/list", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + if (!(await isGitRepo(projectPath))) { + res.json({ success: true, worktrees: [] }); + return; + } + + const { stdout } = await execAsync("git worktree list --porcelain", { + cwd: projectPath, + }); + + const worktrees: Array<{ path: string; branch: string }> = []; + const lines = stdout.split("\n"); + let current: { path?: string; branch?: string } = {}; + + for (const line of lines) { + if (line.startsWith("worktree ")) { + current.path = line.slice(9); + } else if (line.startsWith("branch ")) { + current.branch = line.slice(7).replace("refs/heads/", ""); + } else if (line === "") { + if (current.path && current.branch) { + worktrees.push({ path: current.path, branch: current.branch }); + } + current = {}; + } + } + + res.json({ success: true, worktrees }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get diffs for a worktree + router.post("/diffs", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId required" }); + return; + } + + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + + try { + await fs.access(worktreePath); + const { stdout: diff } = await execAsync("git diff HEAD", { + cwd: worktreePath, + maxBuffer: 10 * 1024 * 1024, + }); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: worktreePath, + }); + + const files = status + .split("\n") + .filter(Boolean) + .map((line) => { + const statusChar = line[0]; + const filePath = line.slice(3); + const statusMap: Record = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + U: "Updated", + "?": "Untracked", + }; + return { + status: statusChar, + path: filePath, + statusText: statusMap[statusChar] || "Unknown", + }; + }); + + res.json({ + success: true, + diff, + files, + hasChanges: files.length > 0, + }); + } catch { + res.json({ success: true, diff: "", files: [], hasChanges: false }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Get diff for a specific file + router.post("/file-diff", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, filePath } = req.body as { + projectPath: string; + featureId: string; + filePath: string; + }; + + if (!projectPath || !featureId || !filePath) { + res.status(400).json({ + success: false, + error: "projectPath, featureId, and filePath required", + }); + return; + } + + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + + try { + await fs.access(worktreePath); + const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: worktreePath, + maxBuffer: 10 * 1024 * 1024, + }); + + res.json({ success: true, diff, filePath }); + } catch { + res.json({ success: true, diff: "", filePath }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Revert feature (remove worktree) + router.post("/revert", async (req: Request, res: Response) => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId required" }); + return; + } + + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + + try { + // Remove worktree + await execAsync(`git worktree remove "${worktreePath}" --force`, { + cwd: projectPath, + }); + // Delete branch + await execAsync(`git branch -D feature/${featureId}`, { cwd: projectPath }); + + res.json({ success: true, removedPath: worktreePath }); + } catch (error) { + // Worktree might not exist + res.json({ success: true, removedPath: null }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Merge feature (merge worktree branch into main) + router.post("/merge", async (req: Request, res: Response) => { + try { + const { projectPath, featureId, options } = req.body as { + projectPath: string; + featureId: string; + options?: { squash?: boolean; message?: string }; + }; + + if (!projectPath || !featureId) { + res + .status(400) + .json({ success: false, error: "projectPath and featureId required" }); + return; + } + + const branchName = `feature/${featureId}`; + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + + // Get current branch + const { stdout: currentBranch } = await execAsync( + "git rev-parse --abbrev-ref HEAD", + { cwd: projectPath } + ); + + // Merge the feature branch + const mergeCmd = options?.squash + ? `git merge --squash ${branchName}` + : `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`; + + await execAsync(mergeCmd, { cwd: projectPath }); + + // If squash merge, need to commit + if (options?.squash) { + await execAsync( + `git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, + { cwd: projectPath } + ); + } + + // Clean up worktree and branch + try { + await execAsync(`git worktree remove "${worktreePath}" --force`, { + cwd: projectPath, + }); + await execAsync(`git branch -D ${branchName}`, { cwd: projectPath }); + } catch { + // Cleanup errors are non-fatal + } + + res.json({ success: true, mergedBranch: branchName }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts new file mode 100644 index 00000000..09119be6 --- /dev/null +++ b/apps/server/src/services/agent-service.ts @@ -0,0 +1,562 @@ +/** + * Agent Service - Runs Claude agents via the Claude Agent SDK + * Manages conversation sessions and streams responses via WebSocket + */ + +import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import path from "path"; +import fs from "fs/promises"; +import type { EventEmitter } from "../lib/events.js"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + images?: Array<{ + data: string; + mimeType: string; + filename: string; + }>; + timestamp: string; + isError?: boolean; +} + +interface Session { + messages: Message[]; + isRunning: boolean; + abortController: AbortController | null; + workingDirectory: string; +} + +interface SessionMetadata { + id: string; + name: string; + projectPath?: string; + workingDirectory: string; + createdAt: string; + updatedAt: string; + archived?: boolean; + tags?: string[]; +} + +export class AgentService { + private sessions = new Map(); + private stateDir: string; + private metadataFile: string; + private events: EventEmitter; + + constructor(dataDir: string, events: EventEmitter) { + this.stateDir = path.join(dataDir, "agent-sessions"); + this.metadataFile = path.join(dataDir, "sessions-metadata.json"); + this.events = events; + } + + async initialize(): Promise { + await fs.mkdir(this.stateDir, { recursive: true }); + } + + /** + * Start or resume a conversation + */ + async startConversation({ + sessionId, + workingDirectory, + }: { + sessionId: string; + workingDirectory?: string; + }) { + if (!this.sessions.has(sessionId)) { + const messages = await this.loadSession(sessionId); + this.sessions.set(sessionId, { + messages, + isRunning: false, + abortController: null, + workingDirectory: workingDirectory || process.cwd(), + }); + } + + const session = this.sessions.get(sessionId)!; + return { + success: true, + messages: session.messages, + sessionId, + }; + } + + /** + * Send a message to the agent and stream responses + */ + async sendMessage({ + sessionId, + message, + workingDirectory, + imagePaths, + }: { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + }) { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + if (session.isRunning) { + throw new Error("Agent is already processing a message"); + } + + // Read images and convert to base64 + const images: Message["images"] = []; + if (imagePaths && imagePaths.length > 0) { + for (const imagePath of imagePaths) { + try { + const imageBuffer = await fs.readFile(imagePath); + const base64Data = imageBuffer.toString("base64"); + const ext = path.extname(imagePath).toLowerCase(); + const mimeTypeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }; + const mediaType = mimeTypeMap[ext] || "image/png"; + + images.push({ + data: base64Data, + mimeType: mediaType, + filename: path.basename(imagePath), + }); + } catch (error) { + console.error(`[AgentService] Failed to load image ${imagePath}:`, error); + } + } + } + + // Add user message + const userMessage: Message = { + id: this.generateId(), + role: "user", + content: message, + images: images.length > 0 ? images : undefined, + timestamp: new Date().toISOString(), + }; + + session.messages.push(userMessage); + session.isRunning = true; + session.abortController = new AbortController(); + + // Emit user message event + this.emitAgentEvent(sessionId, { + type: "message", + message: userMessage, + }); + + await this.saveSession(sessionId, session.messages); + + try { + const options: Options = { + model: "claude-opus-4-5-20251101", + systemPrompt: this.getSystemPrompt(), + maxTurns: 20, + cwd: workingDirectory || session.workingDirectory, + allowedTools: [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "WebSearch", + "WebFetch", + ], + permissionMode: "acceptEdits", + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + abortController: session.abortController!, + }; + + // Build prompt content + let promptContent: string | Array<{ type: string; text?: string; source?: object }> = + message; + + if (imagePaths && imagePaths.length > 0) { + const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; + + if (message && message.trim()) { + contentBlocks.push({ type: "text", text: message }); + } + + for (const imagePath of imagePaths) { + try { + const imageBuffer = await fs.readFile(imagePath); + const base64Data = imageBuffer.toString("base64"); + const ext = path.extname(imagePath).toLowerCase(); + const mimeTypeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }; + const mediaType = mimeTypeMap[ext] || "image/png"; + + contentBlocks.push({ + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64Data, + }, + }); + } catch (error) { + console.error(`[AgentService] Failed to load image ${imagePath}:`, error); + } + } + + if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") { + promptContent = contentBlocks; + } + } + + // Build payload + const promptPayload = Array.isArray(promptContent) + ? (async function* () { + yield { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: promptContent, + }, + parent_tool_use_id: null, + }; + })() + : promptContent; + + const stream = query({ prompt: promptPayload, options }); + + let currentAssistantMessage: Message | null = null; + let responseText = ""; + const toolUses: Array<{ name: string; input: unknown }> = []; + + for await (const msg of stream) { + if (msg.type === "assistant") { + if (msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText += block.text; + + if (!currentAssistantMessage) { + currentAssistantMessage = { + id: this.generateId(), + role: "assistant", + content: responseText, + timestamp: new Date().toISOString(), + }; + session.messages.push(currentAssistantMessage); + } else { + currentAssistantMessage.content = responseText; + } + + this.emitAgentEvent(sessionId, { + type: "stream", + messageId: currentAssistantMessage.id, + content: responseText, + isComplete: false, + }); + } else if (block.type === "tool_use") { + const toolUse = { + name: block.name, + input: block.input, + }; + toolUses.push(toolUse); + + this.emitAgentEvent(sessionId, { + type: "tool_use", + tool: toolUse, + }); + } + } + } + } else if (msg.type === "result") { + if (msg.subtype === "success" && msg.result) { + if (currentAssistantMessage) { + currentAssistantMessage.content = msg.result; + responseText = msg.result; + } + } + + this.emitAgentEvent(sessionId, { + type: "complete", + messageId: currentAssistantMessage?.id, + content: responseText, + toolUses, + }); + } + } + + await this.saveSession(sessionId, session.messages); + + session.isRunning = false; + session.abortController = null; + + return { + success: true, + message: currentAssistantMessage, + }; + } catch (error) { + if (error instanceof AbortError || (error as Error)?.name === "AbortError") { + session.isRunning = false; + session.abortController = null; + return { success: false, aborted: true }; + } + + console.error("[AgentService] Error:", error); + + session.isRunning = false; + session.abortController = null; + + const errorMessage: Message = { + id: this.generateId(), + role: "assistant", + content: `Error: ${(error as Error).message}`, + timestamp: new Date().toISOString(), + isError: true, + }; + + session.messages.push(errorMessage); + await this.saveSession(sessionId, session.messages); + + this.emitAgentEvent(sessionId, { + type: "error", + error: (error as Error).message, + message: errorMessage, + }); + + throw error; + } + } + + /** + * Get conversation history + */ + getHistory(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: "Session not found" }; + } + + return { + success: true, + messages: session.messages, + isRunning: session.isRunning, + }; + } + + /** + * Stop current agent execution + */ + async stopExecution(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: "Session not found" }; + } + + if (session.abortController) { + session.abortController.abort(); + session.isRunning = false; + session.abortController = null; + } + + return { success: true }; + } + + /** + * Clear conversation history + */ + async clearSession(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + session.messages = []; + session.isRunning = false; + await this.saveSession(sessionId, []); + } + + return { success: true }; + } + + // Session management + + async loadSession(sessionId: string): Promise { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + + try { + const data = await fs.readFile(sessionFile, "utf-8"); + return JSON.parse(data); + } catch { + return []; + } + } + + async saveSession(sessionId: string, messages: Message[]): Promise { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + + try { + await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8"); + await this.updateSessionTimestamp(sessionId); + } catch (error) { + console.error("[AgentService] Failed to save session:", error); + } + } + + async loadMetadata(): Promise> { + try { + const data = await fs.readFile(this.metadataFile, "utf-8"); + return JSON.parse(data); + } catch { + return {}; + } + } + + async saveMetadata(metadata: Record): Promise { + await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8"); + } + + async updateSessionTimestamp(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (metadata[sessionId]) { + metadata[sessionId].updatedAt = new Date().toISOString(); + await this.saveMetadata(metadata); + } + } + + async listSessions(includeArchived = false): Promise { + const metadata = await this.loadMetadata(); + let sessions = Object.values(metadata); + + if (!includeArchived) { + sessions = sessions.filter((s) => !s.archived); + } + + return sessions.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } + + async createSession( + name: string, + projectPath?: string, + workingDirectory?: string + ): Promise { + const sessionId = this.generateId(); + const metadata = await this.loadMetadata(); + + const session: SessionMetadata = { + id: sessionId, + name, + projectPath, + workingDirectory: workingDirectory || projectPath || process.cwd(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + metadata[sessionId] = session; + await this.saveMetadata(metadata); + + return session; + } + + async updateSession( + sessionId: string, + updates: Partial + ): Promise { + const metadata = await this.loadMetadata(); + if (!metadata[sessionId]) return null; + + metadata[sessionId] = { + ...metadata[sessionId], + ...updates, + updatedAt: new Date().toISOString(), + }; + + await this.saveMetadata(metadata); + return metadata[sessionId]; + } + + async archiveSession(sessionId: string): Promise { + const result = await this.updateSession(sessionId, { archived: true }); + return result !== null; + } + + async unarchiveSession(sessionId: string): Promise { + const result = await this.updateSession(sessionId, { archived: false }); + return result !== null; + } + + async deleteSession(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (!metadata[sessionId]) return false; + + delete metadata[sessionId]; + await this.saveMetadata(metadata); + + // Delete session file + try { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + await fs.unlink(sessionFile); + } catch { + // File may not exist + } + + // Clear from memory + this.sessions.delete(sessionId); + + return true; + } + + private emitAgentEvent(sessionId: string, data: Record): void { + this.events.emit("agent:stream", { sessionId, ...data }); + } + + private getSystemPrompt(): string { + return `You are an AI assistant helping users build software. You are part of the Automaker application, +which is designed to help developers plan, design, and implement software projects autonomously. + +**Feature Storage:** +Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder. +Use the UpdateFeatureStatus tool to manage features, not direct file edits. + +Your role is to: +- Help users define their project requirements and specifications +- Ask clarifying questions to better understand their needs +- Suggest technical approaches and architectures +- Guide them through the development process +- Be conversational and helpful +- Write, edit, and modify code files as requested +- Execute commands and tests +- Search and analyze the codebase + +When discussing projects, help users think through: +- Core functionality and features +- Technical stack choices +- Data models and architecture +- User experience considerations +- Testing strategies + +You have full access to the codebase and can: +- Read files to understand existing code +- Write new files +- Edit existing files +- Run bash commands +- Search for code patterns +- Execute tests and builds`; + } + + private generateId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts new file mode 100644 index 00000000..bcc22625 --- /dev/null +++ b/apps/server/src/services/auto-mode-service.ts @@ -0,0 +1,815 @@ +/** + * Auto Mode Service - Autonomous feature implementation using Claude Agent SDK + * + * Manages: + * - Worktree creation for isolated development + * - Feature execution with Claude + * - Concurrent execution with max concurrency limits + * - Progress streaming via events + * - Verification and merge workflows + */ + +import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; +import type { EventEmitter, EventType } from "../lib/events.js"; + +const execAsync = promisify(exec); + +interface Feature { + id: string; + title: string; + description: string; + status: string; + priority?: number; + spec?: string; +} + +interface RunningFeature { + featureId: string; + projectPath: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + isAutoMode: boolean; + startTime: number; +} + +interface AutoModeConfig { + maxConcurrency: number; + useWorktrees: boolean; + projectPath: string; +} + +export class AutoModeService { + private events: EventEmitter; + private runningFeatures = new Map(); + private autoLoopRunning = false; + private autoLoopAbortController: AbortController | null = null; + private config: AutoModeConfig | null = null; + + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Start the auto mode loop - continuously picks and executes pending features + */ + async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { + if (this.autoLoopRunning) { + throw new Error("Auto mode is already running"); + } + + this.autoLoopRunning = true; + this.autoLoopAbortController = new AbortController(); + this.config = { + maxConcurrency, + useWorktrees: true, + projectPath, + }; + + this.emitAutoModeEvent("auto_mode_complete", { + message: `Auto mode started with max ${maxConcurrency} concurrent features`, + projectPath, + }); + + // Run the loop in the background + this.runAutoLoop().catch((error) => { + console.error("[AutoMode] Loop error:", error); + this.emitAutoModeEvent("auto_mode_error", { + error: error.message, + }); + }); + } + + private async runAutoLoop(): Promise { + while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) { + try { + // Check if we have capacity + if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { + await this.sleep(5000); + continue; + } + + // Load pending features + const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); + + if (pendingFeatures.length === 0) { + this.emitAutoModeEvent("auto_mode_complete", { + message: "No pending features - auto mode idle", + }); + await this.sleep(10000); + continue; + } + + // Find a feature not currently running + const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); + + if (nextFeature) { + // Start feature execution in background + this.executeFeature( + this.config!.projectPath, + nextFeature.id, + this.config!.useWorktrees, + true + ).catch((error) => { + console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error); + }); + } + + await this.sleep(2000); + } catch (error) { + console.error("[AutoMode] Loop iteration error:", error); + await this.sleep(5000); + } + } + + this.autoLoopRunning = false; + this.emitAutoModeEvent("auto_mode_complete", { + message: "Auto mode stopped", + }); + } + + /** + * Stop the auto mode loop + */ + async stopAutoLoop(): Promise { + this.autoLoopRunning = false; + if (this.autoLoopAbortController) { + this.autoLoopAbortController.abort(); + this.autoLoopAbortController = null; + } + + return this.runningFeatures.size; + } + + /** + * Execute a single feature + */ + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = true, + isAutoMode = false + ): Promise { + if (this.runningFeatures.has(featureId)) { + throw new Error(`Feature ${featureId} is already running`); + } + + const abortController = new AbortController(); + const branchName = `feature/${featureId}`; + let worktreePath: string | null = null; + + // Setup worktree if enabled + if (useWorktrees) { + worktreePath = await this.setupWorktree(projectPath, featureId, branchName); + } + + const workDir = worktreePath || projectPath; + + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath, + branchName, + abortController, + isAutoMode, + startTime: Date.now(), + }); + + // Emit feature start event + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { id: featureId, title: "Loading...", description: "Feature is starting" }, + }); + + try { + // Load feature details + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Update feature status to in-progress + await this.updateFeatureStatus(projectPath, featureId, "in-progress"); + + // Build the prompt + const prompt = this.buildFeaturePrompt(feature); + + // Run the agent + await this.runAgent(workDir, featureId, prompt, abortController); + + // Mark as completed + await this.updateFeatureStatus(projectPath, featureId, "completed"); + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: true, + message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`, + projectPath, + }); + } catch (error) { + if (error instanceof AbortError || (error as Error)?.name === "AbortError") { + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: false, + message: "Feature stopped by user", + projectPath, + }); + } else { + console.error(`[AutoMode] Feature ${featureId} failed:`, error); + await this.updateFeatureStatus(projectPath, featureId, "failed"); + this.emitAutoModeEvent("auto_mode_error", { + featureId, + error: (error as Error).message, + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } + } + + /** + * Stop a specific feature + */ + async stopFeature(featureId: string): Promise { + const running = this.runningFeatures.get(featureId); + if (!running) { + return false; + } + + running.abortController.abort(); + return true; + } + + /** + * Resume a feature (continues from saved context) + */ + async resumeFeature( + projectPath: string, + featureId: string, + useWorktrees = true + ): Promise { + // Check if context exists + const contextPath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "agent-output.md" + ); + + let hasContext = false; + try { + await fs.access(contextPath); + hasContext = true; + } catch { + // No context + } + + if (hasContext) { + // Load previous context and continue + const context = await fs.readFile(contextPath, "utf-8"); + return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + } + + // No context, start fresh + return this.executeFeature(projectPath, featureId, useWorktrees, false); + } + + /** + * Follow up on a feature with additional instructions + */ + async followUpFeature( + projectPath: string, + featureId: string, + prompt: string, + imagePaths?: string[] + ): Promise { + if (this.runningFeatures.has(featureId)) { + throw new Error(`Feature ${featureId} is already running`); + } + + const abortController = new AbortController(); + + // Check if worktree exists + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + let workDir = projectPath; + + try { + await fs.access(worktreePath); + workDir = worktreePath; + } catch { + // No worktree, use project path + } + + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath: workDir !== projectPath ? worktreePath : null, + branchName: `feature/${featureId}`, + abortController, + isAutoMode: false, + startTime: Date.now(), + }); + + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) }, + }); + + try { + await this.runAgent(workDir, featureId, prompt, abortController, imagePaths); + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: true, + message: "Follow-up completed successfully", + projectPath, + }); + } catch (error) { + if (!(error instanceof AbortError)) { + this.emitAutoModeEvent("auto_mode_error", { + featureId, + error: (error as Error).message, + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } + } + + /** + * Verify a feature's implementation + */ + async verifyFeature(projectPath: string, featureId: string): Promise { + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + let workDir = projectPath; + + try { + await fs.access(worktreePath); + workDir = worktreePath; + } catch { + // No worktree + } + + // Run verification - check if tests pass, build works, etc. + const verificationChecks = [ + { cmd: "npm run lint", name: "Lint" }, + { cmd: "npm run typecheck", name: "Type check" }, + { cmd: "npm test", name: "Tests" }, + { cmd: "npm run build", name: "Build" }, + ]; + + let allPassed = true; + const results: Array<{ check: string; passed: boolean; output?: string }> = []; + + for (const check of verificationChecks) { + try { + const { stdout, stderr } = await execAsync(check.cmd, { + cwd: workDir, + timeout: 120000, + }); + results.push({ check: check.name, passed: true, output: stdout || stderr }); + } catch (error) { + allPassed = false; + results.push({ + check: check.name, + passed: false, + output: (error as Error).message, + }); + break; // Stop on first failure + } + } + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: allPassed, + message: allPassed + ? "All verification checks passed" + : `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`, + }); + + return allPassed; + } + + /** + * Commit feature changes + */ + async commitFeature(projectPath: string, featureId: string): Promise { + const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + let workDir = projectPath; + + try { + await fs.access(worktreePath); + workDir = worktreePath; + } catch { + // No worktree + } + + try { + // Check for changes + const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir }); + if (!status.trim()) { + return null; // No changes + } + + // Load feature for commit message + const feature = await this.loadFeature(projectPath, featureId); + const commitMessage = feature + ? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode` + : `feat: Feature ${featureId}`; + + // Stage and commit + await execAsync("git add -A", { cwd: workDir }); + await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { + cwd: workDir, + }); + + // Get commit hash + const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir }); + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: true, + message: `Changes committed: ${hash.trim().substring(0, 8)}`, + }); + + return hash.trim(); + } catch (error) { + console.error(`[AutoMode] Commit failed for ${featureId}:`, error); + return null; + } + } + + /** + * Check if context exists for a feature + */ + async contextExists(projectPath: string, featureId: string): Promise { + const contextPath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "agent-output.md" + ); + + try { + await fs.access(contextPath); + return true; + } catch { + return false; + } + } + + /** + * Analyze project to gather context + */ + async analyzeProject(projectPath: string): Promise { + const abortController = new AbortController(); + + const analysisFeatureId = `analysis-${Date.now()}`; + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId: analysisFeatureId, + projectPath, + feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" }, + }); + + const prompt = `Analyze this project and provide a summary of: +1. Project structure and architecture +2. Main technologies and frameworks used +3. Key components and their responsibilities +4. Build and test commands +5. Any existing conventions or patterns + +Format your response as a structured markdown document.`; + + try { + const options: Options = { + model: "claude-sonnet-4-20250514", + maxTurns: 5, + cwd: projectPath, + allowedTools: ["Read", "Glob", "Grep"], + permissionMode: "acceptEdits", + abortController, + }; + + const stream = query({ prompt, options }); + let analysisResult = ""; + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + analysisResult = block.text; + this.emitAutoModeEvent("auto_mode_progress", { + featureId: analysisFeatureId, + content: block.text, + projectPath, + }); + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + analysisResult = msg.result || analysisResult; + } + } + + // Save analysis + const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md"); + await fs.mkdir(path.dirname(analysisPath), { recursive: true }); + await fs.writeFile(analysisPath, analysisResult); + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId: analysisFeatureId, + passes: true, + message: "Project analysis completed", + projectPath, + }); + } catch (error) { + this.emitAutoModeEvent("auto_mode_error", { + featureId: analysisFeatureId, + error: (error as Error).message, + projectPath, + }); + } + } + + /** + * Get current status + */ + getStatus(): { + isRunning: boolean; + autoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + } { + return { + isRunning: this.autoLoopRunning || this.runningFeatures.size > 0, + autoLoopRunning: this.autoLoopRunning, + runningFeatures: Array.from(this.runningFeatures.keys()), + runningCount: this.runningFeatures.size, + }; + } + + // Private helpers + + private async setupWorktree( + projectPath: string, + featureId: string, + branchName: string + ): Promise { + const worktreesDir = path.join(projectPath, ".automaker", "worktrees"); + const worktreePath = path.join(worktreesDir, featureId); + + await fs.mkdir(worktreesDir, { recursive: true }); + + // Check if worktree already exists + try { + await fs.access(worktreePath); + return worktreePath; + } catch { + // Create new worktree + } + + // Create branch if it doesn't exist + try { + await execAsync(`git branch ${branchName}`, { cwd: projectPath }); + } catch { + // Branch may already exist + } + + // Create worktree + try { + await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { + cwd: projectPath, + }); + } catch (error) { + // Worktree creation failed, fall back to direct execution + console.error(`[AutoMode] Worktree creation failed:`, error); + return projectPath; + } + + return worktreePath; + } + + private async loadFeature(projectPath: string, featureId: string): Promise { + const featurePath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "feature.json" + ); + + try { + const data = await fs.readFile(featurePath, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } + } + + private async updateFeatureStatus( + projectPath: string, + featureId: string, + status: string + ): Promise { + const featurePath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "feature.json" + ); + + try { + const data = await fs.readFile(featurePath, "utf-8"); + const feature = JSON.parse(data); + feature.status = status; + feature.updatedAt = new Date().toISOString(); + await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + } catch { + // Feature file may not exist + } + } + + private async loadPendingFeatures(projectPath: string): Promise { + const featuresDir = path.join(projectPath, ".automaker", "features"); + + try { + const entries = await fs.readdir(featuresDir, { withFileTypes: true }); + const features: Feature[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const featurePath = path.join(featuresDir, entry.name, "feature.json"); + try { + const data = await fs.readFile(featurePath, "utf-8"); + const feature = JSON.parse(data); + if (feature.status === "pending" || feature.status === "ready") { + features.push(feature); + } + } catch { + // Skip invalid features + } + } + } + + // Sort by priority + return features.sort((a, b) => (a.priority || 999) - (b.priority || 999)); + } catch { + return []; + } + } + + private buildFeaturePrompt(feature: Feature): string { + let prompt = `## Feature Implementation Task + +**Feature ID:** ${feature.id} +**Title:** ${feature.title} +**Description:** ${feature.description} +`; + + if (feature.spec) { + prompt += ` +**Specification:** +${feature.spec} +`; + } + + prompt += ` +## Instructions + +Implement this feature by: +1. First, explore the codebase to understand the existing structure +2. Plan your implementation approach +3. Write the necessary code changes +4. Add or update tests as needed +5. Ensure the code follows existing patterns and conventions + +When done, summarize what you implemented and any notes for the developer.`; + + return prompt; + } + + private async runAgent( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + imagePaths?: string[] + ): Promise { + const options: Options = { + model: "claude-opus-4-5-20251101", + maxTurns: 50, + cwd: workDir, + allowedTools: [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + ], + permissionMode: "acceptEdits", + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + abortController, + }; + + // Build prompt - include image paths for the agent to read + let finalPrompt = prompt; + + if (imagePaths && imagePaths.length > 0) { + finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`; + } + + const stream = query({ prompt: finalPrompt, options }); + let responseText = ""; + const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md"); + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText = block.text; + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: block.text, + }); + } else if (block.type === "tool_use") { + this.emitAutoModeEvent("auto_mode_tool", { + featureId, + tool: block.name, + input: block.input, + }); + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + responseText = msg.result || responseText; + } + } + + // Save agent output + try { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, responseText); + } catch { + // May fail if directory doesn't exist + } + } + + private async executeFeatureWithContext( + projectPath: string, + featureId: string, + context: string, + useWorktrees: boolean + ): Promise { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + const prompt = `## Continuing Feature Implementation + +${this.buildFeaturePrompt(feature)} + +## Previous Context +The following is the output from a previous implementation attempt. Continue from where you left off: + +${context} + +## Instructions +Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; + + return this.executeFeature(projectPath, featureId, useWorktrees, false); + } + + /** + * Emit an auto-mode event wrapped in the correct format for the client. + * All auto-mode events are sent as type "auto-mode:event" with the actual + * event type and data in the payload. + */ + private emitAutoModeEvent( + eventType: string, + data: Record + ): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit("auto-mode:event", { + type: eventType, + ...data, + }); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts new file mode 100644 index 00000000..04084eb4 --- /dev/null +++ b/apps/server/src/services/feature-loader.ts @@ -0,0 +1,263 @@ +/** + * Feature Loader - Handles loading and managing features from individual feature folders + * Each feature is stored in .automaker/features/{featureId}/feature.json + */ + +import path from "path"; +import fs from "fs/promises"; + +export interface Feature { + id: string; + category: string; + description: string; + steps?: string[]; + passes?: boolean; + priority?: number; + imagePaths?: Array; + [key: string]: unknown; +} + +export class FeatureLoader { + /** + * Get the features directory path + */ + getFeaturesDir(projectPath: string): string { + return path.join(projectPath, ".automaker", "features"); + } + + /** + * Get the path to a specific feature folder + */ + getFeatureDir(projectPath: string, featureId: string): string { + return path.join(this.getFeaturesDir(projectPath), featureId); + } + + /** + * Get the path to a feature's feature.json file + */ + getFeatureJsonPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), "feature.json"); + } + + /** + * Get the path to a feature's agent-output.md file + */ + getAgentOutputPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md"); + } + + /** + * Generate a new feature ID + */ + generateFeatureId(): string { + return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Get all features for a project + */ + async getAll(projectPath: string): Promise { + try { + const featuresDir = this.getFeaturesDir(projectPath); + + // Check if features directory exists + try { + await fs.access(featuresDir); + } catch { + return []; + } + + // Read all feature directories + const entries = await fs.readdir(featuresDir, { withFileTypes: true }); + const featureDirs = entries.filter((entry) => entry.isDirectory()); + + // Load each feature + const features: Feature[] = []; + for (const dir of featureDirs) { + const featureId = dir.name; + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + try { + const content = await fs.readFile(featureJsonPath, "utf-8"); + const feature = JSON.parse(content); + + if (!feature.id) { + console.warn( + `[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping` + ); + continue; + } + + features.push(feature); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } else if (error instanceof SyntaxError) { + console.warn( + `[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}` + ); + } else { + console.error( + `[FeatureLoader] Failed to load feature ${featureId}:`, + (error as Error).message + ); + } + } + } + + // Sort by creation order (feature IDs contain timestamp) + features.sort((a, b) => { + const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0; + const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0; + return aTime - bTime; + }); + + return features; + } catch (error) { + console.error("[FeatureLoader] Failed to get all features:", error); + return []; + } + } + + /** + * Get a single feature by ID + */ + async get(projectPath: string, featureId: string): Promise { + try { + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const content = await fs.readFile(featureJsonPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error); + throw error; + } + } + + /** + * Create a new feature + */ + async create(projectPath: string, featureData: Partial): Promise { + const featureId = featureData.id || this.generateFeatureId(); + const featureDir = this.getFeatureDir(projectPath, featureId); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + // Ensure features directory exists + const featuresDir = this.getFeaturesDir(projectPath); + await fs.mkdir(featuresDir, { recursive: true }); + + // Create feature directory + await fs.mkdir(featureDir, { recursive: true }); + + // Ensure feature has required fields + const feature: Feature = { + category: featureData.category || "Uncategorized", + description: featureData.description || "", + ...featureData, + id: featureId, + }; + + // Write feature.json + await fs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), "utf-8"); + + console.log(`[FeatureLoader] Created feature ${featureId}`); + return feature; + } + + /** + * Update a feature (partial updates supported) + */ + async update( + projectPath: string, + featureId: string, + updates: Partial + ): Promise { + const feature = await this.get(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Merge updates + const updatedFeature: Feature = { ...feature, ...updates }; + + // Write back to file + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + await fs.writeFile( + featureJsonPath, + JSON.stringify(updatedFeature, null, 2), + "utf-8" + ); + + console.log(`[FeatureLoader] Updated feature ${featureId}`); + return updatedFeature; + } + + /** + * Delete a feature + */ + async delete(projectPath: string, featureId: string): Promise { + try { + const featureDir = this.getFeatureDir(projectPath, featureId); + await fs.rm(featureDir, { recursive: true, force: true }); + console.log(`[FeatureLoader] Deleted feature ${featureId}`); + return true; + } catch (error) { + console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error); + return false; + } + } + + /** + * Get agent output for a feature + */ + async getAgentOutput( + projectPath: string, + featureId: string + ): Promise { + try { + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + const content = await fs.readFile(agentOutputPath, "utf-8"); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + console.error( + `[FeatureLoader] Failed to get agent output for ${featureId}:`, + error + ); + throw error; + } + } + + /** + * Save agent output for a feature + */ + async saveAgentOutput( + projectPath: string, + featureId: string, + content: string + ): Promise { + const featureDir = this.getFeatureDir(projectPath, featureId); + await fs.mkdir(featureDir, { recursive: true }); + + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + await fs.writeFile(agentOutputPath, content, "utf-8"); + } + + /** + * Delete agent output for a feature + */ + async deleteAgentOutput(projectPath: string, featureId: string): Promise { + try { + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + await fs.unlink(agentOutputPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 00000000..c83c5333 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5a82f599 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# Automaker Docker Compose +# For self-hosting the Automaker backend server + +services: + server: + build: + context: . + dockerfile: apps/server/Dockerfile + container_name: automaker-server + restart: unless-stopped + ports: + - "3008:3008" + environment: + # Required + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Optional - authentication (leave empty to disable) + - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} + + # Optional - restrict to specific directories (comma-separated) + - ALLOWED_PROJECT_DIRS=${ALLOWED_PROJECT_DIRS:-/projects} + + # Optional - data directory for sessions, etc. + - DATA_DIR=/data + + # Optional - CORS origin (default allows all) + - CORS_ORIGIN=${CORS_ORIGIN:-*} + + # Optional - additional API keys + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} + volumes: + # Persist data between restarts + - automaker-data:/data + + # Mount your projects directory (read-write access) + - ${PROJECTS_DIR:-./projects}:/projects + +volumes: + automaker-data: diff --git a/package-lock.json b/package-lock.json index d7d208f3..c9820c7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,60 +75,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "apps/app/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.61", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^3.24.1" - } - }, - "apps/app/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "apps/app/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "apps/app/node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -1319,253 +1265,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "apps/app/node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "apps/app/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "apps/app/node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "apps/app/node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "apps/app/node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "apps/app/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "apps/app/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "apps/app/node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "apps/app/node_modules/@isaacs/balanced-match": { "version": "4.0.1", "dev": true, @@ -2986,14 +2685,6 @@ "version": "2.1.0", "license": "MIT" }, - "apps/app/node_modules/@types/node": { - "version": "20.19.25", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "apps/app/node_modules/@types/plist": { "version": "3.0.5", "dev": true, @@ -4208,33 +3899,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "apps/app/node_modules/call-bound": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/callsites": { "version": "3.1.0", "dev": true, @@ -4882,16 +4546,6 @@ "node": ">=0.10.0" } }, - "apps/app/node_modules/dotenv": { - "version": "17.2.3", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "apps/app/node_modules/dotenv-expand": { "version": "11.0.7", "dev": true, @@ -4917,19 +4571,6 @@ "url": "https://dotenvx.com" } }, - "apps/app/node_modules/dunder-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/ejs": { "version": "3.1.10", "dev": true, @@ -5230,22 +4871,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/es-define-property": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "apps/app/node_modules/es-errors": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/es-iterator-helpers": { "version": "1.2.1", "dev": true, @@ -5272,17 +4897,6 @@ "node": ">= 0.4" } }, - "apps/app/node_modules/es-object-atoms": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/es-set-tostringtag": { "version": "2.1.0", "dev": true, @@ -6035,14 +5649,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "apps/app/node_modules/function-bind": { - "version": "1.1.2", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/function.prototype.name": { "version": "1.1.8", "dev": true, @@ -6094,29 +5700,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "apps/app/node_modules/get-intrinsic": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/get-nonce": { "version": "1.0.1", "license": "MIT", @@ -6124,18 +5707,6 @@ "node": ">=6" } }, - "apps/app/node_modules/get-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/get-stream": { "version": "5.2.0", "dev": true, @@ -6166,17 +5737,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/get-tsconfig": { - "version": "4.13.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "apps/app/node_modules/github-from-package": { "version": "0.0.0", "license": "MIT" @@ -6266,17 +5826,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/gopd": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/got": { "version": "11.8.6", "dev": true, @@ -6347,17 +5896,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/has-symbols": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/has-tostringtag": { "version": "1.0.2", "dev": true, @@ -6372,17 +5910,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/hasown": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "license": "MIT", @@ -6614,10 +6141,6 @@ "wrappy": "1" } }, - "apps/app/node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" - }, "apps/app/node_modules/inline-style-parser": { "version": "0.2.7", "license": "MIT" @@ -7471,14 +6994,6 @@ "node": ">=10" } }, - "apps/app/node_modules/math-intrinsics": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "apps/app/node_modules/mdast-util-from-markdown": { "version": "2.0.2", "license": "MIT", @@ -8427,25 +7942,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "apps/app/node_modules/object-assign": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "apps/app/node_modules/object-inspect": { - "version": "1.13.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/object-keys": { "version": "1.1.1", "dev": true, @@ -8534,13 +8030,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/once": { - "version": "1.4.0", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "apps/app/node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -9246,14 +8735,6 @@ "node": ">=4" } }, - "apps/app/node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "apps/app/node_modules/responselike": { "version": "2.0.1", "dev": true, @@ -9404,11 +8885,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, "apps/app/node_modules/sanitize-filename": { "version": "1.6.3", "dev": true, @@ -9505,74 +8981,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/side-channel": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "apps/app/node_modules/side-channel-list": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "apps/app/node_modules/side-channel-map": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "apps/app/node_modules/side-channel-weakmap": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "apps/app/node_modules/simple-concat": { "version": "1.0.1", "funding": [ @@ -10424,18 +9832,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "apps/app/node_modules/typescript-eslint": { "version": "8.48.1", "dev": true, @@ -10475,11 +9871,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/app/node_modules/undici-types": { - "version": "6.21.0", - "dev": true, - "license": "MIT" - }, "apps/app/node_modules/unified": { "version": "11.0.5", "license": "MIT", @@ -10881,10 +10272,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "apps/app/node_modules/wrappy": { - "version": "1.0.2", - "license": "ISC" - }, "apps/app/node_modules/xmlbuilder": { "version": "15.1.1", "dev": true, @@ -10951,13 +10338,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "apps/app/node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "apps/app/node_modules/zod-validation-error": { "version": "4.0.2", "dev": true, @@ -11011,6 +10391,332 @@ "serve": "^14.2.4" } }, + "apps/server": { + "name": "@automaker/server", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.61", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/cors": "^2.8.18", + "@types/express": "^5.0.1", + "@types/node": "^20", + "@types/ws": "^8.18.1", + "tsx": "^4.19.4", + "typescript": "^5" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.1.67", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.67.tgz", + "integrity": "sha512-SPeMOfBeQ4Q6BcTRGRyMzaSEzKja3w8giZn6xboab02rPly5KQmgDK0wNerUntPe+xyw7c01xdu5K/pjZXq0dw==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^3.24.1" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@automaker/app": { "resolved": "apps/app", "link": true @@ -11019,6 +10725,10 @@ "resolved": "apps/marketing", "link": true }, + "node_modules/@automaker/server": { + "resolved": "apps/server", + "link": true + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -11029,6 +10739,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -11821,6 +11973,124 @@ "node": ">= 10" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@zeit/schemas": { "version": "2.36.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", @@ -11828,6 +12098,44 @@ "dev": true, "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -11961,6 +12269,53 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/boxen": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", @@ -11999,12 +12354,40 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", @@ -12206,6 +12589,46 @@ "node": ">= 0.6" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12240,6 +12663,15 @@ "node": ">=4.0.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -12249,6 +12681,32 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -12256,6 +12714,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -12263,6 +12727,102 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -12287,6 +12847,110 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12294,6 +12958,92 @@ "dev": true, "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geist": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/geist/-/geist-1.5.1.tgz", @@ -12303,6 +13053,43 @@ "next": ">=13.2.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -12316,6 +13103,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -12326,6 +13138,50 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -12336,12 +13192,43 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -12381,6 +13268,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -12442,6 +13335,36 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -12453,7 +13376,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12615,6 +13537,39 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/on-headers": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", @@ -12625,6 +13580,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -12641,6 +13605,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-is-inside": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", @@ -12699,6 +13672,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12709,6 +13695,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -12719,6 +13720,21 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -12789,6 +13805,65 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -12809,6 +13884,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -12828,6 +13909,76 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve": { "version": "14.2.5", "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", @@ -12880,6 +14031,27 @@ "node": ">= 0.8" } }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -12948,6 +14120,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12964,6 +14208,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -13053,12 +14306,41 @@ "node": ">=8" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -13072,6 +14354,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-check": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", @@ -13097,7 +14439,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13152,6 +14493,42 @@ "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 922aa5db..98dade6e 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,13 @@ "dev:electron:debug": "npm run dev:electron:debug --workspace=apps/app", "dev:electron:wsl": "npm run dev:electron:wsl --workspace=apps/app", "dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/app", + "dev:server": "npm run dev --workspace=apps/server", + "dev:full": "concurrently \"npm run dev:server\" \"npm run dev:web\"", "build": "npm run build --workspace=apps/app", - "build:electron": "npm run build:electron --workspace=apps/app", + "build:server": "npm run build --workspace=apps/server", + "build:electron": "npm run build --workspace=apps/app", "start": "npm run start --workspace=apps/app", + "start:server": "npm run start --workspace=apps/server", "lint": "npm run lint --workspace=apps/app", "test": "npm run test --workspace=apps/app", "test:headed": "npm run test:headed --workspace=apps/app", diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..1e719824 --- /dev/null +++ b/plan.md @@ -0,0 +1,267 @@ +# Plan: Full Web Support for Automaker + +## Goal +Make the app work fully in web browsers while keeping Electron support. Web mode connects to a backend server (self-hosted or cloud). Electron embeds the same server locally. + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ Next.js Frontend │ +│ (same code both modes) │ +└───────────────┬─────────────────────┘ + │ + ┌───────────┴───────────┐ + │ │ +[Web Mode] [Electron Mode] + │ │ +HTTP/WebSocket HTTP/WebSocket +to remote server to localhost:3008 + │ │ + └───────────┬───────────┘ + │ +┌───────────────▼─────────────────────┐ +│ Backend Server │ +│ (apps/server) │ +│ - Express + WebSocket │ +│ - All services from electron/ │ +│ - Claude Agent SDK │ +│ - File ops, Git, PTY │ +└─────────────────────────────────────┘ +``` + +**Key insight**: Electron uses the same HTTP API - just connects to localhost instead of remote. + +--- + +## New Package: `apps/server` + +``` +apps/server/ +├── package.json +├── src/ +│ ├── index.ts # Express server entry +│ ├── routes/ +│ │ ├── fs.ts # File system routes +│ │ ├── agent.ts # Agent routes +│ │ ├── sessions.ts # Session routes +│ │ ├── auto-mode.ts # Auto mode routes +│ │ ├── features.ts # Features routes +│ │ ├── worktree.ts # Git worktree routes +│ │ ├── setup.ts # Setup/config routes +│ │ └── suggestions.ts # Feature suggestions routes +│ ├── services/ # Moved from electron/services/ +│ │ ├── agent-service.ts +│ │ ├── auto-mode-service.ts +│ │ ├── worktree-manager.ts +│ │ ├── feature-loader.ts +│ │ ├── feature-executor.ts +│ │ └── ... +│ └── lib/ +│ ├── events.ts # Event emitter for streaming +│ └── security.ts # Path validation +``` + +--- + +## Critical Files to Modify + +| File | Change | +|------|--------| +| `apps/app/src/lib/electron.ts` | Add `HttpApiClient` class that implements `ElectronAPI` using fetch/WebSocket | +| `apps/app/electron/main.js` | Simplify to: spawn server + create window (remove 1500+ lines of IPC handlers) | +| `apps/app/electron/preload.js` | Simplify to just expose `isElectron` flag | +| `apps/app/package.json` | Remove server-side deps (Claude SDK, pty) | +| Root `package.json` | Add `apps/server` workspace | + +--- + +## Implementation Phases + +### Phase 1: Create Server Package (Foundation) +1. Create `apps/server` with Express + TypeScript setup +2. Add health check endpoint: `GET /api/health` +3. Copy one simple service (feature-loader) and create route +4. Test with curl/Postman + +### Phase 2: File System API +1. Create `POST /api/fs/read`, `POST /api/fs/write`, etc. +2. Add path security (allowlist validation) +3. Update `electron.ts` with `HttpApiClient` for fs operations +4. Test: file operations work in web mode + +### Phase 3: Agent API with Streaming +1. Add WebSocket server for events (`/api/events`) +2. Migrate `agent-service.js` to TypeScript +3. Create routes: `POST /api/agent/send`, etc. +4. Events stream via WebSocket instead of IPC +5. Test: chat works in web mode + +### Phase 4: Sessions & Features API +1. Migrate session management routes +2. Migrate features CRUD routes +3. Test: project/feature management works + +### Phase 5: Auto Mode & Worktree +1. Migrate `auto-mode-service.js` (complex - has streaming) +2. Migrate `worktree-manager.js` +3. Test: auto mode runs features in web + +### Phase 6: Remaining Services +1. Spec regeneration +2. Feature suggestions +3. Setup/CLI detection +4. Model provider checks + +### Phase 7: Simplify Electron +1. Update `main.js` to spawn server process + create window +2. Remove all IPC handlers +3. Electron app uses HTTP like web +4. Test: Electron still works + +### Phase 8: Production Ready +1. Add authentication (API key header) +2. Configure CORS for production +3. Add `ALLOWED_PROJECT_DIRS` env for security +4. Docker setup for deployment +5. Update build scripts + +--- + +## API Design Pattern + +Convert IPC handlers to REST: + +``` +IPC: dialog:openDirectory → Web: User types path, POST /api/fs/validate +IPC: fs:readFile → POST /api/fs/read { filePath } +IPC: agent:send → POST /api/agent/send { sessionId, message, ... } +IPC: auto-mode:start → POST /api/auto-mode/start { projectPath } +IPC: features:getAll → GET /api/projects/:path/features +``` + +Streaming via WebSocket: +``` +ws://server/api/events + +Events: agent:stream, auto-mode:event, suggestions:event +``` + +--- + +## Web-Specific Handling + +| Feature | Electron | Web | +|---------|----------|-----| +| File picker | Native dialog | Text input + server validation | +| Open link | shell.openExternal | window.open() | +| Data directory | app.getPath('userData') | Server's DATA_DIR env | + +--- + +## Configuration + +**Server `.env`:** +``` +PORT=3008 +DATA_DIR=/path/to/data +ANTHROPIC_API_KEY=xxx +ALLOWED_PROJECT_DIRS=/home/user/projects +``` + +**Frontend `.env.local`:** +``` +NEXT_PUBLIC_SERVER_URL=http://localhost:3008 +``` + +--- + +## Estimated Scope + +- New files: ~15-20 (server package) +- Modified files: ~5 (electron.ts, main.js, preload.js, package.jsons) +- Deleted lines: ~1500 (IPC handlers from main.js) +- Services to migrate: ~10 + +--- + +## Implementation Status + +### ✅ ALL PHASES COMPLETE + +- [x] **Phase 1**: Server package foundation (`apps/server`) + - Express server with WebSocket support + - Event emitter for streaming + - Security module for path validation + - Health check endpoint + +- [x] **Phase 2**: HttpApiClient in frontend + - `apps/app/src/lib/http-api-client.ts` - full implementation + - Modified `electron.ts` to use HTTP client when not in Electron + - No mocks - all calls go through HTTP + +- [x] **Phase 3**: Agent API with streaming + - `apps/server/src/services/agent-service.ts` + - `apps/server/src/routes/agent.ts` + - WebSocket streaming for responses + +- [x] **Phase 4**: Sessions & Features API + - `apps/server/src/routes/sessions.ts` + - `apps/server/src/services/feature-loader.ts` + - `apps/server/src/routes/features.ts` + +- [x] **Phase 5**: Auto Mode & Worktree + - `apps/server/src/services/auto-mode-service.ts` - full implementation with Claude SDK + - `apps/server/src/routes/auto-mode.ts` + - `apps/server/src/routes/worktree.ts` + - `apps/server/src/routes/git.ts` + +- [x] **Phase 6**: Remaining services + - `apps/server/src/routes/setup.ts` - CLI detection, API keys, platform info + - `apps/server/src/routes/suggestions.ts` - AI-powered feature suggestions + - `apps/server/src/routes/spec-regeneration.ts` - spec generation from overview + - `apps/server/src/routes/models.ts` - model providers and availability + - `apps/server/src/routes/running-agents.ts` - active agent tracking + +- [x] **Phase 7**: Simplify Electron + - `apps/app/electron/main-simplified.js` - spawns server, minimal IPC + - `apps/app/electron/preload-simplified.js` - only native features exposed + - Updated `electron.ts` to detect simplified mode + - Updated `http-api-client.ts` to use native dialogs when available + +- [x] **Phase 8**: Production ready + - `apps/server/src/lib/auth.ts` - API key authentication middleware + - `apps/server/Dockerfile` - multi-stage Docker build + - `docker-compose.yml` - easy deployment configuration + - `apps/server/.env.example` - documented configuration + +--- + +## Additional Fixes Applied + +### State Persistence +- Features now cached in localStorage via Zustand persist middleware +- Board view properly handles API failures by keeping cached data +- Theme and UI state properly persisted across refreshes + +### Authentication Display +- Server now returns proper auth method names: `oauth_token_env`, `oauth_token`, `api_key_env`, `api_key` +- Settings view displays correct auth source (OAuth token, API key, subscription, etc.) +- Added support for Codex subscription detection +- Fixed "Unknown method" display issue + +### Bug Fixes +- Fixed board-view.tsx crash when feature status is unknown (defaults to backlog) +- Removed "Mock IPC" label from web mode indicator +- Fixed unused imports and dependency warnings +- Updated API key authentication header support in HTTP client + +--- + +## Summary + +The architecture is simple: **one backend server, two ways to access it** (web browser or Electron shell). + +- **Web users**: Connect browser to your cloud-hosted server +- **Electron users**: App spawns server locally, connects to localhost +- **Same codebase**: Frontend code unchanged, backend services extracted to standalone server