From af5a29aa6cff617bf50e1cad26e70ce108fdc7c6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 12 Dec 2025 12:00:14 -0800 Subject: [PATCH 01/10] Initial tRPC setup w/ os router migration --- apps/array/package.json | 4 + apps/array/src/main/index.ts | 8 +- apps/array/src/main/preload.ts | 27 ++--- apps/array/src/main/services/index.ts | 1 - apps/array/src/main/trpc/context.ts | 11 ++ apps/array/src/main/trpc/index.ts | 1 + apps/array/src/main/trpc/router.ts | 8 ++ .../src/main/{services => trpc/routers}/os.ts | 107 +++++++++--------- apps/array/src/main/trpc/trpc.ts | 9 ++ .../src/renderer/components/Providers.tsx | 12 +- .../folder-picker/components/FolderPicker.tsx | 3 +- .../task-list/components/TaskItem.tsx | 3 +- .../terminal/services/TerminalManager.ts | 3 +- apps/array/src/renderer/trpc/client.ts | 18 +++ apps/array/src/renderer/trpc/index.ts | 1 + apps/array/src/renderer/types/electron.d.ts | 16 --- apps/array/src/renderer/utils/dialog.ts | 4 +- pnpm-lock.yaml | 64 +++++++++++ 18 files changed, 202 insertions(+), 98 deletions(-) create mode 100644 apps/array/src/main/trpc/context.ts create mode 100644 apps/array/src/main/trpc/index.ts create mode 100644 apps/array/src/main/trpc/router.ts rename apps/array/src/main/{services => trpc/routers}/os.ts (58%) create mode 100644 apps/array/src/main/trpc/trpc.ts create mode 100644 apps/array/src/renderer/trpc/client.ts create mode 100644 apps/array/src/renderer/trpc/index.ts diff --git a/apps/array/package.json b/apps/array/package.json index 76eee42d..9dffe9db 100644 --- a/apps/array/package.json +++ b/apps/array/package.json @@ -112,6 +112,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 a846ee62..df0de646 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -10,6 +10,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"; @@ -17,6 +18,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; @@ -31,7 +34,6 @@ 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, @@ -109,6 +111,9 @@ function createWindow(): void { mainWindow?.show(); }); + setMainWindowGetter(() => mainWindow); + createIPCHandler({ router: trpcRouter, windows: [mainWindow] }); + setupExternalLinkHandlers(mainWindow); // Set up menu for keyboard shortcuts @@ -258,7 +263,6 @@ ipcMain.handle("app:get-version", () => app.getVersion()); // Register IPC handlers via services registerPosthogIpc(); registerOAuthHandlers(); -registerOsIpc(() => mainWindow); registerGitIpc(() => mainWindow); registerAgentIpc(taskControllers, () => mainWindow); registerFsIpc(); diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index ece3981a..6c0c836a 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; @@ -90,16 +87,10 @@ 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"), validateRepo: (directoryPath: string): Promise => ipcRenderer.invoke("validate-repo", directoryPath), - checkWriteAccess: (directoryPath: string): Promise => - ipcRenderer.invoke("check-write-access", directoryPath), detectRepo: ( directoryPath: string, ): Promise<{ @@ -140,10 +131,6 @@ 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, diff --git a/apps/array/src/main/services/index.ts b/apps/array/src/main/services/index.ts index 54772f15..2b66ada7 100644 --- a/apps/array/src/main/services/index.ts +++ b/apps/array/src/main/services/index.ts @@ -11,7 +11,6 @@ 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"; 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..ec117745 --- /dev/null +++ b/apps/array/src/main/trpc/router.ts @@ -0,0 +1,8 @@ +import { osRouter } from "./routers/os.js"; +import { router } from "./trpc.js"; + +export const trpcRouter = router({ + os: osRouter, +}); + +export type TrpcRouter = typeof trpcRouter; 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/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/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/folder-picker/components/FolderPicker.tsx b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx index 53579373..e1af77a5 100644 --- a/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx @@ -7,6 +7,7 @@ import { ChevronDownIcon } from "@radix-ui/react-icons"; import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js"; import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore"; +import { trpcVanilla } from "@renderer/trpc"; interface FolderPickerProps { value: string; @@ -44,7 +45,7 @@ export function FolderPicker({ }; const handleOpenFilePicker = async () => { - const selectedPath = await window.electronAPI?.selectDirectory(); + const selectedPath = await trpcVanilla.os.selectDirectory.query(); if (selectedPath) { await addFolder(selectedPath); onChange(selectedPath); diff --git a/apps/array/src/renderer/features/task-list/components/TaskItem.tsx b/apps/array/src/renderer/features/task-list/components/TaskItem.tsx index d1abee11..50b90c53 100644 --- a/apps/array/src/renderer/features/task-list/components/TaskItem.tsx +++ b/apps/array/src/renderer/features/task-list/components/TaskItem.tsx @@ -3,6 +3,7 @@ import { useTaskStore } from "@features/tasks/stores/taskStore"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { Cloud, GitPullRequest } from "@phosphor-icons/react"; import { Badge, Box, Code, Flex, Text } from "@radix-ui/themes"; +import { trpcVanilla } from "@renderer/trpc"; import type { Task } from "@shared/types"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; @@ -47,7 +48,7 @@ function TaskItemComponent({ const handleOpenPR = (e: React.MouseEvent) => { e.stopPropagation(); if (prUrl) { - window.electronAPI.openExternal(prUrl); + trpcVanilla.os.openExternal.mutate({ url: prUrl }); } }; diff --git a/apps/array/src/renderer/features/terminal/services/TerminalManager.ts b/apps/array/src/renderer/features/terminal/services/TerminalManager.ts index de196463..23229891 100644 --- a/apps/array/src/renderer/features/terminal/services/TerminalManager.ts +++ b/apps/array/src/renderer/features/terminal/services/TerminalManager.ts @@ -1,3 +1,4 @@ +import { trpcVanilla } from "@renderer/trpc"; import { FitAddon } from "@xterm/addon-fit"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -98,7 +99,7 @@ function loadAddons(term: XTerm) { const hasModifier = isMac ? event.metaKey : event.ctrlKey; if (hasModifier) { - window.electronAPI?.openExternal(uri).catch((error: Error) => { + trpcVanilla.os.openExternal.mutate({ url: uri }).catch((error: Error) => { log.error("Failed to open link:", uri, error); }); } diff --git a/apps/array/src/renderer/trpc/client.ts b/apps/array/src/renderer/trpc/client.ts new file mode 100644 index 00000000..c27ca108 --- /dev/null +++ b/apps/array/src/renderer/trpc/client.ts @@ -0,0 +1,18 @@ +import { createTRPCProxyClient } from "@trpc/client"; +import { type CreateTRPCReact, createTRPCReact } from "@trpc/react-query"; +import { ipcLink } from "trpc-electron/renderer"; +import type { TrpcRouter } from "../../main/trpc/router.js"; + +export function createTrpcClient() { + return trpcReact.createClient({ + links: [ipcLink()], + }); +} + +export const trpcReact: CreateTRPCReact = + createTRPCReact(); + +// vanilla trpc client for use outside React components +export const trpcVanilla = createTRPCProxyClient({ + links: [ipcLink()], +}); diff --git a/apps/array/src/renderer/trpc/index.ts b/apps/array/src/renderer/trpc/index.ts new file mode 100644 index 00000000..ef5a3e9e --- /dev/null +++ b/apps/array/src/renderer/trpc/index.ts @@ -0,0 +1 @@ +export { createTrpcClient, trpcReact, trpcVanilla } from "./client.js"; diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index c5ed22bb..bb81ccbc 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -45,14 +45,8 @@ declare global { error?: string; }>; oauthCancelFlow: () => Promise<{ success: boolean; error?: string }>; - selectDirectory: () => Promise; - searchDirectories: ( - query: string, - searchRoot?: string, - ) => Promise; findReposDirectory: () => Promise; validateRepo: (directoryPath: string) => Promise; - checkWriteAccess: (directoryPath: string) => Promise; detectRepo: (directoryPath: string) => Promise<{ organization: string; repository: string; @@ -84,16 +78,6 @@ declare global { message: string; }) => void, ) => () => void; - showMessageBox: (options: { - type?: "none" | "info" | "error" | "question" | "warning"; - title?: string; - message?: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - cancelId?: number; - }) => Promise<{ response: number }>; - openExternal: (url: string) => Promise; listRepoFiles: ( repoPath: string, query?: string, diff --git a/apps/array/src/renderer/utils/dialog.ts b/apps/array/src/renderer/utils/dialog.ts index fb976f89..0febcd03 100644 --- a/apps/array/src/renderer/utils/dialog.ts +++ b/apps/array/src/renderer/utils/dialog.ts @@ -1,3 +1,5 @@ +import { trpcVanilla } from "@renderer/trpc"; + interface MessageBoxOptions { type?: "none" | "info" | "error" | "question" | "warning"; title?: string; @@ -19,5 +21,5 @@ export async function showMessageBox( document.activeElement.blur(); } - return window.electronAPI.showMessageBox(options); + return trpcVanilla.os.showMessageBox.mutate({ options }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46408c79..f0fa97bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,15 @@ importers: '@tiptap/suggestion': specifier: ^3.11.0 version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@trpc/client': + specifier: ^11.7.2 + version: 11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3) + '@trpc/react-query': + specifier: ^11.7.2 + version: 11.7.2(@tanstack/react-query@5.90.10(react@18.3.1))(@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@trpc/server': + specifier: ^11.7.2 + version: 11.7.2(typescript@5.9.3) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -251,6 +260,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + trpc-electron: + specifier: ^0.1.2 + version: 0.1.2(@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.2(typescript@5.9.3))(electron@30.5.1) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -3098,6 +3110,27 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@trpc/client@11.7.2': + resolution: {integrity: sha512-OQxqUMfpDvjcszo9dbnqWQXnW2L5IbrKSz2H7l8s+mVM3EvYw7ztQ/gjFIN3iy0NcamiQfd4eE6qjcb9Lm+63A==} + peerDependencies: + '@trpc/server': 11.7.2 + typescript: '>=5.7.2' + + '@trpc/react-query@11.7.2': + resolution: {integrity: sha512-IcLDMqx2mvlGRxkr0/m37TtPvRQ8nonITH3EwYv436x0Igx8eduR9z4tdgGBsjJY9e5W1G7cZ4zKCwrizSimFQ==} + peerDependencies: + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.7.2 + '@trpc/server': 11.7.2 + react: '>=18.2.0' + react-dom: '>=18.2.0' + typescript: '>=5.7.2' + + '@trpc/server@11.7.2': + resolution: {integrity: sha512-AgB26PXY69sckherIhCacKLY49rxE2XP5h38vr/KMZTbLCL1p8IuIoKPjALTcugC2kbyQ7Lbqo2JDVfRSmPmfQ==} + peerDependencies: + typescript: '>=5.7.2' + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -6479,6 +6512,13 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + trpc-electron@0.1.2: + resolution: {integrity: sha512-sQpWBwQWzsgrERugjzUpPqY/+/n8NxkUq6YssQ5+5rALkvGCWq45T5Dreiwm2kh91dZMFlALTyMd8PhB0vgbIg==} + peerDependencies: + '@trpc/client': '>=11.0.0' + '@trpc/server': '>=11.0.0' + electron: '>19.0.0' + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -10048,6 +10088,24 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@trpc/server': 11.7.2(typescript@5.9.3) + typescript: 5.9.3 + + '@trpc/react-query@11.7.2(@tanstack/react-query@5.90.10(react@18.3.1))(@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@tanstack/react-query': 5.90.10(react@18.3.1) + '@trpc/client': 11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3) + '@trpc/server': 11.7.2(typescript@5.9.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 + + '@trpc/server@11.7.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -13988,6 +14046,12 @@ snapshots: trough@2.2.0: {} + trpc-electron@0.1.2(@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.2(typescript@5.9.3))(electron@30.5.1): + dependencies: + '@trpc/client': 11.7.2(@trpc/server@11.7.2(typescript@5.9.3))(typescript@5.9.3) + '@trpc/server': 11.7.2(typescript@5.9.3) + electron: 30.5.1 + ts-algebra@2.0.0: {} ts-interface-checker@0.1.13: {} From 629de3166dbc6fd5426f23c6fb1e019a70cc19e7 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 12 Dec 2025 12:53:52 -0800 Subject: [PATCH 02/10] More migration --- .env.example | 3 - CLAUDE.md | 5 +- apps/array/package.json | 1 - apps/array/src/main/index.ts | 2 - apps/array/src/main/preload.ts | 14 -- apps/array/src/main/services/folders.ts | 2 +- apps/array/src/main/services/index.ts | 2 - apps/array/src/main/services/posthog.ts | 68 ------- apps/array/src/main/services/shell.ts | 2 +- apps/array/src/main/services/store.ts | 180 ------------------ .../services/workspace/workspaceService.ts | 2 +- apps/array/src/main/trpc/router.ts | 6 + .../array/src/main/trpc/routers/encryption.ts | 44 +++++ apps/array/src/main/trpc/routers/logs.ts | 37 ++++ .../src/main/trpc/routers/secure-store.ts | 47 +++++ apps/array/src/main/utils/encryption.ts | 50 +++++ apps/array/src/main/utils/store.ts | 97 ++++++++++ .../features/auth/stores/authStore.ts | 41 ---- .../sessions/utils/parseSessionLogs.ts | 5 +- .../array/src/renderer/lib/electronStorage.ts | 15 +- apps/array/src/renderer/types/electron.d.ts | 8 - pnpm-lock.yaml | 15 -- 22 files changed, 297 insertions(+), 349 deletions(-) delete mode 100644 apps/array/src/main/services/posthog.ts delete mode 100644 apps/array/src/main/services/store.ts create mode 100644 apps/array/src/main/trpc/routers/encryption.ts create mode 100644 apps/array/src/main/trpc/routers/logs.ts create mode 100644 apps/array/src/main/trpc/routers/secure-store.ts create mode 100644 apps/array/src/main/utils/encryption.ts create mode 100644 apps/array/src/main/utils/store.ts 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 88216379..067f24a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,9 +72,8 @@ ## 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 9dffe9db..136f9f73 100644 --- a/apps/array/package.json +++ b/apps/array/package.json @@ -67,7 +67,6 @@ "yaml": "^2.8.1" }, "dependencies": { - "@ai-sdk/openai": "^2.0.52", "@codemirror/lang-angular": "^0.1.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/apps/array/src/main/index.ts b/apps/array/src/main/index.ts index df0de646..d02ffaf0 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -34,7 +34,6 @@ import { registerExternalAppsIpc, } from "./services/externalApps.js"; import { registerOAuthHandlers } from "./services/oauth.js"; -import { registerPosthogIpc } from "./services/posthog.js"; import { initializePostHog, shutdownPostHog, @@ -261,7 +260,6 @@ registerAutoUpdater(() => mainWindow); ipcMain.handle("app:get-version", () => app.getVersion()); // Register IPC handlers via services -registerPosthogIpc(); registerOAuthHandlers(); registerGitIpc(() => mainWindow); registerAgentIpc(taskControllers, () => mainWindow); diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index 6c0c836a..27189e71 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -61,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, 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/index.ts b/apps/array/src/main/services/index.ts index 2b66ada7..4c2a3a9d 100644 --- a/apps/array/src/main/services/index.ts +++ b/apps/array/src/main/services/index.ts @@ -12,12 +12,10 @@ import "./fs.js"; import "./git.js"; import "./oauth.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/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 9977cbe4..b2e6cd14 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/router.ts b/apps/array/src/main/trpc/router.ts index ec117745..2730333d 100644 --- a/apps/array/src/main/trpc/router.ts +++ b/apps/array/src/main/trpc/router.ts @@ -1,8 +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/trpc/routers/secure-store.ts b/apps/array/src/main/trpc/routers/secure-store.ts new file mode 100644 index 00000000..2bfccaf3 --- /dev/null +++ b/apps/array/src/main/trpc/routers/secure-store.ts @@ -0,0 +1,47 @@ +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({ + 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; + } + }), + 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); + } + }), + 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: publicProcedure.query(async () => { + try { + rendererStore.clear(); + } catch (error) { + log.error("Failed to clear store:", error); + } + }), +}); 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/features/auth/stores/authStore.ts b/apps/array/src/renderer/features/auth/stores/authStore.ts index 53f5d036..c8c8bc35 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); @@ -314,17 +305,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); @@ -333,27 +313,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(); @@ -376,8 +338,6 @@ export const useAuthStore = create()( isAuthenticated: false, client: null, projectId: null, - openaiApiKey: null, - encryptedOpenaiKey: null, }); }, }), @@ -387,7 +347,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/sessions/utils/parseSessionLogs.ts b/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts index 735d2a33..b7b64484 100644 --- a/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ b/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts @@ -1,5 +1,7 @@ /// + import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { trpcVanilla } from "@/renderer/trpc"; export interface StoredLogEntry { type: string; @@ -32,8 +34,7 @@ export async function fetchSessionLogs( } try { - const content = await window.electronAPI.fetchS3Logs(logUrl); - + const content = await trpcVanilla.logs.fetchS3Logs.query({ logUrl }); if (!content?.trim()) { return { notifications: [], rawEntries: [] }; } diff --git a/apps/array/src/renderer/lib/electronStorage.ts b/apps/array/src/renderer/lib/electronStorage.ts index 346177cb..ad8876b9 100644 --- a/apps/array/src/renderer/lib/electronStorage.ts +++ b/apps/array/src/renderer/lib/electronStorage.ts @@ -1,17 +1,18 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware"; +import { trpcVanilla } from "../trpc"; /** - * Raw storage adapter that uses Electron IPC to persist state. + * Raw storage adapter that uses electron to persist state. */ const electronStorageRaw: StateStorage = { - getItem: async (name: string): Promise => { - return window.electronAPI.rendererStore.getItem(name); + getItem: async (key: string): Promise => { + return await trpcVanilla.secureStore.getItem.query({ key }); }, - setItem: async (name: string, value: string): Promise => { - await window.electronAPI.rendererStore.setItem(name, value); + setItem: async (key: string, value: string): Promise => { + await trpcVanilla.secureStore.setItem.query({ key, value }); }, - removeItem: async (name: string): Promise => { - await window.electronAPI.rendererStore.removeItem(name); + removeItem: async (key: string): Promise => { + await trpcVanilla.secureStore.removeItem.query({ key }); }, }; diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index bb81ccbc..6d019be0 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -22,14 +22,6 @@ import type { CloudRegion, OAuthTokenResponse } from "@shared/types/oauth"; declare global { interface IElectronAPI { - storeApiKey: (apiKey: string) => Promise; - retrieveApiKey: (encryptedKey: string) => Promise; - fetchS3Logs: (logUrl: string) => Promise; - rendererStore: { - getItem: (key: string) => Promise; - setItem: (key: string, value: string) => Promise; - removeItem: (key: string) => Promise; - }; // OAuth API oauthStartFlow: (region: CloudRegion) => Promise<{ success: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0fa97bc..5cfb0fa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: apps/array: dependencies: - '@ai-sdk/openai': - specifier: ^2.0.52 - version: 2.0.71(zod@4.1.12) '@codemirror/lang-angular': specifier: ^0.1.4 version: 0.1.4 @@ -436,12 +433,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@2.0.71': - resolution: {integrity: sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.17': resolution: {integrity: sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==} engines: {node: '>=18'} @@ -7094,12 +7085,6 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 4.1.12 - '@ai-sdk/openai@2.0.71(zod@4.1.12)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.17(zod@4.1.12) - zod: 4.1.12 - '@ai-sdk/provider-utils@3.0.17(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 From 7f4a9a66bdb932524e7ea6d1cd295d77b486c234 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 12 Dec 2025 14:26:55 -0800 Subject: [PATCH 03/10] Remove dead code --- apps/array/src/main/index.ts | 2 +- apps/array/src/main/preload.ts | 100 +------ apps/array/src/main/services/fs.ts | 253 ------------------ apps/array/src/main/services/git.ts | 217 +-------------- .../src/main/services/session-manager.ts | 30 --- .../features/editor/components/PlanEditor.tsx | 183 ------------- .../components/TabContentRenderer.tsx | 16 -- .../components/TaskArtifactEditorPanel.tsx | 38 --- .../task-detail/components/TaskArtifacts.tsx | 127 --------- .../components/TaskArtifactsPanel.tsx | 45 ---- .../task-detail/components/TodoListPanel.tsx | 61 ----- apps/array/src/renderer/types/electron.d.ts | 30 --- 12 files changed, 7 insertions(+), 1095 deletions(-) delete mode 100644 apps/array/src/renderer/features/editor/components/PlanEditor.tsx delete mode 100644 apps/array/src/renderer/features/task-detail/components/TaskArtifactEditorPanel.tsx delete mode 100644 apps/array/src/renderer/features/task-detail/components/TaskArtifacts.tsx delete mode 100644 apps/array/src/renderer/features/task-detail/components/TaskArtifactsPanel.tsx delete mode 100644 apps/array/src/renderer/features/task-detail/components/TodoListPanel.tsx diff --git a/apps/array/src/main/index.ts b/apps/array/src/main/index.ts index d02ffaf0..7aeaf56c 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -261,7 +261,7 @@ ipcMain.handle("app:get-version", () => app.getVersion()); // Register IPC handlers via services registerOAuthHandlers(); -registerGitIpc(() => mainWindow); +registerGitIpc(); registerAgentIpc(taskControllers, () => mainWindow); registerFsIpc(); registerFileWatcherIpc(() => mainWindow); diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index 27189e71..297f5833 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -73,8 +73,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("oauth:refresh-token", refreshToken, region), oauthCancelFlow: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke("oauth:cancel-flow"), - findReposDirectory: (): Promise => - ipcRenderer.invoke("find-repos-directory"), + // Repo API validateRepo: (directoryPath: string): Promise => ipcRenderer.invoke("validate-repo", directoryPath), detectRepo: ( @@ -85,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, @@ -123,8 +103,7 @@ contextBridge.exposeInMainWorld("electronAPI", { 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 }> => @@ -138,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; @@ -173,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: ( @@ -440,6 +345,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ): Promise => ipcRenderer.invoke("worktree-get-main-repo", mainRepoPath, worktreePath), }, + // External Apps API externalApps: { getDetectedApps: (): Promise< Array<{ 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/session-manager.ts b/apps/array/src/main/services/session-manager.ts index dffa50b5..0a0ef092 100644 --- a/apps/array/src/main/services/session-manager.ts +++ b/apps/array/src/main/services/session-manager.ts @@ -542,10 +542,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) { @@ -625,32 +621,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/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 ? ( - - ) : ( -