diff --git a/.env.example b/.env.example index 7e0f4ac5..242ec98d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,3 @@ -OPENAI_API_KEY="xxx" -ANTHROPIC_API_KEY="xxx" - APPLE_CODESIGN_IDENTITY="Developer ID Application: PostHog Inc. (xxx)" APPLE_ID="peter@posthog.com" APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxx-xxx-xxx" diff --git a/CLAUDE.md b/CLAUDE.md index 0cc9670e..d3fb8b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,8 @@ Import directly from source files instead. ## Environment Variables - Copy `.env.example` to `.env` -- `ANTHROPIC_API_KEY` - Required for agent -- `OPENAI_API_KEY` - Optional -- `VITE_POSTHOG_*` - PostHog tracking config + +TODO: Update me ## Testing diff --git a/apps/array/package.json b/apps/array/package.json index 41e37b3b..879b37ad 100644 --- a/apps/array/package.json +++ b/apps/array/package.json @@ -111,6 +111,9 @@ "@tiptap/react": "^3.11.0", "@tiptap/starter-kit": "^3.11.0", "@tiptap/suggestion": "^3.11.0", + "@trpc/client": "^11.7.2", + "@trpc/react-query": "^11.7.2", + "@trpc/server": "^11.7.2", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/addon-web-links": "^0.11.0", @@ -142,6 +145,7 @@ "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "trpc-electron": "^0.1.2", "uuid": "^9.0.1", "vscode-icons-js": "^11.6.1", "zod": "^4.1.12", diff --git a/apps/array/src/main/index.ts b/apps/array/src/main/index.ts index 5204ff68..c9f93f7a 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -16,6 +16,7 @@ import { type MenuItemConstructorOptions, shell, } from "electron"; +import { createIPCHandler } from "trpc-electron/main"; import "./lib/logger"; import { ANALYTICS_EVENTS } from "../types/analytics.js"; import { dockBadgeService } from "./services/dockBadge.js"; @@ -23,6 +24,8 @@ import { cleanupAgentSessions, registerAgentIpc, } from "./services/session-manager.js"; +import { setMainWindowGetter } from "./trpc/context.js"; +import { trpcRouter } from "./trpc/index.js"; // Legacy type kept for backwards compatibility with taskControllers map type TaskController = unknown; @@ -37,14 +40,11 @@ import { registerExternalAppsIpc, } from "./services/externalApps.js"; import { registerOAuthHandlers } from "./services/oauth.js"; -import { registerOsIpc } from "./services/os.js"; -import { registerPosthogIpc } from "./services/posthog.js"; import { initializePostHog, shutdownPostHog, trackAppEvent, } from "./services/posthog-analytics.js"; -import { registerSettingsIpc } from "./services/settings.js"; import { registerShellIpc } from "./services/shell.js"; import { registerAutoUpdater } from "./services/updates.js"; import { registerWorkspaceIpc } from "./services/workspace/index.js"; @@ -115,6 +115,9 @@ function createWindow(): void { mainWindow?.show(); }); + setMainWindowGetter(() => mainWindow); + createIPCHandler({ router: trpcRouter, windows: [mainWindow] }); + setupExternalLinkHandlers(mainWindow); // Set up menu for keyboard shortcuts @@ -293,10 +296,8 @@ registerAutoUpdater(() => mainWindow); ipcMain.handle("app:get-version", () => app.getVersion()); // Register IPC handlers via services -registerPosthogIpc(); registerOAuthHandlers(); -registerOsIpc(() => mainWindow); -registerGitIpc(() => mainWindow); +registerGitIpc(); registerAgentIpc(taskControllers, () => mainWindow); registerFsIpc(); registerFileWatcherIpc(() => mainWindow); @@ -305,4 +306,3 @@ registerWorktreeIpc(); registerShellIpc(); registerExternalAppsIpc(); registerWorkspaceIpc(() => mainWindow); -registerSettingsIpc(); diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index ece3981a..dae432b1 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -1,5 +1,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron"; +import { exposeElectronTRPC } from "trpc-electron/main"; import type { CreateWorkspaceOptions, RegisteredFolder, @@ -19,6 +20,12 @@ import type { } from "./services/contextMenu.types.js"; import "electron-log/preload"; +process.once("loaded", () => { + exposeElectronTRPC(); +}); + +/// -- Legacy IPC handlers -- /// + type IpcEventListener = (data: T) => void; function createIpcListener( @@ -38,16 +45,6 @@ function createVoidIpcListener( return () => ipcRenderer.removeListener(channel, listener); } -interface MessageBoxOptions { - type?: "none" | "info" | "error" | "question" | "warning"; - title?: string; - message?: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - cancelId?: number; -} - interface AgentStartParams { taskId: string; taskRunId: string; @@ -64,20 +61,6 @@ interface AgentStartParams { } contextBridge.exposeInMainWorld("electronAPI", { - storeApiKey: (apiKey: string): Promise => - ipcRenderer.invoke("store-api-key", apiKey), - retrieveApiKey: (encryptedKey: string): Promise => - ipcRenderer.invoke("retrieve-api-key", encryptedKey), - fetchS3Logs: (logUrl: string): Promise => - ipcRenderer.invoke("fetch-s3-logs", logUrl), - rendererStore: { - getItem: (key: string): Promise => - ipcRenderer.invoke("renderer-store:get", key), - setItem: (key: string, value: string): Promise => - ipcRenderer.invoke("renderer-store:set", key, value), - removeItem: (key: string): Promise => - ipcRenderer.invoke("renderer-store:remove", key), - }, // OAuth API oauthStartFlow: ( region: CloudRegion, @@ -90,16 +73,9 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("oauth:refresh-token", refreshToken, region), oauthCancelFlow: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke("oauth:cancel-flow"), - selectDirectory: (): Promise => - ipcRenderer.invoke("select-directory"), - searchDirectories: (query: string, searchRoot?: string): Promise => - ipcRenderer.invoke("search-directories", query, searchRoot), - findReposDirectory: (): Promise => - ipcRenderer.invoke("find-repos-directory"), + // Repo API validateRepo: (directoryPath: string): Promise => ipcRenderer.invoke("validate-repo", directoryPath), - checkWriteAccess: (directoryPath: string): Promise => - ipcRenderer.invoke("check-write-access", directoryPath), detectRepo: ( directoryPath: string, ): Promise<{ @@ -108,25 +84,6 @@ contextBridge.exposeInMainWorld("electronAPI", { branch?: string; remote?: string; } | null> => ipcRenderer.invoke("detect-repo", directoryPath), - validateRepositoryMatch: ( - path: string, - organization: string, - repository: string, - ): Promise<{ - valid: boolean; - detected?: { organization: string; repository: string } | null; - error?: string; - }> => - ipcRenderer.invoke( - "validate-repository-match", - path, - organization, - repository, - ), - checkSSHAccess: (): Promise<{ - available: boolean; - error?: string; - }> => ipcRenderer.invoke("check-ssh-access"), cloneRepository: ( repoUrl: string, targetPath: string, @@ -140,18 +97,13 @@ contextBridge.exposeInMainWorld("electronAPI", { message: string; }) => void, ): (() => void) => createIpcListener(`clone-progress:${cloneId}`, listener), - showMessageBox: (options: MessageBoxOptions): Promise<{ response: number }> => - ipcRenderer.invoke("show-message-box", options), - openExternal: (url: string): Promise => - ipcRenderer.invoke("open-external", url), listRepoFiles: ( repoPath: string, query?: string, limit?: number, ): Promise> => ipcRenderer.invoke("list-repo-files", repoPath, query, limit), - clearRepoFileCache: (repoPath: string): Promise => - ipcRenderer.invoke("clear-repo-file-cache", repoPath), + // Agent API agentStart: async ( params: AgentStartParams, ): Promise<{ sessionId: string; channel: string }> => @@ -165,18 +117,6 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("agent-cancel", sessionId), agentCancelPrompt: async (sessionId: string): Promise => ipcRenderer.invoke("agent-cancel-prompt", sessionId), - agentListSessions: async ( - taskId?: string, - ): Promise< - Array<{ - sessionId: string; - acpSessionId: string; - channel: string; - taskId: string; - }> - > => ipcRenderer.invoke("agent-list-sessions", taskId), - agentLoadSession: async (sessionId: string, cwd: string): Promise => - ipcRenderer.invoke("agent-load-session", sessionId, cwd), agentReconnect: async (params: { taskId: string; taskRunId: string; @@ -200,68 +140,6 @@ contextBridge.exposeInMainWorld("electronAPI", { listener: (payload: unknown) => void, ): (() => void) => createIpcListener(channel, listener), // Plan mode operations - agentStartPlanMode: async (params: { - taskId: string; - taskTitle: string; - taskDescription: string; - repoPath: string; - apiKey: string; - apiHost: string; - projectId: number; - }): Promise<{ taskId: string; channel: string }> => - ipcRenderer.invoke("agent-start-plan-mode", params), - agentGeneratePlan: async (params: { - taskId: string; - taskTitle: string; - taskDescription: string; - repoPath: string; - questionAnswers: unknown[]; - apiKey: string; - apiHost: string; - projectId: number; - }): Promise<{ taskId: string; channel: string }> => - ipcRenderer.invoke("agent-generate-plan", params), - readPlanFile: (repoPath: string, taskId: string): Promise => - ipcRenderer.invoke("read-plan-file", repoPath, taskId), - writePlanFile: ( - repoPath: string, - taskId: string, - content: string, - ): Promise => - ipcRenderer.invoke("write-plan-file", repoPath, taskId, content), - ensurePosthogFolder: (repoPath: string, taskId: string): Promise => - ipcRenderer.invoke("ensure-posthog-folder", repoPath, taskId), - listTaskArtifacts: (repoPath: string, taskId: string): Promise => - ipcRenderer.invoke("list-task-artifacts", repoPath, taskId), - readTaskArtifact: ( - repoPath: string, - taskId: string, - fileName: string, - ): Promise => - ipcRenderer.invoke("read-task-artifact", repoPath, taskId, fileName), - appendToArtifact: ( - repoPath: string, - taskId: string, - fileName: string, - content: string, - ): Promise => - ipcRenderer.invoke( - "append-to-artifact", - repoPath, - taskId, - fileName, - content, - ), - saveQuestionAnswers: ( - repoPath: string, - taskId: string, - answers: Array<{ - questionId: string; - selectedOption: string; - customInput?: string; - }>, - ): Promise => - ipcRenderer.invoke("save-question-answers", repoPath, taskId, answers), readRepoFile: (repoPath: string, filePath: string): Promise => ipcRenderer.invoke("read-repo-file", repoPath, filePath), writeRepoFile: ( @@ -467,6 +345,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ): Promise => ipcRenderer.invoke("worktree-get-main-repo", mainRepoPath, worktreePath), }, + // External Apps API externalApps: { getDetectedApps: (): Promise< Array<{ @@ -533,17 +412,6 @@ contextBridge.exposeInMainWorld("electronAPI", { }>, ): (() => void) => createIpcListener("workspace:warning", listener), }, - // Settings API - settings: { - getWorktreeLocation: (): Promise => - ipcRenderer.invoke("settings:get-worktree-location"), - setWorktreeLocation: (location: string): Promise => - ipcRenderer.invoke("settings:set-worktree-location", location), - getTerminalLayout: (): Promise<"split" | "tabbed"> => - ipcRenderer.invoke("settings:get-terminal-layout"), - setTerminalLayout: (mode: "split" | "tabbed"): Promise => - ipcRenderer.invoke("settings:set-terminal-layout", mode), - }, // Dock Badge API dockBadge: { show: (): Promise => ipcRenderer.invoke("dock-badge:show"), diff --git a/apps/array/src/main/services/folders.ts b/apps/array/src/main/services/folders.ts index a61228ff..808bfeca 100644 --- a/apps/array/src/main/services/folders.ts +++ b/apps/array/src/main/services/folders.ts @@ -7,9 +7,9 @@ import type { RegisteredFolder } from "../../shared/types"; import { generateId } from "../../shared/utils/id"; import { createIpcHandler } from "../lib/ipcHandler"; import { logger } from "../lib/logger"; +import { clearAllStoreData, foldersStore } from "../utils/store"; import { isGitRepository } from "./git"; import { getWorktreeLocation } from "./settingsStore"; -import { clearAllStoreData, foldersStore } from "./store"; import { deleteWorktreeIfExists } from "./worktreeUtils"; const execAsync = promisify(exec); diff --git a/apps/array/src/main/services/fs.ts b/apps/array/src/main/services/fs.ts index 7114a2fb..53baadec 100644 --- a/apps/array/src/main/services/fs.ts +++ b/apps/array/src/main/services/fs.ts @@ -159,259 +159,6 @@ export function registerFsIpc(): void { }, ); - ipcMain.handle( - "clear-repo-file-cache", - async (_event: IpcMainInvokeEvent, repoPath: string): Promise => { - if (repoPath) { - repoFileCache.delete(repoPath); - } - }, - ); - - // Plan file operations - ipcMain.handle( - "ensure-posthog-folder", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - ): Promise => { - const posthogDir = path.join(repoPath, ".posthog", taskId); - await fsPromises.mkdir(posthogDir, { recursive: true }); - return posthogDir; - }, - ); - - ipcMain.handle( - "read-plan-file", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - ): Promise => { - try { - const planPath = path.join(repoPath, ".posthog", taskId, "plan.md"); - const content = await fsPromises.readFile(planPath, "utf-8"); - return content; - } catch (error) { - // File doesn't exist or can't be read - log.debug(`Plan file not found for task ${taskId}:`, error); - return null; - } - }, - ); - - ipcMain.handle( - "write-plan-file", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - content: string, - ): Promise => { - try { - const posthogDir = path.join(repoPath, ".posthog", taskId); - await fsPromises.mkdir(posthogDir, { recursive: true }); - const planPath = path.join(posthogDir, "plan.md"); - await fsPromises.writeFile(planPath, content, "utf-8"); - log.debug(`Plan file written for task ${taskId}`); - } catch (error) { - log.error(`Failed to write plan file for task ${taskId}:`, error); - throw error; - } - }, - ); - - ipcMain.handle( - "list-task-artifacts", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - ): Promise< - Array<{ name: string; path: string; size: number; modifiedAt: string }> - > => { - try { - const posthogDir = path.join(repoPath, ".posthog", taskId); - - // Check if directory exists - try { - await fsPromises.access(posthogDir); - } catch { - return []; // Directory doesn't exist yet - } - - const entries = await fsPromises.readdir(posthogDir, { - withFileTypes: true, - }); - - const artifacts = []; - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(".md")) { - const filePath = path.join(posthogDir, entry.name); - const stats = await fsPromises.stat(filePath); - artifacts.push({ - name: entry.name, - path: filePath, - size: stats.size, - modifiedAt: stats.mtime.toISOString(), - }); - } - } - - return artifacts; - } catch (error) { - log.error(`Failed to list artifacts for task ${taskId}:`, error); - return []; - } - }, - ); - - ipcMain.handle( - "read-task-artifact", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - fileName: string, - ): Promise => { - try { - const filePath = path.join(repoPath, ".posthog", taskId, fileName); - const content = await fsPromises.readFile(filePath, "utf-8"); - return content; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === "ENOENT") { - return null; - } - log.error( - `Failed to read artifact ${fileName} for task ${taskId}:`, - error, - ); - return null; - } - }, - ); - - ipcMain.handle( - "append-to-artifact", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - fileName: string, - content: string, - ): Promise => { - try { - const filePath = path.join(repoPath, ".posthog", taskId, fileName); - - // Ensure the file exists before appending - try { - await fsPromises.access(filePath); - } catch { - throw new Error(`File ${fileName} does not exist for task ${taskId}`); - } - - await fsPromises.appendFile(filePath, content, "utf-8"); - log.debug(`Appended content to ${fileName} for task ${taskId}`); - } catch (error) { - log.error( - `Failed to append to artifact ${fileName} for task ${taskId}:`, - error, - ); - throw error; - } - }, - ); - - ipcMain.handle( - "save-question-answers", - async ( - _event: IpcMainInvokeEvent, - repoPath: string, - taskId: string, - answers: Array<{ - questionId: string; - selectedOption: string; - customInput?: string; - }>, - ): Promise => { - try { - const posthogDir = path.join(repoPath, ".posthog", taskId); - const researchPath = path.join(posthogDir, "research.json"); - - // Ensure .posthog/taskId directory exists - await fsPromises.mkdir(posthogDir, { recursive: true }); - - // Read existing research.json or create minimal structure - let researchData: { - actionabilityScore: number; - context: string; - keyFiles: string[]; - blockers?: string[]; - questions?: Array<{ - id: string; - question: string; - options: string[]; - }>; - answered?: boolean; - answers?: Array<{ - questionId: string; - selectedOption: string; - customInput?: string; - }>; - }; - try { - const content = await fsPromises.readFile(researchPath, "utf-8"); - researchData = JSON.parse(content); - } catch { - log.debug( - `research.json not found for task ${taskId}, creating with answers only`, - ); - researchData = { - actionabilityScore: 0.5, - context: "User provided answers to clarifying questions", - keyFiles: [], - }; - } - - // Update with answers - researchData.answered = true; - researchData.answers = answers; - - // Write back to file - await fsPromises.writeFile( - researchPath, - JSON.stringify(researchData, null, 2), - "utf-8", - ); - - log.debug(`Saved answers to research.json for task ${taskId}`); - - // Commit the answers (local mode - no push) - try { - await execAsync(`cd "${repoPath}" && git add .posthog/`, { - cwd: repoPath, - }); - await execAsync( - `cd "${repoPath}" && git commit -m "Answer research questions for task ${taskId}"`, - { cwd: repoPath }, - ); - log.debug(`Committed answers for task ${taskId}`); - } catch (gitError) { - log.warn( - `Failed to commit answers (may not be a git repo or no changes):`, - gitError, - ); - // Don't throw - answers are still saved - } - } catch (error) { - log.error(`Failed to save answers for task ${taskId}:`, error); - throw error; - } - }, - ); - ipcMain.handle( "read-repo-file", async ( diff --git a/apps/array/src/main/services/git.ts b/apps/array/src/main/services/git.ts index f04e42ed..aaacd57f 100644 --- a/apps/array/src/main/services/git.ts +++ b/apps/array/src/main/services/git.ts @@ -1,10 +1,10 @@ -import { type ChildProcess, exec, execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import type { ChangedFile, GitFileStatus } from "@shared/types"; -import { type BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"; +import { type IpcMainInvokeEvent, ipcMain } from "electron"; import { logger } from "../lib/logger"; const log = logger.scope("git"); @@ -51,32 +51,11 @@ const getAllFilesInDirectory = async ( return files; }; -const CLONE_MAX_BUFFER = 10 * 1024 * 1024; - export interface GitHubRepo { organization: string; repository: string; } -interface CloneProgress { - status: "cloning" | "complete" | "error"; - message: string; -} - -interface ValidationResult { - valid: boolean; - detected?: GitHubRepo | null; - error?: string; -} - -const sendCloneProgress = ( - win: BrowserWindow, - cloneId: string, - progress: CloneProgress, -) => { - win.webContents.send(`clone-progress:${cloneId}`, progress); -}; - export const parseGitHubUrl = (url: string): GitHubRepo | null => { const match = url.match(/github\.com[:/](.+?)\/(.+?)(\.git)?$/) || @@ -699,13 +678,7 @@ export const detectSSHError = (output: string): string | undefined => { return `SSH test failed: ${output.substring(0, 200)}`; }; -export function registerGitIpc( - getMainWindow: () => BrowserWindow | null, -): void { - ipcMain.handle("find-repos-directory", async (): Promise => { - return findReposDirectory(); - }); - +export function registerGitIpc(): void { ipcMain.handle( "validate-repo", async ( @@ -742,190 +715,6 @@ export function registerGitIpc( }, ); - ipcMain.handle( - "validate-repository-match", - async ( - _event: IpcMainInvokeEvent, - directoryPath: string, - expectedOrg: string, - expectedRepo: string, - ): Promise => { - if (!directoryPath) { - return { valid: false, error: "No directory path provided" }; - } - - try { - await fsPromises.access(directoryPath); - } catch { - return { valid: false, error: "Directory does not exist" }; - } - - if (!(await isGitRepository(directoryPath))) { - return { valid: false, error: "Not a git repository" }; - } - - const remoteUrl = await getRemoteUrl(directoryPath); - if (!remoteUrl) { - return { - valid: false, - detected: null, - error: "Could not detect GitHub repository", - }; - } - - const detected = parseGitHubUrl(remoteUrl); - if (!detected) { - return { - valid: false, - detected: null, - error: "Could not parse GitHub repository URL", - }; - } - - const matches = - detected.organization.toLowerCase() === expectedOrg.toLowerCase() && - detected.repository.toLowerCase() === expectedRepo.toLowerCase(); - - return { - valid: matches, - detected, - error: matches - ? undefined - : `Folder contains ${detected.organization}/${detected.repository}, expected ${expectedOrg}/${expectedRepo}`, - }; - }, - ); - - ipcMain.handle( - "check-ssh-access", - async (): Promise<{ available: boolean; error?: string }> => { - try { - const { stdout, stderr } = await execAsync( - 'ssh -T git@github.com -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 2>&1 || echo "SSH_TEST_COMPLETE"', - ); - - const output = stdout + stderr; - const error = detectSSHError(output); - - return error ? { available: false, error } : { available: true }; - } catch (error) { - return { - available: false, - error: `Failed to test SSH: ${error instanceof Error ? error.message : "Unknown error"}`, - }; - } - }, - ); - - const activeClones = new Map(); - - const setupCloneProcess = ( - cloneId: string, - repoUrl: string, - targetPath: string, - win: BrowserWindow, - ): ChildProcess => { - // Expand home directory for SSH config path - const homeDir = os.homedir(); - const sshConfigPath = path.join(homeDir, ".ssh", "config"); - - // Use GIT_SSH_COMMAND to ensure SSH uses the config file - const env = { - ...process.env, - GIT_SSH_COMMAND: `ssh -F ${sshConfigPath}`, - }; - - const cloneProcess = exec( - `git clone --progress "${repoUrl}" "${targetPath}"`, - { - maxBuffer: CLONE_MAX_BUFFER, - env, - }, - ); - - sendCloneProgress(win, cloneId, { - status: "cloning", - message: `Cloning ${repoUrl}...`, - }); - - let stderrData = ""; - - cloneProcess.stdout?.on("data", (data: Buffer) => { - if (activeClones.get(cloneId)) { - sendCloneProgress(win, cloneId, { - status: "cloning", - message: data.toString().trim(), - }); - } - }); - - cloneProcess.stderr?.on("data", (data: Buffer) => { - const text = data.toString(); - stderrData += text; - - if (activeClones.get(cloneId)) { - // Parse progress from git output (e.g., "Receiving objects: 45% (6234/13948)") - const progressMatch = text.match(/(\w+\s+\w+):\s+(\d+)%/); - let progressMessage = text.trim(); - - if (progressMatch) { - const [, stage, percent] = progressMatch; - progressMessage = `${stage}: ${percent}%`; - } - - sendCloneProgress(win, cloneId, { - status: "cloning", - message: progressMessage, - }); - } - }); - - cloneProcess.on("close", (code: number) => { - if (!activeClones.get(cloneId)) return; - - const status = code === 0 ? "complete" : "error"; - const message = - code === 0 - ? "Repository cloned successfully" - : `Clone failed with exit code ${code}. stderr: ${stderrData}`; - - sendCloneProgress(win, cloneId, { status, message }); - activeClones.delete(cloneId); - }); - - cloneProcess.on("error", (error: Error) => { - log.error("Process error:", error); - if (activeClones.get(cloneId)) { - sendCloneProgress(win, cloneId, { - status: "error", - message: `Clone error: ${error.message}`, - }); - activeClones.delete(cloneId); - } - }); - - return cloneProcess; - }; - - ipcMain.handle( - "clone-repository", - async ( - _event: IpcMainInvokeEvent, - repoUrl: string, - targetPath: string, - cloneId: string, - ): Promise<{ cloneId: string }> => { - const win = getMainWindow(); - - if (!win) throw new Error("Main window not available"); - - activeClones.set(cloneId, true); - setupCloneProcess(cloneId, repoUrl, targetPath, win); - - return { cloneId }; - }, - ); - ipcMain.handle( "get-changed-files-head", async ( diff --git a/apps/array/src/main/services/index.ts b/apps/array/src/main/services/index.ts index 54772f15..bef78da0 100644 --- a/apps/array/src/main/services/index.ts +++ b/apps/array/src/main/services/index.ts @@ -11,14 +11,10 @@ import "./folders.js"; import "./fs.js"; import "./git.js"; import "./oauth.js"; -import "./os.js"; import "./posthog-analytics.js"; -import "./posthog.js"; import "./session-manager.js"; -import "./settings.js"; import "./settingsStore.js"; import "./shell.js"; -import "./store.js"; import "./transcription-prompts.js"; import "./updates.js"; import "./worktree.js"; diff --git a/apps/array/src/main/services/posthog.ts b/apps/array/src/main/services/posthog.ts deleted file mode 100644 index 661ba3fe..00000000 --- a/apps/array/src/main/services/posthog.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type IpcMainInvokeEvent, ipcMain, safeStorage } from "electron"; -import { logger } from "../lib/logger"; - -const log = logger.scope("posthog"); - -export function registerPosthogIpc(): void { - // IPC handlers for secure storage - ipcMain.handle( - "store-api-key", - async (_event: IpcMainInvokeEvent, apiKey: string): Promise => { - if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(apiKey); - return encrypted.toString("base64"); - } - return apiKey; - }, - ); - - ipcMain.handle( - "retrieve-api-key", - async ( - _event: IpcMainInvokeEvent, - encryptedKey: string, - ): Promise => { - if (safeStorage.isEncryptionAvailable()) { - try { - const buffer = Buffer.from(encryptedKey, "base64"); - return safeStorage.decryptString(buffer); - } catch { - return null; - } - } - return encryptedKey; - }, - ); - - ipcMain.handle( - "fetch-s3-logs", - async ( - _event: IpcMainInvokeEvent, - logUrl: string, - ): Promise => { - try { - log.debug("Fetching S3 logs from:", logUrl); - const response = await fetch(logUrl); - - // 404 is expected for new task runs - file doesn't exist yet - if (response.status === 404) { - return null; - } - - if (!response.ok) { - log.warn( - "Failed to fetch S3 logs:", - response.status, - response.statusText, - ); - return null; - } - - return await response.text(); - } catch (error) { - log.error("Failed to fetch S3 logs:", error); - return null; - } - }, - ); -} diff --git a/apps/array/src/main/services/session-manager.ts b/apps/array/src/main/services/session-manager.ts index e60b17a2..356b9287 100644 --- a/apps/array/src/main/services/session-manager.ts +++ b/apps/array/src/main/services/session-manager.ts @@ -448,12 +448,9 @@ export class SessionManager { credentials: PostHogCredentials, mockNodeDir: string, ): void { - const token = this.getToken(credentials.apiKey); const newPath = `${mockNodeDir}:${process.env.PATH || ""}`; process.env.PATH = newPath; - process.env.POSTHOG_AUTH_HEADER = `Bearer ${token}`; - process.env.ANTHROPIC_API_KEY = token; - process.env.ANTHROPIC_AUTH_TOKEN = token; + process.env.POSTHOG_AUTH_HEADER = `Bearer ${credentials.apiKey}`; const llmGatewayUrl = process.env.LLM_GATEWAY_URL || @@ -601,10 +598,6 @@ interface AgentSessionParams { } type SessionResponse = { sessionId: string; channel: string }; -type SessionListItem = SessionResponse & { - acpSessionId: string; - taskId: string; -}; function validateSessionParams(params: AgentSessionParams): void { if (!params.taskId || !params.repoPath) { @@ -684,32 +677,6 @@ export function registerAgentIpc( }, ); - ipcMain.handle( - "agent-list-sessions", - async ( - _event: IpcMainInvokeEvent, - taskId?: string, - ): Promise => { - return sessionManager.listSessions(taskId).map((s) => ({ - sessionId: s.taskRunId, - acpSessionId: s.taskRunId, - channel: s.channel, - taskId: s.taskId, - })); - }, - ); - - ipcMain.handle( - "agent-load-session", - async (_event: IpcMainInvokeEvent, sessionId: string, _cwd: string) => { - const exists = sessionManager.getSession(sessionId) !== undefined; - if (!exists) { - log.warn("Session not found for load", { sessionId }); - } - return exists; - }, - ); - ipcMain.handle( "agent-reconnect", async ( diff --git a/apps/array/src/main/services/settings.ts b/apps/array/src/main/services/settings.ts deleted file mode 100644 index de44291a..00000000 --- a/apps/array/src/main/services/settings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createIpcHandler } from "../lib/ipcHandler"; -import { logger } from "../lib/logger"; -import { getWorktreeLocation, setWorktreeLocation } from "./settingsStore"; - -const log = logger.scope("settings"); -const handle = createIpcHandler("settings"); - -export function registerSettingsIpc(): void { - handle("settings:get-worktree-location", () => getWorktreeLocation()); - - handle("settings:set-worktree-location", (_event, location: string) => { - setWorktreeLocation(location); - log.info(`Worktree location set to: ${location}`); - }); -} diff --git a/apps/array/src/main/services/shell.ts b/apps/array/src/main/services/shell.ts index f1bf4c00..e706c375 100644 --- a/apps/array/src/main/services/shell.ts +++ b/apps/array/src/main/services/shell.ts @@ -1,6 +1,6 @@ import { createIpcHandler } from "../lib/ipcHandler"; import { shellManager } from "../lib/shellManager"; -import { foldersStore } from "./store"; +import { foldersStore } from "../utils/store"; import { buildWorkspaceEnv } from "./workspace/workspaceEnv"; const handle = createIpcHandler("shell"); diff --git a/apps/array/src/main/services/store.ts b/apps/array/src/main/services/store.ts deleted file mode 100644 index 775f2d16..00000000 --- a/apps/array/src/main/services/store.ts +++ /dev/null @@ -1,180 +0,0 @@ -import crypto from "node:crypto"; -import os from "node:os"; -import path from "node:path"; -import { app, ipcMain } from "electron"; -import Store from "electron-store"; -import { machineIdSync } from "node-machine-id"; -import type { - RegisteredFolder, - TaskFolderAssociation, -} from "../../shared/types"; -import { deleteWorktreeIfExists } from "./worktreeUtils"; - -// Key derived from hardware UUID - data only decryptable on this machine -// No keychain prompts, prevents token theft via cloud sync/backups -const APP_SALT = "array-v1"; -const ENCRYPTION_VERSION = 1; - -function getMachineKey(): Buffer { - const machineId = machineIdSync(); - const identifier = [machineId, os.platform(), os.arch()].join("|"); - return crypto.scryptSync(identifier, APP_SALT, 32); -} - -function encrypt(plaintext: string): string { - const key = getMachineKey(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); - - const encrypted = Buffer.concat([ - cipher.update(plaintext, "utf8"), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - - return JSON.stringify({ - v: ENCRYPTION_VERSION, - iv: iv.toString("base64"), - data: encrypted.toString("base64"), - tag: authTag.toString("base64"), - }); -} - -function decrypt(encryptedJson: string): string | null { - try { - const { iv, data, tag } = JSON.parse(encryptedJson); - const key = getMachineKey(); - const decipher = crypto.createDecipheriv( - "aes-256-gcm", - key, - Buffer.from(iv, "base64"), - ); - decipher.setAuthTag(Buffer.from(tag, "base64")); - - return decipher.update(data, "base64", "utf8") + decipher.final("utf8"); - } catch { - return null; - } -} - -interface FoldersSchema { - folders: RegisteredFolder[]; - taskAssociations: TaskFolderAssociation[]; -} - -interface RendererStoreSchema { - [key: string]: string; -} - -const schema = { - folders: { - type: "array" as const, - default: [], - items: { - type: "object" as const, - properties: { - id: { type: "string" as const }, - path: { type: "string" as const }, - name: { type: "string" as const }, - lastAccessed: { type: "string" as const }, - createdAt: { type: "string" as const }, - }, - required: ["id", "path", "name", "lastAccessed", "createdAt"], - }, - }, - taskAssociations: { - type: "array" as const, - default: [], - items: { - type: "object" as const, - properties: { - taskId: { type: "string" as const }, - folderId: { type: "string" as const }, - folderPath: { type: "string" as const }, - worktree: { - type: "object" as const, - properties: { - worktreePath: { type: "string" as const }, - worktreeName: { type: "string" as const }, - branchName: { type: "string" as const }, - baseBranch: { type: "string" as const }, - createdAt: { type: "string" as const }, - }, - }, - }, - required: ["taskId", "folderId", "folderPath"], - }, - }, -}; - -function getStorePath(): string { - const userDataPath = app.getPath("userData"); - if (userDataPath.includes("@posthog")) { - return path.join(path.dirname(userDataPath), "Array"); - } - return userDataPath; -} - -export const foldersStore = new Store({ - name: "folders", - schema, - cwd: getStorePath(), - defaults: { - folders: [], - taskAssociations: [], - }, -}); - -export async function clearAllStoreData(): Promise { - // Delete all worktrees before clearing store - const associations = foldersStore.get("taskAssociations", []); - for (const assoc of associations) { - if (assoc.worktree) { - await deleteWorktreeIfExists( - assoc.folderPath, - assoc.worktree.worktreePath, - ); - } - } - - foldersStore.clear(); - rendererStore.clear(); -} - -export const rendererStore = new Store({ - name: "renderer-storage", - cwd: getStorePath(), -}); - -// IPC handlers for renderer storage with machine-key encryption -ipcMain.handle("renderer-store:get", (_event, key: string): string | null => { - if (!rendererStore.has(key)) { - return null; - } - const encrypted = rendererStore.get(key) as string; - return decrypt(encrypted); -}); - -ipcMain.handle( - "renderer-store:set", - (_event, key: string, value: string): void => { - rendererStore.set(key, encrypt(value)); - }, -); - -ipcMain.handle("renderer-store:remove", (_event, key: string): void => { - rendererStore.delete(key); -}); - -ipcMain.handle("settings:get-terminal-layout", (): string => { - const encrypted = rendererStore.get("terminal-layout-mode"); - if (!encrypted) { - return "split"; - } - const decrypted = decrypt(encrypted as string); - return decrypted || "split"; -}); - -ipcMain.handle("settings:set-terminal-layout", (_event, mode: string): void => { - rendererStore.set("terminal-layout-mode", encrypt(mode)); -}); diff --git a/apps/array/src/main/services/workspace/workspaceService.ts b/apps/array/src/main/services/workspace/workspaceService.ts index 399951a7..8b1db473 100644 --- a/apps/array/src/main/services/workspace/workspaceService.ts +++ b/apps/array/src/main/services/workspace/workspaceService.ts @@ -14,9 +14,9 @@ import type { WorktreeInfo, } from "../../../shared/types"; import { logger } from "../../lib/logger"; +import { foldersStore } from "../../utils/store"; import { fileService } from "../fileWatcher"; import { getWorktreeLocation } from "../settingsStore"; -import { foldersStore } from "../store"; import { deleteWorktreeIfExists } from "../worktreeUtils"; import { loadConfig, normalizeScripts } from "./configLoader"; import { cleanupWorkspaceSessions, ScriptRunner } from "./scriptRunner"; diff --git a/apps/array/src/main/trpc/context.ts b/apps/array/src/main/trpc/context.ts new file mode 100644 index 00000000..649a08ff --- /dev/null +++ b/apps/array/src/main/trpc/context.ts @@ -0,0 +1,11 @@ +import type { BrowserWindow } from "electron"; + +let mainWindowGetter: (() => BrowserWindow | null) | null = null; + +export function setMainWindowGetter(getter: () => BrowserWindow | null): void { + mainWindowGetter = getter; +} + +export function getMainWindow(): BrowserWindow | null { + return mainWindowGetter?.() ?? null; +} diff --git a/apps/array/src/main/trpc/index.ts b/apps/array/src/main/trpc/index.ts new file mode 100644 index 00000000..cc2b78b0 --- /dev/null +++ b/apps/array/src/main/trpc/index.ts @@ -0,0 +1 @@ +export { type TrpcRouter, trpcRouter } from "./router.js"; diff --git a/apps/array/src/main/trpc/router.ts b/apps/array/src/main/trpc/router.ts new file mode 100644 index 00000000..2730333d --- /dev/null +++ b/apps/array/src/main/trpc/router.ts @@ -0,0 +1,14 @@ +import { encryptionRouter } from "./routers/encryption.js"; +import { logsRouter } from "./routers/logs.js"; +import { osRouter } from "./routers/os.js"; +import { secureStoreRouter } from "./routers/secure-store.js"; +import { router } from "./trpc.js"; + +export const trpcRouter = router({ + os: osRouter, + logs: logsRouter, + secureStore: secureStoreRouter, + encryption: encryptionRouter, +}); + +export type TrpcRouter = typeof trpcRouter; diff --git a/apps/array/src/main/trpc/routers/encryption.ts b/apps/array/src/main/trpc/routers/encryption.ts new file mode 100644 index 00000000..271fa7be --- /dev/null +++ b/apps/array/src/main/trpc/routers/encryption.ts @@ -0,0 +1,44 @@ +import { safeStorage } from "electron"; +import { z } from "zod"; +import { logger } from "../../lib/logger"; +import { publicProcedure, router } from "../trpc.js"; + +const log = logger.scope("encryptionRouter"); + +export const encryptionRouter = router({ + /** + * Encrypt a string + */ + encrypt: publicProcedure + .input(z.object({ stringToEncrypt: z.string() })) + .query(async ({ input }) => { + try { + if (safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(input.stringToEncrypt); + return encrypted.toString("base64"); + } + return input.stringToEncrypt; + } catch (error) { + log.error("Failed to encrypt string:", error); + return null; + } + }), + + /** + * Decrypt a string + */ + decrypt: publicProcedure + .input(z.object({ stringToDecrypt: z.string() })) + .query(async ({ input }) => { + try { + if (safeStorage.isEncryptionAvailable()) { + const buffer = Buffer.from(input.stringToDecrypt, "base64"); + return safeStorage.decryptString(buffer); + } + return input.stringToDecrypt; + } catch (error) { + log.error("Failed to decrypt string:", error); + return null; + } + }), +}); diff --git a/apps/array/src/main/trpc/routers/logs.ts b/apps/array/src/main/trpc/routers/logs.ts new file mode 100644 index 00000000..fdd2f9c8 --- /dev/null +++ b/apps/array/src/main/trpc/routers/logs.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { logger } from "../../lib/logger"; +import { publicProcedure, router } from "../trpc.js"; + +const log = logger.scope("logsRouter"); + +export const logsRouter = router({ + /** + * Fetch logs from S3 using presigned URL + */ + fetchS3Logs: publicProcedure + .input(z.object({ logUrl: z.string() })) + .query(async ({ input }) => { + try { + const response = await fetch(input.logUrl); + + // 404 is expected for new task runs - file doesn't exist yet + if (response.status === 404) { + return null; + } + + if (!response.ok) { + log.warn( + "Failed to fetch S3 logs:", + response.status, + response.statusText, + ); + return null; + } + + return await response.text(); + } catch (error) { + log.error("Failed to fetch S3 logs:", error); + return null; + } + }), +}); diff --git a/apps/array/src/main/services/os.ts b/apps/array/src/main/trpc/routers/os.ts similarity index 58% rename from apps/array/src/main/services/os.ts rename to apps/array/src/main/trpc/routers/os.ts index 7f7e4761..6ae6102e 100644 --- a/apps/array/src/main/services/os.ts +++ b/apps/array/src/main/trpc/routers/os.ts @@ -1,33 +1,33 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { - type BrowserWindow, - dialog, - type IpcMainInvokeEvent, - ipcMain, - shell, -} from "electron"; +import { dialog, shell } from "electron"; +import { z } from "zod"; +import { getMainWindow } from "../context.js"; +import { publicProcedure, router } from "../trpc.js"; const fsPromises = fs.promises; -interface MessageBoxOptionsCustom { - type?: "info" | "error" | "warning" | "question"; - title?: string; - message?: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - cancelId?: number; -} +const messageBoxOptionsSchema = z.object({ + type: z.enum(["none", "info", "error", "question", "warning"]).optional(), + title: z.string().optional(), + message: z.string().optional(), + detail: z.string().optional(), + buttons: z.array(z.string()).optional(), + defaultId: z.number().optional(), + cancelId: z.number().optional(), +}); const expandHomePath = (searchPath: string): string => searchPath.startsWith("~") ? searchPath.replace(/^~/, os.homedir()) : searchPath; -export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void { - ipcMain.handle("select-directory", async (): Promise => { +export const osRouter = router({ + /** + * Show directory picker dialog + */ + selectDirectory: publicProcedure.query(async () => { const win = getMainWindow(); if (!win) return null; @@ -43,19 +43,19 @@ export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void { return null; } return result.filePaths[0]; - }); + }), - ipcMain.handle( - "check-write-access", - async ( - _event: IpcMainInvokeEvent, - directoryPath: string, - ): Promise => { - if (!directoryPath) return false; + /** + * Check if a directory has write access + */ + checkWriteAccess: publicProcedure + .input(z.object({ directoryPath: z.string() })) + .query(async ({ input }) => { + if (!input.directoryPath) return false; try { - await fsPromises.access(directoryPath, fs.constants.W_OK); + await fsPromises.access(input.directoryPath, fs.constants.W_OK); const testFile = path.join( - directoryPath, + input.directoryPath, `.agent-write-test-${Date.now()}`, ); await fsPromises.writeFile(testFile, "ok"); @@ -64,18 +64,18 @@ export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void { } catch { return false; } - }, - ); + }), - ipcMain.handle( - "show-message-box", - async ( - _event: IpcMainInvokeEvent, - options: MessageBoxOptionsCustom, - ): Promise<{ response: number }> => { + /** + * Show a message box dialog + */ + showMessageBox: publicProcedure + .input(z.object({ options: messageBoxOptionsSchema })) + .mutation(async ({ input }) => { const win = getMainWindow(); if (!win) throw new Error("Main window not available"); + const options = input.options; const result = await dialog.showMessageBox(win, { type: options?.type || "info", title: options?.title || "Array", @@ -89,22 +89,26 @@ export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void { cancelId: options?.cancelId ?? 1, }); return { response: result.response }; - }, - ); + }), - ipcMain.handle( - "open-external", - async (_event: IpcMainInvokeEvent, url: string): Promise => { - await shell.openExternal(url); - }, - ); + /** + * Open URL in external browser + */ + openExternal: publicProcedure + .input(z.object({ url: z.string() })) + .mutation(async ({ input }) => { + await shell.openExternal(input.url); + }), - ipcMain.handle( - "search-directories", - async (_event: IpcMainInvokeEvent, query: string): Promise => { - if (!query?.trim()) return []; + /** + * Search for directories matching a query + */ + searchDirectories: publicProcedure + .input(z.object({ query: z.string(), searchRoot: z.string().optional() })) + .query(async ({ input }) => { + if (!input.query?.trim()) return []; - const searchPath = expandHomePath(query.trim()); + const searchPath = expandHomePath(input.query.trim()); const lastSlashIdx = searchPath.lastIndexOf("/"); const basePath = lastSlashIdx === -1 ? "" : searchPath.substring(0, lastSlashIdx + 1); @@ -133,6 +137,5 @@ export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void { } catch { return []; } - }, - ); -} + }), +}); diff --git a/apps/array/src/main/trpc/routers/secure-store.ts b/apps/array/src/main/trpc/routers/secure-store.ts new file mode 100644 index 00000000..fb25970e --- /dev/null +++ b/apps/array/src/main/trpc/routers/secure-store.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { decrypt, encrypt } from "@/main/utils/encryption"; +import { rendererStore } from "@/main/utils/store"; +import { logger } from "../../lib/logger"; +import { publicProcedure, router } from "../trpc.js"; + +const log = logger.scope("secureStoreRouter"); + +export const secureStoreRouter = router({ + /** + * Get an encrypted item from the store + */ + getItem: publicProcedure + .input(z.object({ key: z.string() })) + .query(async ({ input }) => { + try { + if (!rendererStore.has(input.key)) return null; + const encrypted = rendererStore.get(input.key) as string; + return decrypt(encrypted); + } catch (error) { + log.error("Failed to get item:", error); + return null; + } + }), + + /** + * Set an encrypted item in the store + */ + setItem: publicProcedure + .input(z.object({ key: z.string(), value: z.string() })) + .query(async ({ input }) => { + try { + rendererStore.set(input.key, encrypt(input.value)); + } catch (error) { + log.error("Failed to set item:", error); + } + }), + + /** + * Remove an item from the store + */ + removeItem: publicProcedure + .input(z.object({ key: z.string() })) + .query(async ({ input }) => { + try { + rendererStore.delete(input.key); + } catch (error) { + log.error("Failed to remove item:", error); + } + }), + + /** + * Clear all items from the store + */ + clear: publicProcedure.query(async () => { + try { + rendererStore.clear(); + } catch (error) { + log.error("Failed to clear store:", error); + } + }), +}); diff --git a/apps/array/src/main/trpc/trpc.ts b/apps/array/src/main/trpc/trpc.ts new file mode 100644 index 00000000..c871601e --- /dev/null +++ b/apps/array/src/main/trpc/trpc.ts @@ -0,0 +1,9 @@ +import { initTRPC } from "@trpc/server"; + +const trpc = initTRPC.create({ + isServer: true, +}); + +export const router = trpc.router; +export const publicProcedure = trpc.procedure; +export const middleware = trpc.middleware; diff --git a/apps/array/src/main/utils/encryption.ts b/apps/array/src/main/utils/encryption.ts new file mode 100644 index 00000000..864a0f4b --- /dev/null +++ b/apps/array/src/main/utils/encryption.ts @@ -0,0 +1,50 @@ +import crypto from "node:crypto"; +import os from "node:os"; +import { machineIdSync } from "node-machine-id"; + +// Key derived from hardware UUID - data only decryptable on this machine +// No keychain prompts, prevents token theft via cloud sync/backups +const APP_SALT = "array-v1"; +const ENCRYPTION_VERSION = 1; + +function getMachineKey(): Buffer { + const machineId = machineIdSync(); + const identifier = [machineId, os.platform(), os.arch()].join("|"); + return crypto.scryptSync(identifier, APP_SALT, 32); +} + +export function encrypt(plaintext: string): string { + const key = getMachineKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return JSON.stringify({ + v: ENCRYPTION_VERSION, + iv: iv.toString("base64"), + data: encrypted.toString("base64"), + tag: authTag.toString("base64"), + }); +} + +export function decrypt(encryptedJson: string): string | null { + try { + const { iv, data, tag } = JSON.parse(encryptedJson); + const key = getMachineKey(); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(iv, "base64"), + ); + decipher.setAuthTag(Buffer.from(tag, "base64")); + + return decipher.update(data, "base64", "utf8") + decipher.final("utf8"); + } catch { + return null; + } +} diff --git a/apps/array/src/main/utils/store.ts b/apps/array/src/main/utils/store.ts new file mode 100644 index 00000000..9174d54a --- /dev/null +++ b/apps/array/src/main/utils/store.ts @@ -0,0 +1,97 @@ +import path from "node:path"; +import { app } from "electron"; +import Store from "electron-store"; +import type { + RegisteredFolder, + TaskFolderAssociation, +} from "../../shared/types"; +import { deleteWorktreeIfExists } from "../services/worktreeUtils"; + +interface FoldersSchema { + folders: RegisteredFolder[]; + taskAssociations: TaskFolderAssociation[]; +} + +interface RendererStoreSchema { + [key: string]: string; +} + +const schema = { + folders: { + type: "array" as const, + default: [], + items: { + type: "object" as const, + properties: { + id: { type: "string" as const }, + path: { type: "string" as const }, + name: { type: "string" as const }, + lastAccessed: { type: "string" as const }, + createdAt: { type: "string" as const }, + }, + required: ["id", "path", "name", "lastAccessed", "createdAt"], + }, + }, + taskAssociations: { + type: "array" as const, + default: [], + items: { + type: "object" as const, + properties: { + taskId: { type: "string" as const }, + folderId: { type: "string" as const }, + folderPath: { type: "string" as const }, + worktree: { + type: "object" as const, + properties: { + worktreePath: { type: "string" as const }, + worktreeName: { type: "string" as const }, + branchName: { type: "string" as const }, + baseBranch: { type: "string" as const }, + createdAt: { type: "string" as const }, + }, + }, + }, + required: ["taskId", "folderId", "folderPath"], + }, + }, +}; + +function getStorePath(): string { + const userDataPath = app.getPath("userData"); + if (userDataPath.includes("@posthog")) { + return path.join(path.dirname(userDataPath), "Array"); + } + return userDataPath; +} + +export const rendererStore = new Store({ + name: "renderer-storage", + cwd: getStorePath(), +}); + +export const foldersStore = new Store({ + name: "folders", + schema, + cwd: getStorePath(), + defaults: { + folders: [], + taskAssociations: [], + }, +}); + +export async function clearAllStoreData(): Promise { + // Delete all worktrees before clearing store + const associations = foldersStore.get("taskAssociations", []); + for (const assoc of associations) { + if (assoc.worktree) { + await deleteWorktreeIfExists( + assoc.folderPath, + assoc.worktree.worktreePath, + ); + } + } + + foldersStore.clear(); + rendererStore.clear(); +} diff --git a/apps/array/src/renderer/components/MainLayout.tsx b/apps/array/src/renderer/components/MainLayout.tsx index 32c93fe7..66de6693 100644 --- a/apps/array/src/renderer/components/MainLayout.tsx +++ b/apps/array/src/renderer/components/MainLayout.tsx @@ -13,25 +13,17 @@ import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { TaskInput } from "@features/task-detail/components/TaskInput"; -import { TaskList } from "@features/task-list/components/TaskList"; import { useIntegrations } from "@hooks/useIntegrations"; import { Box, Flex } from "@radix-ui/themes"; import { clearApplicationStorage } from "@renderer/lib/clearStorage"; -import type { Task } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useEffect, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Toaster } from "sonner"; export function MainLayout() { - const { - view, - toggleSettings, - navigateToTask, - navigateToTaskInput, - goBack, - goForward, - } = useNavigationStore(); + const { view, toggleSettings, navigateToTaskInput, goBack, goForward } = + useNavigationStore(); const clearAllLayouts = usePanelLayoutStore((state) => state.clearAllLayouts); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const toggleRightSidebar = useRightSidebarStore((state) => state.toggle); @@ -120,10 +112,6 @@ export function MainLayout() { }; }, [goBack, goForward]); - const handleSelectTask = (task: Task) => { - navigateToTask(task); - }; - return ( @@ -131,10 +119,6 @@ export function MainLayout() { - {view.type === "task-list" && ( - - )} - {view.type === "task-input" && } {view.type === "task-detail" && view.data && ( diff --git a/apps/array/src/renderer/components/Providers.tsx b/apps/array/src/renderer/components/Providers.tsx index 652c272a..19fc2419 100644 --- a/apps/array/src/renderer/components/Providers.tsx +++ b/apps/array/src/renderer/components/Providers.tsx @@ -1,16 +1,22 @@ import { ThemeWrapper } from "@components/ThemeWrapper"; import { queryClient } from "@renderer/lib/queryClient"; +import { createTrpcClient, trpcReact } from "@renderer/trpc"; import { QueryClientProvider } from "@tanstack/react-query"; import type React from "react"; +import { useState } from "react"; interface ProvidersProps { children: React.ReactNode; } export const Providers: React.FC = ({ children }) => { + const [trpcClient] = useState(() => createTrpcClient()); + return ( - - {children} - + + + {children} + + ); }; diff --git a/apps/array/src/renderer/features/auth/stores/authStore.ts b/apps/array/src/renderer/features/auth/stores/authStore.ts index a43c06e0..e23d942e 100644 --- a/apps/array/src/renderer/features/auth/stores/authStore.ts +++ b/apps/array/src/renderer/features/auth/stores/authStore.ts @@ -36,10 +36,6 @@ interface AuthState { client: PostHogAPIClient | null; projectId: number | null; // Current team/project ID - // OpenAI API key (separate concern, kept for now) - openaiApiKey: string | null; - encryptedOpenaiKey: string | null; - // OAuth methods loginWithOAuth: (region: CloudRegion) => Promise; refreshAccessToken: () => Promise; @@ -47,7 +43,6 @@ interface AuthState { initializeOAuth: () => Promise; // Other methods - setOpenAIKey: (apiKey: string) => Promise; logout: () => void; } @@ -69,10 +64,6 @@ export const useAuthStore = create()( client: null, projectId: null, - // OpenAI key - openaiApiKey: null, - encryptedOpenaiKey: null, - loginWithOAuth: async (region: CloudRegion) => { const result = await window.electronAPI.oauthStartFlow(region); @@ -321,17 +312,6 @@ export const useAuthStore = create()( region: tokens.cloudRegion, }); - if (state.encryptedOpenaiKey) { - const decryptedOpenaiKey = - await window.electronAPI.retrieveApiKey( - state.encryptedOpenaiKey, - ); - - if (decryptedOpenaiKey) { - set({ openaiApiKey: decryptedOpenaiKey }); - } - } - return true; } catch (error) { log.error("Failed to validate OAuth session:", error); @@ -340,27 +320,9 @@ export const useAuthStore = create()( } } - if (state.encryptedOpenaiKey) { - const decryptedOpenaiKey = await window.electronAPI.retrieveApiKey( - state.encryptedOpenaiKey, - ); - - if (decryptedOpenaiKey) { - set({ openaiApiKey: decryptedOpenaiKey }); - } - } - return state.isAuthenticated; }, - setOpenAIKey: async (apiKey: string) => { - const encryptedKey = await window.electronAPI.storeApiKey(apiKey); - set({ - openaiApiKey: apiKey, - encryptedOpenaiKey: encryptedKey, - }); - }, - logout: () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); resetUser(); @@ -383,8 +345,6 @@ export const useAuthStore = create()( isAuthenticated: false, client: null, projectId: null, - openaiApiKey: null, - encryptedOpenaiKey: null, }); }, }), @@ -394,7 +354,6 @@ export const useAuthStore = create()( partialize: (state) => ({ cloudRegion: state.cloudRegion, storedTokens: state.storedTokens, - encryptedOpenaiKey: state.encryptedOpenaiKey, projectId: state.projectId, }), }, diff --git a/apps/array/src/renderer/features/editor/components/PlanEditor.tsx b/apps/array/src/renderer/features/editor/components/PlanEditor.tsx deleted file mode 100644 index 55bb21bf..00000000 --- a/apps/array/src/renderer/features/editor/components/PlanEditor.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { RichTextEditor } from "@features/editor/components/RichTextEditor"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { FloppyDiskIcon } from "@phosphor-icons/react"; -import { Box, Button, TextArea } from "@radix-ui/themes"; -import { logger } from "@renderer/lib/logger"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; - -const log = logger.scope("plan-editor"); - -interface PlanEditorProps { - taskId: string; - repoPath: string; - fileName?: string; // Defaults to "plan.md" - initialContent?: string; - onSave?: (content: string) => void; - tabId?: string; // For updating tab metadata -} - -export function PlanEditor({ - taskId, - repoPath, - fileName = "plan.md", - initialContent, - onSave, - tabId, -}: PlanEditorProps) { - const [content, setContent] = useState(initialContent || ""); - const [isSaving, setIsSaving] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const updateTabMetadata = usePanelLayoutStore( - (state) => state.updateTabMetadata, - ); - - const isMarkdownFile = fileName.endsWith(".md"); - - const queryClient = useQueryClient(); - const { data: fetchedContent } = useQuery({ - queryKey: ["task-file", repoPath, taskId, fileName], - enabled: !initialContent && !!repoPath && !!taskId, - queryFn: async () => { - if (!window.electronAPI) { - throw new Error("Electron API unavailable"); - } - if (fileName === "plan.md") { - const result = await window.electronAPI.readPlanFile(repoPath, taskId); - return result ?? ""; - } - const result = await window.electronAPI.readTaskArtifact( - repoPath, - taskId, - fileName, - ); - return result ?? ""; - }, - }); - - useEffect(() => { - if (!initialContent && fetchedContent && content === "") { - setContent(fetchedContent); - } - }, [fetchedContent, initialContent, content]); - - const handleSave = useCallback( - async (contentToSave: string) => { - if (!repoPath || !taskId) return; - - setIsSaving(true); - try { - if (fileName === "plan.md") { - await window.electronAPI?.writePlanFile( - repoPath, - taskId, - contentToSave, - ); - } - onSave?.(contentToSave); - queryClient.setQueryData( - ["task-file", repoPath, taskId, fileName], - contentToSave, - ); - setHasUnsavedChanges(false); - } catch (error) { - log.error("Failed to save file:", error); - } finally { - setIsSaving(false); - } - }, - [repoPath, taskId, fileName, onSave, queryClient], - ); - - const handleManualSave = useCallback(() => { - handleSave(content); - }, [content, handleSave]); - - // Track unsaved changes - useEffect(() => { - setHasUnsavedChanges(content !== fetchedContent); - }, [content, fetchedContent]); - - // Update tab metadata when unsaved changes state changes - useEffect(() => { - if (tabId) { - updateTabMetadata(taskId, tabId, { hasUnsavedChanges }); - } - }, [hasUnsavedChanges, tabId, taskId, updateTabMetadata]); - - // Keyboard shortcut for save (Cmd+S / Ctrl+S) - useHotkeys( - "mod+s", - (event) => { - event.preventDefault(); - if (hasUnsavedChanges && !isSaving) { - handleManualSave(); - } - }, - { enableOnFormTags: ["INPUT", "TEXTAREA"] }, - [hasUnsavedChanges, isSaving, handleManualSave], - ); - - return ( - - {/* Editor */} - - {isMarkdownFile ? ( - - ) : ( -