From ebaecca94956044e28151fa96f24b2d44a81ebb7 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:11:58 +0100 Subject: [PATCH 01/19] fix: add cross-platform Node.js executable finder for desktop launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Electron app is launched from desktop environments (macOS Finder, Windows Explorer, Linux desktop launchers), the PATH environment variable is often limited and doesn't include Node.js installation paths. This adds a new `findNodeExecutable()` utility to @automaker/platform that: - Searches common installation paths (Homebrew, system, Program Files) - Supports version managers: NVM, fnm, nvm-windows, Scoop, Chocolatey - Falls back to shell resolution (which/where) when available - Enhances PATH for child processes via `buildEnhancedPath()` - Works cross-platform: macOS, Windows, and Linux 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/main.ts | 252 +++++++++-------- libs/platform/src/index.ts | 8 + libs/platform/src/node-finder.ts | 341 ++++++++++++++++++++++++ libs/platform/tests/node-finder.test.ts | 115 ++++++++ 4 files changed, 588 insertions(+), 128 deletions(-) create mode 100644 libs/platform/src/node-finder.ts create mode 100644 libs/platform/tests/node-finder.test.ts diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index efc12ab7..2d1a0c2f 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -5,11 +5,12 @@ * Only native features (dialogs, shell) use IPC. */ -import path from "path"; -import { spawn, ChildProcess } from "child_process"; -import fs from "fs"; -import http, { Server } from "http"; -import { app, BrowserWindow, ipcMain, dialog, shell } from "electron"; +import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; +import fs from 'fs'; +import http, { Server } from 'http'; +import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; @@ -19,9 +20,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; if (isDev) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - require("dotenv").config({ path: path.join(__dirname, "../.env") }); + require('dotenv').config({ path: path.join(__dirname, '../.env') }); } catch (error) { - console.warn("[Electron] dotenv not available:", (error as Error).message); + console.warn('[Electron] dotenv not available:', (error as Error).message); } } @@ -36,17 +37,17 @@ const STATIC_PORT = 3007; */ function getIconPath(): string | null { let iconFile: string; - if (process.platform === "win32") { - iconFile = "icon.ico"; - } else if (process.platform === "darwin") { - iconFile = "logo_larger.png"; + if (process.platform === 'win32') { + iconFile = 'icon.ico'; + } else if (process.platform === 'darwin') { + iconFile = 'logo_larger.png'; } else { - iconFile = "logo_larger.png"; + iconFile = 'logo_larger.png'; } const iconPath = isDev - ? path.join(__dirname, "../public", iconFile) - : path.join(__dirname, "../dist/public", iconFile); + ? path.join(__dirname, '../public', iconFile) + : path.join(__dirname, '../dist/public', iconFile); if (!fs.existsSync(iconPath)) { console.warn(`[Electron] Icon not found at: ${iconPath}`); @@ -60,18 +61,18 @@ function getIconPath(): string | null { * Start static file server for production builds */ async function startStaticServer(): Promise { - const staticPath = path.join(__dirname, "../dist"); + const staticPath = path.join(__dirname, '../dist'); staticServer = http.createServer((request, response) => { - let filePath = path.join(staticPath, request.url?.split("?")[0] || "/"); + let filePath = path.join(staticPath, request.url?.split('?')[0] || '/'); - if (filePath.endsWith("/")) { - filePath = path.join(filePath, "index.html"); + if (filePath.endsWith('/')) { + filePath = path.join(filePath, 'index.html'); } else if (!path.extname(filePath)) { // For client-side routing, serve index.html for paths without extensions - const possibleFile = filePath + ".html"; + const possibleFile = filePath + '.html'; if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { - filePath = path.join(staticPath, "index.html"); + filePath = path.join(staticPath, 'index.html'); } else if (fs.existsSync(possibleFile)) { filePath = possibleFile; } @@ -79,35 +80,35 @@ async function startStaticServer(): Promise { fs.stat(filePath, (err, stats) => { if (err || !stats?.isFile()) { - filePath = path.join(staticPath, "index.html"); + filePath = path.join(staticPath, 'index.html'); } fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(500); - response.end("Server Error"); + response.end('Server Error'); return; } const ext = path.extname(filePath); const contentTypes: Record = { - ".html": "text/html", - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".eot": "application/vnd.ms-fontobject", + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', }; response.writeHead(200, { - "Content-Type": contentTypes[ext] || "application/octet-stream", + 'Content-Type': contentTypes[ext] || 'application/octet-stream', }); response.end(content); }); @@ -116,12 +117,10 @@ async function startStaticServer(): Promise { return new Promise((resolve, reject) => { staticServer!.listen(STATIC_PORT, () => { - console.log( - `[Electron] Static server running at http://localhost:${STATIC_PORT}` - ); + console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`); resolve(); }); - staticServer!.on("error", reject); + staticServer!.on('error', reject); }); } @@ -129,41 +128,39 @@ async function startStaticServer(): Promise { * Start the backend server */ async function startServer(): Promise { - let command: string; + // Find Node.js executable (handles desktop launcher scenarios) + const nodeResult = findNodeExecutable({ + skipSearch: isDev, + logger: (msg) => console.log(`[Electron] ${msg}`), + }); + const command = nodeResult.nodePath; let args: string[]; let serverPath: string; if (isDev) { - command = "node"; - serverPath = path.join(__dirname, "../../server/src/index.ts"); + serverPath = path.join(__dirname, '../../server/src/index.ts'); - const serverNodeModules = path.join( - __dirname, - "../../server/node_modules/tsx" - ); - const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx"); + const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx'); + const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx'); let tsxCliPath: string; - if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) { - tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs"); - } else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) { - tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs"); + if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); + } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); } else { try { - tsxCliPath = require.resolve("tsx/cli.mjs", { - paths: [path.join(__dirname, "../../server")], + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../server')], }); } catch { - throw new Error( - "Could not find tsx. Please run 'npm install' in the server directory." - ); + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); } } - args = [tsxCliPath, "watch", serverPath]; + args = [tsxCliPath, 'watch', serverPath]; } else { - command = "node"; - serverPath = path.join(process.resourcesPath, "server", "index.js"); + serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; if (!fs.existsSync(serverPath)) { @@ -172,13 +169,20 @@ async function startServer(): Promise { } const serverNodeModules = app.isPackaged - ? path.join(process.resourcesPath, "server", "node_modules") - : path.join(__dirname, "../../server/node_modules"); + ? path.join(process.resourcesPath, 'server', 'node_modules') + : path.join(__dirname, '../../server/node_modules'); + + // Build enhanced PATH that includes Node.js directory (cross-platform) + const enhancedPath = buildEnhancedPath(command, process.env.PATH || ''); + if (enhancedPath !== process.env.PATH) { + console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`); + } const env = { ...process.env, + PATH: enhancedPath, PORT: SERVER_PORT.toString(), - DATA_DIR: app.getPath("userData"), + DATA_DIR: app.getPath('userData'), NODE_PATH: serverNodeModules, // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // If not set, server will allow access to all paths @@ -187,30 +191,30 @@ async function startServer(): Promise { }), }; - console.log("[Electron] Starting backend server..."); - console.log("[Electron] Server path:", serverPath); - console.log("[Electron] NODE_PATH:", serverNodeModules); + console.log('[Electron] Starting backend server...'); + console.log('[Electron] Server path:', serverPath); + console.log('[Electron] NODE_PATH:', serverNodeModules); serverProcess = spawn(command, args, { cwd: path.dirname(serverPath), env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ['ignore', 'pipe', 'pipe'], }); - serverProcess.stdout?.on("data", (data) => { + serverProcess.stdout?.on('data', (data) => { console.log(`[Server] ${data.toString().trim()}`); }); - serverProcess.stderr?.on("data", (data) => { + serverProcess.stderr?.on('data', (data) => { console.error(`[Server Error] ${data.toString().trim()}`); }); - serverProcess.on("close", (code) => { + serverProcess.on('close', (code) => { console.log(`[Server] Process exited with code ${code}`); serverProcess = null; }); - serverProcess.on("error", (err) => { + serverProcess.on('error', (err) => { console.error(`[Server] Failed to start server process:`, err); serverProcess = null; }); @@ -225,30 +229,27 @@ async function waitForServer(maxAttempts = 30): Promise { 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}`)); - } + 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.on('error', reject); req.setTimeout(1000, () => { req.destroy(); - reject(new Error("Timeout")); + reject(new Error('Timeout')); }); }); - console.log("[Electron] Server is ready"); + console.log('[Electron] Server is ready'); return; } catch { await new Promise((r) => setTimeout(r, 500)); } } - throw new Error("Server failed to start"); + throw new Error('Server failed to start'); } /** @@ -262,12 +263,12 @@ function createWindow(): void { minWidth: 1280, minHeight: 768, webPreferences: { - preload: path.join(__dirname, "preload.js"), + preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, - titleBarStyle: "hiddenInset", - backgroundColor: "#0a0a0a", + titleBarStyle: 'hiddenInset', + backgroundColor: '#0a0a0a', }; if (iconPath) { @@ -286,17 +287,17 @@ function createWindow(): void { mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); } - if (isDev && process.env.OPEN_DEVTOOLS === "true") { + if (isDev && process.env.OPEN_DEVTOOLS === 'true') { mainWindow.webContents.openDevTools(); } - mainWindow.on("closed", () => { + mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); - return { action: "deny" }; + return { action: 'deny' }; }); } @@ -304,28 +305,22 @@ function createWindow(): void { app.whenReady().then(async () => { // Ensure userData path is consistent across dev/prod so files land in Automaker dir try { - const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker"); - if (app.getPath("userData") !== desiredUserDataPath) { - app.setPath("userData", desiredUserDataPath); - console.log("[Electron] userData path set to:", desiredUserDataPath); + const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker'); + if (app.getPath('userData') !== desiredUserDataPath) { + app.setPath('userData', desiredUserDataPath); + console.log('[Electron] userData path set to:', desiredUserDataPath); } } catch (error) { - console.warn( - "[Electron] Failed to set userData path:", - (error as Error).message - ); + console.warn('[Electron] Failed to set userData path:', (error as Error).message); } - if (process.platform === "darwin" && app.dock) { + if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); if (iconPath) { try { app.dock.setIcon(iconPath); } catch (error) { - console.warn( - "[Electron] Failed to set dock icon:", - (error as Error).message - ); + console.warn('[Electron] Failed to set dock icon:', (error as Error).message); } } } @@ -342,32 +337,36 @@ app.whenReady().then(async () => { // Create window createWindow(); } catch (error) { - console.error("[Electron] Failed to start:", error); + console.error('[Electron] Failed to start:', error); + dialog.showErrorBox( + 'Automaker Failed to Start', + `The application failed to start.\n\n${(error as Error).message}\n\nPlease ensure Node.js is installed and accessible.` + ); app.quit(); } - app.on("activate", () => { + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { app.quit(); } }); -app.on("before-quit", () => { +app.on('before-quit', () => { if (serverProcess) { - console.log("[Electron] Stopping server..."); + console.log('[Electron] Stopping server...'); serverProcess.kill(); serverProcess = null; } if (staticServer) { - console.log("[Electron] Stopping static server..."); + console.log('[Electron] Stopping static server...'); staticServer.close(); staticServer = null; } @@ -378,28 +377,28 @@ app.on("before-quit", () => { // ============================================ // Native file dialogs -ipcMain.handle("dialog:openDirectory", async () => { +ipcMain.handle('dialog:openDirectory', async () => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openDirectory", "createDirectory"], + properties: ['openDirectory', 'createDirectory'], }); return result; }); -ipcMain.handle("dialog:openFile", async (_, options = {}) => { +ipcMain.handle('dialog:openFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openFile"], + properties: ['openFile'], ...options, }); return result; }); -ipcMain.handle("dialog:saveFile", async (_, options = {}) => { +ipcMain.handle('dialog:saveFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePath: undefined }; } @@ -408,7 +407,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => { }); // Shell operations -ipcMain.handle("shell:openExternal", async (_, url: string) => { +ipcMain.handle('shell:openExternal', async (_, url: string) => { try { await shell.openExternal(url); return { success: true }; @@ -417,7 +416,7 @@ ipcMain.handle("shell:openExternal", async (_, url: string) => { } }); -ipcMain.handle("shell:openPath", async (_, filePath: string) => { +ipcMain.handle('shell:openPath', async (_, filePath: string) => { try { await shell.openPath(filePath); return { success: true }; @@ -427,27 +426,24 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => { }); // App info -ipcMain.handle( - "app:getPath", - async (_, name: Parameters[0]) => { - return app.getPath(name); - } -); +ipcMain.handle('app:getPath', async (_, name: Parameters[0]) => { + return app.getPath(name); +}); -ipcMain.handle("app:getVersion", async () => { +ipcMain.handle('app:getVersion', async () => { return app.getVersion(); }); -ipcMain.handle("app:isPackaged", async () => { +ipcMain.handle('app:isPackaged', async () => { return app.isPackaged; }); // Ping - for connection check -ipcMain.handle("ping", async () => { - return "pong"; +ipcMain.handle('ping', async () => { + return 'pong'; }); // Get server URL for HTTP client -ipcMain.handle("server:getUrl", async () => { +ipcMain.handle('server:getUrl', async () => { return `http://localhost:${SERVER_PORT}`; }); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 0794e109..ae4cc0d8 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -44,3 +44,11 @@ export { // Secure file system (validates paths before I/O operations) export * as secureFs from './secure-fs.js'; + +// Node.js executable finder (cross-platform) +export { + findNodeExecutable, + buildEnhancedPath, + type NodeFinderResult, + type NodeFinderOptions, +} from './node-finder.js'; diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts new file mode 100644 index 00000000..8dabfa3b --- /dev/null +++ b/libs/platform/src/node-finder.ts @@ -0,0 +1,341 @@ +/** + * Cross-platform Node.js executable finder + * + * Handles finding Node.js when the app is launched from desktop environments + * (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited. + */ + +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +/** Result of finding Node.js executable */ +export interface NodeFinderResult { + /** Path to the Node.js executable */ + nodePath: string; + /** How Node.js was found */ + source: + | 'homebrew' + | 'system' + | 'nvm' + | 'fnm' + | 'nvm-windows' + | 'program-files' + | 'scoop' + | 'chocolatey' + | 'which' + | 'where' + | 'fallback'; +} + +/** Options for finding Node.js */ +export interface NodeFinderOptions { + /** Skip the search and return 'node' immediately (useful for dev mode) */ + skipSearch?: boolean; + /** Custom logger function */ + logger?: (message: string) => void; +} + +/** + * Find Node.js executable from version manager directories (NVM, fnm) + * Uses semantic version sorting to prefer the latest version + */ +function findNodeFromVersionManager( + basePath: string, + binSubpath: string = 'bin/node' +): string | null { + if (!fs.existsSync(basePath)) return null; + + try { + const versions = fs + .readdirSync(basePath) + .filter((v) => /^v?\d+/.test(v)) + // Semantic version sort - newest first using localeCompare with numeric option + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); + + for (const version of versions) { + const nodePath = path.join(basePath, version, binSubpath); + if (fs.existsSync(nodePath)) { + return nodePath; + } + } + } catch { + // Directory read failed, skip this location + } + + return null; +} + +/** + * Find Node.js on macOS + */ +function findNodeMacOS(homeDir: string): NodeFinderResult | null { + // Check Homebrew paths in order of preference + const homebrewPaths = [ + // Apple Silicon + '/opt/homebrew/bin/node', + // Intel + '/usr/local/bin/node', + ]; + + for (const nodePath of homebrewPaths) { + if (fs.existsSync(nodePath)) { + return { nodePath, source: 'homebrew' }; + } + } + + // System Node + if (fs.existsSync('/usr/bin/node')) { + return { nodePath: '/usr/bin/node', source: 'system' }; + } + + // NVM installation + const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } + + // fnm installation (multiple possible locations) + const fnmPaths = [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), + ]; + + for (const fnmBasePath of fnmPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + return null; +} + +/** + * Find Node.js on Linux + */ +function findNodeLinux(homeDir: string): NodeFinderResult | null { + // Common Linux paths + const systemPaths = [ + '/usr/bin/node', + '/usr/local/bin/node', + // Snap installation + '/snap/bin/node', + ]; + + for (const nodePath of systemPaths) { + if (fs.existsSync(nodePath)) { + return { nodePath, source: 'system' }; + } + } + + // NVM installation + const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } + + // fnm installation + const fnmPaths = [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, '.fnm', 'node-versions'), + ]; + + for (const fnmBasePath of fnmPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + return null; +} + +/** + * Find Node.js on Windows + */ +function findNodeWindows(homeDir: string): NodeFinderResult | null { + // Program Files paths + const programFilesPaths = [ + path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), + path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'), + ]; + + for (const nodePath of programFilesPaths) { + if (fs.existsSync(nodePath)) { + return { nodePath, source: 'program-files' }; + } + } + + // NVM for Windows + const nvmWindowsPath = path.join( + process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), + 'nvm' + ); + const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe'); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm-windows' }; + } + + // fnm on Windows + const fnmWindowsPaths = [ + path.join( + process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), + 'fnm_multishells' + ), + path.join(homeDir, '.fnm', 'node-versions'), + ]; + + for (const fnmBasePath of fnmWindowsPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe'); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + // Scoop installation + const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); + if (fs.existsSync(scoopPath)) { + return { nodePath: scoopPath, source: 'scoop' }; + } + + // Chocolatey installation + const chocoPath = path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'node.exe' + ); + if (fs.existsSync(chocoPath)) { + return { nodePath: chocoPath, source: 'chocolatey' }; + } + + return null; +} + +/** + * Try to find Node.js using shell commands (which/where) + */ +function findNodeViaShell(platform: NodeJS.Platform): NodeFinderResult | null { + try { + const command = platform === 'win32' ? 'where node' : 'which node'; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // 'where' on Windows can return multiple lines, take the first + const nodePath = result.split(/\r?\n/)[0]; + + if (nodePath && fs.existsSync(nodePath)) { + return { + nodePath, + source: platform === 'win32' ? 'where' : 'which', + }; + } + } catch { + // Shell command failed (likely when launched from desktop without PATH) + } + + return null; +} + +/** + * Find Node.js executable - handles desktop launcher scenarios where PATH is limited + * + * @param options - Configuration options + * @returns Result with path and source information + * + * @example + * ```typescript + * import { findNodeExecutable } from '@automaker/platform'; + * + * // In development, skip the search + * const result = findNodeExecutable({ skipSearch: isDev }); + * console.log(`Using Node.js from ${result.source}: ${result.nodePath}`); + * + * // Spawn a process with the found Node.js + * spawn(result.nodePath, ['script.js']); + * ``` + */ +export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderResult { + const { skipSearch = false, logger = () => {} } = options; + + // Skip search if requested (e.g., in development mode) + if (skipSearch) { + return { nodePath: 'node', source: 'fallback' }; + } + + const platform = process.platform; + const homeDir = os.homedir(); + + // Platform-specific search + let result: NodeFinderResult | null = null; + + switch (platform) { + case 'darwin': + result = findNodeMacOS(homeDir); + break; + case 'linux': + result = findNodeLinux(homeDir); + break; + case 'win32': + result = findNodeWindows(homeDir); + break; + } + + if (result) { + logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); + return result; + } + + // Fallback - try shell resolution (works when launched from terminal) + result = findNodeViaShell(platform); + if (result) { + logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); + return result; + } + + // Ultimate fallback + logger('Could not find Node.js, falling back to "node"'); + return { nodePath: 'node', source: 'fallback' }; +} + +/** + * Build an enhanced PATH that includes the Node.js directory + * Useful for ensuring child processes can find Node.js + * + * @param nodePath - Path to the Node.js executable + * @param currentPath - Current PATH environment variable + * @returns Enhanced PATH with Node.js directory prepended if not already present + * + * @example + * ```typescript + * import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; + * + * const { nodePath } = findNodeExecutable(); + * const enhancedPath = buildEnhancedPath(nodePath, process.env.PATH); + * + * spawn(nodePath, ['script.js'], { + * env: { ...process.env, PATH: enhancedPath } + * }); + * ``` + */ +export function buildEnhancedPath(nodePath: string, currentPath: string = ''): string { + // If using fallback 'node', don't modify PATH + if (nodePath === 'node') { + return currentPath; + } + + const nodeDir = path.dirname(nodePath); + + // Don't add if already present or if it's just '.' + if (nodeDir === '.' || currentPath.includes(nodeDir)) { + return currentPath; + } + + // Use platform-appropriate path separator + return `${nodeDir}${path.delimiter}${currentPath}`; +} diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts new file mode 100644 index 00000000..98521636 --- /dev/null +++ b/libs/platform/tests/node-finder.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js'; +import path from 'path'; + +describe('node-finder', () => { + describe('findNodeExecutable', () => { + it("should return 'node' with fallback source when skipSearch is true", () => { + const result = findNodeExecutable({ skipSearch: true }); + + expect(result.nodePath).toBe('node'); + expect(result.source).toBe('fallback'); + }); + + it('should call logger when node is found', () => { + const logger = vi.fn(); + findNodeExecutable({ logger }); + + // Logger should be called at least once (either found or fallback message) + expect(logger).toHaveBeenCalled(); + }); + + it('should return a valid NodeFinderResult structure', () => { + const result = findNodeExecutable(); + + expect(result).toHaveProperty('nodePath'); + expect(result).toHaveProperty('source'); + expect(typeof result.nodePath).toBe('string'); + expect(result.nodePath.length).toBeGreaterThan(0); + }); + + it('should find node on the current system', () => { + // This test verifies that node can be found on the test machine + const result = findNodeExecutable(); + + // Should find node since we're running in Node.js + expect(result.nodePath).toBeDefined(); + + // Source should be one of the valid sources + const validSources = [ + 'homebrew', + 'system', + 'nvm', + 'fnm', + 'nvm-windows', + 'program-files', + 'scoop', + 'chocolatey', + 'which', + 'where', + 'fallback', + ]; + expect(validSources).toContain(result.source); + }); + }); + + describe('buildEnhancedPath', () => { + const delimiter = path.delimiter; + + it("should return current path unchanged when nodePath is 'node'", () => { + const currentPath = '/usr/bin:/usr/local/bin'; + const result = buildEnhancedPath('node', currentPath); + + expect(result).toBe(currentPath); + }); + + it("should return empty string when nodePath is 'node' and currentPath is empty", () => { + const result = buildEnhancedPath('node', ''); + + expect(result).toBe(''); + }); + + it('should prepend node directory to path', () => { + const nodePath = '/opt/homebrew/bin/node'; + const currentPath = '/usr/bin:/usr/local/bin'; + + const result = buildEnhancedPath(nodePath, currentPath); + + expect(result).toBe(`/opt/homebrew/bin${delimiter}${currentPath}`); + }); + + it('should not duplicate node directory if already in path', () => { + const nodePath = '/usr/local/bin/node'; + const currentPath = '/usr/local/bin:/usr/bin'; + + const result = buildEnhancedPath(nodePath, currentPath); + + expect(result).toBe(currentPath); + }); + + it('should handle empty currentPath', () => { + const nodePath = '/opt/homebrew/bin/node'; + + const result = buildEnhancedPath(nodePath, ''); + + expect(result).toBe(`/opt/homebrew/bin${delimiter}`); + }); + + it('should handle Windows-style paths', () => { + const nodePath = 'C:\\Program Files\\nodejs\\node.exe'; + const currentPath = 'C:\\Windows\\System32'; + + const result = buildEnhancedPath(nodePath, currentPath); + + expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`); + }); + + it('should use default empty string for currentPath', () => { + const nodePath = '/usr/local/bin/node'; + + const result = buildEnhancedPath(nodePath); + + expect(result).toBe(`/usr/local/bin${delimiter}`); + }); + }); +}); From 49f04cf403986d1a5dd6cc3a98dfaf07379c3d62 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:12:32 +0100 Subject: [PATCH 02/19] chore: remove old file from apps/app --- apps/app/next-env.d.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/app/next-env.d.ts diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts deleted file mode 100644 index c4b7818f..00000000 --- a/apps/app/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From d3005393dfc7dfffaf6f4aee7aa9c07d2564eacc Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:49:19 +0100 Subject: [PATCH 03/19] fix: address code review feedback for node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PATH collision detection using proper path segment matching instead of substring includes() which could cause false positives - Reorder fnm Windows paths to prioritize canonical installation path over shell shims (fnm_multishells) - Make Windows path test platform-aware since path.dirname handles backslash paths differently on non-Windows systems 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 11 +++++++---- libs/platform/tests/node-finder.test.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 8dabfa3b..9b45d9e8 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -180,13 +180,14 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { return { nodePath: nvmNode, source: 'nvm-windows' }; } - // fnm on Windows + // fnm on Windows (prioritize canonical installation path over shell shims) const fnmWindowsPaths = [ + path.join(homeDir, '.fnm', 'node-versions'), path.join( process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), - 'fnm_multishells' + 'fnm', + 'node-versions' ), - path.join(homeDir, '.fnm', 'node-versions'), ]; for (const fnmBasePath of fnmWindowsPaths) { @@ -332,7 +333,9 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s const nodeDir = path.dirname(nodePath); // Don't add if already present or if it's just '.' - if (nodeDir === '.' || currentPath.includes(nodeDir)) { + // Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18) + const pathSegments = currentPath.split(path.delimiter); + if (nodeDir === '.' || pathSegments.includes(nodeDir)) { return currentPath; } diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 98521636..f37d0d85 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -96,12 +96,21 @@ describe('node-finder', () => { }); it('should handle Windows-style paths', () => { + // On Windows, path.dirname recognizes backslash paths + // On other platforms, backslash is not a path separator const nodePath = 'C:\\Program Files\\nodejs\\node.exe'; const currentPath = 'C:\\Windows\\System32'; const result = buildEnhancedPath(nodePath, currentPath); - expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`); + if (process.platform === 'win32') { + // On Windows, should prepend the node directory + expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`); + } else { + // On non-Windows, backslash paths are treated as relative paths + // path.dirname returns '.' so the function returns currentPath unchanged + expect(result).toBe(currentPath); + } }); it('should use default empty string for currentPath', () => { From 887fb93b3bb3ca28f28defd6be16eb377628d168 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:54:26 +0100 Subject: [PATCH 04/19] fix: address additional code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add path.normalize() for Windows mixed separator handling - Add validation to check Node executable exists after finding it - Improve error dialog with specific troubleshooting advice for Node.js related errors vs general errors - Include source info in validation error message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/main.ts | 14 +++++++++++++- libs/platform/src/node-finder.ts | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 2d1a0c2f..f4b181c1 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -134,6 +134,12 @@ async function startServer(): Promise { logger: (msg) => console.log(`[Electron] ${msg}`), }); const command = nodeResult.nodePath; + + // Validate that the found Node executable actually exists + if (command !== 'node' && !fs.existsSync(command)) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } + let args: string[]; let serverPath: string; @@ -338,9 +344,15 @@ app.whenReady().then(async () => { createWindow(); } catch (error) { console.error('[Electron] Failed to start:', error); + const errorMessage = (error as Error).message; + const isNodeError = errorMessage.includes('Node.js'); dialog.showErrorBox( 'Automaker Failed to Start', - `The application failed to start.\n\n${(error as Error).message}\n\nPlease ensure Node.js is installed and accessible.` + `The application failed to start.\n\n${errorMessage}\n\n${ + isNodeError + ? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).' + : 'Please check the application logs for more details.' + }` ); app.quit(); } diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 9b45d9e8..cc130478 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -334,8 +334,10 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s // Don't add if already present or if it's just '.' // Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18) - const pathSegments = currentPath.split(path.delimiter); - if (nodeDir === '.' || pathSegments.includes(nodeDir)) { + // Normalize paths for comparison to handle mixed separators on Windows + const normalizedNodeDir = path.normalize(nodeDir); + const pathSegments = currentPath.split(path.delimiter).map((s) => path.normalize(s)); + if (normalizedNodeDir === '.' || pathSegments.includes(normalizedNodeDir)) { return currentPath; } From b18672f66def19c130d3f1540a23c350bee89fc2 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:01:32 +0100 Subject: [PATCH 05/19] refactor(platform): address code review feedback for node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract VERSION_DIR_PATTERN regex to named constant - Pass logger to findNodeViaShell for consistent debug logging - Fix buildEnhancedPath to not add trailing delimiter for empty currentPath 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 17 ++++++++++++++--- libs/platform/tests/node-finder.test.ts | 6 +++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index cc130478..9c29c308 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,6 +10,9 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0") */ +const VERSION_DIR_PATTERN = /^v?\d+/; + /** Result of finding Node.js executable */ export interface NodeFinderResult { /** Path to the Node.js executable */ @@ -50,7 +53,7 @@ function findNodeFromVersionManager( try { const versions = fs .readdirSync(basePath) - .filter((v) => /^v?\d+/.test(v)) + .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); @@ -219,7 +222,10 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { /** * Try to find Node.js using shell commands (which/where) */ -function findNodeViaShell(platform: NodeJS.Platform): NodeFinderResult | null { +function findNodeViaShell( + platform: NodeJS.Platform, + logger: (message: string) => void = () => {} +): NodeFinderResult | null { try { const command = platform === 'win32' ? 'where node' : 'which node'; const result = execSync(command, { @@ -238,6 +244,7 @@ function findNodeViaShell(platform: NodeJS.Platform): NodeFinderResult | null { } } catch { // Shell command failed (likely when launched from desktop without PATH) + logger('Shell command failed to find Node.js (expected when launched from desktop)'); } return null; @@ -293,7 +300,7 @@ export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderR } // Fallback - try shell resolution (works when launched from terminal) - result = findNodeViaShell(platform); + result = findNodeViaShell(platform, logger); if (result) { logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); return result; @@ -342,5 +349,9 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s } // Use platform-appropriate path separator + // Handle empty currentPath without adding trailing delimiter + if (!currentPath) { + return nodeDir; + } return `${nodeDir}${path.delimiter}${currentPath}`; } diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index f37d0d85..4304fbbb 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -87,12 +87,12 @@ describe('node-finder', () => { expect(result).toBe(currentPath); }); - it('should handle empty currentPath', () => { + it('should handle empty currentPath without trailing delimiter', () => { const nodePath = '/opt/homebrew/bin/node'; const result = buildEnhancedPath(nodePath, ''); - expect(result).toBe(`/opt/homebrew/bin${delimiter}`); + expect(result).toBe('/opt/homebrew/bin'); }); it('should handle Windows-style paths', () => { @@ -118,7 +118,7 @@ describe('node-finder', () => { const result = buildEnhancedPath(nodePath); - expect(result).toBe(`/usr/local/bin${delimiter}`); + expect(result).toBe('/usr/local/bin'); }); }); }); From b00568176c5751b822ea287638cc91b2be3d6514 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:07:38 +0100 Subject: [PATCH 06/19] refactor(platform): improve node-finder security and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add null byte validation to shell command output (security hardening) - Expand VERSION_DIR_PATTERN comment to explain intentional pre-release support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 9c29c308..399b38ee 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,7 +10,11 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0") */ +/** + * Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") + * Intentionally permissive to match pre-release versions (v18.17.0-beta, v18.17.0-rc1) + * since localeCompare with numeric:true handles sorting correctly + */ const VERSION_DIR_PATTERN = /^v?\d+/; /** Result of finding Node.js executable */ @@ -236,7 +240,8 @@ function findNodeViaShell( // 'where' on Windows can return multiple lines, take the first const nodePath = result.split(/\r?\n/)[0]; - if (nodePath && fs.existsSync(nodePath)) { + // Validate path: check for null bytes (security) and existence + if (nodePath && !nodePath.includes('\x00') && fs.existsSync(nodePath)) { return { nodePath, source: platform === 'win32' ? 'where' : 'which', From 6e341c1c15e7a201aeb2b8911b46b9eb071cd811 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:16:33 +0100 Subject: [PATCH 07/19] feat(platform): add executable permission validation to node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isExecutable() helper to verify files have execute permission - On Unix: uses fs.constants.X_OK to check execute permission - On Windows: only checks file existence (X_OK not meaningful) - Replace fs.existsSync with isExecutable for all node path checks - Add JSDoc comment documenting version sorting limitations - Add test to verify found node binary is executable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 41 +++++++++++++++++++------ libs/platform/tests/node-finder.test.ts | 20 ++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 399b38ee..cfe8d7d9 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -44,9 +44,32 @@ export interface NodeFinderOptions { logger?: (message: string) => void; } +/** + * Check if a file exists and is executable + * On Windows, only checks existence (X_OK is not meaningful) + */ +function isExecutable(filePath: string): boolean { + try { + if (process.platform === 'win32') { + // On Windows, fs.constants.X_OK is not meaningful - just check existence + fs.accessSync(filePath, fs.constants.F_OK); + } else { + // On Unix-like systems, check for execute permission + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + /** * Find Node.js executable from version manager directories (NVM, fnm) * Uses semantic version sorting to prefer the latest version + * + * Note: Version sorting uses localeCompare with numeric:true which handles most cases + * correctly (e.g., v18.17.0 > v18.9.0) but may not perfectly sort pre-release versions + * (e.g., v20.0.0-beta vs v19.9.9). This is acceptable as we prefer the latest stable. */ function findNodeFromVersionManager( basePath: string, @@ -63,7 +86,7 @@ function findNodeFromVersionManager( for (const version of versions) { const nodePath = path.join(basePath, version, binSubpath); - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return nodePath; } } @@ -87,13 +110,13 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of homebrewPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'homebrew' }; } } // System Node - if (fs.existsSync('/usr/bin/node')) { + if (isExecutable('/usr/bin/node')) { return { nodePath: '/usr/bin/node', source: 'system' }; } @@ -133,7 +156,7 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of systemPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'system' }; } } @@ -172,7 +195,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of programFilesPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'program-files' }; } } @@ -206,7 +229,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { // Scoop installation const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); - if (fs.existsSync(scoopPath)) { + if (isExecutable(scoopPath)) { return { nodePath: scoopPath, source: 'scoop' }; } @@ -216,7 +239,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { 'bin', 'node.exe' ); - if (fs.existsSync(chocoPath)) { + if (isExecutable(chocoPath)) { return { nodePath: chocoPath, source: 'chocolatey' }; } @@ -240,8 +263,8 @@ function findNodeViaShell( // 'where' on Windows can return multiple lines, take the first const nodePath = result.split(/\r?\n/)[0]; - // Validate path: check for null bytes (security) and existence - if (nodePath && !nodePath.includes('\x00') && fs.existsSync(nodePath)) { + // Validate path: check for null bytes (security) and executable permission + if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) { return { nodePath, source: platform === 'win32' ? 'where' : 'which', diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 4304fbbb..62976446 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js'; import path from 'path'; +import fs from 'fs'; describe('node-finder', () => { describe('findNodeExecutable', () => { @@ -51,6 +52,25 @@ describe('node-finder', () => { ]; expect(validSources).toContain(result.source); }); + + it('should find an executable node binary', () => { + const result = findNodeExecutable(); + + // Skip this test if fallback is used (node not found via path search) + if (result.source === 'fallback') { + expect(result.nodePath).toBe('node'); + return; + } + + // Verify the found path is actually executable + if (process.platform === 'win32') { + // On Windows, just check file exists (X_OK is not meaningful) + expect(() => fs.accessSync(result.nodePath, fs.constants.F_OK)).not.toThrow(); + } else { + // On Unix-like systems, verify execute permission + expect(() => fs.accessSync(result.nodePath, fs.constants.X_OK)).not.toThrow(); + } + }); }); describe('buildEnhancedPath', () => { From 9f97426859ea10920da8981dc4fb7ded0a0aa83d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:24:23 +0100 Subject: [PATCH 08/19] docs(README): update setup instructions to include package build step - Added a step to build local shared packages before running Automaker - Updated the sequence of instructions for clarity --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb6e93d2..8d4347fe 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,10 @@ cd automaker # 2. Install dependencies npm install -# 3. Run Automaker (pick your mode) +# 3. Build local shared packages +npm run build:packages + +# 4. Run Automaker (pick your mode) npm run dev # Then choose your run mode when prompted, or use specific commands below ``` From 41ea6f78eb9f958f5ccd3fc72cd6f9535f3c0109 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:28:13 +0100 Subject: [PATCH 09/19] feat(platform): prefer stable Node.js versions over pre-releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PRE_RELEASE_PATTERN to identify beta, rc, alpha, nightly, canary, dev, pre versions - Modify findNodeFromVersionManager to try stable versions first - Pre-release versions are used as fallback if no stable version found - Add tests for pre-release detection and version prioritization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 25 ++++++------ libs/platform/tests/node-finder.test.ts | 53 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index cfe8d7d9..ed2cbb03 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,13 +10,12 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -/** - * Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") - * Intentionally permissive to match pre-release versions (v18.17.0-beta, v18.17.0-rc1) - * since localeCompare with numeric:true handles sorting correctly - */ +/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ const VERSION_DIR_PATTERN = /^v?\d+/; +/** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */ +const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + /** Result of finding Node.js executable */ export interface NodeFinderResult { /** Path to the Node.js executable */ @@ -65,11 +64,8 @@ function isExecutable(filePath: string): boolean { /** * Find Node.js executable from version manager directories (NVM, fnm) - * Uses semantic version sorting to prefer the latest version - * - * Note: Version sorting uses localeCompare with numeric:true which handles most cases - * correctly (e.g., v18.17.0 > v18.9.0) but may not perfectly sort pre-release versions - * (e.g., v20.0.0-beta vs v19.9.9). This is acceptable as we prefer the latest stable. + * Uses semantic version sorting to prefer the latest stable version + * Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback */ function findNodeFromVersionManager( basePath: string, @@ -78,13 +74,18 @@ function findNodeFromVersionManager( if (!fs.existsSync(basePath)) return null; try { - const versions = fs + const allVersions = fs .readdirSync(basePath) .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); - for (const version of versions) { + // Separate stable and pre-release versions, preferring stable + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + + // Try stable versions first, then fall back to pre-release + for (const version of [...stableVersions, ...preReleaseVersions]) { const nodePath = path.join(basePath, version, binSubpath); if (isExecutable(nodePath)) { return nodePath; diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 62976446..6956884b 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -4,6 +4,59 @@ import path from 'path'; import fs from 'fs'; describe('node-finder', () => { + describe('version sorting and pre-release filtering', () => { + // Test the PRE_RELEASE_PATTERN logic indirectly + const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + + it('should identify pre-release versions correctly', () => { + const preReleaseVersions = [ + 'v20.0.0-beta', + 'v18.17.0-rc1', + 'v19.0.0-alpha', + 'v21.0.0-nightly', + 'v20.0.0-canary', + 'v18.0.0-dev', + 'v17.0.0-pre', + ]; + + for (const version of preReleaseVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(true); + } + }); + + it('should not match stable versions as pre-release', () => { + const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0']; + + for (const version of stableVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(false); + } + }); + + it('should sort versions with numeric comparison', () => { + const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0']; + const sorted = [...versions].sort((a, b) => + b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }) + ); + + expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']); + }); + + it('should prefer stable over pre-release when filtering', () => { + const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1']; + + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + const prioritized = [...stableVersions, ...preReleaseVersions]; + + // Stable versions should come first + expect(prioritized[0]).toBe('v19.9.9'); + expect(prioritized[1]).toBe('v18.17.0'); + // Pre-release versions should come after + expect(prioritized[2]).toBe('v20.0.0-beta'); + expect(prioritized[3]).toBe('v21.0.0-rc1'); + }); + }); + describe('findNodeExecutable', () => { it("should return 'node' with fallback source when skipSearch is true", () => { const result = findNodeExecutable({ skipSearch: true }); From 9702f142c4b742569ea40dca9c65f5814be38cfd Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Dec 2025 02:33:39 -0500 Subject: [PATCH 10/19] chore: update build scripts in package.json for improved package management - Modified the build command to first execute the build:packages script, ensuring all necessary packages are built before the UI. - Streamlined the build:packages command by consolidating workspace flags for better readability and maintenance. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bf80c93c..7022ef9a 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/ui", "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/ui", - "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", + "build": "npm run build:packages && npm run build --workspace=apps/ui", + "build:packages": "npm run build -w @automaker/types -w @automaker/platform -w @automaker/utils -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils", "build:server": "npm run build --workspace=apps/server", "build:electron": "npm run build:electron --workspace=apps/ui", "build:electron:dir": "npm run build:electron:dir --workspace=apps/ui", From 9586589453283dd7fb2585f1dc9dec05453aeddd Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Dec 2025 12:10:54 -0500 Subject: [PATCH 11/19] fixing auto verify for kanban issues --- .github/workflows/security-audit.yml | 30 ++ apps/server/src/index.ts | 2 + apps/server/src/routes/github/index.ts | 18 + .../github/routes/check-github-remote.ts | 71 +++ .../server/src/routes/github/routes/common.ts | 35 ++ .../src/routes/github/routes/list-issues.ts | 89 ++++ .../src/routes/github/routes/list-prs.ts | 93 ++++ .../suggestions/generate-suggestions.ts | 12 +- apps/server/src/services/auto-mode-service.ts | 76 +++- .../sidebar/components/sidebar-navigation.tsx | 4 +- .../layout/sidebar/hooks/use-navigation.ts | 64 ++- .../sidebar/hooks/use-project-creation.ts | 22 +- .../ui/src/components/views/analysis-view.tsx | 82 ---- apps/ui/src/components/views/board-view.tsx | 4 +- .../kanban-card/card-content-sections.tsx | 33 +- .../components/kanban-card/kanban-card.tsx | 6 +- .../board-view/dialogs/add-feature-dialog.tsx | 6 - .../dialogs/edit-feature-dialog.tsx | 4 - .../dialogs/feature-suggestions-dialog.tsx | 21 +- .../board-view/hooks/use-board-actions.ts | 2 - .../board-view/shared/testing-tab-content.tsx | 50 +-- .../components/views/github-issues-view.tsx | 334 ++++++++++++++ .../src/components/views/github-prs-view.tsx | 421 ++++++++++++++++++ .../src/components/views/interview-view.tsx | 5 - apps/ui/src/lib/electron.ts | 174 +++++--- apps/ui/src/lib/http-api-client.ts | 10 + apps/ui/src/routes/github-issues.tsx | 6 + apps/ui/src/routes/github-prs.tsx | 6 + apps/ui/src/store/app-store.ts | 1 - libs/types/src/feature.ts | 1 - 30 files changed, 1376 insertions(+), 306 deletions(-) create mode 100644 .github/workflows/security-audit.yml create mode 100644 apps/server/src/routes/github/index.ts create mode 100644 apps/server/src/routes/github/routes/check-github-remote.ts create mode 100644 apps/server/src/routes/github/routes/common.ts create mode 100644 apps/server/src/routes/github/routes/list-issues.ts create mode 100644 apps/server/src/routes/github/routes/list-prs.ts create mode 100644 apps/ui/src/components/views/github-issues-view.tsx create mode 100644 apps/ui/src/components/views/github-prs-view.tsx create mode 100644 apps/ui/src/routes/github-issues.tsx create mode 100644 apps/ui/src/routes/github-prs.tsx diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 00000000..1a867179 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,30 @@ +name: Security Audit + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + schedule: + # Run weekly on Mondays at 9 AM UTC + - cron: '0 9 * * 1' + +jobs: + audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + + - name: Run npm audit + run: npm audit --audit-level=moderate + continue-on-error: false diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4a19c4c7..b2e9f115 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -46,6 +46,7 @@ import { SettingsService } from './services/settings-service.js'; import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; +import { createGitHubRoutes } from './routes/github/index.js'; // Load environment variables dotenv.config(); @@ -145,6 +146,7 @@ app.use('/api/templates', createTemplatesRoutes()); app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); +app.use('/api/github', createGitHubRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts new file mode 100644 index 00000000..bda4d217 --- /dev/null +++ b/apps/server/src/routes/github/index.ts @@ -0,0 +1,18 @@ +/** + * GitHub routes - HTTP API for GitHub integration + */ + +import { Router } from 'express'; +import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; +import { createListIssuesHandler } from './routes/list-issues.js'; +import { createListPRsHandler } from './routes/list-prs.js'; + +export function createGitHubRoutes(): Router { + const router = Router(); + + router.post('/check-remote', createCheckGitHubRemoteHandler()); + router.post('/issues', createListIssuesHandler()); + router.post('/prs', createListPRsHandler()); + + return router; +} diff --git a/apps/server/src/routes/github/routes/check-github-remote.ts b/apps/server/src/routes/github/routes/check-github-remote.ts new file mode 100644 index 00000000..34a07198 --- /dev/null +++ b/apps/server/src/routes/github/routes/check-github-remote.ts @@ -0,0 +1,71 @@ +/** + * GET /check-github-remote endpoint - Check if project has a GitHub remote + */ + +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; + +export interface GitHubRemoteStatus { + hasGitHubRemote: boolean; + remoteUrl: string | null; + owner: string | null; + repo: string | null; +} + +export async function checkGitHubRemote(projectPath: string): Promise { + const status: GitHubRemoteStatus = { + hasGitHubRemote: false, + remoteUrl: null, + owner: null, + repo: null, + }; + + try { + // Get the remote URL (origin by default) + const { stdout } = await execAsync('git remote get-url origin', { + cwd: projectPath, + env: execEnv, + }); + + const remoteUrl = stdout.trim(); + status.remoteUrl = remoteUrl; + + // Check if it's a GitHub URL + // Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git + const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/); + const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/); + + const match = httpsMatch || sshMatch; + if (match) { + status.hasGitHubRemote = true; + status.owner = match[1]; + status.repo = match[2].replace(/\.git$/, ''); + } + } catch { + // No remote or not a git repo - that's okay + } + + return status; +} + +export function createCheckGitHubRemoteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const status = await checkGitHubRemote(projectPath); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, 'Check GitHub remote failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/common.ts b/apps/server/src/routes/github/routes/common.ts new file mode 100644 index 00000000..790f92c3 --- /dev/null +++ b/apps/server/src/routes/github/routes/common.ts @@ -0,0 +1,35 @@ +/** + * Common utilities for GitHub routes + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +export const execAsync = promisify(exec); + +// Extended PATH to include common tool installation locations +export const extendedPath = [ + process.env.PATH, + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin`, +] + .filter(Boolean) + .join(':'); + +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function logError(error: unknown, context: string): void { + console.error(`[GitHub] ${context}:`, error); +} diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts new file mode 100644 index 00000000..49850242 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -0,0 +1,89 @@ +/** + * POST /list-issues endpoint - List GitHub issues for a project + */ + +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; + +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; +} + +export interface GitHubIssue { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + body: string; +} + +export interface ListIssuesResult { + success: boolean; + issues?: GitHubIssue[]; + openIssues?: GitHubIssue[]; + closedIssues?: GitHubIssue[]; + error?: string; +} + +export function createListIssuesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // First check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + // Fetch open issues + const { stdout: openStdout } = await execAsync( + 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', + { + cwd: projectPath, + env: execEnv, + } + ); + + // Fetch closed issues + const { stdout: closedStdout } = await execAsync( + 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', + { + cwd: projectPath, + env: execEnv, + } + ); + + const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); + const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); + + res.json({ + success: true, + openIssues, + closedIssues, + issues: [...openIssues, ...closedIssues], + }); + } catch (error) { + logError(error, 'List GitHub issues failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts new file mode 100644 index 00000000..2dd0b2c4 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -0,0 +1,93 @@ +/** + * POST /list-prs endpoint - List GitHub pull requests for a project + */ + +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; + +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; +} + +export interface GitHubPR { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + isDraft: boolean; + headRefName: string; + reviewDecision: string | null; + mergeable: string; + body: string; +} + +export interface ListPRsResult { + success: boolean; + prs?: GitHubPR[]; + openPRs?: GitHubPR[]; + mergedPRs?: GitHubPR[]; + error?: string; +} + +export function createListPRsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // First check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + // Fetch open PRs + const { stdout: openStdout } = await execAsync( + 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', + { + cwd: projectPath, + env: execEnv, + } + ); + + // Fetch merged PRs + const { stdout: mergedStdout } = await execAsync( + 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', + { + cwd: projectPath, + env: execEnv, + } + ); + + const openPRs: GitHubPR[] = JSON.parse(openStdout || '[]'); + const mergedPRs: GitHubPR[] = JSON.parse(mergedStdout || '[]'); + + res.json({ + success: true, + openPRs, + mergedPRs, + prs: [...openPRs, ...mergedPRs], + }); + } catch (error) { + logError(error, 'List GitHub PRs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 42514a0a..c8000ce5 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -23,10 +23,6 @@ const suggestionsSchema = { id: { type: 'string' }, category: { type: 'string' }, description: { type: 'string' }, - steps: { - type: 'array', - items: { type: 'string' }, - }, priority: { type: 'number', minimum: 1, @@ -34,7 +30,7 @@ const suggestionsSchema = { }, reasoning: { type: 'string' }, }, - required: ['category', 'description', 'steps', 'priority', 'reasoning'], + required: ['category', 'description', 'priority', 'reasoning'], }, }, }, @@ -62,9 +58,8 @@ 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 +3. Priority (1=high, 2=medium, 3=low) +4. Brief reasoning for why this would help The response will be automatically formatted as structured JSON.`; @@ -164,7 +159,6 @@ The response will be automatically formatted as structured JSON.`; 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/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index da48308e..6621ff8a 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -599,15 +599,18 @@ export class AutoModeService { } ); - // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval'); + // Determine final status based on testing mode: + // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) + // - skipTests=true (manual verification): go to 'waiting_approval' for manual review + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, message: `Feature completed in ${Math.round( (Date.now() - tempRunningFeature.startTime) / 1000 - )}s`, + )}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, }); } catch (error) { @@ -868,13 +871,16 @@ Address the follow-up instructions above. Review the previous work and make the } ); - // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval'); + // Determine final status based on testing mode: + // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) + // - skipTests=true (manual verification): go to 'waiting_approval' for manual review + const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, - message: 'Follow-up completed successfully', + message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, }); } catch (error) { @@ -1652,15 +1658,17 @@ You can use the Read tool to view these images at any time during implementation `; } - prompt += ` + // Add verification instructions based on testing mode + if (feature.skipTests) { + // Manual verification - just implement the feature + 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 +4. Ensure the code follows existing patterns and conventions When done, wrap your final summary in tags like this: @@ -1678,6 +1686,56 @@ When done, wrap your final summary in tags like this: This helps parse your summary correctly in the output logs.`; + } else { + // Automated testing - implement and verify with Playwright + 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. Ensure the code follows existing patterns and conventions + +## Verification with Playwright (REQUIRED) + +After implementing the feature, you MUST verify it works correctly using Playwright: + +1. **Create a temporary Playwright test** to verify the feature works as expected +2. **Run the test** to confirm the feature is working +3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition + +Example verification workflow: +\`\`\`bash +# Create a simple verification test +npx playwright test my-verification-test.spec.ts + +# After successful verification, delete the test +rm my-verification-test.spec.ts +\`\`\` + +The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test. + +When done, wrap your final summary in tags like this: + + +## Summary: [Feature Title] + +### Changes Implemented +- [List of changes made] + +### Files Modified +- [List of files] + +### Verification Status +- [Describe how the feature was verified with Playwright] + +### Notes for Developer +- [Any important notes] + + +This helps parse your summary correctly in the output logs.`; + } return prompt; } diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index 5178affa..b22dd8c1 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -51,7 +51,9 @@ export function SidebarNavigation({ return ( - - )} ); } diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx new file mode 100644 index 00000000..3d254cb2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -0,0 +1,334 @@ +import { useState, useEffect, useCallback } from 'react'; +import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react'; +import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export function GitHubIssuesView() { + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [selectedIssue, setSelectedIssue] = useState(null); + const { currentProject } = useAppStore(); + + const fetchIssues = useCallback(async () => { + if (!currentProject?.path) { + setError('No project selected'); + setLoading(false); + return; + } + + try { + setError(null); + const api = getElectronAPI(); + if (api.github) { + const result = await api.github.listIssues(currentProject.path); + if (result.success) { + setOpenIssues(result.openIssues || []); + setClosedIssues(result.closedIssues || []); + } else { + setError(result.error || 'Failed to fetch issues'); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Error fetching issues:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch issues'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [currentProject?.path]); + + useEffect(() => { + fetchIssues(); + }, [fetchIssues]); + + const handleRefresh = useCallback(() => { + setRefreshing(true); + fetchIssues(); + }, [fetchIssues]); + + const handleOpenInGitHub = useCallback((url: string) => { + const api = getElectronAPI(); + api.openExternalLink(url); + }, []); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to Load Issues

+

{error}

+ +
+ ); + } + + const totalIssues = openIssues.length + closedIssues.length; + + return ( +
+ {/* Issues List */} +
+ {/* Header */} +
+
+
+ +
+
+

Issues

+

+ {totalIssues === 0 + ? 'No issues found' + : `${openIssues.length} open, ${closedIssues.length} closed`} +

+
+
+ +
+ + {/* Issues List */} +
+ {totalIssues === 0 ? ( +
+
+ +
+

No Issues

+

This repository has no issues yet.

+
+ ) : ( +
+ {/* Open Issues */} + {openIssues.map((issue) => ( + setSelectedIssue(issue)} + onOpenExternal={() => handleOpenInGitHub(issue.url)} + formatDate={formatDate} + /> + ))} + + {/* Closed Issues Section */} + {closedIssues.length > 0 && ( + <> +
+ Closed Issues ({closedIssues.length}) +
+ {closedIssues.map((issue) => ( + setSelectedIssue(issue)} + onOpenExternal={() => handleOpenInGitHub(issue.url)} + formatDate={formatDate} + /> + ))} + + )} +
+ )} +
+
+ + {/* Issue Detail Panel */} + {selectedIssue && ( +
+ {/* Detail Header */} +
+
+ {selectedIssue.state === 'OPEN' ? ( + + ) : ( + + )} + + #{selectedIssue.number} {selectedIssue.title} + +
+
+ + +
+
+ + {/* Issue Detail Content */} +
+ {/* Title */} +

{selectedIssue.title}

+ + {/* Meta info */} +
+ + {selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'} + + + #{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '} + {selectedIssue.author.login} + +
+ + {/* Labels */} + {selectedIssue.labels.length > 0 && ( +
+ {selectedIssue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {selectedIssue.body ? ( +
+
{selectedIssue.body}
+
+ ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View comments, add reactions, and more on GitHub. +

+ +
+
+
+ )} +
+ ); +} + +interface IssueRowProps { + issue: GitHubIssue; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; +} + +function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) { + return ( +
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + +
+
+ {issue.title} +
+ +
+ + #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} + +
+ + {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx new file mode 100644 index 00000000..4c613653 --- /dev/null +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -0,0 +1,421 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + GitPullRequest, + Loader2, + RefreshCw, + ExternalLink, + GitMerge, + Circle, + X, + AlertCircle, +} from 'lucide-react'; +import { getElectronAPI, GitHubPR } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export function GitHubPRsView() { + const [openPRs, setOpenPRs] = useState([]); + const [mergedPRs, setMergedPRs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [selectedPR, setSelectedPR] = useState(null); + const { currentProject } = useAppStore(); + + const fetchPRs = useCallback(async () => { + if (!currentProject?.path) { + setError('No project selected'); + setLoading(false); + return; + } + + try { + setError(null); + const api = getElectronAPI(); + if (api.github) { + const result = await api.github.listPRs(currentProject.path); + if (result.success) { + setOpenPRs(result.openPRs || []); + setMergedPRs(result.mergedPRs || []); + } else { + setError(result.error || 'Failed to fetch pull requests'); + } + } + } catch (err) { + console.error('[GitHubPRsView] Error fetching PRs:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch pull requests'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [currentProject?.path]); + + useEffect(() => { + fetchPRs(); + }, [fetchPRs]); + + const handleRefresh = useCallback(() => { + setRefreshing(true); + fetchPRs(); + }, [fetchPRs]); + + const handleOpenInGitHub = useCallback((url: string) => { + const api = getElectronAPI(); + api.openExternalLink(url); + }, []); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + const getReviewStatus = (pr: GitHubPR) => { + if (pr.isDraft) return { label: 'Draft', color: 'text-muted-foreground', bg: 'bg-muted' }; + switch (pr.reviewDecision) { + case 'APPROVED': + return { label: 'Approved', color: 'text-green-500', bg: 'bg-green-500/10' }; + case 'CHANGES_REQUESTED': + return { label: 'Changes requested', color: 'text-orange-500', bg: 'bg-orange-500/10' }; + case 'REVIEW_REQUIRED': + return { label: 'Review required', color: 'text-yellow-500', bg: 'bg-yellow-500/10' }; + default: + return null; + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to Load Pull Requests

+

{error}

+ +
+ ); + } + + const totalPRs = openPRs.length + mergedPRs.length; + + return ( +
+ {/* PR List */} +
+ {/* Header */} +
+
+
+ +
+
+

Pull Requests

+

+ {totalPRs === 0 + ? 'No pull requests found' + : `${openPRs.length} open, ${mergedPRs.length} merged`} +

+
+
+ +
+ + {/* PR List */} +
+ {totalPRs === 0 ? ( +
+
+ +
+

No Pull Requests

+

+ This repository has no pull requests yet. +

+
+ ) : ( +
+ {/* Open PRs */} + {openPRs.map((pr) => ( + setSelectedPR(pr)} + onOpenExternal={() => handleOpenInGitHub(pr.url)} + formatDate={formatDate} + getReviewStatus={getReviewStatus} + /> + ))} + + {/* Merged PRs Section */} + {mergedPRs.length > 0 && ( + <> +
+ Merged ({mergedPRs.length}) +
+ {mergedPRs.map((pr) => ( + setSelectedPR(pr)} + onOpenExternal={() => handleOpenInGitHub(pr.url)} + formatDate={formatDate} + getReviewStatus={getReviewStatus} + /> + ))} + + )} +
+ )} +
+
+ + {/* PR Detail Panel */} + {selectedPR && ( +
+ {/* Detail Header */} +
+
+ {selectedPR.state === 'MERGED' ? ( + + ) : ( + + )} + + #{selectedPR.number} {selectedPR.title} + + {selectedPR.isDraft && ( + + Draft + + )} +
+
+ + +
+
+ + {/* PR Detail Content */} +
+ {/* Title */} +

{selectedPR.title}

+ + {/* Meta info */} +
+ + {selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'} + + {getReviewStatus(selectedPR) && ( + + {getReviewStatus(selectedPR)!.label} + + )} + + #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '} + {selectedPR.author.login} + +
+ + {/* Branch info */} + {selectedPR.headRefName && ( +
+ Branch: + + {selectedPR.headRefName} + +
+ )} + + {/* Labels */} + {selectedPR.labels.length > 0 && ( +
+ {selectedPR.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {selectedPR.body ? ( +
+
{selectedPR.body}
+
+ ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View code changes, comments, and reviews on GitHub. +

+ +
+
+
+ )} +
+ ); +} + +interface PRRowProps { + pr: GitHubPR; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; + getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null; +} + +function PRRow({ + pr, + isSelected, + onClick, + onOpenExternal, + formatDate, + getReviewStatus, +}: PRRowProps) { + const reviewStatus = getReviewStatus(pr); + + return ( +
+ {pr.state === 'MERGED' ? ( + + ) : ( + + )} + +
+
+ {pr.title} + {pr.isDraft && ( + + Draft + + )} +
+ +
+ + #{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login} + + {pr.headRefName && ( + + {pr.headRefName} + + )} +
+ +
+ {/* Review Status */} + {reviewStatus && ( + + {reviewStatus.label} + + )} + + {/* Labels */} + {pr.labels.map((label) => ( + + {label.name} + + ))} +
+
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/interview-view.tsx b/apps/ui/src/components/views/interview-view.tsx index 47127e10..71d0fa4d 100644 --- a/apps/ui/src/components/views/interview-view.tsx +++ b/apps/ui/src/components/views/interview-view.tsx @@ -345,11 +345,6 @@ export function InterviewView() { category: 'Core', description: 'Initial project setup', status: 'backlog' as const, - steps: [ - 'Step 1: Review app_spec.txt', - 'Step 2: Set up development environment', - 'Step 3: Start implementing features', - ], skipTests: true, }; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 0cb4c4db..87bb61cd 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -92,12 +92,77 @@ export interface RunningAgentsAPI { getAll: () => Promise; } +// GitHub types +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; +} + +export interface GitHubIssue { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + body: string; +} + +export interface GitHubPR { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + isDraft: boolean; + headRefName: string; + reviewDecision: string | null; + mergeable: string; + body: string; +} + +export interface GitHubRemoteStatus { + hasGitHubRemote: boolean; + remoteUrl: string | null; + owner: string | null; + repo: string | null; +} + +export interface GitHubAPI { + checkRemote: (projectPath: string) => Promise<{ + success: boolean; + hasGitHubRemote?: boolean; + remoteUrl?: string | null; + owner?: string | null; + repo?: string | null; + error?: string; + }>; + listIssues: (projectPath: string) => Promise<{ + success: boolean; + openIssues?: GitHubIssue[]; + closedIssues?: GitHubIssue[]; + error?: string; + }>; + listPRs: (projectPath: string) => Promise<{ + success: boolean; + openPRs?: GitHubPR[]; + mergedPRs?: GitHubPR[]; + error?: string; + }>; +} + // Feature Suggestions types export interface FeatureSuggestion { id: string; category: string; description: string; - steps: string[]; priority: number; reasoning: string; } @@ -326,6 +391,7 @@ export interface ElectronAPI { autoMode?: AutoModeAPI; features?: FeaturesAPI; runningAgents?: RunningAgentsAPI; + github?: GitHubAPI; enhancePrompt?: { enhance: ( originalText: string, @@ -861,6 +927,9 @@ const getMockElectronAPI = (): ElectronAPI => { // Mock Running Agents API runningAgents: createMockRunningAgentsAPI(), + // Mock GitHub API + github: createMockGitHubAPI(), + // Mock Claude API claude: { getUsage: async () => { @@ -1963,12 +2032,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-0`, category: 'Code Smell', description: 'Extract duplicate validation logic into reusable utility', - steps: [ - 'Identify all files with similar validation patterns', - 'Create a validation utilities module', - 'Replace duplicate code with utility calls', - 'Add unit tests for the new utilities', - ], priority: 1, reasoning: 'Reduces code duplication and improves maintainability', }, @@ -1976,12 +2039,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-1`, category: 'Complexity', description: 'Break down large handleSubmit function into smaller functions', - steps: [ - 'Identify the handleSubmit function in form components', - 'Extract validation logic into separate function', - 'Extract API call logic into separate function', - 'Extract success/error handling into separate functions', - ], priority: 2, reasoning: 'Function is too long and handles multiple responsibilities', }, @@ -1989,12 +2046,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-2`, category: 'Architecture', description: 'Move business logic out of React components into hooks', - steps: [ - 'Identify business logic in component files', - 'Create custom hooks for reusable logic', - 'Update components to use the new hooks', - 'Add tests for the extracted hooks', - ], priority: 3, reasoning: 'Improves separation of concerns and testability', }, @@ -2007,12 +2058,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-0`, category: 'High', description: 'Sanitize user input before rendering to prevent XSS', - steps: [ - 'Audit all places where user input is rendered', - 'Implement input sanitization using DOMPurify', - 'Add Content-Security-Policy headers', - 'Test with common XSS payloads', - ], priority: 1, reasoning: 'User input is rendered without proper sanitization', }, @@ -2020,12 +2065,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-1`, category: 'Medium', description: 'Add rate limiting to authentication endpoints', - steps: [ - 'Implement rate limiting middleware', - 'Configure limits for login attempts', - 'Add account lockout after failed attempts', - 'Log suspicious activity', - ], priority: 2, reasoning: 'Prevents brute force attacks on authentication', }, @@ -2033,12 +2072,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-2`, category: 'Low', description: 'Remove sensitive information from error messages', - steps: [ - 'Audit error handling in API routes', - 'Create generic error messages for production', - 'Log detailed errors server-side only', - 'Implement proper error boundaries', - ], priority: 3, reasoning: 'Error messages may leak implementation details', }, @@ -2051,12 +2084,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-0`, category: 'Rendering', description: 'Add React.memo to prevent unnecessary re-renders', - steps: [ - 'Profile component renders with React DevTools', - 'Identify components that re-render unnecessarily', - 'Wrap pure components with React.memo', - 'Use useCallback for event handlers passed as props', - ], priority: 1, reasoning: "Components re-render even when props haven't changed", }, @@ -2064,12 +2091,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-1`, category: 'Bundle Size', description: 'Implement code splitting for route components', - steps: [ - 'Use React.lazy for route components', - 'Add Suspense boundaries with loading states', - 'Analyze bundle with webpack-bundle-analyzer', - 'Consider dynamic imports for heavy libraries', - ], priority: 2, reasoning: 'Initial bundle is larger than necessary', }, @@ -2077,12 +2098,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-2`, category: 'Caching', description: 'Add memoization for expensive computations', - steps: [ - 'Identify expensive calculations in render', - 'Use useMemo for derived data', - 'Consider using react-query for server state', - 'Add caching headers for static assets', - ], priority: 3, reasoning: 'Expensive computations run on every render', }, @@ -2095,12 +2110,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-0`, category: 'User Experience', description: 'Add dark mode toggle with system preference detection', - steps: [ - 'Create a ThemeProvider context to manage theme state', - 'Add a toggle component in the settings or header', - 'Implement CSS variables for theme colors', - 'Add localStorage persistence for user preference', - ], priority: 1, reasoning: 'Dark mode is a standard feature that improves accessibility and user comfort', }, @@ -2108,11 +2117,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-1`, category: 'Performance', description: 'Implement lazy loading for heavy components', - steps: [ - 'Identify components that are heavy or rarely used', - 'Use React.lazy() and Suspense for code splitting', - 'Add loading states for lazy-loaded components', - ], priority: 2, reasoning: 'Improves initial load time and reduces bundle size', }, @@ -2120,12 +2124,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f id: `suggestion-${Date.now()}-2`, category: 'Accessibility', description: 'Add keyboard navigation support throughout the app', - steps: [ - 'Implement focus management for modals and dialogs', - 'Add keyboard shortcuts for common actions', - 'Ensure all interactive elements are focusable', - 'Add ARIA labels and roles where needed', - ], priority: 3, reasoning: 'Improves accessibility for users who rely on keyboard navigation', }, @@ -2592,6 +2590,38 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI { }; } +// Mock GitHub API implementation +function createMockGitHubAPI(): GitHubAPI { + return { + checkRemote: async (projectPath: string) => { + console.log('[Mock] Checking GitHub remote for:', projectPath); + return { + success: true, + hasGitHubRemote: false, + remoteUrl: null, + owner: null, + repo: null, + }; + }, + listIssues: async (projectPath: string) => { + console.log('[Mock] Listing GitHub issues for:', projectPath); + return { + success: true, + openIssues: [], + closedIssues: [], + }; + }, + listPRs: async (projectPath: string) => { + console.log('[Mock] Listing GitHub PRs for:', projectPath); + return { + success: true, + openPRs: [], + mergedPRs: [], + }; + }, + }; +} + // Utility functions for project management export interface Project { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index cdabf0e3..18e77fe4 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -21,6 +21,9 @@ import type { SuggestionsEvent, SpecRegenerationEvent, SuggestionType, + GitHubAPI, + GitHubIssue, + GitHubPR, } from './electron'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; @@ -743,6 +746,13 @@ export class HttpApiClient implements ElectronAPI { }> => this.get('/api/running-agents'), }; + // GitHub API + github: GitHubAPI = { + checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), + listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), + listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), + }; + // Workspace API workspace = { getConfig: (): Promise<{ diff --git a/apps/ui/src/routes/github-issues.tsx b/apps/ui/src/routes/github-issues.tsx new file mode 100644 index 00000000..16506e47 --- /dev/null +++ b/apps/ui/src/routes/github-issues.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { GitHubIssuesView } from '@/components/views/github-issues-view'; + +export const Route = createFileRoute('/github-issues')({ + component: GitHubIssuesView, +}); diff --git a/apps/ui/src/routes/github-prs.tsx b/apps/ui/src/routes/github-prs.tsx new file mode 100644 index 00000000..78e321a6 --- /dev/null +++ b/apps/ui/src/routes/github-prs.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { GitHubPRsView } from '@/components/views/github-prs-view'; + +export const Route = createFileRoute('/github-prs')({ + component: GitHubPRsView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 8927617e..394160b7 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -266,7 +266,6 @@ export interface Feature { titleGenerating?: boolean; category: string; description: string; - steps: string[]; status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed'; images?: FeatureImage[]; imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index a4946336..6aa2473b 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -18,7 +18,6 @@ export interface Feature { titleGenerating?: boolean; category: string; description: string; - steps?: string[]; passes?: boolean; priority?: number; status?: string; From f17d062440ee5029411b6798993e7c354435cb05 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 22 Dec 2025 12:28:06 -0500 Subject: [PATCH 12/19] fix(ui): prevent logo from overlapping macOS traffic light buttons Add platform detection to apply additional left padding (pl-20) and top padding (pt-4) on macOS to prevent the sidebar header/logo from overlapping with the native window control buttons (close, minimize, maximize). Fixes #176 --- .../layout/sidebar/components/sidebar-header.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 093474c0..6e75e975 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -3,6 +3,10 @@ import { cn } from '@/lib/utils'; import { AutomakerLogo } from './automaker-logo'; import { BugReportButton } from './bug-report-button'; +// Detect if running on macOS for traffic light button spacing +const isMac = + typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; + interface SidebarHeaderProps { sidebarOpen: boolean; navigate: (opts: NavigateOptions) => void; @@ -20,7 +24,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) { // Background gradient for depth 'bg-gradient-to-b from-transparent to-background/5', 'flex items-center', - sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center' + sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center', + // Add left padding on macOS to avoid overlapping with traffic light buttons + isMac && 'pt-4 pl-20' )} > From a2030d5877714e1106c54e60dedde77dda29d651 Mon Sep 17 00:00:00 2001 From: James Botwina <34082590+JBotwina@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:33:42 -0500 Subject: [PATCH 13/19] Update apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/components/layout/sidebar/components/sidebar-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 6e75e975..184b8f82 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -5,7 +5,7 @@ import { BugReportButton } from './bug-report-button'; // Detect if running on macOS for traffic light button spacing const isMac = - typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; + typeof navigator !== 'undefined' && navigator.userAgentData?.platform === 'macOS'; interface SidebarHeaderProps { sidebarOpen: boolean; From 64bf02d59cd3c9b165cc84180b2d13e872f485bb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 22 Dec 2025 12:36:56 -0500 Subject: [PATCH 14/19] fix --- .../components/layout/sidebar/components/sidebar-header.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 184b8f82..72e14a68 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -4,8 +4,7 @@ import { AutomakerLogo } from './automaker-logo'; import { BugReportButton } from './bug-report-button'; // Detect if running on macOS for traffic light button spacing -const isMac = - typeof navigator !== 'undefined' && navigator.userAgentData?.platform === 'macOS'; +const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent); interface SidebarHeaderProps { sidebarOpen: boolean; From 3c48b2ceb74525261b5233b85b381435aea24c6f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 22 Dec 2025 12:40:56 -0500 Subject: [PATCH 15/19] add more robust util fn --- .../layout/sidebar/components/sidebar-header.tsx | 5 +---- apps/ui/src/lib/utils.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 72e14a68..d209988e 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -1,11 +1,8 @@ import type { NavigateOptions } from '@tanstack/react-router'; -import { cn } from '@/lib/utils'; +import { cn, isMac } from '@/lib/utils'; import { AutomakerLogo } from './automaker-logo'; import { BugReportButton } from './bug-report-button'; -// Detect if running on macOS for traffic light button spacing -const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent); - interface SidebarHeaderProps { sidebarOpen: boolean; navigate: (opts: NavigateOptions) => void; diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 6d65e3e7..9aba7991 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -52,3 +52,13 @@ export function pathsEqual(p1: string | undefined | null, p2: string | undefined if (!p1 || !p2) return p1 === p2; return normalizePath(p1) === normalizePath(p2); } + +/** + * Detect if running on macOS. + * Checks Electron process.platform first, then falls back to navigator APIs. + */ +export const isMac = + typeof process !== 'undefined' && process.platform === 'darwin' + ? true + : typeof navigator !== 'undefined' && + (/Mac/.test(navigator.userAgent) || navigator.platform?.toLowerCase().includes('mac')); From 0c508ce1305f3da3e3b3f87c2d9bcb021a84b02d Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Dec 2025 12:49:48 -0500 Subject: [PATCH 16/19] feat: add end-to-end testing guide and project creation tests - Introduced a comprehensive E2E Testing Guide outlining best practices for Playwright tests, including principles for test isolation, element selection, and setup utilities. - Added new test files for project creation and opening existing projects, ensuring functionality for creating blank projects and projects from GitHub templates. - Implemented utility functions for setting up test states and managing localStorage, enhancing maintainability and reducing boilerplate in tests. --- apps/ui/tests/e2e-testing-guide.md | 306 +++++++++++++++++++++++++ apps/ui/tests/open-project.spec.ts | 125 ++++++++++ apps/ui/tests/project-creation.spec.ts | 188 +++++++++++++++ apps/ui/tests/utils/project/setup.ts | 165 ++++++++++++- 4 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 apps/ui/tests/e2e-testing-guide.md create mode 100644 apps/ui/tests/open-project.spec.ts create mode 100644 apps/ui/tests/project-creation.spec.ts diff --git a/apps/ui/tests/e2e-testing-guide.md b/apps/ui/tests/e2e-testing-guide.md new file mode 100644 index 00000000..8b07dd53 --- /dev/null +++ b/apps/ui/tests/e2e-testing-guide.md @@ -0,0 +1,306 @@ +# E2E Testing Guide + +Best practices and patterns for writing reliable, non-flaky Playwright e2e tests in this codebase. + +## Core Principles + +1. **No arbitrary timeouts** - Never use `page.waitForTimeout()`. Always wait for specific conditions. +2. **Use data-testid attributes** - Prefer `[data-testid="..."]` selectors over CSS classes or text content. +3. **Clean up after tests** - Use unique temp directories and clean them up in `afterAll`. +4. **Test isolation** - Each test should be independent and not rely on state from other tests. + +## Setting Up Test State + +### Use Setup Utilities (Recommended) + +Use the provided utility functions to set up localStorage state. These utilities hide the internal store structure and version details, making tests more maintainable. + +```typescript +import { setupWelcomeView, setupRealProject } from './utils'; + +// Show welcome view with workspace directory configured +await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + +// Show welcome view with recent projects +await setupWelcomeView(page, { + workspaceDir: TEST_TEMP_DIR, + recentProjects: [ + { + id: 'project-123', + name: 'My Project', + path: '/path/to/project', + lastOpened: new Date().toISOString(), + }, + ], +}); + +// Set up a real project on the filesystem +await setupRealProject(page, projectPath, projectName, { + setAsCurrent: true, // Opens board view (default) +}); +``` + +### Why Use Utilities Instead of Raw localStorage + +1. **Version management** - Store versions are centralized in one place +2. **Less brittle** - If store structure changes, update one file instead of every test +3. **Cleaner tests** - Focus on test logic, not setup boilerplate +4. **Type safety** - Utilities provide typed interfaces for test data + +### Manual LocalStorage Setup (Advanced) + +If you need custom setup not covered by utilities, use `page.addInitScript()`. +Store versions are defined in `tests/utils/project/setup.ts`: + +- `APP_STORE`: version 2 (matches `app-store.ts`) +- `SETUP_STORE`: version 0 (matches `setup-store.ts` default) + +### Temp Directory Management + +Create unique temp directories for test isolation: + +```typescript +import { createTempDirPath, cleanupTempDir } from './utils'; + +const TEST_TEMP_DIR = createTempDirPath('my-test-name'); + +test.describe('My Tests', () => { + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); +}); +``` + +## Waiting for Elements + +### Prefer `toBeVisible()` over `waitForSelector()` + +```typescript +// Good - uses Playwright's auto-waiting with expect +await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + +// Avoid - manual waiting +await page.waitForSelector('[data-testid="welcome-view"]'); +``` + +### Wait for network idle after navigation + +```typescript +await page.goto('/'); +await page.waitForLoadState('networkidle'); +``` + +### Use appropriate timeouts + +- Quick UI updates: 5000ms (default) +- Page loads/navigation: 10000ms +- Async operations (API calls, file system): 15000ms + +```typescript +// Fast UI element +await expect(button).toBeVisible(); + +// Page load +await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + +// Async operation completion +await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); +``` + +## Element Selection + +### Use data-testid attributes + +```typescript +// Good - stable selector +const button = page.locator('[data-testid="create-new-project"]'); + +// Avoid - brittle selectors +const button = page.locator('.btn-primary'); +const button = page.getByText('Create'); +``` + +### Scope selectors when needed + +When text appears in multiple places, scope to a parent: + +```typescript +// Bad - might match multiple elements +await expect(page.getByText(projectName)).toBeVisible(); + +// Good - scoped to specific container +await expect(page.locator('[data-testid="project-selector"]').getByText(projectName)).toBeVisible(); +``` + +### Handle strict mode violations + +If a selector matches multiple elements: + +```typescript +// Use .first() if you need the first match +await page.locator('[data-testid="item"]').first().click(); + +// Or scope to a unique parent +await page.locator('[data-testid="sidebar"]').locator('[data-testid="item"]').click(); +``` + +## Clicking Elements + +### Always verify visibility before clicking + +```typescript +const button = page.locator('[data-testid="submit"]'); +await expect(button).toBeVisible(); +await button.click(); +``` + +### Handle dialogs that may close quickly + +Some dialogs may appear briefly or auto-close. Don't rely on clicking them: + +```typescript +// Instead of trying to close a dialog that might disappear: +// await expect(dialog).toBeVisible(); +// await closeButton.click(); // May fail if dialog closes first + +// Just verify the end state: +await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); +``` + +## Filesystem Verification + +Verify files were created after async operations: + +```typescript +// Wait for UI to confirm operation completed first +await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + +// Then verify filesystem +const projectPath = path.join(TEST_TEMP_DIR, projectName); +expect(fs.existsSync(projectPath)).toBe(true); + +const appSpecPath = path.join(projectPath, '.automaker', 'app_spec.txt'); +expect(fs.existsSync(appSpecPath)).toBe(true); + +const content = fs.readFileSync(appSpecPath, 'utf-8'); +expect(content).toContain(projectName); +``` + +## Test Structure + +### Use descriptive test names + +```typescript +test('should create a new blank project from welcome view', async ({ page }) => { + // ... +}); +``` + +### Group related tests with describe blocks + +```typescript +test.describe('Project Creation', () => { + test('should create a new blank project from welcome view', ...); + test('should create a project from template', ...); +}); +``` + +### Use serial mode when tests depend on each other + +```typescript +test.describe.configure({ mode: 'serial' }); +``` + +## Common Patterns + +### Waiting for either of two outcomes + +When multiple outcomes are possible (e.g., dialog or direct navigation): + +```typescript +// Wait for either the dialog or the board view +await Promise.race([ + initDialog.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}), + boardView.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}), +]); + +// Then handle whichever appeared +if (await initDialog.isVisible()) { + await closeButton.click(); +} + +await expect(boardView).toBeVisible(); +``` + +### Generating unique test data + +```typescript +const projectName = `test-project-${Date.now()}`; +``` + +## Running Tests + +```bash +# Run all tests +npm run test + +# Run specific test file +npm run test -- project-creation.spec.ts + +# Run with headed browser (see what's happening) +npm run test:headed -- project-creation.spec.ts + +# Run multiple times to check for flakiness +npm run test -- project-creation.spec.ts --repeat-each=5 +``` + +## Debugging Failed Tests + +1. Check the screenshot in `test-results/` +2. Read the error context markdown file in `test-results/` +3. Run with `--headed` to watch the test +4. Add `await page.pause()` to pause execution at a specific point + +## Available Test Utilities + +Import from `./utils`: + +### State Setup Utilities + +- `setupWelcomeView(page, options?)` - Set up empty state showing welcome view + - `options.workspaceDir` - Pre-configure workspace directory + - `options.recentProjects` - Add projects to recent list (not current) +- `setupRealProject(page, path, name, options?)` - Set up state with a real filesystem project + - `options.setAsCurrent` - Open board view (default: true) + - `options.additionalProjects` - Add more projects to list +- `setupMockProject(page)` - Set up mock project for unit-style tests +- `setupComplete(page)` - Mark setup wizard as complete + +### Filesystem Utilities + +- `createTempDirPath(prefix)` - Create unique temp directory path +- `cleanupTempDir(path)` - Remove temp directory +- `createTestGitRepo(tempDir)` - Create a git repo for testing + +### Waiting Utilities + +- `waitForNetworkIdle(page)` - Wait for network to be idle +- `waitForElement(page, testId)` - Wait for element by test ID + +### Async File Verification + +Use `expect().toPass()` for polling filesystem operations: + +```typescript +await expect(async () => { + expect(fs.existsSync(filePath)).toBe(true); +}).toPass({ timeout: 10000 }); +``` + +See `tests/utils/index.ts` for the full list of available utilities. diff --git a/apps/ui/tests/open-project.spec.ts b/apps/ui/tests/open-project.spec.ts new file mode 100644 index 00000000..87885604 --- /dev/null +++ b/apps/ui/tests/open-project.spec.ts @@ -0,0 +1,125 @@ +/** + * Open Project End-to-End Test + * + * Tests opening an existing project directory from the welcome view. + * This verifies that: + * 1. An existing directory can be opened as a project + * 2. The .automaker directory is initialized if it doesn't exist + * 3. The project is loaded and shown in the board view + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils'; + +// Create unique temp dir for this test run +const TEST_TEMP_DIR = createTempDirPath('open-project-test'); + +test.describe('Open Project', () => { + test.beforeAll(async () => { + // Create test temp directory + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.afterAll(async () => { + // Cleanup temp directory + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should open an existing project directory from recent projects', async ({ page }) => { + const projectName = `existing-project-${Date.now()}`; + const projectPath = path.join(TEST_TEMP_DIR, projectName); + const projectId = `project-${Date.now()}`; + + // Create the project directory with some files to simulate an existing codebase + fs.mkdirSync(projectPath, { recursive: true }); + + // Create a package.json to simulate a real project + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify( + { + name: projectName, + version: '1.0.0', + description: 'A test project for e2e testing', + }, + null, + 2 + ) + ); + + // Create a README.md + fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${projectName}\n\nA test project.`); + + // Create a src directory with an index.ts file + fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(projectPath, 'src', 'index.ts'), + 'export const hello = () => console.log("Hello World");' + ); + + // Set up welcome view with the project in recent projects (but NOT as current project) + await setupWelcomeView(page, { + recentProjects: [ + { + id: projectId, + name: projectName, + path: projectPath, + lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + }, + ], + }); + + // Navigate to the app + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Wait for welcome view to be visible + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + + // Verify we see the "Recent Projects" section + await expect(page.getByText('Recent Projects')).toBeVisible({ timeout: 5000 }); + + // Click on the recent project to open it + const recentProjectCard = page.locator(`[data-testid="recent-project-${projectId}"]`); + await expect(recentProjectCard).toBeVisible(); + await recentProjectCard.click(); + + // Wait for the board view to appear (project was opened) + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify the project name appears in the project selector (sidebar) + await expect( + page.locator('[data-testid="project-selector"]').getByText(projectName) + ).toBeVisible({ timeout: 5000 }); + + // Verify .automaker directory was created (initialized for the first time) + // Use polling since file creation may be async + const automakerDir = path.join(projectPath, '.automaker'); + await expect(async () => { + expect(fs.existsSync(automakerDir)).toBe(true); + }).toPass({ timeout: 10000 }); + + // Verify the required structure was created by initializeProject: + // - .automaker/categories.json + // - .automaker/features directory + // - .automaker/context directory + // Note: app_spec.txt is NOT created automatically for existing projects + const categoriesPath = path.join(automakerDir, 'categories.json'); + await expect(async () => { + expect(fs.existsSync(categoriesPath)).toBe(true); + }).toPass({ timeout: 10000 }); + + // Verify subdirectories were created + expect(fs.existsSync(path.join(automakerDir, 'features'))).toBe(true); + expect(fs.existsSync(path.join(automakerDir, 'context'))).toBe(true); + + // Verify the original project files still exist (weren't modified) + expect(fs.existsSync(path.join(projectPath, 'package.json'))).toBe(true); + expect(fs.existsSync(path.join(projectPath, 'README.md'))).toBe(true); + expect(fs.existsSync(path.join(projectPath, 'src', 'index.ts'))).toBe(true); + }); +}); diff --git a/apps/ui/tests/project-creation.spec.ts b/apps/ui/tests/project-creation.spec.ts new file mode 100644 index 00000000..9d71d4ff --- /dev/null +++ b/apps/ui/tests/project-creation.spec.ts @@ -0,0 +1,188 @@ +/** + * Project Creation End-to-End Tests + * + * Tests the project creation flows: + * 1. Creating a new blank project from the welcome view + * 2. Creating a new project from a GitHub template + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils'; + +// Create unique temp dir for this test run +const TEST_TEMP_DIR = createTempDirPath('project-creation-test'); + +test.describe('Project Creation', () => { + test.beforeAll(async () => { + // Create test temp directory + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.afterAll(async () => { + // Cleanup temp directory + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should create a new blank project from welcome view', async ({ page }) => { + const projectName = `test-project-${Date.now()}`; + + // Set up welcome view with workspace directory pre-configured + await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + + // Navigate to the app + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Wait for welcome view to be visible + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + + // Click the "Create New Project" dropdown button + const createButton = page.locator('[data-testid="create-new-project"]'); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // Click "Quick Setup" option from the dropdown + const quickSetupOption = page.locator('[data-testid="quick-setup-option"]'); + await expect(quickSetupOption).toBeVisible(); + await quickSetupOption.click(); + + // Wait for the new project modal to appear + const modal = page.locator('[data-testid="new-project-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Enter the project name + const projectNameInput = page.locator('[data-testid="project-name-input"]'); + await expect(projectNameInput).toBeVisible(); + await projectNameInput.fill(projectName); + + // Verify the workspace directory is shown (from our pre-configured localStorage) + // Wait for workspace to be loaded (it shows "Will be created at:" when ready) + await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 }); + + // Click the Create Project button + const createProjectButton = page.locator('[data-testid="confirm-create-project"]'); + await expect(createProjectButton).toBeVisible(); + await createProjectButton.click(); + + // Wait for project creation to complete + // The app may show an init dialog briefly and then navigate to board view + // We just need to verify we end up on the board view with our project + + // Wait for the board view - this confirms the project was created and opened + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify the project name appears in the project selector (sidebar) + await expect( + page.locator('[data-testid="project-selector"]').getByText(projectName) + ).toBeVisible({ timeout: 5000 }); + + // Verify the project was created in the filesystem + const projectPath = path.join(TEST_TEMP_DIR, projectName); + expect(fs.existsSync(projectPath)).toBe(true); + + // Verify .automaker directory was created + const automakerDir = path.join(projectPath, '.automaker'); + expect(fs.existsSync(automakerDir)).toBe(true); + + // Verify app_spec.txt was created + const appSpecPath = path.join(automakerDir, 'app_spec.txt'); + expect(fs.existsSync(appSpecPath)).toBe(true); + + // Verify the app_spec.txt contains the project name + const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8'); + expect(appSpecContent).toContain(projectName); + }); + + test('should create a new project from GitHub template', async ({ page }) => { + // Increase timeout for this test since git clone takes time + test.setTimeout(60000); + + const projectName = `template-project-${Date.now()}`; + + // Set up welcome view with workspace directory pre-configured + await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + + // Navigate to the app + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Wait for welcome view to be visible + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + + // Click the "Create New Project" dropdown button + const createButton = page.locator('[data-testid="create-new-project"]'); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // Click "Quick Setup" option from the dropdown + const quickSetupOption = page.locator('[data-testid="quick-setup-option"]'); + await expect(quickSetupOption).toBeVisible(); + await quickSetupOption.click(); + + // Wait for the new project modal to appear + const modal = page.locator('[data-testid="new-project-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Enter the project name first + const projectNameInput = page.locator('[data-testid="project-name-input"]'); + await expect(projectNameInput).toBeVisible(); + await projectNameInput.fill(projectName); + + // Wait for workspace directory to be loaded + await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 }); + + // Click on the "Starter Kit" tab + const starterKitTab = modal.getByText('Starter Kit'); + await expect(starterKitTab).toBeVisible(); + await starterKitTab.click(); + + // Select the first template (Automaker Starter Kit) + const firstTemplate = page.locator('[data-testid="template-automaker-starter-kit"]'); + await expect(firstTemplate).toBeVisible(); + await firstTemplate.click(); + + // Verify the template is selected (check mark should appear) + await expect(firstTemplate.locator('.lucide-check')).toBeVisible(); + + // Click the Create Project button + const createProjectButton = page.locator('[data-testid="confirm-create-project"]'); + await expect(createProjectButton).toBeVisible(); + await createProjectButton.click(); + + // Wait for git clone to complete and board view to appear + // This takes longer due to the git clone operation + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 45000 }); + + // Verify the project name appears in the project selector (sidebar) + await expect( + page.locator('[data-testid="project-selector"]').getByText(projectName) + ).toBeVisible({ timeout: 5000 }); + + // Verify the project was cloned in the filesystem + const projectPath = path.join(TEST_TEMP_DIR, projectName); + expect(fs.existsSync(projectPath)).toBe(true); + + // Verify .automaker directory was created + const automakerDir = path.join(projectPath, '.automaker'); + expect(fs.existsSync(automakerDir)).toBe(true); + + // Verify app_spec.txt was created with template info + const appSpecPath = path.join(automakerDir, 'app_spec.txt'); + expect(fs.existsSync(appSpecPath)).toBe(true); + const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8'); + expect(appSpecContent).toContain(projectName); + expect(appSpecContent).toContain('Automaker Starter Kit'); + + // Verify the template files were cloned (check for package.json which should exist in the template) + const packageJsonPath = path.join(projectPath, 'package.json'); + expect(fs.existsSync(packageJsonPath)).toBe(true); + + // Verify it's a git repository (cloned from GitHub) + const gitDir = path.join(projectPath, '.git'); + expect(fs.existsSync(gitDir)).toBe(true); + }); +}); diff --git a/apps/ui/tests/utils/project/setup.ts b/apps/ui/tests/utils/project/setup.ts index ee24b376..23f87d8d 100644 --- a/apps/ui/tests/utils/project/setup.ts +++ b/apps/ui/tests/utils/project/setup.ts @@ -1,5 +1,164 @@ import { Page } from '@playwright/test'; +/** + * Store version constants - centralized to avoid hardcoding across tests + * These MUST match the versions used in the actual stores + */ +const STORE_VERSIONS = { + APP_STORE: 2, // Must match app-store.ts persist version + SETUP_STORE: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 +} as const; + +/** + * Project interface for test setup + */ +export interface TestProject { + id: string; + name: string; + path: string; + lastOpened?: string; +} + +/** + * Options for setting up the welcome view + */ +export interface WelcomeViewSetupOptions { + /** Directory path to pre-configure as the workspace directory */ + workspaceDir?: string; + /** Recent projects to show (but not as current project) */ + recentProjects?: TestProject[]; +} + +/** + * Set up localStorage to show the welcome view with no current project + * This is the cleanest way to test project creation flows + * + * @param page - Playwright page + * @param options - Configuration options + */ +export async function setupWelcomeView( + page: Page, + options?: WelcomeViewSetupOptions +): Promise { + await page.addInitScript( + ({ + opts, + versions, + }: { + opts: WelcomeViewSetupOptions | undefined; + versions: typeof STORE_VERSIONS; + }) => { + // Set up empty app state (no current project) - shows welcome view + const appState = { + state: { + projects: opts?.recentProjects || [], + currentProject: null, + currentView: 'welcome', + theme: 'dark', + sidebarOpen: true, + apiKeys: { anthropic: '', google: '' }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + }, + version: versions.APP_STORE, + }; + localStorage.setItem('automaker-storage', JSON.stringify(appState)); + + // Mark setup as complete to skip the setup wizard + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + skipClaudeSetup: false, + }, + version: versions.SETUP_STORE, + }; + localStorage.setItem('automaker-setup', JSON.stringify(setupState)); + + // Set workspace directory if provided + if (opts?.workspaceDir) { + localStorage.setItem('automaker:lastProjectDir', opts.workspaceDir); + } + }, + { opts: options, versions: STORE_VERSIONS } + ); +} + +/** + * Set up localStorage with a project at a real filesystem path + * Use this when testing with actual files on disk + * + * @param page - Playwright page + * @param projectPath - Absolute path to the project directory + * @param projectName - Display name for the project + * @param options - Additional options + */ +export async function setupRealProject( + page: Page, + projectPath: string, + projectName: string, + options?: { + /** Set as current project (opens board view) or just add to recent projects */ + setAsCurrent?: boolean; + /** Additional recent projects to include */ + additionalProjects?: TestProject[]; + } +): Promise { + await page.addInitScript( + ({ + path, + name, + opts, + versions, + }: { + path: string; + name: string; + opts: typeof options; + versions: typeof STORE_VERSIONS; + }) => { + const projectId = `project-${Date.now()}`; + const project: TestProject = { + id: projectId, + name: name, + path: path, + lastOpened: new Date().toISOString(), + }; + + const allProjects = [project, ...(opts?.additionalProjects || [])]; + const currentProject = opts?.setAsCurrent !== false ? project : null; + + const appState = { + state: { + projects: allProjects, + currentProject: currentProject, + currentView: currentProject ? 'board' : 'welcome', + theme: 'dark', + sidebarOpen: true, + apiKeys: { anthropic: '', google: '' }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + }, + version: versions.APP_STORE, + }; + localStorage.setItem('automaker-storage', JSON.stringify(appState)); + + // Mark setup as complete + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + skipClaudeSetup: false, + }, + version: versions.SETUP_STORE, + }; + localStorage.setItem('automaker-setup', JSON.stringify(setupState)); + }, + { path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS } + ); +} + /** * Set up a mock project in localStorage to bypass the welcome screen * This simulates having opened a project before @@ -595,7 +754,7 @@ export async function setupFirstRun(page: Page): Promise { * Set up the app to skip the setup wizard (setup already complete) */ export async function setupComplete(page: Page): Promise { - await page.addInitScript(() => { + await page.addInitScript((versions: typeof STORE_VERSIONS) => { // Mark setup as complete const setupState = { state: { @@ -604,11 +763,11 @@ export async function setupComplete(page: Page): Promise { currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: versions.SETUP_STORE, }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); - }); + }, STORE_VERSIONS); } /** From 53c1a464093ecca17c28cdd12087ac9ee08a36c6 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 22 Dec 2025 12:49:53 -0500 Subject: [PATCH 17/19] .includes is never called on undefined --- apps/ui/src/lib/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 9aba7991..82ad7452 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -61,4 +61,5 @@ export const isMac = typeof process !== 'undefined' && process.platform === 'darwin' ? true : typeof navigator !== 'undefined' && - (/Mac/.test(navigator.userAgent) || navigator.platform?.toLowerCase().includes('mac')); + (/Mac/.test(navigator.userAgent) || + (navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false)); From edef4c7cee3fc239798a44576441e9fed901e1ce Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Dec 2025 13:13:47 -0500 Subject: [PATCH 18/19] refactor: optimize issue and PR fetching by using parallel execution - Updated the list-issues and list-prs handlers to fetch open and closed issues, as well as open and merged PRs in parallel, improving performance. - Removed the redundant 'issues' and 'prs' properties from the result interfaces to streamline the response structure. - Added 'skipTests' flag in integration tests to indicate tests that should be skipped, enhancing test management. --- .../src/routes/github/routes/list-issues.ts | 37 ++++++++++--------- .../src/routes/github/routes/list-prs.ts | 37 +++++++++---------- .../auto-mode-service.integration.test.ts | 6 +++ 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 49850242..08f94135 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -28,7 +28,6 @@ export interface GitHubIssue { export interface ListIssuesResult { success: boolean; - issues?: GitHubIssue[]; openIssues?: GitHubIssue[]; closedIssues?: GitHubIssue[]; error?: string; @@ -54,23 +53,26 @@ export function createListIssuesHandler() { return; } - // Fetch open issues - const { stdout: openStdout } = await execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', - { - cwd: projectPath, - env: execEnv, - } - ); + // Fetch open and closed issues in parallel + const [openResult, closedResult] = await Promise.all([ + execAsync( + 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', + { + cwd: projectPath, + env: execEnv, + } + ), + execAsync( + 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', + { + cwd: projectPath, + env: execEnv, + } + ), + ]); - // Fetch closed issues - const { stdout: closedStdout } = await execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', - { - cwd: projectPath, - env: execEnv, - } - ); + const { stdout: openStdout } = openResult; + const { stdout: closedStdout } = closedResult; const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); @@ -79,7 +81,6 @@ export function createListIssuesHandler() { success: true, openIssues, closedIssues, - issues: [...openIssues, ...closedIssues], }); } catch (error) { logError(error, 'List GitHub issues failed'); diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts index 2dd0b2c4..87f42a38 100644 --- a/apps/server/src/routes/github/routes/list-prs.ts +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -32,7 +32,6 @@ export interface GitHubPR { export interface ListPRsResult { success: boolean; - prs?: GitHubPR[]; openPRs?: GitHubPR[]; mergedPRs?: GitHubPR[]; error?: string; @@ -58,23 +57,24 @@ export function createListPRsHandler() { return; } - // Fetch open PRs - const { stdout: openStdout } = await execAsync( - 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', - { - cwd: projectPath, - env: execEnv, - } - ); - - // Fetch merged PRs - const { stdout: mergedStdout } = await execAsync( - 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', - { - cwd: projectPath, - env: execEnv, - } - ); + const [openResult, mergedResult] = await Promise.all([ + execAsync( + 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', + { + cwd: projectPath, + env: execEnv, + } + ), + execAsync( + 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', + { + cwd: projectPath, + env: execEnv, + } + ), + ]); + const { stdout: openStdout } = openResult; + const { stdout: mergedStdout } = mergedResult; const openPRs: GitHubPR[] = JSON.parse(openStdout || '[]'); const mergedPRs: GitHubPR[] = JSON.parse(mergedStdout || '[]'); @@ -83,7 +83,6 @@ export function createListPRsHandler() { success: true, openPRs, mergedPRs, - prs: [...openPRs, ...mergedPRs], }); } catch (error) { logError(error, 'List GitHub PRs failed'); diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index d9d6ee13..e0ab4c4d 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -146,6 +146,7 @@ describe('auto-mode-service.ts (integration)', () => { category: 'test', description: 'Test without worktree', status: 'pending', + skipTests: true, }); const mockProvider = { @@ -181,6 +182,7 @@ describe('auto-mode-service.ts (integration)', () => { category: 'ui', description: 'Execute this feature', status: 'pending', + skipTests: true, }); const mockProvider = { @@ -327,6 +329,7 @@ describe('auto-mode-service.ts (integration)', () => { category: 'test', description: 'Auto feature 1', status: 'pending', + skipTests: true, }); await createTestFeature(testRepo.path, 'auto-2', { @@ -334,6 +337,7 @@ describe('auto-mode-service.ts (integration)', () => { category: 'test', description: 'Auto feature 2', status: 'pending', + skipTests: true, }); const mockProvider = { @@ -520,6 +524,7 @@ describe('auto-mode-service.ts (integration)', () => { description: 'Feature with skip planning', status: 'pending', planningMode: 'skip', + skipTests: true, }); const mockProvider = { @@ -555,6 +560,7 @@ describe('auto-mode-service.ts (integration)', () => { status: 'pending', planningMode: 'lite', requirePlanApproval: false, + skipTests: true, }); const mockProvider = { From 4cff240520fb6a1213b2f7ca66e5e3e65ee61a47 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 22 Dec 2025 19:36:15 +0100 Subject: [PATCH 19/19] fix: summary modal not appearing when clicking the button in kanban card --- .../kanban-card/agent-info-panel.tsx | 222 ++++++++++-------- 1 file changed, 118 insertions(+), 104 deletions(-) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 5d410761..b36dea20 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -128,116 +128,130 @@ export function AgentInfoPanel({ // Agent Info Panel for non-backlog cards if (showAgentInfo && feature.status !== 'backlog' && agentInfo) { return ( -
- {/* Model & Phase */} -
-
- - {formatModelName(feature.model ?? DEFAULT_MODEL)} -
- {agentInfo.currentPhase && ( -
- {agentInfo.currentPhase} + <> +
+ {/* Model & Phase */} +
+
+ + {formatModelName(feature.model ?? DEFAULT_MODEL)}
- )} -
- - {/* Task List Progress */} - {agentInfo.todos.length > 0 && ( -
-
- - - {agentInfo.todos.filter((t) => t.status === 'completed').length}/ - {agentInfo.todos.length} tasks - -
-
- {agentInfo.todos.slice(0, 3).map((todo, idx) => ( -
- {todo.status === 'completed' ? ( - - ) : todo.status === 'in_progress' ? ( - - ) : ( - - )} - - {todo.content} - -
- ))} - {agentInfo.todos.length > 3 && ( -

- +{agentInfo.todos.length - 3} more -

- )} -
-
- )} - - {/* Summary for waiting_approval and verified */} - {(feature.status === 'waiting_approval' || feature.status === 'verified') && ( - <> - {(feature.summary || summary || agentInfo.summary) && ( -
-
-
- - Summary -
- -
-

- {feature.summary || summary || agentInfo.summary} -

+ {agentInfo.currentPhase && ( +
+ {agentInfo.currentPhase}
)} - {!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && ( -
- - - {agentInfo.toolCallCount} tool calls +
+ + {/* Task List Progress */} + {agentInfo.todos.length > 0 && ( +
+
+ + + {agentInfo.todos.filter((t) => t.status === 'completed').length}/ + {agentInfo.todos.length} tasks - {agentInfo.todos.length > 0 && ( - - - {agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done - +
+
+ {agentInfo.todos.slice(0, 3).map((todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + + )} + + {todo.content} + +
+ ))} + {agentInfo.todos.length > 3 && ( +

+ +{agentInfo.todos.length - 3} more +

)}
- )} - - )} -
+
+ )} + + {/* Summary for waiting_approval and verified */} + {(feature.status === 'waiting_approval' || feature.status === 'verified') && ( + <> + {(feature.summary || summary || agentInfo.summary) && ( +
+
+
+ + Summary +
+ +
+

+ {feature.summary || summary || agentInfo.summary} +

+
+ )} + {!feature.summary && + !summary && + !agentInfo.summary && + agentInfo.toolCallCount > 0 && ( +
+ + + {agentInfo.toolCallCount} tool calls + + {agentInfo.todos.length > 0 && ( + + + {agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done + + )} +
+ )} + + )} +
+ {/* SummaryDialog must be rendered alongside the expand button */} + + ); }