diff --git a/apps/code/src/main/db/migrations/0006_scratchpad_workspaces.sql b/apps/code/src/main/db/migrations/0006_scratchpad_workspaces.sql new file mode 100644 index 000000000..019e95330 --- /dev/null +++ b/apps/code/src/main/db/migrations/0006_scratchpad_workspaces.sql @@ -0,0 +1 @@ +ALTER TABLE `workspaces` ADD `scratchpad` integer DEFAULT false NOT NULL; diff --git a/apps/code/src/main/db/migrations/meta/0006_snapshot.json b/apps/code/src/main/db/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..1626dda9e --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,534 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f6a30fcd-1e7c-4abc-9d5e-148ee9d5c001", + "prevId": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scratchpad": { + "name": "scratchpad", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 5ea0be65d..747f048fd 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1775755977659, "tag": "0005_youthful_scarlet_spider", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1777000000000, + "tag": "0006_scratchpad_workspaces", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 804af19ba..859334c29 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -42,6 +42,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastViewedAt: null, lastActivityAt: null, linkedBranch: null, + scratchpad: data.scratchpad ?? false, createdAt: now, updatedAt: now, }; diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index d22079bee..b5ebaa7cb 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -12,6 +12,7 @@ export interface CreateWorkspaceData { taskId: string; repositoryId: string | null; mode: WorkspaceMode; + scratchpad?: boolean; } export interface IWorkspaceRepository { @@ -82,6 +83,7 @@ export class WorkspaceRepository implements IWorkspaceRepository { taskId: data.taskId, repositoryId: data.repositoryId, mode: data.mode, + scratchpad: data.scratchpad ?? false, createdAt: timestamp, updatedAt: timestamp, }; diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8e4f14404..f41f42af9 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -31,6 +31,12 @@ export const workspaces = sqliteTable( pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), + /** + * Scratchpad workspaces are drafts with no git lifecycle until Publish. + * Stored as integer (0/1) by sqlite; surfaced to the renderer as boolean. + * Orthogonal to `mode`. + */ + scratchpad: integer({ mode: "boolean" }).notNull().default(false), createdAt: createdAt(), updatedAt: updatedAt(), }, diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..f82f89be9 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -53,9 +53,12 @@ import { McpCallbackService } from "../services/mcp-callback/service"; import { McpProxyService } from "../services/mcp-proxy/service"; import { NotificationService } from "../services/notification/service"; import { OAuthService } from "../services/oauth/service"; +import { PosthogCodeMcpService } from "../services/posthog-code-mcp/service"; import { PosthogPluginService } from "../services/posthog-plugin/service"; +import { PreviewService } from "../services/preview/service"; import { ProcessTrackingService } from "../services/process-tracking/service"; import { ProvisioningService } from "../services/provisioning/service"; +import { ScratchpadService } from "../services/scratchpad/service"; import { settingsStore } from "../services/settingsStore"; import { ShellService } from "../services/shell/service"; import { SleepService } from "../services/sleep/service"; @@ -134,6 +137,9 @@ container.bind(MAIN_TOKENS.NotificationService).to(NotificationService); container.bind(MAIN_TOKENS.OAuthService).to(OAuthService); container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService); container.bind(MAIN_TOKENS.PosthogPluginService).to(PosthogPluginService); +container.bind(MAIN_TOKENS.PosthogCodeMcpService).to(PosthogCodeMcpService); +container.bind(MAIN_TOKENS.PreviewService).to(PreviewService); +container.bind(MAIN_TOKENS.ScratchpadService).to(ScratchpadService); container.bind(MAIN_TOKENS.SleepService).to(SleepService); container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.UIService).to(UIService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..0ce56371d 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -68,6 +68,9 @@ export const MAIN_TOKENS = Object.freeze({ SleepService: Symbol.for("Main.SleepService"), ShellService: Symbol.for("Main.ShellService"), PosthogPluginService: Symbol.for("Main.PosthogPluginService"), + PosthogCodeMcpService: Symbol.for("Main.PosthogCodeMcpService"), + PreviewService: Symbol.for("Main.PreviewService"), + ScratchpadService: Symbol.for("Main.ScratchpadService"), UIService: Symbol.for("Main.UIService"), UpdatesService: Symbol.for("Main.UpdatesService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), diff --git a/apps/code/src/main/services/agent/auth-adapter.test.ts b/apps/code/src/main/services/agent/auth-adapter.test.ts index 4d3aaf1ff..1f02d958e 100644 --- a/apps/code/src/main/services/agent/auth-adapter.test.ts +++ b/apps/code/src/main/services/agent/auth-adapter.test.ts @@ -58,6 +58,10 @@ function createDependencies() { (id: string) => `http://127.0.0.1:9998/${encodeURIComponent(id)}`, ), }, + posthogCodeMcp: { + start: vi.fn().mockResolvedValue(undefined), + getServerUrl: vi.fn().mockReturnValue("http://127.0.0.1:9997/mcp"), + }, }; } @@ -77,6 +81,7 @@ describe("AgentAuthAdapter", () => { deps.authService as never, deps.authProxy as never, deps.mcpProxy as never, + deps.posthogCodeMcp as never, ); }); diff --git a/apps/code/src/main/services/agent/auth-adapter.ts b/apps/code/src/main/services/agent/auth-adapter.ts index 1cfa711fe..1de62f106 100644 --- a/apps/code/src/main/services/agent/auth-adapter.ts +++ b/apps/code/src/main/services/agent/auth-adapter.ts @@ -11,6 +11,7 @@ import { logger } from "../../utils/logger"; import type { AuthService } from "../auth/service"; import type { AuthProxyService } from "../auth-proxy/service"; import type { McpProxyService } from "../mcp-proxy/service"; +import type { PosthogCodeMcpService } from "../posthog-code-mcp/service"; import type { Credentials } from "./schemas"; const log = logger.scope("agent-auth-adapter"); @@ -63,6 +64,8 @@ export class AgentAuthAdapter { private readonly authProxy: AuthProxyService, @inject(MAIN_TOKENS.McpProxyService) private readonly mcpProxy: McpProxyService, + @inject(MAIN_TOKENS.PosthogCodeMcpService) + private readonly posthogCodeMcp: PosthogCodeMcpService, ) {} createPosthogConfig(credentials: Credentials): AgentPosthogConfig { @@ -102,6 +105,18 @@ export class AgentAuthAdapter { ], }); + // Register the in-process PostHog Code MCP server. Surfaces tools like + // `posthog_code__registerPreview` to the agent. Runs on its own loopback + // port — does not go through `mcp-proxy` since that proxy is geared for + // remote token-rotating servers. + await this.posthogCodeMcp.start(); + servers.push({ + name: "posthog_code", + type: "http", + url: this.posthogCodeMcp.getServerUrl(), + headers: [], + }); + const installations = await this.fetchMcpInstallations(credentials); for (const installation of installations) { diff --git a/apps/code/src/main/services/posthog-code-mcp/service.test.ts b/apps/code/src/main/services/posthog-code-mcp/service.test.ts new file mode 100644 index 000000000..0c5988295 --- /dev/null +++ b/apps/code/src/main/services/posthog-code-mcp/service.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PreviewService } from "../preview/service"; +import type { ScratchpadService } from "../scratchpad/service"; +import { PosthogCodeMcpService } from "./service"; + +describe("PosthogCodeMcpService", () => { + let service: PosthogCodeMcpService; + let previewRegister: ReturnType; + let scratchpadGetPath: ReturnType; + + beforeEach(() => { + previewRegister = vi.fn(); + scratchpadGetPath = vi.fn(); + const previewStub = { + register: previewRegister, + unregister: vi.fn(), + list: vi.fn(), + } as unknown as PreviewService; + const scratchpadStub = { + getScratchpadPath: scratchpadGetPath, + readManifest: vi.fn(), + writeManifest: vi.fn(), + } as unknown as ScratchpadService; + service = new PosthogCodeMcpService(previewStub, scratchpadStub); + }); + + afterEach(async () => { + await service.stop(); + vi.restoreAllMocks(); + }); + + it("returns the spawned preview URL when registerPreview succeeds", async () => { + scratchpadGetPath.mockResolvedValueOnce("/userData/scratchpads/t/p"); + previewRegister.mockResolvedValueOnce({ url: "http://127.0.0.1:5173" }); + + const result = await service.handleRegisterPreview({ + taskId: "t", + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ url: "http://127.0.0.1:5173" }); + expect(previewRegister).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: "t", + scratchpadRoot: "/userData/scratchpads/t/p", + name: "frontend", + port: 5173, + }), + ); + }); + + it("returns a structured error when no scratchpad exists for the taskId", async () => { + scratchpadGetPath.mockResolvedValueOnce(null); + + const result = await service.handleRegisterPreview({ + taskId: "missing", + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/No scratchpad found/); + expect(previewRegister).not.toHaveBeenCalled(); + }); + + it("returns a structured error when register() throws (port denylist, cwd guard, etc.)", async () => { + scratchpadGetPath.mockResolvedValueOnce("/userData/scratchpads/t/p"); + previewRegister.mockRejectedValueOnce(new Error("port 5432 is reserved")); + + const result = await service.handleRegisterPreview({ + taskId: "t", + name: "db", + command: "pg ctl", + port: 5432, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("port 5432 is reserved"); + }); +}); diff --git a/apps/code/src/main/services/posthog-code-mcp/service.ts b/apps/code/src/main/services/posthog-code-mcp/service.ts new file mode 100644 index 000000000..9eadf2a4b --- /dev/null +++ b/apps/code/src/main/services/posthog-code-mcp/service.ts @@ -0,0 +1,240 @@ +import http from "node:http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + registerPreviewInputSchema, + registerPreviewOutputSchema, +} from "../preview/schemas"; +import type { PreviewService } from "../preview/service"; +import type { ScratchpadService } from "../scratchpad/service"; + +const log = logger.scope("posthog-code-mcp"); + +// Event channel reserved for future tools — no events emitted today since +// `askClarification` (the only consumer) was removed in favour of Claude's +// built-in `AskUserQuestion` tool. +type PosthogCodeMcpEvents = Record; + +/** + * In-process MCP server exposing PostHog Code-specific tools to the agent. + * + * Currently exposes a single tool, `registerPreview`, used by the agent to + * announce a running dev server so the host can spawn the supervised + * process and open a Preview tab. Clarification questions during scaffolding + * are handled by Claude's built-in `AskUserQuestion` tool — we don't need + * a custom MCP equivalent. + */ +@injectable() +export class PosthogCodeMcpService extends TypedEventEmitter { + private httpServer: http.Server | null = null; + private port: number | null = null; + private startPromise: Promise | null = null; + + constructor( + @inject(MAIN_TOKENS.PreviewService) + private readonly previewService: PreviewService, + @inject(MAIN_TOKENS.ScratchpadService) + private readonly scratchpadService: ScratchpadService, + ) { + super(); + } + + /** The HTTP path the MCP transport accepts. */ + public static readonly MCP_PATH = "/mcp"; + /** The MCP server name as exposed to Claude Code (becomes `mcp__posthog_code__*`). */ + public static readonly SERVER_NAME = "posthog_code"; + + /** + * Start the in-process MCP server. Idempotent: safe to call repeatedly. + */ + public async start(): Promise { + if (this.httpServer && this.port) return; + if (this.startPromise) return this.startPromise; + this.startPromise = this.doStart().catch((err) => { + this.startPromise = null; + throw err; + }); + return this.startPromise; + } + + /** + * Build a fresh MCP server instance with all tools registered. + * Stateless StreamableHTTP requires a new server + transport per request, + * so we factor server creation out of the lifecycle. + */ + private buildMcpServer(): McpServer { + const mcpServer = new McpServer( + { name: "posthog-code", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + + mcpServer.registerTool( + "registerPreview", + { + title: "Register a running dev server as a preview", + description: + "Declare that a development server you started is now running on " + + "the given port. Once the server passes a health check, the user " + + "sees a Preview tab pointing at http://127.0.0.1:{port}. Pass " + + "`taskId` so the preview is associated with the correct scratchpad.", + inputSchema: registerPreviewInputSchema.shape, + outputSchema: registerPreviewOutputSchema.shape, + }, + async (rawInput) => { + const input = registerPreviewInputSchema.parse(rawInput); + return this.handleRegisterPreview(input); + }, + ); + + return mcpServer; + } + + private async doStart(): Promise { + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://placeholder"); + if (url.pathname !== PosthogCodeMcpService.MCP_PATH) { + res.writeHead(404); + res.end("Not found"); + return; + } + + // Per-request server + transport. The MCP SDK's stateless mode + // (`sessionIdGenerator: undefined`) requires this — sharing one + // transport across requests works for the first call but breaks tool + // discovery on subsequent ones (and on agent reconnect after an app + // restart, when the agent re-runs `tools/list`). + const server = this.buildMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on("close", () => { + void transport.close(); + void server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res); + } catch (err) { + log.error("posthog-code MCP transport error", { err }); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal error"); + } + } + }); + + await new Promise((resolve, reject) => { + httpServer.listen(0, "127.0.0.1", () => { + const addr = httpServer.address(); + if (typeof addr === "object" && addr) { + this.port = addr.port; + log.info("posthog-code MCP server started", { port: this.port }); + resolve(); + } else { + reject(new Error("Failed to get posthog-code MCP server address")); + } + }); + httpServer.on("error", (err) => { + log.error("posthog-code MCP server error", { err }); + reject(err); + }); + }); + + this.httpServer = httpServer; + } + + /** + * URL the agent should connect to for this MCP server. Throws if the server + * has not started yet. + */ + public getServerUrl(): string { + if (!this.port) { + throw new Error("posthog-code MCP server not started"); + } + return `http://127.0.0.1:${this.port}${PosthogCodeMcpService.MCP_PATH}`; + } + + @preDestroy() + async stop(): Promise { + const httpServer = this.httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + this.httpServer = null; + this.port = null; + this.startPromise = null; + log.info("posthog-code MCP server stopped"); + } + + /** + * Tool handler for `registerPreview`. Resolves only after the spawned + * dev server passes the health probe (or the timeout fires). + */ + public async handleRegisterPreview(input: { + taskId: string; + name: string; + command: string; + port: number; + cwd?: string; + healthPath?: string; + }): Promise<{ + content: Array<{ type: "text"; text: string }>; + structuredContent?: { url: string }; + isError?: boolean; + }> { + let scratchpadRoot: string | null; + try { + scratchpadRoot = await this.scratchpadService.getScratchpadPath( + input.taskId, + ); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to resolve scratchpad"; + return { + content: [{ type: "text", text: message }], + isError: true, + }; + } + if (!scratchpadRoot) { + return { + content: [ + { + type: "text", + text: `No scratchpad found for taskId "${input.taskId}"`, + }, + ], + isError: true, + }; + } + + try { + const result = await this.previewService.register({ + taskId: input.taskId, + scratchpadRoot, + name: input.name, + command: input.command, + port: input.port, + cwd: input.cwd, + healthPath: input.healthPath, + }); + return { + content: [{ type: "text", text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "registerPreview failed"; + log.warn("registerPreview failed", { taskId: input.taskId, message }); + return { + content: [{ type: "text", text: message }], + isError: true, + }; + } + } +} diff --git a/apps/code/src/main/services/posthog-code-mcp/tools/register-preview.ts b/apps/code/src/main/services/posthog-code-mcp/tools/register-preview.ts new file mode 100644 index 000000000..3dbf66e60 --- /dev/null +++ b/apps/code/src/main/services/posthog-code-mcp/tools/register-preview.ts @@ -0,0 +1,62 @@ +import path from "node:path"; + +/** Ports we never let the agent target. Best-effort UX guard, not a security boundary. */ +export const DENIED_PORTS = new Set([ + 22, // SSH + 25, // SMTP + 80, // HTTP + 443, // HTTPS + 5432, // Postgres + 6379, // Redis + 8123, // ClickHouse + 9000, // PostHog dev +]); + +export interface PreviewInputCandidate { + name: string; + command: string; + port: number; + cwd?: string; + healthPath?: string; +} + +export interface ValidatedPreviewInput { + /** Resolved absolute cwd, guaranteed inside `scratchpadRoot`. */ + cwd: string; + port: number; +} + +/** + * Validate the agent-supplied preview registration input. Rejects: + * - non-1..65535 ports + * - ports on the denylist + * - cwd paths that escape the scratchpad root + * + * Throws `Error` with a descriptive message on failure. Pure — no side effects, + * no I/O, no socket probing. + */ +export function validatePreviewInput( + input: PreviewInputCandidate, + scratchpadRoot: string, +): ValidatedPreviewInput { + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { + throw new Error( + `port must be an integer between 1 and 65535 (got ${input.port})`, + ); + } + + if (DENIED_PORTS.has(input.port)) { + throw new Error( + `port ${input.port} is on the denylist (system or PostHog-reserved port)`, + ); + } + + const requestedCwd = input.cwd ?? "."; + const resolved = path.resolve(scratchpadRoot, requestedCwd); + const relative = path.relative(scratchpadRoot, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("cwd resolves outside scratchpad root"); + } + + return { cwd: resolved, port: input.port }; +} diff --git a/apps/code/src/main/services/preview/schemas.ts b/apps/code/src/main/services/preview/schemas.ts new file mode 100644 index 000000000..0236ae2ff --- /dev/null +++ b/apps/code/src/main/services/preview/schemas.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +// ----------------------------------------------------------------------------- +// Tool / service input schemas +// ----------------------------------------------------------------------------- + +export const registerPreviewInputSchema = z.object({ + taskId: z.string().min(1), + name: z.string().min(1), + command: z.string().min(1), + port: z.number().int().min(1).max(65535), + cwd: z.string().optional(), + healthPath: z.string().optional(), +}); + +export type RegisterPreviewInput = z.infer; + +export const registerPreviewOutputSchema = z.object({ + url: z.string(), +}); + +export type RegisterPreviewOutput = z.infer; + +export const unregisterPreviewInputSchema = z.object({ + taskId: z.string().min(1), + name: z.string().min(1).optional(), +}); + +export type UnregisterPreviewInput = z.infer< + typeof unregisterPreviewInputSchema +>; + +export const listPreviewsInputSchema = z.object({ + taskId: z.string().min(1), +}); + +export const previewStatusSchema = z.enum([ + "starting", + "ready", + "degraded", + "exited", +]); + +export type PreviewStatus = z.infer; + +export const previewListEntrySchema = z.object({ + name: z.string(), + url: z.string(), + port: z.number().int().nonnegative(), + status: previewStatusSchema, +}); + +export type PreviewListEntry = z.infer; + +export const listPreviewsOutputSchema = z.array(previewListEntrySchema); + +// ----------------------------------------------------------------------------- +// Service event payload schemas +// ----------------------------------------------------------------------------- + +export const previewRegisteredPayloadSchema = z.object({ + taskId: z.string(), + name: z.string(), + url: z.string(), + port: z.number().int().nonnegative(), +}); + +export type PreviewRegisteredPayload = z.infer< + typeof previewRegisteredPayloadSchema +>; + +export const previewReadyPayloadSchema = z.object({ + taskId: z.string(), + name: z.string(), + url: z.string(), + port: z.number().int().nonnegative(), +}); + +export type PreviewReadyPayload = z.infer; + +export const previewExitedPayloadSchema = z.object({ + taskId: z.string(), + name: z.string(), + exitCode: z.number().nullable(), + signal: z.string().nullable().optional(), +}); + +export type PreviewExitedPayload = z.infer; + +export const previewUnregisteredPayloadSchema = z.object({ + taskId: z.string(), + name: z.string(), +}); + +export type PreviewUnregisteredPayload = z.infer< + typeof previewUnregisteredPayloadSchema +>; diff --git a/apps/code/src/main/services/preview/service.test.ts b/apps/code/src/main/services/preview/service.test.ts new file mode 100644 index 000000000..71e1d16ff --- /dev/null +++ b/apps/code/src/main/services/preview/service.test.ts @@ -0,0 +1,367 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockPty = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock("node-pty", () => mockPty); + +vi.mock("../../utils/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +vi.mock("../../di/tokens.js", () => ({ + MAIN_TOKENS: { + ScratchpadService: Symbol.for("Main.ScratchpadService"), + }, +})); + +import type { Manifest } from "../scratchpad/schemas"; +import type { ScratchpadService } from "../scratchpad/service"; +import type { PreviewExitedPayload, PreviewReadyPayload } from "./schemas"; +import { PreviewService, PreviewServiceEvent } from "./service"; + +interface FakePty { + pid: number; + onExit: ReturnType; + kill: ReturnType; + /** Trigger the registered exit listener. */ + triggerExit: (info: { exitCode: number; signal?: number }) => void; +} + +function createFakePty(pid = 4321): FakePty { + let listener: ((info: { exitCode: number; signal?: number }) => void) | null = + null; + const onExit = vi.fn( + (cb: (info: { exitCode: number; signal?: number }) => void) => { + listener = cb; + return { dispose: vi.fn() }; + }, + ); + return { + pid, + onExit, + kill: vi.fn(), + triggerExit: (info: { exitCode: number; signal?: number }) => { + listener?.(info); + }, + } as unknown as FakePty; +} + +function createMockScratchpad(): { + service: ScratchpadService; + manifest: Manifest; + writeManifest: ReturnType; +} { + const manifest: Manifest = { + projectId: 1, + published: false, + }; + const writeManifest = vi.fn( + async (_taskId: string, patch: Partial) => { + Object.assign(manifest, patch); + return manifest; + }, + ); + const service = { + readManifest: vi.fn(async () => manifest), + writeManifest, + } as unknown as ScratchpadService; + return { service, manifest, writeManifest }; +} + +const SCRATCHPAD_ROOT = "/tmp/scratchpads/task-1/myapp"; + +describe("PreviewService", () => { + let service: PreviewService; + let scratchpad: ReturnType; + let mockFetch: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + scratchpad = createMockScratchpad(); + service = new PreviewService(scratchpad.service); + + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(async () => { + vi.useRealTimers(); + await service.shutdown(); + vi.unstubAllGlobals(); + }); + + /** Drive the health-poll loop forward by one tick (interval+microtasks). */ + async function tickPoll(): Promise { + await vi.advanceTimersByTimeAsync(0); + // Allow any pending fetch promise to resolve, then advance the next interval. + await vi.advanceTimersByTimeAsync(1000); + } + + it("happy path: spawn, health-passes, manifest written, returns URL, emits PreviewReady", async () => { + const fakePty = createFakePty(); + mockPty.spawn.mockReturnValue(fakePty); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const readyPayloads: PreviewReadyPayload[] = []; + service.on(PreviewServiceEvent.PreviewReady, (p) => readyPayloads.push(p)); + + const promise = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + + // Let the loop run. + await tickPoll(); + await tickPoll(); + + const result = await promise; + expect(result.url).toBe("http://127.0.0.1:5173"); + expect(mockPty.spawn).toHaveBeenCalledWith( + "/bin/sh", + ["-lc", "pnpm dev"], + expect.objectContaining({ cwd: SCRATCHPAD_ROOT }), + ); + expect(scratchpad.writeManifest).toHaveBeenCalledWith("task-1", { + preview: [ + { name: "frontend", command: "pnpm dev", port: 5173, cwd: undefined }, + ], + }); + expect(readyPayloads).toEqual([ + { + taskId: "task-1", + name: "frontend", + url: "http://127.0.0.1:5173", + port: 5173, + }, + ]); + }); + + it("multi-preview: two concurrent registrations under different names both run", async () => { + const ptyA = createFakePty(1001); + const ptyB = createFakePty(1002); + mockPty.spawn.mockReturnValueOnce(ptyA).mockReturnValueOnce(ptyB); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const promiseA = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + const promiseB = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "backend", + command: "pnpm api", + port: 4000, + }); + + await tickPoll(); + await tickPoll(); + + const [a, b] = await Promise.all([promiseA, promiseB]); + expect(a.url).toBe("http://127.0.0.1:5173"); + expect(b.url).toBe("http://127.0.0.1:4000"); + const list = await service.list("task-1"); + expect(list).toHaveLength(2); + expect(list.map((p) => p.name).sort()).toEqual(["backend", "frontend"]); + }); + + it("re-register same (taskId, name) kills the prior process first", async () => { + const ptyA = createFakePty(1001); + const ptyB = createFakePty(1002); + mockPty.spawn.mockReturnValueOnce(ptyA).mockReturnValueOnce(ptyB); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const promiseA = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + await tickPoll(); + await tickPoll(); + await promiseA; + + expect(ptyA.kill).not.toHaveBeenCalled(); + + const promiseB = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + await tickPoll(); + await tickPoll(); + await promiseB; + + expect(ptyA.kill).toHaveBeenCalledTimes(1); + }); + + it("rejects denylisted ports (5432) before spawning", async () => { + await expect( + service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "db", + command: "echo", + port: 5432, + }), + ).rejects.toThrow(/denylist/); + expect(mockPty.spawn).not.toHaveBeenCalled(); + }); + + it("rejects port already bound by another in-flight preview", async () => { + const ptyA = createFakePty(1001); + mockPty.spawn.mockReturnValueOnce(ptyA); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const promiseA = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + await tickPoll(); + await tickPoll(); + await promiseA; + + await expect( + service.register({ + taskId: "task-2", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "other", + command: "pnpm dev", + port: 5173, + }), + ).rejects.toThrow(/already in use/); + }); + + it("rejects cwd outside scratchpad root", async () => { + await expect( + service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + cwd: "../../../../etc", + }), + ).rejects.toThrow(/outside scratchpad root/); + expect(mockPty.spawn).not.toHaveBeenCalled(); + }); + + it("emits PreviewExited with exit code when process crashes mid-run", async () => { + const ptyA = createFakePty(1001); + mockPty.spawn.mockReturnValue(ptyA); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const exited: PreviewExitedPayload[] = []; + service.on(PreviewServiceEvent.PreviewExited, (p) => exited.push(p)); + + const promise = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + await tickPoll(); + await tickPoll(); + await promise; + + ptyA.triggerExit({ exitCode: 137, signal: 9 }); + + expect(exited).toEqual([ + { + taskId: "task-1", + name: "frontend", + exitCode: 137, + signal: "9", + }, + ]); + }); + + it("shutdown() kills all preview processes", async () => { + const ptyA = createFakePty(1001); + const ptyB = createFakePty(1002); + mockPty.spawn.mockReturnValueOnce(ptyA).mockReturnValueOnce(ptyB); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + await Promise.all([ + (async () => { + const p = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "frontend", + command: "pnpm dev", + port: 5173, + }); + await tickPoll(); + await tickPoll(); + await p; + })(), + (async () => { + const p = service.register({ + taskId: "task-1", + scratchpadRoot: SCRATCHPAD_ROOT, + name: "backend", + command: "pnpm api", + port: 4000, + }); + await tickPoll(); + await tickPoll(); + await p; + })(), + ]); + + await service.shutdown(); + expect(ptyA.kill).toHaveBeenCalledTimes(1); + expect(ptyB.kill).toHaveBeenCalledTimes(1); + expect(await service.list("task-1")).toEqual([]); + }); + + it("resumeFromManifest spawns previews from manifest entries; per-preview failures are isolated", async () => { + scratchpad.manifest.preview = [ + { name: "frontend", command: "pnpm dev", port: 5173 }, + { name: "broken", command: "pnpm bork", port: 5432 }, // denylisted, will fail + { name: "backend", command: "pnpm api", port: 4000 }, + ]; + const ptyA = createFakePty(1001); + const ptyC = createFakePty(1003); + mockPty.spawn.mockReturnValueOnce(ptyA).mockReturnValueOnce(ptyC); + mockFetch.mockResolvedValue(new Response(null, { status: 200 })); + + const exited: PreviewExitedPayload[] = []; + service.on(PreviewServiceEvent.PreviewExited, (p) => exited.push(p)); + + const promise = service.resumeFromManifest("task-1", SCRATCHPAD_ROOT); + // Drive both health probes to completion. + for (let i = 0; i < 6; i += 1) { + await tickPoll(); + } + await promise; + + // Two valid entries spawned, one denylisted entry surfaced via PreviewExited. + expect(mockPty.spawn).toHaveBeenCalledTimes(2); + const failed = exited.find((e) => e.signal === "RESUME_FAILED"); + expect(failed?.name).toBe("broken"); + }); +}); diff --git a/apps/code/src/main/services/preview/service.ts b/apps/code/src/main/services/preview/service.ts new file mode 100644 index 000000000..d26dcace0 --- /dev/null +++ b/apps/code/src/main/services/preview/service.ts @@ -0,0 +1,424 @@ +import { inject, injectable, preDestroy } from "inversify"; +import * as pty from "node-pty"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { validatePreviewInput } from "../posthog-code-mcp/tools/register-preview"; +import type { PreviewEntry } from "../scratchpad/schemas"; +import type { ScratchpadService } from "../scratchpad/service"; +import type { + PreviewExitedPayload, + PreviewListEntry, + PreviewReadyPayload, + PreviewRegisteredPayload, + PreviewStatus, + PreviewUnregisteredPayload, +} from "./schemas"; + +const log = logger.scope("preview-service"); + +const HEALTH_TIMEOUT_MS = 60_000; +const HEALTH_INTERVAL_MS = 1_000; + +export const PreviewServiceEvent = { + PreviewRegistered: "previewRegistered", + PreviewReady: "previewReady", + PreviewExited: "previewExited", + PreviewUnregistered: "previewUnregistered", +} as const; + +export interface PreviewServiceEvents { + [PreviewServiceEvent.PreviewRegistered]: PreviewRegisteredPayload; + [PreviewServiceEvent.PreviewReady]: PreviewReadyPayload; + [PreviewServiceEvent.PreviewExited]: PreviewExitedPayload; + [PreviewServiceEvent.PreviewUnregistered]: PreviewUnregisteredPayload; +} + +interface PreviewProcess { + taskId: string; + name: string; + pid: number | undefined; + ptyProcess: pty.IPty; + port: number; + command: string; + cwd: string; + status: PreviewStatus; + exitListeners: pty.IDisposable[]; + /** Cancels the in-flight health probe when the process is killed early. */ + healthAbort: AbortController; +} + +interface RegisterArgs { + taskId: string; + scratchpadRoot: string; + name: string; + command: string; + port: number; + cwd?: string; + healthPath?: string; +} + +function makeKey(taskId: string, name: string): string { + return `${taskId}::${name}`; +} + +/** + * Owns the lifecycle of long-running preview processes (e.g. `pnpm dev`) + * spawned on behalf of the agent via the `posthog_code__registerPreview` + * MCP tool. Multiple previews per task are supported, keyed by + * `(taskId, name)` — re-registering with the same key kills the prior one. + * + * The service: + * - validates input via `validatePreviewInput` (cwd traversal + port denylist) + * - spawns the command in a pty + * - polls `http://127.0.0.1:{port}{healthPath}` for up to 60s + * - on success: writes the manifest's `preview` array and emits `PreviewReady` + * - on timeout / early exit: kills the process and emits `PreviewExited` + * - on app shutdown: kills every running preview (`@preDestroy`) + */ +@injectable() +export class PreviewService extends TypedEventEmitter { + private readonly processes = new Map(); + + constructor( + @inject(MAIN_TOKENS.ScratchpadService) + private readonly scratchpadService: ScratchpadService, + ) { + super(); + } + + public async register(args: RegisterArgs): Promise<{ url: string }> { + const { cwd, port } = validatePreviewInput( + { + name: args.name, + command: args.command, + port: args.port, + cwd: args.cwd, + healthPath: args.healthPath, + }, + args.scratchpadRoot, + ); + + // Reject if a *different* preview is already on this port. + for (const [key, p] of this.processes) { + if (p.port === port && key !== makeKey(args.taskId, args.name)) { + throw new Error( + `port ${port} is already in use by preview "${p.name}" of task "${p.taskId}"`, + ); + } + } + + // Re-registering same `(taskId, name)` — kill the prior process first. + const existingKey = makeKey(args.taskId, args.name); + const existing = this.processes.get(existingKey); + if (existing) { + log.info("Re-registering preview, killing prior process", { + taskId: args.taskId, + name: args.name, + priorPid: existing.pid, + }); + this.killProcess(existing); + this.processes.delete(existingKey); + } + + const url = `http://127.0.0.1:${port}`; + log.info("Spawning preview process", { + taskId: args.taskId, + name: args.name, + command: args.command, + cwd, + port, + }); + + const ptyProcess = pty.spawn("/bin/sh", ["-lc", args.command], { + cwd, + env: { ...process.env } as { [key: string]: string }, + }); + + const proc: PreviewProcess = { + taskId: args.taskId, + name: args.name, + pid: ptyProcess.pid, + ptyProcess, + port, + command: args.command, + cwd, + status: "starting", + exitListeners: [], + healthAbort: new AbortController(), + }; + this.processes.set(existingKey, proc); + + let exited = false; + type ExitInfo = { exitCode: number; signal?: number }; + let exitInfo = null as ExitInfo | null; + const exitListener = ptyProcess.onExit( + (info: { exitCode: number; signal?: number }) => { + exited = true; + exitInfo = info; + proc.status = "exited"; + log.info("Preview process exited", { + taskId: args.taskId, + name: args.name, + exitCode: info.exitCode, + signal: info.signal, + }); + this.emit(PreviewServiceEvent.PreviewExited, { + taskId: args.taskId, + name: args.name, + exitCode: info.exitCode, + signal: info.signal != null ? String(info.signal) : null, + }); + // If health-probe never finished, leave the process out of `processes` + // map so resources aren't leaked. + const current = this.processes.get(existingKey); + if (current === proc) { + this.processes.delete(existingKey); + } + }, + ); + proc.exitListeners.push(exitListener); + + this.emit(PreviewServiceEvent.PreviewRegistered, { + taskId: args.taskId, + name: args.name, + url, + port, + }); + + const healthPath = args.healthPath ?? "/"; + const healthOk = await this.waitForHealth({ + url: `http://127.0.0.1:${port}${healthPath}`, + isExited: () => exited, + timeoutMs: HEALTH_TIMEOUT_MS, + intervalMs: HEALTH_INTERVAL_MS, + signal: proc.healthAbort.signal, + }); + + if (!healthOk) { + // Either timed out or process exited before we got a response. + if (!exited) { + // Timeout — kill it. + this.killProcess(proc); + this.processes.delete(existingKey); + this.emit(PreviewServiceEvent.PreviewExited, { + taskId: args.taskId, + name: args.name, + exitCode: -1, + signal: "TIMEOUT", + }); + throw new Error( + `preview "${args.name}" did not become ready on port ${port} within ${ + HEALTH_TIMEOUT_MS / 1000 + }s`, + ); + } + const code = exitInfo?.exitCode ?? null; + throw new Error( + `preview "${args.name}" exited before becoming ready (exit code ${code})`, + ); + } + + proc.status = "ready"; + log.info("Preview ready", { + taskId: args.taskId, + name: args.name, + url, + }); + + // Update the manifest's preview array atomically. + try { + const manifest = await this.scratchpadService.readManifest(args.taskId); + const filtered = (manifest.preview ?? []).filter( + (p) => p.name !== args.name, + ); + const next: PreviewEntry[] = [ + ...filtered, + { + name: args.name, + command: args.command, + port, + cwd: args.cwd, + }, + ]; + await this.scratchpadService.writeManifest(args.taskId, { + preview: next, + }); + } catch (err) { + log.warn("Failed to persist preview to manifest", { + taskId: args.taskId, + name: args.name, + err, + }); + } + + this.emit(PreviewServiceEvent.PreviewReady, { + taskId: args.taskId, + name: args.name, + url, + port, + }); + + return { url }; + } + + public async unregister(taskId: string, name?: string): Promise { + if (name) { + const key = makeKey(taskId, name); + const proc = this.processes.get(key); + if (!proc) return; + this.killProcess(proc); + this.processes.delete(key); + this.emit(PreviewServiceEvent.PreviewUnregistered, { taskId, name }); + return; + } + + // Kill every preview belonging to this task. + const targets: PreviewProcess[] = []; + for (const [key, p] of this.processes) { + if (p.taskId === taskId) { + targets.push(p); + this.processes.delete(key); + } + } + for (const p of targets) { + this.killProcess(p); + this.emit(PreviewServiceEvent.PreviewUnregistered, { + taskId, + name: p.name, + }); + } + } + + public async list(taskId: string): Promise { + const out: PreviewListEntry[] = []; + for (const p of this.processes.values()) { + if (p.taskId !== taskId) continue; + out.push({ + name: p.name, + url: `http://127.0.0.1:${p.port}`, + port: p.port, + status: p.status, + }); + } + return out; + } + + /** + * Re-register every preview saved in `manifest.preview` for `taskId`. + * Per-preview failures are caught and surfaced as `PreviewExited` events + * so a single broken entry doesn't block the others (or app startup). + */ + public async resumeFromManifest( + taskId: string, + scratchpadRoot: string, + ): Promise { + let manifest: Awaited>; + try { + manifest = await this.scratchpadService.readManifest(taskId); + } catch (err) { + log.warn("resumeFromManifest: cannot read manifest", { taskId, err }); + return; + } + const entries = manifest.preview ?? []; + await Promise.all( + entries.map(async (entry) => { + try { + await this.register({ + taskId, + scratchpadRoot, + name: entry.name, + command: entry.command, + port: entry.port, + cwd: entry.cwd, + }); + } catch (err) { + log.warn("resumeFromManifest: failed to resume preview", { + taskId, + name: entry.name, + err, + }); + this.emit(PreviewServiceEvent.PreviewExited, { + taskId, + name: entry.name, + exitCode: -1, + signal: "RESUME_FAILED", + }); + } + }), + ); + } + + @preDestroy() + public async shutdown(): Promise { + log.info("Shutting down PreviewService", { + count: this.processes.size, + }); + const toKill = Array.from(this.processes.values()); + this.processes.clear(); + for (const p of toKill) { + this.killProcess(p); + } + } + + private killProcess(proc: PreviewProcess): void { + proc.healthAbort.abort(); + for (const d of proc.exitListeners) { + try { + d.dispose(); + } catch { + // ignore + } + } + proc.exitListeners = []; + try { + proc.ptyProcess.kill(); + } catch (err) { + log.warn("Failed to kill preview pty", { name: proc.name, err }); + } + } + + /** + * Poll `url` once per `intervalMs` until it returns 2xx, 3xx, or 404 + * (treated as "the server is up"), the process exits, `signal` aborts, + * or `timeoutMs` elapses. Returns `true` on success, `false` otherwise. + */ + private async waitForHealth(opts: { + url: string; + isExited: () => boolean; + timeoutMs: number; + intervalMs: number; + signal?: AbortSignal; + }): Promise { + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + if (opts.signal?.aborted) return false; + if (opts.isExited()) return false; + try { + const res = await fetch(opts.url, { + method: "GET", + signal: opts.signal, + }); + const code = res.status; + if ((code >= 200 && code < 400) || code === 404) { + return true; + } + } catch { + // Server isn't up yet (or fetch was aborted) — fall through. + } + if (opts.signal?.aborted) return false; + await new Promise((resolve) => { + const timer = setTimeout(resolve, opts.intervalMs); + opts.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + } + return false; + } +} diff --git a/apps/code/src/main/services/scratchpad/publish.test.ts b/apps/code/src/main/services/scratchpad/publish.test.ts new file mode 100644 index 000000000..38272ffc2 --- /dev/null +++ b/apps/code/src/main/services/scratchpad/publish.test.ts @@ -0,0 +1,330 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { sanitizeRepoName } from "@shared/utils/repo"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ScratchpadService, ScratchpadServiceEvent } from "./service"; + +const fsPromises = fs.promises; + +interface RunGitCall { + cwd: string; + args: string[]; +} + +class TestPublishService extends ScratchpadService { + public token: string | null = "test-token"; + public fetchMock = vi.fn(); + public runGitMock = vi.fn<(cwd: string, args: string[]) => Promise>(); + public runGitCalls: RunGitCall[] = []; + /** When true, runGit creates the .git dir on `init` and removes it on cleanup. */ + public simulateGit = true; + + constructor(private readonly baseDir: string) { + // GitService not needed; we override getGhAuthToken. + super({} as never); + this.runGitMock.mockImplementation(async (cwd, args) => { + this.runGitCalls.push({ cwd, args }); + if (this.simulateGit && args.includes("init")) { + await fsPromises.mkdir(path.join(cwd, ".git"), { recursive: true }); + } + }); + this.fetchImpl = (...fetchArgs) => this.fetchMock(...fetchArgs); + } + + protected override getBaseDir(): string { + return this.baseDir; + } + + protected override async getGhAuthToken(): Promise { + return this.token; + } + + protected override async runGit(cwd: string, args: string[]): Promise { + return this.runGitMock(cwd, args); + } +} + +function makeFetchResponse(init: { + ok: boolean; + status: number; + body?: unknown; + text?: string; +}): Response { + const bodyText = init.text ?? JSON.stringify(init.body ?? {}); + return { + ok: init.ok, + status: init.status, + json: async () => init.body ?? {}, + text: async () => bodyText, + } as unknown as Response; +} + +describe("sanitizeRepoName", () => { + it("collapses spaces to hyphens, preserves dots and underscores", () => { + expect(sanitizeRepoName("My Cool App!")).toBe("My-Cool-App"); + expect(sanitizeRepoName("example.com_v2")).toBe("example.com_v2"); + }); + + it("trims leading/trailing hyphens and clamps to 100 chars", () => { + expect(sanitizeRepoName("---hello---")).toBe("hello"); + expect(sanitizeRepoName("a".repeat(150)).length).toBe(100); + }); +}); + +describe("ScratchpadService.publish", () => { + let baseDir: string; + let service: TestPublishService; + + beforeEach(async () => { + baseDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "scratchpad-publish-"), + ); + service = new TestPublishService(baseDir); + }); + + afterEach(async () => { + await fsPromises.rm(baseDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("happy path: writes default .gitignore, runs git steps in order, patches manifest, emits Published", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "My App", + 42, + ); + await fsPromises.writeFile( + path.join(scratchpadPath, "index.html"), + "", + "utf8", + ); + + service.fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + ok: true, + status: 201, + body: { + ssh_url: "git@github.com:octocat/my-app.git", + clone_url: "https://github.com/octocat/my-app.git", + full_name: "octocat/my-app", + }, + }), + ); + + const publishedHandler = vi.fn(); + service.on(ScratchpadServiceEvent.Published, publishedHandler); + + const result = await service.publish("task-1", { + repoName: "my-app", + visibility: "private", + }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error("expected success"); + expect(result.repoFullName).toBe("octocat/my-app"); + expect(result.githubRemote).toBe("git@github.com:octocat/my-app.git"); + + // .gitignore was written. + const gitignore = await fsPromises.readFile( + path.join(scratchpadPath, ".gitignore"), + "utf8", + ); + expect(gitignore).toContain("node_modules/"); + expect(gitignore).toContain(".env*"); + + // Git steps fired in expected order. + const gitArgsInOrder = service.runGitCalls.map((c) => c.args); + expect(gitArgsInOrder).toEqual([ + ["-c", "init.defaultBranch=main", "init"], + ["symbolic-ref", "HEAD", "refs/heads/main"], + ["add", "."], + ["commit", "-m", "Initial commit"], + ["remote", "add", "origin", "git@github.com:octocat/my-app.git"], + ["push", "-u", "origin", "main"], + ]); + + // Manifest patched. + const manifest = await service.readManifest("task-1"); + expect(manifest.published).toBe(true); + expect(manifest.githubRemote).toBe("git@github.com:octocat/my-app.git"); + expect(manifest.publishedAt).toMatch(/T/); + + // Event emitted. + expect(publishedHandler).toHaveBeenCalledTimes(1); + expect(publishedHandler.mock.calls[0][0]).toMatchObject({ + taskId: "task-1", + repoFullName: "octocat/my-app", + githubRemote: "git@github.com:octocat/my-app.git", + }); + }); + + it("already-published manifest returns success: false, no side effects", async () => { + await service.scaffoldEmpty("task-1", "App", 1); + await service.writeManifest("task-1", { published: true }); + + const result = await service.publish("task-1", { repoName: "x" }); + + expect(result).toEqual({ + success: false, + code: "already_published", + message: "Already published", + }); + expect(service.runGitMock).not.toHaveBeenCalled(); + expect(service.fetchMock).not.toHaveBeenCalled(); + }); + + it("secret leakage: failure with offending paths, no git init", async () => { + const { scratchpadPath } = await service.scaffoldEmpty("task-1", "App", 1); + // User explicitly committed a permissive .gitignore that doesn't exclude + // `.env` or `*.pem`. The secret guard should still flag them. + await fsPromises.writeFile( + path.join(scratchpadPath, ".gitignore"), + "node_modules/\n", + "utf8", + ); + await fsPromises.writeFile( + path.join(scratchpadPath, ".env"), + "API_KEY=hunter2", + "utf8", + ); + await fsPromises.writeFile( + path.join(scratchpadPath, "private.pem"), + "-----BEGIN-----", + "utf8", + ); + + const result = await service.publish("task-1", { repoName: "x" }); + + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.code).toBe("secret_leakage"); + expect(result.paths).toEqual( + expect.arrayContaining([".env", "private.pem"]), + ); + expect(service.runGitMock).not.toHaveBeenCalled(); + }); + + it("respects .gitignore for the secret guard (env files in node_modules are ignored)", async () => { + const { scratchpadPath } = await service.scaffoldEmpty("task-1", "App", 1); + // Secret in node_modules — ignored by default .gitignore. + await fsPromises.mkdir(path.join(scratchpadPath, "node_modules", "x"), { + recursive: true, + }); + await fsPromises.writeFile( + path.join(scratchpadPath, "node_modules", "x", ".env"), + "secret", + "utf8", + ); + + service.fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + ok: true, + status: 201, + body: { + ssh_url: "git@github.com:octocat/app.git", + full_name: "octocat/app", + }, + }), + ); + + const result = await service.publish("task-1", { repoName: "app" }); + + expect(result.success).toBe(true); + }); + + it("GitHub 422: returns repo_name_conflict, cleans up local .git", async () => { + const { scratchpadPath } = await service.scaffoldEmpty("task-1", "App", 1); + + service.fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + ok: false, + status: 422, + body: { + message: "Repository creation failed.", + errors: [{ message: "name already exists on this account" }], + }, + }), + ); + + const result = await service.publish("task-1", { repoName: "app" }); + + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.code).toBe("repo_name_conflict"); + + // .git was cleaned up. + await expect( + fsPromises.access(path.join(scratchpadPath, ".git")), + ).rejects.toThrow(); + + // Manifest still unpublished. + const manifest = await service.readManifest("task-1"); + expect(manifest.published).toBe(false); + }); + + it("push failure after repo create: returns push_failed, leaves .git intact, manifest unchanged", async () => { + const { scratchpadPath } = await service.scaffoldEmpty("task-1", "App", 1); + + service.fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + ok: true, + status: 201, + body: { + ssh_url: "git@github.com:octocat/app.git", + full_name: "octocat/app", + }, + }), + ); + + // Make the push step fail. + service.runGitMock.mockImplementation(async (cwd, args) => { + service.runGitCalls.push({ cwd, args }); + if (args.includes("init")) { + await fsPromises.mkdir(path.join(cwd, ".git"), { recursive: true }); + return; + } + if (args[0] === "push") { + throw new Error("network unreachable"); + } + }); + + const result = await service.publish("task-1", { repoName: "app" }); + + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.code).toBe("push_failed"); + expect(result.message).toContain("octocat/app"); + + // .git stays intact (so user can manually push later). + await expect( + fsPromises.access(path.join(scratchpadPath, ".git")), + ).resolves.toBeUndefined(); + + // Manifest still unpublished. + const manifest = await service.readManifest("task-1"); + expect(manifest.published).toBe(false); + }); + + it("no gh token: returns no_gh_token, cleans up local .git", async () => { + const { scratchpadPath } = await service.scaffoldEmpty("task-1", "App", 1); + service.token = null; + + const result = await service.publish("task-1", { repoName: "app" }); + + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.code).toBe("no_gh_token"); + + await expect( + fsPromises.access(path.join(scratchpadPath, ".git")), + ).rejects.toThrow(); + }); + + it("missing scratchpad returns git_error", async () => { + const result = await service.publish("nonexistent", { repoName: "x" }); + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.code).toBe("git_error"); + }); +}); diff --git a/apps/code/src/main/services/scratchpad/schemas.ts b/apps/code/src/main/services/scratchpad/schemas.ts new file mode 100644 index 000000000..769ca663a --- /dev/null +++ b/apps/code/src/main/services/scratchpad/schemas.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +// ----------------------------------------------------------------------------- +// Manifest schema (`.posthog.json` at the scratchpad root) +// ----------------------------------------------------------------------------- + +export const previewEntrySchema = z.object({ + name: z.string(), + command: z.string(), + port: z.number().int().nonnegative(), + cwd: z.string().optional(), +}); + +export type PreviewEntry = z.infer; + +export const manifestSchema = z.object({ + /** + * Linked PostHog project. `null` when the user picked "Skip for now" at + * scratchpad creation time — instrumentation skills and Publish will + * prompt them to link a project later. + */ + projectId: z.number().int().nullable(), + /** AUTHORITATIVE for draft state. */ + published: z.boolean(), + preview: z.array(previewEntrySchema).optional(), + /** ISO8601, set on Publish. */ + publishedAt: z.string().optional(), + /** Set on Publish. */ + githubRemote: z.string().optional(), +}); + +export type Manifest = z.infer; + +export const manifestPatchSchema = manifestSchema.partial(); + +export type ManifestPatch = z.infer; + +// ----------------------------------------------------------------------------- +// Event payload schemas +// ----------------------------------------------------------------------------- + +export const createdEventSchema = z.object({ + taskId: z.string(), + name: z.string(), + scratchpadPath: z.string(), + manifest: manifestSchema, +}); + +export type CreatedEventPayload = z.infer; + +export const manifestUpdatedEventSchema = z.object({ + taskId: z.string(), + manifest: manifestSchema, +}); + +export type ManifestUpdatedEventPayload = z.infer< + typeof manifestUpdatedEventSchema +>; + +export const previewRegisteredEventSchema = z.object({ + taskId: z.string(), + preview: previewEntrySchema, +}); + +export type PreviewRegisteredEventPayload = z.infer< + typeof previewRegisteredEventSchema +>; + +export const previewReadyEventSchema = z.object({ + taskId: z.string(), + name: z.string(), + port: z.number().int().nonnegative(), + url: z.string(), +}); + +export type PreviewReadyEventPayload = z.infer; + +export const previewExitedEventSchema = z.object({ + taskId: z.string(), + name: z.string(), + exitCode: z.number().nullable(), +}); + +export type PreviewExitedEventPayload = z.infer< + typeof previewExitedEventSchema +>; + +export const publishedEventSchema = z.object({ + taskId: z.string(), + manifest: manifestSchema, + repoFullName: z.string(), + githubRemote: z.string(), +}); + +export type PublishedEventPayload = z.infer; + +export const deletedEventSchema = z.object({ + taskId: z.string(), +}); + +export type DeletedEventPayload = z.infer; + +// ----------------------------------------------------------------------------- +// tRPC input/output schemas +// ----------------------------------------------------------------------------- + +export const taskIdInput = z.object({ + taskId: z.string().min(1), +}); + +export const createScratchpadInput = z.object({ + taskId: z.string().min(1), + name: z.string().min(1), + projectId: z.number().int().nullish(), +}); + +export const createScratchpadOutput = z.object({ + scratchpadPath: z.string(), +}); + +export type CreateScratchpadOutput = z.infer; + +export const writeManifestInput = z.object({ + taskId: z.string().min(1), + patch: manifestPatchSchema, +}); + +export const scratchpadListEntrySchema = z.object({ + taskId: z.string(), + name: z.string(), + manifest: manifestSchema, +}); + +export type ScratchpadListEntry = z.infer; + +export const scratchpadListOutput = z.array(scratchpadListEntrySchema); + +// ----------------------------------------------------------------------------- +// Publish I/O +// ----------------------------------------------------------------------------- + +export const publishVisibilitySchema = z.enum(["public", "private"]); + +export type PublishVisibility = z.infer; + +export const publishInputSchema = z.object({ + taskId: z.string().min(1), + repoName: z.string().min(1).max(100), + visibility: publishVisibilitySchema.default("private"), +}); + +export type PublishInput = z.infer; + +/** + * Successful publish: the GitHub repo was created and the local manifest is + * patched with `published: true`. + * + * Failure cases use `success: false` with a structured `code` so the renderer + * can present the right recovery UI: + * + * - `already_published`: manifest already had `published: true`; no side effects. + * - `secret_leakage`: gitignore-filtered file walk found env files / large blobs. + * `paths` lists offending files. + * - `repo_name_conflict`: GitHub returned 422 (name already taken). + * - `push_failed`: repo was created on GitHub but the push failed; manual + * intervention required. + * - `github_error`: any other non-2xx from `POST /user/repos`. + * - `no_gh_token`: `gh auth token` returned no token. + * - `git_error`: `git init` / `git add` / `git commit` failed. + */ +export const publishOutputSchema = z.discriminatedUnion("success", [ + z.object({ + success: z.literal(true), + manifest: manifestSchema, + repoFullName: z.string(), + githubRemote: z.string(), + }), + z.object({ + success: z.literal(false), + code: z.enum([ + "already_published", + "secret_leakage", + "repo_name_conflict", + "push_failed", + "github_error", + "no_gh_token", + "git_error", + ]), + message: z.string(), + paths: z.array(z.string()).optional(), + }), +]); + +export type PublishResult = z.infer; diff --git a/apps/code/src/main/services/scratchpad/service.test.ts b/apps/code/src/main/services/scratchpad/service.test.ts new file mode 100644 index 000000000..00572fb73 --- /dev/null +++ b/apps/code/src/main/services/scratchpad/service.test.ts @@ -0,0 +1,341 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ScratchpadService, + ScratchpadServiceEvent, + sanitizeScratchpadName, +} from "./service"; + +const fsPromises = fs.promises; + +class TestScratchpadService extends ScratchpadService { + constructor(private readonly baseDir: string) { + // GitService is unused in the existing test surface; cast a stub. + super({} as never); + } + + protected override getBaseDir(): string { + return this.baseDir; + } +} + +describe("sanitizeScratchpadName", () => { + it('lowercases and hyphenates "My App!" to "my-app"', () => { + expect(sanitizeScratchpadName("My App!")).toBe("my-app"); + }); + + it("collapses runs of non-ASCII characters into single hyphens", () => { + expect(sanitizeScratchpadName("naïve café — déjà vu")).toBe( + "na-ve-caf-d-j-vu", + ); + }); + + it("trims leading and trailing hyphens", () => { + expect(sanitizeScratchpadName("---hello---")).toBe("hello"); + }); + + it("truncates to 64 characters", () => { + const longName = "a".repeat(80); + const sanitized = sanitizeScratchpadName(longName); + expect(sanitized.length).toBe(64); + expect(sanitized).toBe("a".repeat(64)); + }); +}); + +describe("ScratchpadService", () => { + let baseDir: string; + let service: TestScratchpadService; + + beforeEach(async () => { + baseDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "scratchpad-test-"), + ); + service = new TestScratchpadService(baseDir); + }); + + afterEach(async () => { + await fsPromises.rm(baseDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe("scaffoldEmpty", () => { + it("creates the directory and writes a default manifest", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "My App!", + 42, + ); + + expect(scratchpadPath).toBe(path.join(baseDir, "task-1", "my-app")); + + const stat = await fsPromises.stat(scratchpadPath); + expect(stat.isDirectory()).toBe(true); + + const manifestRaw = await fsPromises.readFile( + path.join(scratchpadPath, ".posthog.json"), + "utf8", + ); + const manifest = JSON.parse(manifestRaw); + expect(manifest).toEqual({ projectId: 42, published: false }); + }); + + it("emits a Created event", async () => { + const handler = vi.fn(); + service.on(ScratchpadServiceEvent.Created, handler); + + await service.scaffoldEmpty("task-1", "Hello", 7); + + expect(handler).toHaveBeenCalledTimes(1); + const payload = handler.mock.calls[0][0]; + expect(payload.taskId).toBe("task-1"); + expect(payload.name).toBe("Hello"); + expect(payload.scratchpadPath).toBe( + path.join(baseDir, "task-1", "hello"), + ); + expect(payload.manifest).toEqual({ projectId: 7, published: false }); + }); + + it("throws when the name sanitizes to an empty string", async () => { + await expect(service.scaffoldEmpty("task-1", "!!!", 1)).rejects.toThrow( + /Cannot derive scratchpad directory name/, + ); + }); + + it("initializes the directory as a git repo on `main`", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "Repo Test", + 1, + ); + + // .git directory exists + const gitStat = await fsPromises.stat(path.join(scratchpadPath, ".git")); + expect(gitStat.isDirectory()).toBe(true); + + // Default branch is main (no commits yet — read .git/HEAD) + const head = await fsPromises.readFile( + path.join(scratchpadPath, ".git", "HEAD"), + "utf8", + ); + expect(head.trim()).toBe("ref: refs/heads/main"); + }); + }); + + describe("readManifest", () => { + it("throws when no scratchpad directory exists", async () => { + await expect(service.readManifest("task-missing")).rejects.toThrow( + /No scratchpad found/, + ); + }); + + it("throws when the manifest file is missing", async () => { + const dir = path.join(baseDir, "task-1", "empty"); + await fsPromises.mkdir(dir, { recursive: true }); + + await expect(service.readManifest("task-1")).rejects.toThrow(); + }); + + it("throws on Zod validation failure", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "App", + 5, + ); + await fsPromises.writeFile( + path.join(scratchpadPath, ".posthog.json"), + JSON.stringify({ projectId: "not-a-number", published: false }), + "utf8", + ); + + await expect(service.readManifest("task-1")).rejects.toThrow(); + }); + + it("returns the parsed manifest", async () => { + await service.scaffoldEmpty("task-1", "App", 5); + const manifest = await service.readManifest("task-1"); + expect(manifest).toEqual({ projectId: 5, published: false }); + }); + }); + + describe("writeManifest", () => { + it("merges the patch into the existing manifest", async () => { + await service.scaffoldEmpty("task-1", "App", 5); + const updated = await service.writeManifest("task-1", { + published: true, + publishedAt: "2026-04-26T00:00:00.000Z", + }); + + expect(updated).toEqual({ + projectId: 5, + published: true, + publishedAt: "2026-04-26T00:00:00.000Z", + }); + + const reread = await service.readManifest("task-1"); + expect(reread).toEqual(updated); + }); + + it("emits ManifestUpdated", async () => { + await service.scaffoldEmpty("task-1", "App", 5); + const handler = vi.fn(); + service.on(ScratchpadServiceEvent.ManifestUpdated, handler); + + await service.writeManifest("task-1", { published: true }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].taskId).toBe("task-1"); + expect(handler.mock.calls[0][0].manifest.published).toBe(true); + }); + + it("does not corrupt the existing manifest if rename fails mid-write", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "App", + 5, + ); + const originalRaw = await fsPromises.readFile( + path.join(scratchpadPath, ".posthog.json"), + "utf8", + ); + + const renameSpy = vi + .spyOn(fsPromises, "rename") + .mockRejectedValueOnce(new Error("simulated crash")); + + await expect( + service.writeManifest("task-1", { published: true }), + ).rejects.toThrow(/simulated crash/); + + renameSpy.mockRestore(); + + const afterRaw = await fsPromises.readFile( + path.join(scratchpadPath, ".posthog.json"), + "utf8", + ); + expect(afterRaw).toBe(originalRaw); + + // The tmp file (if any was left behind) must not be readable as the real + // manifest, so re-reading the manifest still yields the original state. + const manifest = await service.readManifest("task-1"); + expect(manifest).toEqual({ projectId: 5, published: false }); + }); + + it("serializes concurrent calls without losing updates", async () => { + await service.scaffoldEmpty("task-1", "App", 5); + + // 5 parallel patches, each adding a distinct field via githubRemote/publishedAt. + // We use `published` + `publishedAt` + `githubRemote` to verify ordered + // merging: the last write wins for any overlapping keys, and all keys + // present across writes survive. + const patches = [ + { published: true }, + { publishedAt: "2026-04-26T00:00:01.000Z" }, + { githubRemote: "https://github.com/example/repo-a.git" }, + { publishedAt: "2026-04-26T00:00:02.000Z" }, + { githubRemote: "https://github.com/example/repo-b.git" }, + ]; + + await Promise.all( + patches.map((patch) => service.writeManifest("task-1", patch)), + ); + + const final = await service.readManifest("task-1"); + // All keys present, last writer wins per key. + expect(final).toEqual({ + projectId: 5, + published: true, + publishedAt: "2026-04-26T00:00:02.000Z", + githubRemote: "https://github.com/example/repo-b.git", + }); + }); + }); + + describe("delete", () => { + it("removes the directory tree and emits Deleted", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "App", + 5, + ); + const taskDir = path.dirname(scratchpadPath); + const handler = vi.fn(); + service.on(ScratchpadServiceEvent.Deleted, handler); + + await service.delete("task-1"); + + await expect(fsPromises.stat(taskDir)).rejects.toThrow(); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual({ taskId: "task-1" }); + }); + + it("is a no-op when the scratchpad does not exist", async () => { + await expect(service.delete("task-missing")).resolves.toBeUndefined(); + }); + }); + + describe("list", () => { + it("returns an empty array when the base dir does not exist", async () => { + await fsPromises.rm(baseDir, { recursive: true, force: true }); + const result = await service.list(); + expect(result).toEqual([]); + }); + + it("returns all scaffolded scratchpads", async () => { + await service.scaffoldEmpty("task-1", "App One", 1); + await service.scaffoldEmpty("task-2", "App Two", 2); + + const result = await service.list(); + result.sort((a, b) => a.taskId.localeCompare(b.taskId)); + + expect(result).toEqual([ + { + taskId: "task-1", + name: "app-one", + manifest: { projectId: 1, published: false }, + }, + { + taskId: "task-2", + name: "app-two", + manifest: { projectId: 2, published: false }, + }, + ]); + }); + + it("skips scratchpads with malformed manifests", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "App", + 1, + ); + await fsPromises.writeFile( + path.join(scratchpadPath, ".posthog.json"), + "{ not valid json", + "utf8", + ); + await service.scaffoldEmpty("task-2", "App Two", 2); + + const result = await service.list(); + expect(result).toHaveLength(1); + expect(result[0].taskId).toBe("task-2"); + }); + }); + + describe("getScratchpadPath", () => { + it("returns the directory when present", async () => { + const { scratchpadPath } = await service.scaffoldEmpty( + "task-1", + "App", + 1, + ); + const result = await service.getScratchpadPath("task-1"); + expect(result).toBe(scratchpadPath); + }); + + it("returns null when missing", async () => { + const result = await service.getScratchpadPath("nope"); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/code/src/main/services/scratchpad/service.ts b/apps/code/src/main/services/scratchpad/service.ts new file mode 100644 index 000000000..ccaef6f92 --- /dev/null +++ b/apps/code/src/main/services/scratchpad/service.ts @@ -0,0 +1,673 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { REPO_NAME_RE, sanitizeRepoName } from "@shared/utils/repo"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { GitService } from "../git/service"; +import { getScratchpadLocation } from "../settingsStore"; +import { + type CreatedEventPayload, + type DeletedEventPayload, + type Manifest, + type ManifestPatch, + type ManifestUpdatedEventPayload, + manifestSchema, + type PreviewExitedEventPayload, + type PreviewReadyEventPayload, + type PreviewRegisteredEventPayload, + type PublishedEventPayload, + type PublishResult, + type PublishVisibility, + type ScratchpadListEntry, +} from "./schemas"; + +const execFileAsync = promisify(execFile); + +const fsPromises = fs.promises; + +const log = logger.scope("scratchpad-service"); +const publishLog = logger.scope("scratchpad-publish"); + +const MANIFEST_FILE = ".posthog.json"; +const MANIFEST_TMP_FILE = ".posthog.json.tmp"; +const MAX_NAME_LENGTH = 64; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const SECRET_FILENAME_PATTERNS = [/^\.env/, /\.pem$/, /\.key$/]; + +const DEFAULT_GITIGNORE = `node_modules/ +.env* +dist/ +build/ +.DS_Store +*.pem +*.key +.next/ +.vite/ +.posthog.json +.posthog.json.tmp +`; + +// `REPO_NAME_RE` and `sanitizeRepoName` live in `@shared/utils/repo` so the +// renderer's PublishDialog can validate using the exact same rules. + +export const ScratchpadServiceEvent = { + Created: "created", + ManifestUpdated: "manifestUpdated", + PreviewRegistered: "previewRegistered", + PreviewReady: "previewReady", + PreviewExited: "previewExited", + Published: "published", + Deleted: "deleted", +} as const; + +export interface ScratchpadServiceEvents { + [ScratchpadServiceEvent.Created]: CreatedEventPayload; + [ScratchpadServiceEvent.ManifestUpdated]: ManifestUpdatedEventPayload; + [ScratchpadServiceEvent.PreviewRegistered]: PreviewRegisteredEventPayload; + [ScratchpadServiceEvent.PreviewReady]: PreviewReadyEventPayload; + [ScratchpadServiceEvent.PreviewExited]: PreviewExitedEventPayload; + [ScratchpadServiceEvent.Published]: PublishedEventPayload; + [ScratchpadServiceEvent.Deleted]: DeletedEventPayload; +} + +/** + * Sanitize a user-supplied name into a directory-safe slug: + * - lowercase + * - non-alphanumeric ASCII collapsed to a single `-` + * - trim leading/trailing `-` + * - max 64 chars + */ +export function sanitizeScratchpadName(name: string): string { + const lowered = name.toLowerCase(); + // Replace any run of non-ASCII-alphanumerics with a single hyphen. + const replaced = lowered.replace(/[^a-z0-9]+/g, "-"); + const trimmed = replaced.replace(/^-+|-+$/g, ""); + return trimmed.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, ""); +} + +@injectable() +export class ScratchpadService extends TypedEventEmitter { + /** Per-taskId mutex chain to serialize writeManifest calls. */ + private readonly writeChains = new Map>(); + + constructor( + @inject(MAIN_TOKENS.GitService) + private readonly gitService: GitService, + ) { + super(); + } + + /** + * Override-point for tests; production code returns the configured + * `/scratchpads/` directory. + */ + protected getBaseDir(): string { + return getScratchpadLocation(); + } + + /** + * Override-point for tests. Returns the GitHub OAuth/CLI token used to call + * the GitHub REST API. Returns null when no token is available. + */ + protected async getGhAuthToken(): Promise { + if (!this.gitService) return null; + const result = await this.gitService.getGhAuthToken(); + return result.success ? result.token : null; + } + + /** + * Override-point for tests; production calls `fetch`. + */ + protected fetchImpl: typeof fetch = (...args) => fetch(...args); + + /** + * Override-point for tests; production runs `git` directly via `execFile`. + */ + protected async runGit(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); + } + + private getTaskDir(taskId: string): string { + return path.join(this.getBaseDir(), taskId); + } + + /** + * Returns the full scratchpad directory (`//`) + * if it exists on disk, otherwise null. + */ + public async getScratchpadPath(taskId: string): Promise { + const taskDir = this.getTaskDir(taskId); + let entries: fs.Dirent[]; + try { + entries = await fsPromises.readdir(taskDir, { withFileTypes: true }); + } catch { + return null; + } + const dir = entries.find((e) => e.isDirectory()); + return dir ? path.join(taskDir, dir.name) : null; + } + + /** + * Create a fresh scratchpad directory for `taskId` named ``, + * write a default manifest with `published: false`, and emit `Created`. + */ + public async scaffoldEmpty( + taskId: string, + name: string, + projectId: number | null | undefined, + ): Promise<{ scratchpadPath: string }> { + const sanitized = sanitizeScratchpadName(name); + if (!sanitized) { + throw new Error(`Cannot derive scratchpad directory name from "${name}"`); + } + const scratchpadPath = path.join(this.getTaskDir(taskId), sanitized); + + const normalizedProjectId = projectId ?? null; + log.info("Scaffolding scratchpad", { + taskId, + name, + sanitized, + projectId: normalizedProjectId, + }); + + await fsPromises.mkdir(scratchpadPath, { recursive: true }); + + // Initialize as a git repo immediately. Most folder-aware UI in the app + // (file watchers, status bar, "Changes" panel) assumes a git directory, + // and refusing to init until publish-time leaves the user stuck on a + // "This folder is not a git repository" warning the moment the + // scratchpad opens. Default branch `main`, no commits yet. + try { + await execFileAsync("git", ["init", "-b", "main"], { + cwd: scratchpadPath, + }); + } catch (err) { + log.error("Failed to git-init scratchpad", { scratchpadPath, err }); + throw new Error( + `Failed to initialize git repository in scratchpad: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + const manifest: Manifest = { + projectId: normalizedProjectId, + published: false, + }; + + await this.writeManifestAtomic(scratchpadPath, manifest); + + this.emit(ScratchpadServiceEvent.Created, { + taskId, + name, + scratchpadPath, + manifest, + }); + + return { scratchpadPath }; + } + + /** + * Reads `/.posthog.json`. Throws if the file is missing or fails + * Zod validation. + */ + public async readManifest(taskId: string): Promise { + const scratchpadPath = await this.getScratchpadPath(taskId); + if (!scratchpadPath) { + throw new Error(`No scratchpad found for taskId "${taskId}"`); + } + return this.readManifestFromPath(scratchpadPath); + } + + private async readManifestFromPath( + scratchpadPath: string, + ): Promise { + const manifestPath = path.join(scratchpadPath, MANIFEST_FILE); + const raw = await fsPromises.readFile(manifestPath, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `Manifest at ${manifestPath} is not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return manifestSchema.parse(parsed); + } + + /** + * Atomically merge `patch` into the manifest. Writes are serialized per + * taskId via an in-process promise chain. + */ + public async writeManifest( + taskId: string, + patch: ManifestPatch, + ): Promise { + return this.runSerialized(taskId, async () => { + const scratchpadPath = await this.getScratchpadPath(taskId); + if (!scratchpadPath) { + throw new Error(`No scratchpad found for taskId "${taskId}"`); + } + const current = await this.readManifestFromPath(scratchpadPath); + const merged: Manifest = manifestSchema.parse({ ...current, ...patch }); + await this.writeManifestAtomic(scratchpadPath, merged); + + this.emit(ScratchpadServiceEvent.ManifestUpdated, { + taskId, + manifest: merged, + }); + + return merged; + }); + } + + /** + * Atomic write: write to `.posthog.json.tmp`, then rename to `.posthog.json`. + * If the rename fails, the existing manifest is left untouched. + */ + private async writeManifestAtomic( + scratchpadPath: string, + manifest: Manifest, + ): Promise { + const tmpPath = path.join(scratchpadPath, MANIFEST_TMP_FILE); + const finalPath = path.join(scratchpadPath, MANIFEST_FILE); + const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + await fsPromises.writeFile(tmpPath, serialized, "utf8"); + try { + await fsPromises.rename(tmpPath, finalPath); + } catch (error) { + // Best-effort cleanup of the tmp file; rethrow the original error. + try { + await fsPromises.unlink(tmpPath); + } catch { + // ignore + } + throw error; + } + } + + /** + * Convert an unpublished scratchpad into a real GitHub repo + initial commit + * + push, and flip the manifest's `published` flag. + * + * NOTE: project-access pre-flight (`posthogClient.getProject(...)`) and the + * subsequent project rename live in the renderer (`usePublishScratchpad`). + * The service trusts that the caller already validated access. The manifest + * patch happens here so the on-disk state stays consistent with the GitHub + * remote even if the post-publish rename fails. + * + * Failure handling per the plan: + * - Failure before `git remote add origin` (no GitHub remote created): clean + * up local `.git` so the user can retry from scratch. + * - Failure after the GitHub repo is created but before the manifest patch + * succeeds: leave the GitHub remote intact and surface a `push_failed` + * error. We deliberately do NOT auto-delete remote GitHub repos. + */ + public async publish( + taskId: string, + options: { repoName: string; visibility?: PublishVisibility }, + ): Promise { + const visibility: PublishVisibility = options.visibility ?? "private"; + const scratchpadPath = await this.getScratchpadPath(taskId); + if (!scratchpadPath) { + return { + success: false, + code: "git_error", + message: `No scratchpad found for taskId "${taskId}"`, + }; + } + + // 1. Already-published guard. + const manifest = await this.readManifestFromPath(scratchpadPath); + if (manifest.published) { + return { + success: false, + code: "already_published", + message: "Already published", + }; + } + + // 3. Secret-leakage guard. Ensure a `.gitignore` exists, then walk the + // tree and reject if any non-ignored file looks dangerous. + await this.ensureGitignore(scratchpadPath); + const offending = await this.findOffendingFiles(scratchpadPath); + if (offending.length > 0) { + return { + success: false, + code: "secret_leakage", + message: `Refusing to publish: ${offending.length} file(s) look like secrets or are too large.`, + paths: offending, + }; + } + + // 4. git init + initial commit. `init.defaultBranch=main` so we don't have + // to rename the branch after the fact. + let gitInitialized = false; + try { + await this.runGit(scratchpadPath, [ + "-c", + "init.defaultBranch=main", + "init", + ]); + gitInitialized = true; + // Make sure we land on `main` even on git versions that ignore the -c. + await this.runGit(scratchpadPath, [ + "symbolic-ref", + "HEAD", + "refs/heads/main", + ]).catch(() => undefined); + await this.runGit(scratchpadPath, ["add", "."]); + await this.runGit(scratchpadPath, ["commit", "-m", "Initial commit"]); + } catch (error) { + publishLog.warn("git init/commit failed", { taskId, error }); + if (gitInitialized) { + await this.cleanupLocalGit(scratchpadPath); + } + return { + success: false, + code: "git_error", + message: + error instanceof Error ? error.message : "Failed to initialize git", + }; + } + + // 6. POST /user/repos. + const token = await this.getGhAuthToken(); + if (!token) { + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "no_gh_token", + message: + "No GitHub auth token available. Run `gh auth login` and retry.", + }; + } + + const repoNameSanitized = sanitizeRepoName(options.repoName); + if (!repoNameSanitized || !REPO_NAME_RE.test(repoNameSanitized)) { + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "github_error", + message: `Invalid repo name: ${options.repoName}`, + }; + } + + let repoFullName: string; + let remoteUrl: string; + try { + const response = await this.fetchImpl( + "https://api.github.com/user/repos", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: repoNameSanitized, + private: visibility === "private", + description: "Scaffolded with PostHog Code", + }), + }, + ); + + if (response.status === 422) { + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "repo_name_conflict", + message: `A repository named "${repoNameSanitized}" already exists on your account. Pick a different name and retry.`, + }; + } + + if (!response.ok) { + const body = await response.text().catch(() => ""); + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "github_error", + message: `GitHub API returned ${response.status}: ${body.slice(0, 200)}`, + }; + } + + const data = (await response.json()) as { + ssh_url?: string; + clone_url?: string; + full_name?: string; + }; + remoteUrl = data.ssh_url || data.clone_url || ""; + repoFullName = data.full_name ?? repoNameSanitized; + if (!remoteUrl) { + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "github_error", + message: "GitHub did not return a remote URL.", + }; + } + } catch (error) { + publishLog.warn("Failed to call GitHub API", { taskId, error }); + await this.cleanupLocalGit(scratchpadPath); + return { + success: false, + code: "github_error", + message: + error instanceof Error ? error.message : "Failed to call GitHub", + }; + } + + // 7. git remote add origin + git push -u origin main. From here on, the + // remote exists and we deliberately leave it intact on failure. + try { + await this.runGit(scratchpadPath, ["remote", "add", "origin", remoteUrl]); + await this.runGit(scratchpadPath, ["push", "-u", "origin", "main"]); + } catch (error) { + publishLog.error("Push failed after creating GitHub repo", { + taskId, + repoFullName, + error, + }); + return { + success: false, + code: "push_failed", + message: `Created ${repoFullName} on GitHub, but the push failed: ${ + error instanceof Error ? error.message : String(error) + }. Resolve the issue manually and push from a terminal.`, + }; + } + + // 9. Patch the manifest atomically so disk state matches GitHub state. + const updated = await this.writeManifest(taskId, { + published: true, + publishedAt: new Date().toISOString(), + githubRemote: remoteUrl, + }); + + // 10. Emit Published. + this.emit(ScratchpadServiceEvent.Published, { + taskId, + manifest: updated, + repoFullName, + githubRemote: remoteUrl, + }); + + return { + success: true, + manifest: updated, + repoFullName, + githubRemote: remoteUrl, + }; + } + + private async ensureGitignore(scratchpadPath: string): Promise { + const gitignorePath = path.join(scratchpadPath, ".gitignore"); + try { + await fsPromises.writeFile(gitignorePath, DEFAULT_GITIGNORE, { + flag: "wx", + encoding: "utf8", + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } + + /** + * Returns relative paths that would be tracked by `git add .` and either: + * - have a basename matching one of the secret patterns, OR + * - exceed `MAX_FILE_BYTES`. + * + * Uses `git ls-files --others --cached --exclude-standard -z` so gitignore + * semantics (negation, nested gitignores, global gitignore) are handled + * correctly without re-implementing them. The scratchpad is always a git + * repo by the time we get here (`scaffoldEmpty` runs `git init`), and + * `--others` covers the unstaged-but-not-yet-tracked common case at + * publish time. + */ + private async findOffendingFiles(scratchpadPath: string): Promise { + const stdout = await this.gitListTrackable(scratchpadPath); + const candidates = stdout + .split("\0") + .filter((p) => p.length > 0 && !p.startsWith(".git/")); + + const offending: string[] = []; + await Promise.all( + candidates.map(async (rel) => { + const basename = rel.split("/").pop() ?? rel; + if (SECRET_FILENAME_PATTERNS.some((re) => re.test(basename))) { + offending.push(rel); + return; + } + try { + const stat = await fsPromises.stat(path.join(scratchpadPath, rel)); + if (stat.size > MAX_FILE_BYTES) offending.push(rel); + } catch { + // file may have been removed mid-check; ignore + } + }), + ); + return offending; + } + + /** + * Override-point for tests; production runs git directly. + * Returns NUL-separated paths from `git ls-files`. + */ + protected async gitListTrackable(cwd: string): Promise { + const { stdout } = await execFileAsync( + "git", + ["ls-files", "--others", "--cached", "--exclude-standard", "-z"], + { cwd, maxBuffer: 16 * 1024 * 1024 }, + ); + return stdout; + } + + private async cleanupLocalGit(scratchpadPath: string): Promise { + try { + await fsPromises.rm(path.join(scratchpadPath, ".git"), { + recursive: true, + force: true, + }); + } catch (error) { + publishLog.warn("Failed to clean up local .git", { + scratchpadPath, + error, + }); + } + } + + /** + * Removes the scratchpad's task directory tree (`/`) and emits + * `Deleted`. No-op if the directory does not exist. + */ + public async delete(taskId: string): Promise { + const taskDir = this.getTaskDir(taskId); + log.info("Deleting scratchpad", { taskId, taskDir }); + try { + await fsPromises.rm(taskDir, { recursive: true, force: true }); + } catch (error) { + log.warn("Failed to remove scratchpad directory", { taskId, error }); + throw error; + } + this.emit(ScratchpadServiceEvent.Deleted, { taskId }); + } + + /** + * Lists all scratchpads on disk. Entries with missing or malformed manifests + * are skipped (and logged) so a single bad manifest doesn't poison the list. + */ + public async list(): Promise { + const baseDir = this.getBaseDir(); + let taskDirents: fs.Dirent[]; + try { + taskDirents = await fsPromises.readdir(baseDir, { withFileTypes: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return []; + throw error; + } + + const results = await Promise.all( + taskDirents + .filter((d) => d.isDirectory()) + .map(async (taskDirent) => { + const taskId = taskDirent.name; + const taskDir = path.join(baseDir, taskId); + let inner: fs.Dirent[]; + try { + inner = await fsPromises.readdir(taskDir, { withFileTypes: true }); + } catch { + return null; + } + const scratchpadDir = inner.find((d) => d.isDirectory()); + if (!scratchpadDir) return null; + + const scratchpadPath = path.join(taskDir, scratchpadDir.name); + try { + const manifest = await this.readManifestFromPath(scratchpadPath); + return { + taskId, + name: scratchpadDir.name, + manifest, + } satisfies ScratchpadListEntry; + } catch (error) { + log.warn("Skipping scratchpad with bad manifest", { + taskId, + name: scratchpadDir.name, + error, + }); + return null; + } + }), + ); + return results.filter((e): e is ScratchpadListEntry => e !== null); + } + + /** + * Append `fn` to the per-taskId promise chain so concurrent calls run + * sequentially without losing updates. + */ + private runSerialized(taskId: string, fn: () => Promise): Promise { + const previous = this.writeChains.get(taskId) ?? Promise.resolve(); + const next = previous.then(fn, fn); + // Suppress the chain-tracking promise so a rejected write doesn't surface + // as an unhandled rejection; callers still see the rejection via `next`. + const tracked: Promise = next + .catch(() => undefined) + .finally(() => { + if (this.writeChains.get(taskId) === tracked) { + this.writeChains.delete(taskId); + } + }); + this.writeChains.set(taskId, tracked); + return next; + } +} diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 2acf4a02f..0b1ce46f5 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -121,6 +121,15 @@ export function getWorktreeLocation(): string { return settingsStore.get("worktreeLocation", getDefaultWorktreeLocation()); } +/** + * Location for scratchpad directories (drafts of new PostHog products). + * Lives under the user's app data dir, separate from worktrees: scratchpads + * are scoped to the app installation rather than to a host repo. + */ +export function getScratchpadLocation(): string { + return path.join(getUserDataDir(), "scratchpads"); +} + /** * Get all worktree locations to check (current + legacy). * Use this when searching for existing worktrees for backwards compatibility. diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index d770db847..da106b25d 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -33,6 +33,12 @@ export const workspaceSchema = z.object({ baseBranch: z.string().nullable(), linkedBranch: z.string().nullable(), createdAt: z.string(), + /** + * Scratchpad axis is orthogonal to `mode`. Scratchpad workspaces behave + * like `mode: "local"` for branch derivation and worktree handling, but + * the host treats them as drafts (no git lifecycle until Publish). + */ + scratchpad: z.boolean().optional(), }); // Input schemas @@ -45,6 +51,11 @@ export const createWorkspaceInput = z mode: workspaceModeSchema, branch: z.string().optional(), useExistingBranch: z.boolean().optional(), + /** + * Scratchpad axis is orthogonal to `mode`. Scratchpad workspaces behave + * like `mode: "local"` for branch derivation and worktree handling. + */ + scratchpad: z.boolean().optional(), }) .refine( (data) => diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 2510b65c1..441667bae 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -440,6 +440,7 @@ export class WorkspaceService extends TypedEventEmitter mode, branch, useExistingBranch, + scratchpad, } = options; const existingWorkspace = await this.getWorkspaceInfo(taskId); @@ -462,6 +463,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "cloud", + scratchpad: scratchpad ?? false, }); return { @@ -503,6 +505,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "local", + scratchpad: scratchpad ?? false, }); const localBranch = await getBranchFromPath(folderPath); @@ -605,6 +608,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "worktree", + scratchpad: scratchpad ?? false, }); this.worktreeRepo.create({ @@ -843,6 +847,9 @@ export class WorkspaceService extends TypedEventEmitter const linkedBranchByTaskId = new Map( dbRows.map((row) => [row.taskId, row.linkedBranch ?? null]), ); + const scratchpadByTaskId = new Map( + dbRows.map((row) => [row.taskId, row.scratchpad ?? false]), + ); const workspaces: Record = {}; for (const assoc of associations) { @@ -858,6 +865,7 @@ export class WorkspaceService extends TypedEventEmitter baseBranch: null, linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, createdAt: new Date().toISOString(), + scratchpad: scratchpadByTaskId.get(assoc.taskId) ?? false, }; continue; } @@ -895,6 +903,7 @@ export class WorkspaceService extends TypedEventEmitter baseBranch: null, linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, createdAt: new Date().toISOString(), + scratchpad: scratchpadByTaskId.get(assoc.taskId) ?? false, }; } diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..4dd869a24 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -25,8 +25,10 @@ import { mcpCallbackRouter } from "./routers/mcp-callback"; import { notificationRouter } from "./routers/notification"; import { oauthRouter } from "./routers/oauth"; import { osRouter } from "./routers/os"; +import { previewRouter } from "./routers/preview"; import { processTrackingRouter } from "./routers/process-tracking"; import { provisioningRouter } from "./routers/provisioning"; +import { scratchpadRouter } from "./routers/scratchpad"; import { secureStoreRouter } from "./routers/secure-store"; import { shellRouter } from "./routers/shell"; import { skillsRouter } from "./routers/skills"; @@ -65,8 +67,10 @@ export const trpcRouter = router({ oauth: oauthRouter, logs: logsRouter, os: osRouter, + preview: previewRouter, processTracking: processTrackingRouter, provisioning: provisioningRouter, + scratchpad: scratchpadRouter, sleep: sleepRouter, suspension: suspensionRouter, secureStore: secureStoreRouter, diff --git a/apps/code/src/main/trpc/routers/preview.ts b/apps/code/src/main/trpc/routers/preview.ts new file mode 100644 index 000000000..52d64d3d2 --- /dev/null +++ b/apps/code/src/main/trpc/routers/preview.ts @@ -0,0 +1,45 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + listPreviewsInputSchema, + listPreviewsOutputSchema, + unregisterPreviewInputSchema, +} from "../../services/preview/schemas"; +import { + type PreviewService, + PreviewServiceEvent, + type PreviewServiceEvents, +} from "../../services/preview/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.PreviewService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const previewRouter = router({ + unregister: publicProcedure + .input(unregisterPreviewInputSchema) + .mutation(async ({ input }) => { + await getService().unregister(input.taskId, input.name); + return { success: true } as const; + }), + + list: publicProcedure + .input(listPreviewsInputSchema) + .output(listPreviewsOutputSchema) + .query(({ input }) => getService().list(input.taskId)), + + onRegistered: subscribe(PreviewServiceEvent.PreviewRegistered), + onReady: subscribe(PreviewServiceEvent.PreviewReady), + onExited: subscribe(PreviewServiceEvent.PreviewExited), + onUnregistered: subscribe(PreviewServiceEvent.PreviewUnregistered), +}); diff --git a/apps/code/src/main/trpc/routers/scratchpad.ts b/apps/code/src/main/trpc/routers/scratchpad.ts new file mode 100644 index 000000000..953fdf4bd --- /dev/null +++ b/apps/code/src/main/trpc/routers/scratchpad.ts @@ -0,0 +1,76 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + createScratchpadInput, + createScratchpadOutput, + manifestSchema, + publishInputSchema, + publishOutputSchema, + scratchpadListOutput, + taskIdInput, + writeManifestInput, +} from "../../services/scratchpad/schemas"; +import { + type ScratchpadService, + ScratchpadServiceEvent, + type ScratchpadServiceEvents, +} from "../../services/scratchpad/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.ScratchpadService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const scratchpadRouter = router({ + create: publicProcedure + .input(createScratchpadInput) + .output(createScratchpadOutput) + .mutation(({ input }) => + getService().scaffoldEmpty(input.taskId, input.name, input.projectId), + ), + + delete: publicProcedure.input(taskIdInput).mutation(async ({ input }) => { + await getService().delete(input.taskId); + return { success: true } as const; + }), + + list: publicProcedure + .output(scratchpadListOutput) + .query(() => getService().list()), + + readManifest: publicProcedure + .input(taskIdInput) + .output(manifestSchema) + .query(({ input }) => getService().readManifest(input.taskId)), + + writeManifest: publicProcedure + .input(writeManifestInput) + .output(manifestSchema) + .mutation(({ input }) => + getService().writeManifest(input.taskId, input.patch), + ), + + publish: publicProcedure + .input(publishInputSchema) + .output(publishOutputSchema) + .mutation(({ input }) => + getService().publish(input.taskId, { + repoName: input.repoName, + visibility: input.visibility, + }), + ), + + onCreated: subscribe(ScratchpadServiceEvent.Created), + onManifestUpdated: subscribe(ScratchpadServiceEvent.ManifestUpdated), + onPublished: subscribe(ScratchpadServiceEvent.Published), + onDeleted: subscribe(ScratchpadServiceEvent.Deleted), +}); diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index e8920c6b2..a356ecbd5 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -170,6 +170,9 @@ export function createWindow(): void { preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", + // Enables the tag used by the Preview panel to embed local + // dev servers (Vite, Next, etc.) inside the app. + webviewTag: true, ...(isDev && { webSecurity: false }), }, }); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 12d4a6fe5..cdd77910c 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -13,6 +13,7 @@ import { import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { PreviewSubscriber } from "@features/preview/components/PreviewSubscriber"; import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"; import { useFocusStore } from "@renderer/stores/focusStore"; @@ -257,8 +258,12 @@ function App() { const content = renderContent(); + const showPreviewSubscriber = + isAuthenticated && hasCompletedOnboarding && hasCodeAccess === true; + return ( + {showPreviewSubscriber ? : null} {isAuthenticated ? ( {content} ) : ( diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index a7f6b141b..71a045d59 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -573,6 +573,36 @@ export class PostHogAPIClient { return data as Schemas.Team; } + async createProject(input: { + name: string; + organizationId: string; + }): Promise { + const data = await this.api.post( + // @ts-expect-error this endpoint is not in the generated client + "/api/organizations/{organization_id}/projects/", + { + path: { organization_id: input.organizationId }, + body: { name: input.name } as unknown, + }, + ); + return data as Schemas.Team; + } + + async updateProject( + projectId: number, + patch: { name?: string }, + ): Promise { + const data = await this.api.patch( + // @ts-expect-error this endpoint is not in the generated client + "/api/projects/{project_id}/", + { + path: { project_id: projectId.toString() }, + body: patch as unknown, + }, + ); + return data as Schemas.Team; + } + async listSignalSourceConfigs( projectId: number, ): Promise { diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 48cbcde36..db10b532a 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -2,6 +2,7 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNav import { Command } from "@features/command/components/Command"; import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useFolders } from "@features/folders/hooks/useFolders"; +import { useOpenProductCreationDialog } from "@features/scratchpads/hooks/useOpenProductCreationDialog"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { @@ -10,6 +11,7 @@ import { GearIcon, HomeIcon, MoonIcon, + RocketIcon, SunIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; @@ -35,6 +37,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { folders } = useFolders(); const { theme, cycleTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); + const openProductCreationDialog = useOpenProductCreationDialog(); const view = useNavigationStore((state) => state.view); const setReviewMode = useReviewNavigationStore( (state) => state.setReviewMode, @@ -196,6 +199,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { New task + + + New app + {folders.length > 0 && ( diff --git a/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx b/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx index 70fd34e77..fb9c955d3 100644 --- a/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx +++ b/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx @@ -13,12 +13,14 @@ import { Button, DropdownMenu, DropdownMenuContent, + DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; import { flattenSelectOptions } from "@renderer/features/sessions/stores/sessionStore"; +import type { RefObject } from "react"; interface ModeStyle { icon: React.ReactNode; @@ -70,6 +72,13 @@ interface ModeSelectorProps { onChange: (value: string) => void; allowBypassPermissions: boolean; disabled?: boolean; + /** + * When set, the dropdown content is portaled into this container instead of + * ``. Required when the trigger sits inside a Radix Themes Dialog, + * whose `FocusScope` would otherwise yank focus out of the menu the moment + * Base UI tries to focus an item. + */ + portalContainer?: RefObject; } export function ModeSelector({ @@ -77,6 +86,7 @@ export function ModeSelector({ onChange, allowBypassPermissions, disabled, + portalContainer, }: ModeSelectorProps) { if (!modeOption || modeOption.type !== "select") return null; @@ -95,7 +105,7 @@ export function ModeSelector({ options.find((opt) => opt.value === currentValue)?.name ?? currentValue; return ( - + } /> - - Mode - - {options.map((option) => { - const style = getStyle(option.value); - return ( - - {style.icon} - {option.name} - - ); - })} - - + + + Mode + + {options.map((option) => { + const style = getStyle(option.value); + return ( + + {style.icon} + {option.name} + + ); + })} + + + ); } diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts index 47c08f67c..3b919459a 100644 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts +++ b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts @@ -104,6 +104,15 @@ export interface PanelLayoutStore { label: string; }, ) => void; + addPreviewTab: ( + taskId: string, + panelId: string, + preview: { + name: string; + url: string; + taskId: string; + }, + ) => void; clearAllLayouts: () => void; } @@ -899,6 +908,115 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + addPreviewTab: (taskId, _panelId, preview) => { + const tabId = `preview-${preview.name}`; + const newTab: Tab = { + id: tabId, + label: `Preview: ${preview.name}`, + data: { + type: "preview", + taskId: preview.taskId, + previewName: preview.name, + url: preview.url, + }, + component: null, + draggable: true, + closeable: true, + }; + + set((state) => + updateTaskLayout(state, taskId, (layout) => { + // 1. Existing preview tab → update URL and activate (re-register + // may have hopped ports). + const existingTab = findTabInTree(layout.panelTree, tabId); + if (existingTab) { + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, data: newTab.data } : tab, + ), + activeTabId: tabId, + }, + }; + }, + ); + return { panelTree: updatedTree }; + } + + // 2. There's already a non-main leaf panel (e.g. another preview + // or a split the user opened) → drop the new preview tab in + // there alongside it. + const nonMainPanel = findNonMainLeafPanel(layout.panelTree); + if (nonMainPanel) { + const updatedTree = updateTreeNode( + layout.panelTree, + nonMainPanel.id, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: [...panel.content.tabs, newTab], + activeTabId: tabId, + }, + }; + }, + ); + const metadata = updateMetadataForTab(layout, tabId, "add"); + return { panelTree: updatedTree, ...metadata }; + } + + // 3. No split yet → split the main panel 50/50 and put the + // preview in the new (right) pane so the chat stays visible. + const mainPanel = getLeafPanel( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + ); + if (!mainPanel) return {}; + + const newPanelId = generatePanelId(); + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [newTab], + activeTabId: tabId, + showTabs: true, + droppable: true, + }, + }; + + const splitTree = updateTreeNode( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + (panel) => ({ + type: "group" as const, + id: generatePanelId(), + direction: "horizontal" as const, + sizes: [50, 50], + children: [panel, newPanel], + }), + ); + + const metadata = updateMetadataForTab(layout, tabId, "add"); + return { + panelTree: splitTree, + focusedPanelId: newPanelId, + ...metadata, + }; + }), + ); + }, + clearAllLayouts: () => { set({ taskLayouts: {} }); }, diff --git a/apps/code/src/renderer/features/panels/store/panelTypes.ts b/apps/code/src/renderer/features/panels/store/panelTypes.ts index d50c9e9f4..5bc99d9f2 100644 --- a/apps/code/src/renderer/features/panels/store/panelTypes.ts +++ b/apps/code/src/renderer/features/panels/store/panelTypes.ts @@ -31,6 +31,12 @@ export type TabData = | { type: "review"; } + | { + type: "preview"; + taskId: string; + previewName: string; + url: string; + } | { type: "other"; }; diff --git a/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.test.ts b/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.test.ts new file mode 100644 index 000000000..d42f04519 --- /dev/null +++ b/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.test.ts @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Mocks -------------------------------------------------------------- + +const mockClient = vi.hoisted(() => ({ + createProject: vi.fn(), + updateProject: vi.fn(), + getCurrentUser: vi.fn(), +})); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +// Imports after mocks so the modules pick up the mocked dependencies. +import { useCreateProject } from "./useCreateProject"; +import { useUpdateProject } from "./useUpdateProject"; + +// --- Helpers ------------------------------------------------------------ + +function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +function newClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +// --- Tests -------------------------------------------------------------- + +describe("useCreateProject / useUpdateProject", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("createProject: calls client.createProject with the resolved org id", async () => { + mockClient.createProject.mockResolvedValueOnce({ + id: 42, + name: "My Project", + }); + + const queryClient = newClient(); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + + let mutationResult: unknown; + await waitFor(async () => { + mutationResult = await result.current.mutateAsync({ + name: "My Project", + organizationId: "org-1", + }); + }); + + expect(mockClient.createProject).toHaveBeenCalledTimes(1); + expect(mockClient.createProject).toHaveBeenCalledWith({ + name: "My Project", + organizationId: "org-1", + }); + expect(mutationResult).toEqual({ id: 42, name: "My Project" }); + }); + + it("createProject: lazily resolves organizationId from getCurrentUser when omitted", async () => { + mockClient.getCurrentUser.mockResolvedValueOnce({ + organization: { id: "org-from-user" }, + }); + mockClient.createProject.mockResolvedValueOnce({ + id: 7, + name: "Auto-Org", + }); + + const queryClient = newClient(); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + + await waitFor(async () => { + await result.current.mutateAsync({ name: "Auto-Org" }); + }); + + expect(mockClient.getCurrentUser).toHaveBeenCalledTimes(1); + expect(mockClient.createProject).toHaveBeenCalledWith({ + name: "Auto-Org", + organizationId: "org-from-user", + }); + }); + + it("updateProject: PATCHes with the provided patch body", async () => { + mockClient.updateProject.mockResolvedValueOnce({ id: 5, name: "new" }); + + const queryClient = newClient(); + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + + await waitFor(async () => { + await result.current.mutateAsync({ + projectId: 5, + patch: { name: "new" }, + }); + }); + + expect(mockClient.updateProject).toHaveBeenCalledTimes(1); + expect(mockClient.updateProject).toHaveBeenCalledWith(5, { name: "new" }); + }); + + it("createProject: invalidates the projects-list cache on success", async () => { + mockClient.createProject.mockResolvedValueOnce({ id: 1, name: "x" }); + + const queryClient = newClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + + await waitFor(async () => { + await result.current.mutateAsync({ + name: "x", + organizationId: "org-1", + }); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["projects", "list"], + }); + }); +}); diff --git a/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.ts b/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.ts new file mode 100644 index 000000000..5d8da0863 --- /dev/null +++ b/apps/code/src/renderer/features/posthog-projects/hooks/useCreateProject.ts @@ -0,0 +1,45 @@ +import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; +import type { Schemas } from "@renderer/api/generated"; +import { useQueryClient } from "@tanstack/react-query"; +import { logger } from "@utils/logger"; + +const log = logger.scope("posthog-projects"); + +export interface CreateProjectInput { + name: string; + organizationId?: string; +} + +export function useCreateProject() { + const queryClient = useQueryClient(); + + return useAuthenticatedMutation( + async (client, { name, organizationId }) => { + let resolvedOrgId = organizationId; + if (!resolvedOrgId) { + const user = await client.getCurrentUser(); + const userOrgId = (user as { organization?: { id?: string } | null }) + .organization?.id; + if (!userOrgId) { + throw new Error( + "Cannot create project: current user has no organization", + ); + } + resolvedOrgId = userOrgId; + } + + return await client.createProject({ + name, + organizationId: resolvedOrgId, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects", "list"] }); + }, + onError: (error) => { + log.error("Failed to create project", error); + }, + }, + ); +} diff --git a/apps/code/src/renderer/features/posthog-projects/hooks/useUpdateProject.ts b/apps/code/src/renderer/features/posthog-projects/hooks/useUpdateProject.ts new file mode 100644 index 000000000..221d22867 --- /dev/null +++ b/apps/code/src/renderer/features/posthog-projects/hooks/useUpdateProject.ts @@ -0,0 +1,27 @@ +import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; +import type { Schemas } from "@renderer/api/generated"; +import { useQueryClient } from "@tanstack/react-query"; +import { logger } from "@utils/logger"; + +const log = logger.scope("posthog-projects"); + +export interface UpdateProjectInput { + projectId: number; + patch: { name?: string }; +} + +export function useUpdateProject() { + const queryClient = useQueryClient(); + + return useAuthenticatedMutation( + (client, { projectId, patch }) => client.updateProject(projectId, patch), + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects", "list"] }); + }, + onError: (error) => { + log.error("Failed to update project", error); + }, + }, + ); +} diff --git a/apps/code/src/renderer/features/preview/components/PreviewPanel.tsx b/apps/code/src/renderer/features/preview/components/PreviewPanel.tsx new file mode 100644 index 000000000..6655232e3 --- /dev/null +++ b/apps/code/src/renderer/features/preview/components/PreviewPanel.tsx @@ -0,0 +1,88 @@ +import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { ExternalLink, Link as LinkIcon, RotateCw } from "lucide-react"; +import { useCallback, useRef } from "react"; + +interface PreviewPanelProps { + url: string; + previewName: string; +} + +interface WebviewElement extends HTMLElement { + reload: () => void; + src: string; +} + +export function PreviewPanel({ url, previewName }: PreviewPanelProps) { + const webviewRef = useRef(null); + + const handleReload = useCallback(() => { + const node = webviewRef.current; + if (node && typeof node.reload === "function") { + node.reload(); + } + }, []); + + const handleCopyUrl = useCallback(() => { + void navigator.clipboard.writeText(url); + }, [url]); + + const handleOpenExternal = useCallback(() => { + void trpcClient.os.openExternal.mutate({ url }); + }, [url]); + + return ( + + + + + + + + + + + + + + + + + + + {previewName} – {url} + + +
+ } + src={url} + partition="preview" + className="size-full" + /> +
+
+ ); +} diff --git a/apps/code/src/renderer/features/preview/components/PreviewSubscriber.tsx b/apps/code/src/renderer/features/preview/components/PreviewSubscriber.tsx new file mode 100644 index 000000000..e2233df53 --- /dev/null +++ b/apps/code/src/renderer/features/preview/components/PreviewSubscriber.tsx @@ -0,0 +1,29 @@ +import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import { useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; + +/** + * Mounted at the top of the authenticated app shell. Subscribes once to + * `preview.onReady` and translates each event into a panel tab via + * `usePanelLayoutStore.addPreviewTab`. Renders nothing. + */ +export function PreviewSubscriber() { + const trpcReact = useTRPC(); + + useSubscription( + trpcReact.preview.onReady.subscriptionOptions(undefined, { + onData: (data) => { + usePanelLayoutStore + .getState() + .addPreviewTab(data.taskId, DEFAULT_PANEL_IDS.MAIN_PANEL, { + name: data.name, + url: data.url, + taskId: data.taskId, + }); + }, + }), + ); + + return null; +} diff --git a/apps/code/src/renderer/features/scratchpads/components/DraftTaskHeaderActions.tsx b/apps/code/src/renderer/features/scratchpads/components/DraftTaskHeaderActions.tsx new file mode 100644 index 000000000..c6936d87a --- /dev/null +++ b/apps/code/src/renderer/features/scratchpads/components/DraftTaskHeaderActions.tsx @@ -0,0 +1,49 @@ +import { PublishDialog } from "@features/scratchpads/components/PublishDialog"; +import { useDraftTaskIds } from "@features/scratchpads/hooks/useDraftTaskInfo"; +import { UploadSimpleIcon } from "@phosphor-icons/react"; +import { Button } from "@radix-ui/themes"; +import { useState } from "react"; + +interface DraftTaskHeaderActionsProps { + taskId: string; + taskTitle: string; +} + +/** + * Surfaces the Publish button when the active task is a draft scratchpad. + * Renders nothing if the task isn't a draft. + */ +export function DraftTaskHeaderActions({ + taskId, + taskTitle, +}: DraftTaskHeaderActionsProps) { + const draftTaskIds = useDraftTaskIds(); + const isDraft = draftTaskIds.has(taskId); + + const [dialogOpen, setDialogOpen] = useState(false); + + if (!isDraft) return null; + + return ( + <> + + {dialogOpen && ( + + )} + + ); +} diff --git a/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.test.tsx b/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.test.tsx new file mode 100644 index 000000000..19bbfe6e4 --- /dev/null +++ b/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.test.tsx @@ -0,0 +1,255 @@ +import { Theme } from "@radix-ui/themes"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Hoisted mocks ------------------------------------------------------ + +const mockSagaRun = vi.hoisted(() => vi.fn()); +const mockSagaCtor = vi.hoisted(() => vi.fn()); +const mockNavigateToTask = vi.hoisted(() => vi.fn()); +const mockConnectToTask = vi.hoisted(() => vi.fn()); +const mockToast = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); +const mockClient = vi.hoisted(() => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock("@renderer/sagas/scratchpad/scratchpad-creation", () => ({ + ScratchpadCreationSaga: class { + constructor(deps: unknown) { + mockSagaCtor(deps); + } + async run(input: unknown) { + return mockSagaRun(input); + } + }, +})); + +vi.mock("@hooks/useAuthenticatedClient", () => ({ + useAuthenticatedClient: () => mockClient, +})); + +vi.mock("@features/scratchpads/components/ProjectPicker", () => ({ + ProjectPicker: ({ + onChange, + }: { + value: number | null; + onChange: (id: number) => void; + }) => ( + + ), +})); + +vi.mock("@stores/navigationStore", () => ({ + useNavigationStore: (selector: (s: unknown) => unknown) => + selector({ navigateToTask: mockNavigateToTask }), +})); + +vi.mock("@features/sessions/service/service", () => ({ + getSessionService: () => ({ connectToTask: mockConnectToTask }), +})); + +vi.mock("@features/tasks/hooks/useTasks", () => ({ + useCreateTask: () => ({ invalidateTasks: vi.fn() }), +})); + +vi.mock("@utils/toast", () => ({ toast: mockToast })); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("@features/settings/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + lastUsedAdapter: "claude", + setLastUsedAdapter: vi.fn(), + allowBypassPermissions: false, + defaultInitialTaskMode: "last_used", + lastUsedInitialTaskMode: "plan", + }), +})); + +vi.mock("../../task-detail/hooks/usePreviewConfig", () => ({ + usePreviewConfig: () => ({ + configOptions: [], + modeOption: undefined, + modelOption: undefined, + thoughtOption: undefined, + isLoading: false, + setConfigOption: vi.fn(), + }), +})); + +vi.mock("@features/sessions/components/UnifiedModelSelector", () => ({ + UnifiedModelSelector: () =>
, +})); + +vi.mock("@features/sessions/components/ReasoningLevelSelector", () => ({ + ReasoningLevelSelector: () =>
, +})); + +vi.mock("@features/sessions/stores/sessionStore", () => ({ + getCurrentModeFromConfigOptions: () => undefined, +})); + +vi.mock("@features/message-editor/components/ModeSelector", () => ({ + ModeSelector: () =>
, +})); + +import { useScratchpadCreationStore } from "../stores/scratchpadCreationStore"; +// Imports after mocks so module-level refs pick them up. +import { ProductCreationDialog } from "./ProductCreationDialog"; + +function renderDialog() { + return render( + + + , + ); +} + +function fillRequiredFields() { + fireEvent.change(screen.getByPlaceholderText("Uber for dogs"), { + target: { value: "My Product" }, + }); + fireEvent.change( + screen.getByPlaceholderText(/Web app to get a dog delivered/i), + { + target: { value: "An idea" }, + }, + ); +} + +describe("ProductCreationDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + useScratchpadCreationStore.getState().reset(); + useScratchpadCreationStore.getState().openDialog(); + mockSagaRun.mockResolvedValue({ + success: true, + data: { + task: { id: "task-1" }, + workspace: {}, + scratchpadPath: "/sp", + projectId: 42, + initialPrompt: [{ type: "text", text: "scaffold" }], + }, + }); + mockConnectToTask.mockResolvedValue(undefined); + }); + + afterEach(() => { + useScratchpadCreationStore.getState().reset(); + }); + + it("renders the framing banner, name field, prompt input and project radios", () => { + renderDialog(); + expect( + screen.getByText(/Let's clarify, build, and deploy/), + ).toBeInTheDocument(); + expect(screen.getByText(/I'll run up to/)).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: /Clarification rounds/i }), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Uber for dogs")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/Web app to get a dog delivered/i), + ).toBeInTheDocument(); + expect( + screen.getByLabelText(/Set up on publish later/i), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/Use existing project/i)).toBeInTheDocument(); + expect(screen.getByTestId("model-selector")).toBeInTheDocument(); + expect(screen.getByTestId("reasoning-selector")).toBeInTheDocument(); + }); + + it("submit with default 'later' mode passes no projectId", async () => { + renderDialog(); + fillRequiredFields(); + + fireEvent.click(screen.getByRole("button", { name: /start building/i })); + + await waitFor(() => { + expect(mockSagaRun).toHaveBeenCalledTimes(1); + }); + + const input = mockSagaRun.mock.calls[0]?.[0]; + expect(input).toMatchObject({ + productName: "My Product", + initialIdea: "An idea", + rounds: 3, + }); + expect(input.projectId).toBeUndefined(); + }); + + it("submit with an existing project passes projectId", async () => { + renderDialog(); + fillRequiredFields(); + + fireEvent.click(screen.getByLabelText(/Use existing project/i)); + fireEvent.click(screen.getByTestId("project-picker")); + + fireEvent.click(screen.getByRole("button", { name: /start building/i })); + + await waitFor(() => { + expect(mockSagaRun).toHaveBeenCalledTimes(1); + }); + + const input = mockSagaRun.mock.calls[0]?.[0]; + expect(input).toMatchObject({ + productName: "My Product", + initialIdea: "An idea", + rounds: 3, + projectId: 42, + }); + }); + + it("submit is disabled when required fields are empty", () => { + renderDialog(); + expect( + screen.getByRole("button", { name: /start building/i }), + ).toBeDisabled(); + }); + + it("surfaces saga errors in the store and re-enables submit", async () => { + mockSagaRun.mockResolvedValueOnce({ + success: false, + error: "things broke", + failedStep: "task_creation", + }); + + renderDialog(); + fillRequiredFields(); + + fireEvent.click(screen.getByRole("button", { name: /start building/i })); + + await waitFor(() => { + expect(useScratchpadCreationStore.getState().lastError).toBe( + "things broke", + ); + }); + + expect(useScratchpadCreationStore.getState().step).toBe("idle"); + expect( + screen.getByRole("button", { name: /start building/i }), + ).not.toBeDisabled(); + expect(screen.getByRole("alert")).toHaveTextContent("things broke"); + }); +}); diff --git a/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.tsx b/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.tsx new file mode 100644 index 000000000..48d5eeb48 --- /dev/null +++ b/apps/code/src/renderer/features/scratchpads/components/ProductCreationDialog.tsx @@ -0,0 +1,504 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { ModeSelector } from "@features/message-editor/components/ModeSelector"; +import { ProjectPicker } from "@features/scratchpads/components/ProjectPicker"; +import { useScratchpadCreationStore } from "@features/scratchpads/stores/scratchpadCreationStore"; +import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; +import { getSessionService } from "@features/sessions/service/service"; +import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useCreateTask } from "@features/tasks/hooks/useTasks"; +import { useAuthenticatedClient } from "@hooks/useAuthenticatedClient"; +import { Sparkle } from "@phosphor-icons/react"; +import { RocketIcon } from "@radix-ui/react-icons"; +import { + Button, + Dialog, + Flex, + RadioGroup, + SegmentedControl, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import { ScratchpadCreationSaga } from "@renderer/sagas/scratchpad/scratchpad-creation"; +import type { ExecutionMode } from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewConfig } from "../../task-detail/hooks/usePreviewConfig"; + +const log = logger.scope("product-creation-dialog"); + +const MIN_ROUNDS = 1; +const MAX_ROUNDS = 4; +const ROUND_OPTIONS: number[] = Array.from( + { length: MAX_ROUNDS }, + (_, i) => i + 1, +); +const DEFAULT_ROUNDS = 3; + +type ProjectMode = "later" | "existing"; + +export function ProductCreationDialog() { + const open = useScratchpadCreationStore((s) => s.open); + const step = useScratchpadCreationStore((s) => s.step); + const lastError = useScratchpadCreationStore((s) => s.lastError); + const closeDialog = useScratchpadCreationStore((s) => s.closeDialog); + const setStep = useScratchpadCreationStore((s) => s.setStep); + const setError = useScratchpadCreationStore((s) => s.setError); + const reset = useScratchpadCreationStore((s) => s.reset); + + const posthogClient = useAuthenticatedClient(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + const { invalidateTasks } = useCreateTask(); + + const { lastUsedAdapter, setLastUsedAdapter, allowBypassPermissions } = + useSettingsStore(); + const adapter = lastUsedAdapter ?? "claude"; + + const { + modeOption, + modelOption, + thoughtOption, + isLoading: isPreviewLoading, + setConfigOption, + } = usePreviewConfig(adapter); + + const menuPortalRef = useRef(null); + + const [productName, setProductName] = useState(""); + const [initialIdea, setInitialIdea] = useState(""); + const [rounds, setRounds] = useState(DEFAULT_ROUNDS); + const [projectMode, setProjectMode] = useState("later"); + const [selectedProjectId, setSelectedProjectId] = useState( + null, + ); + + const isSubmitting = step === "submitting"; + + const trimmedName = productName.trim(); + const trimmedIdea = initialIdea.trim(); + const projectChoiceValid = + projectMode === "later" || selectedProjectId !== null; + const canSubmit = + !isSubmitting && + trimmedName.length > 0 && + trimmedIdea.length > 0 && + projectChoiceValid; + + const handleModeChange = useCallback( + (value: string) => { + if (modeOption) setConfigOption(modeOption.id, value); + }, + [modeOption, setConfigOption], + ); + const handleModelChange = useCallback( + (value: string) => { + if (modelOption) setConfigOption(modelOption.id, value); + }, + [modelOption, setConfigOption], + ); + const handleThoughtChange = useCallback( + (value: string) => { + if (thoughtOption) setConfigOption(thoughtOption.id, value); + }, + [thoughtOption, setConfigOption], + ); + + // Scratchpads always default to "auto" regardless of the user's New-task + // mode preference — the agent is doing scaffolding from scratch and needs + // to run things without per-step approval. + const SCRATCHPAD_DEFAULT_MODE: ExecutionMode = "auto"; + const currentExecutionMode: ExecutionMode = + getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? + SCRATCHPAD_DEFAULT_MODE; + + // Force the mode option's value to "auto" once per dialog open. The + // preview-config hook seeds modeOption.currentValue from the user's + // saved preference; we override that here so the dialog always starts + // in "auto", but the user can still pick something else from the + // selector after the override has fired. + const initialModeApplied = useRef(false); + useEffect(() => { + if (!open) { + initialModeApplied.current = false; + return; + } + if (initialModeApplied.current) return; + if (!modeOption || modeOption.type !== "select") return; + initialModeApplied.current = true; + if (modeOption.currentValue !== SCRATCHPAD_DEFAULT_MODE) { + setConfigOption(modeOption.id, SCRATCHPAD_DEFAULT_MODE); + } + }, [open, modeOption, setConfigOption]); + const currentModel = + modelOption?.type === "select" ? modelOption.currentValue : undefined; + const currentReasoningLevel = + thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; + + const resetForm = () => { + setProductName(""); + setInitialIdea(""); + setRounds(DEFAULT_ROUNDS); + setProjectMode("later"); + setSelectedProjectId(null); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + if (isSubmitting) return; + closeDialog(); + resetForm(); + reset(); + } + }; + + const handleSubmit = async () => { + if (!canSubmit) return; + + setError(null); + setStep("submitting"); + + try { + let projectId: number | undefined; + if (projectMode === "existing") { + if (selectedProjectId === null) { + throw new Error("Please pick a PostHog project"); + } + projectId = selectedProjectId; + } + + const saga = new ScratchpadCreationSaga({ posthogClient }); + + const result = await saga.run({ + productName: trimmedName, + initialIdea: trimmedIdea, + rounds: clampRounds(rounds), + adapter, + executionMode: currentExecutionMode, + ...(currentModel ? { model: currentModel } : {}), + ...(currentReasoningLevel + ? { reasoningLevel: currentReasoningLevel } + : {}), + ...(projectId !== undefined ? { projectId } : {}), + }); + + if (!result.success) { + log.error("Scratchpad creation failed", { + failedStep: result.failedStep, + error: result.error, + }); + setError(result.error ?? "Failed to create app"); + setStep("idle"); + return; + } + + // Prime the tasks-list cache so the sidebar shows the new task + // immediately, then navigate while the dialog is still open as a + // loading curtain — closing first reveals the empty TaskInput + // underneath for a frame before navigation lands on task-detail. + invalidateTasks(result.data.task); + await navigateToTask(result.data.task); + + void getSessionService() + .connectToTask({ + task: result.data.task, + repoPath: result.data.scratchpadPath, + initialPrompt: result.data.initialPrompt, + adapter, + executionMode: currentExecutionMode, + ...(currentModel ? { model: currentModel } : {}), + ...(currentReasoningLevel + ? { reasoningLevel: currentReasoningLevel } + : {}), + }) + .catch((err) => { + log.error("Agent session failed to connect after scaffold", { err }); + toast.error("Couldn't start the agent", { + description: + err instanceof Error ? err.message : "Unknown connection error.", + }); + }); + + closeDialog(); + reset(); + resetForm(); + } catch (error) { + log.error("Scratchpad creation threw", { error }); + const message = + error instanceof Error ? error.message : "Failed to create app"; + setError(message); + setStep("idle"); + } + }; + + if (isSubmitting) { + return ( + {}}> + + + + + Preparing your app + + + + + + ); + } + + return ( + + + + + + + Create a new app + + + + + + + + Let's clarify, build, and deploy + + + + I'll run up to{" "} + setRounds(clampRounds(Number(v)))} + disabled={isSubmitting} + aria-label="Clarification rounds" + className="!h-[20px] mx-1 inline-flex align-middle text-[12px]" + > + {ROUND_OPTIONS.map((n) => ( + + {n} + + ))} + {" "} + {rounds === 1 ? "round" : "rounds"} of clarifying questions, then + build it with a live preview, then help you deploy. PostHog wired + up from the start. + + + + + + What are we calling it? + + setProductName(e.target.value)} + placeholder="Uber for dogs" + size="2" + disabled={isSubmitting} + autoFocus + /> + + + + + What would you like me to build? + +