diff --git a/packages/desktop/src/main/__tests__/project.test.ts b/packages/desktop/src/main/__tests__/project.test.ts index 115c8eb..4b15c12 100644 --- a/packages/desktop/src/main/__tests__/project.test.ts +++ b/packages/desktop/src/main/__tests__/project.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -388,4 +388,253 @@ describe("project tools", () => { devServers.stopAll(); rmSync(dataDir, { recursive: true, force: true }); }); + + it("registers project_setup_detect and project_retry_dev_server tools", () => { + const { tools, devServers, dataDir } = makeTools(); + expect(tools["project_setup_detect"], "missing project_setup_detect").toBeTruthy(); + expect( + tools["project_retry_dev_server"], + "missing project_retry_dev_server", + ).toBeTruthy(); + devServers.stopAll(); + rmSync(dataDir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// ProjectService.setupDetect +// --------------------------------------------------------------------------- + +describe("ProjectService.setupDetect", () => { + let ctx: ReturnType; + + beforeEach(() => { + ctx = makeCtx(); + }); + + afterEach(() => { + ctx.devServers.stopAll(); + rmSync(ctx.dataDir, { recursive: true, force: true }); + }); + + it("returns all base detection fields plus setup-specific fields", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-setup-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "setup-test", + dependencies: { next: "15.0.0" }, + scripts: { dev: "next dev", build: "next build" }, + }), + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), ""); + + const result = ctx.projects.setupDetect(dir); + + expect(result.name).toBe("setup-test"); + expect(result.framework).toBe("nextjs"); + expect(result.packageManager).toBe("pnpm"); + expect(result.hasPackageJson).toBe(true); + expect(result.scripts.map((s) => s.name)).toContain("dev"); + expect(result.envFiles).toBeInstanceOf(Array); + expect(result.isMonorepo).toBe(false); + expect(result.workspaces).toEqual([]); + expect(result.likelyPort).toBe(3000); + expect(typeof result.setupNotes).toBe("string"); + expect(result.setupNotes).toContain("setup-test"); + expect(result.setupNotes).toContain("nextjs"); + expect(result.setupNotes).toContain("3000"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("detects .env files in the project root", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-env-")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "envtest" })); + writeFileSync(join(dir, ".env"), "FOO=bar"); + writeFileSync(join(dir, ".env.local"), "SECRET=xyz"); + + const result = ctx.projects.setupDetect(dir); + expect(result.envFiles).toContain(".env"); + expect(result.envFiles).toContain(".env.local"); + expect(result.setupNotes).toContain(".env"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("reports no env files when none exist", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-noenv-")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "noenv" })); + + const result = ctx.projects.setupDetect(dir); + expect(result.envFiles).toEqual([]); + expect(result.setupNotes).toContain("No .env files found"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("detects a pnpm monorepo and enumerates workspace packages", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-mono-")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "monorepo-root" })); + writeFileSync(join(dir, "pnpm-lock.yaml"), ""); + writeFileSync( + join(dir, "pnpm-workspace.yaml"), + "packages:\n - 'packages/*'\n", + ); + const pkgDir = join(dir, "packages", "web"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "@mono/web", + dependencies: { next: "15.0.0" }, + scripts: { dev: "next dev" }, + }), + ); + + const result = ctx.projects.setupDetect(dir); + expect(result.isMonorepo).toBe(true); + expect(result.workspaces).toHaveLength(1); + expect(result.workspaces[0].name).toBe("@mono/web"); + expect(result.workspaces[0].dir).toBe(join("packages", "web")); + expect(result.workspaces[0].framework).toBe("nextjs"); + expect(result.setupNotes).toContain("Monorepo: yes"); + expect(result.setupNotes).toContain("@mono/web"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("extracts a port from a --port flag in the dev script", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-port-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "custom-port", + scripts: { dev: "vite --port 4200" }, + }), + ); + + const result = ctx.projects.setupDetect(dir); + expect(result.likelyPort).toBe(4200); + expect(result.setupNotes).toContain("4200"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("falls back to framework default port when no --port flag is present", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-defaultport-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "astro-app", + dependencies: { astro: "4.0.0" }, + scripts: { dev: "astro dev" }, + }), + ); + + const result = ctx.projects.setupDetect(dir); + expect(result.likelyPort).toBe(4321); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("throws a ProjectError for a missing directory", () => { + expect(() => ctx.projects.setupDetect("/definitely/missing/xyz")).toThrow( + ProjectError, + ); + }); +}); + +// --------------------------------------------------------------------------- +// ProjectService.retryDevServer +// --------------------------------------------------------------------------- + +describe("ProjectService.retryDevServer", () => { + let ctx: ReturnType; + + beforeEach(() => { + ctx = makeCtx(); + }); + + afterEach(() => { + ctx.devServers.stopAll(); + rmSync(ctx.dataDir, { recursive: true, force: true }); + }); + + it("stops the existing errored server and starts a fresh one, returning the log tail", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-retry-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "retry-app", scripts: { dev: "node server.mjs" } }), + ); + writeFileSync(join(dir, "yarn.lock"), ""); + const project = ctx.projects.open({ cwd: dir }).project; + + const fakeErroredServer = { + id: "dev_errored", + cwd: dir, + status: "errored" as const, + command: "yarn", + args: ["dev"], + startedAt: Date.now() - 5000, + pid: null, + port: null, + exitCode: 1, + signal: null, + name: "retry-app:dev", + }; + const fakeNewServer = { id: "dev_fresh", cwd: dir, status: "starting" as const } as never; + + // findByCwd is called twice inside retryDevServer: once for candidates, once for + // "stop all before restart". Both times it should return the errored server. + const findSpy = vi + .spyOn(ctx.devServers, "findByCwd") + .mockReturnValue([fakeErroredServer]); + const stopSpy = vi.spyOn(ctx.devServers, "stop").mockReturnValue(true); + const logSpy = vi + .spyOn(ctx.devServers, "getLogs") + .mockReturnValue([{ seq: 0, stream: "stderr", text: "error: port in use", ts: Date.now() }]); + // start() is called exactly once (for the fresh server) because open() was called + // before the mocks were applied. + const startSpy = vi.spyOn(ctx.devServers, "start").mockReturnValue(fakeNewServer); + + const result = ctx.projects.retryDevServer(project.id, { logTailLines: 10 }); + + expect(stopSpy).toHaveBeenCalledWith("dev_errored"); + expect(logSpy).toHaveBeenCalledWith("dev_errored", 10); + expect(result.previousLogsTail).toHaveLength(1); + expect(result.previousLogsTail[0].text).toContain("error: port in use"); + expect(result.devServer.id).toBe("dev_fresh"); + + startSpy.mockRestore(); + findSpy.mockRestore(); + stopSpy.mockRestore(); + logSpy.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("starts a fresh server and returns an empty tail when no previous server exists", () => { + const dir = mkdtempSync(join(tmpdir(), "meith-retry-fresh-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "fresh-retry", scripts: { dev: "node s.mjs" } }), + ); + const project = ctx.projects.open({ cwd: dir }).project; + + const fakeServer = { id: "dev_new", cwd: dir, status: "starting" as const } as never; + const spy = vi.spyOn(ctx.devServers, "start").mockReturnValue(fakeServer); + const findSpy = vi.spyOn(ctx.devServers, "findByCwd").mockReturnValue([]); + + const result = ctx.projects.retryDevServer(project.id); + expect(result.previousLogsTail).toEqual([]); + expect(result.devServer.id).toBe("dev_new"); + + spy.mockRestore(); + findSpy.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("throws a ProjectError for an unknown projectId", () => { + expect(() => ctx.projects.retryDevServer("proj_unknown")).toThrow(ProjectError); + }); }); diff --git a/packages/desktop/src/main/services/ProjectService.ts b/packages/desktop/src/main/services/ProjectService.ts index be4bdcc..84860a3 100644 --- a/packages/desktop/src/main/services/ProjectService.ts +++ b/packages/desktop/src/main/services/ProjectService.ts @@ -9,10 +9,11 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { basename, isAbsolute, join, resolve } from "node:path"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { type DevServer, type PackageManager, + type ProcessLogEntry, type Project, type ProjectFramework, type ProjectKind, @@ -37,6 +38,59 @@ export interface ProjectDetection { hasPackageJson: boolean; } +/** Monorepo workspace entry found inside a project root. */ +export interface WorkspaceEntry { + /** Package name from its package.json (or the directory name as fallback). */ + name: string; + /** Directory path relative to the project root. */ + dir: string; + framework: ProjectFramework; + scripts: ProjectScript[]; +} + +/** + * Extended detection result produced by `setupDetect()`. Adds env-file + * discovery, monorepo topology, likely dev-server port, and a prose + * `setupNotes` summary — enough for an agent to build a `RunConfig` without + * reading the project itself. + */ +export interface ProjectSetupDetection extends ProjectDetection { + /** `.env*` files found in the project root (relative paths). */ + envFiles: string[]; + /** + * Whether the root looks like a monorepo (pnpm-workspace.yaml, workspaces + * field in package.json, turbo.json, or nx.json present). + */ + isMonorepo: boolean; + /** + * Workspace packages discovered inside a monorepo root. Empty for + * non-monorepos or when no packages could be parsed. + */ + workspaces: WorkspaceEntry[]; + /** + * Best-guess listening port for the primary dev server, derived from + * framework defaults or a `PORT` / `--port` hint found in the dev script. + * Null when no reasonable guess is possible. + */ + likelyPort: number | null; + /** + * Human-readable prose summary an agent can store as `runConfig.setupNotes` + * and surface to the user or feed into a retry prompt. + */ + setupNotes: string; +} + +/** + * Result returned by `retryDevServer()`: the newly started dev server plus the + * tail of logs from the previous (failed) server to help the agent diagnose the + * failure. + */ +export interface RetryDevServerResult { + devServer: DevServer; + /** Last N log lines from the server that was stopped (for diagnosis). */ + previousLogsTail: ProcessLogEntry[]; +} + /** Static information about an available project template. */ export interface TemplateInfo { name: string; @@ -149,6 +203,90 @@ export class ProjectService { }; } + /** + * Extended project inspection for the "setup mode" agent flow. + * + * Builds on `detect()` and additionally: + * - lists `.env*` files in the root, + * - detects monorepo topology (pnpm-workspace.yaml / turbo / nx / workspaces + * field) and enumerates workspace packages, + * - guesses the most likely dev-server port from framework defaults or a + * `PORT`/`--port` hint embedded in the dev script command, and + * - produces a prose `setupNotes` summary suitable for storing on + * `runConfig.setupNotes`. + * + * Like `detect()` this is a pure read — nothing is persisted. + */ + setupDetect(cwd: string): ProjectSetupDetection { + const dir = normalizeCwd(cwd); + const base = this.detect(dir); + + // Env files --------------------------------------------------------------- + const envFiles = detectEnvFiles(dir); + + // Monorepo topology ------------------------------------------------------- + const { isMonorepo, workspaces } = detectMonorepo(dir, base.packageManager); + + // Likely port ------------------------------------------------------------- + const devScript = pickDevScript(base.scripts); + const likelyPort = guessPort(base.framework, devScript?.command ?? null); + + // Prose notes ------------------------------------------------------------- + const setupNotes = buildSetupNotes({ + dir, + base, + envFiles, + isMonorepo, + workspaces, + likelyPort, + devScript, + }); + + return { ...base, envFiles, isMonorepo, workspaces, likelyPort, setupNotes }; + } + + /** + * Stop the most recent failed/errored/exited dev server for a project, + * capture its log tail for diagnosis, and immediately start a fresh one. + * + * This is the retry entry-point an agent calls after `get_process_logs` + * reveals a startup failure — it bundles "capture logs → stop → restart" + * into a single atomic tool call so the agent only needs to deal with one + * round-trip. + */ + retryDevServer( + projectId: string, + opts: { scriptName?: string; logTailLines?: number } = {}, + ): RetryDevServerResult { + const project = this.get(projectId); + if (!project) throw new ProjectError(`Unknown project: ${projectId}`); + + const tailLines = opts.logTailLines ?? 50; + + // Find the most recent non-running server for this project. + const candidates = this.devServers + .findByCwd(project.cwd) + .filter((s) => s.status === "errored" || s.status === "exited") + .sort((a, b) => b.startedAt - a.startedAt); + + const previous = candidates[0] ?? null; + const previousLogsTail: ProcessLogEntry[] = previous + ? this.devServers.getLogs(previous.id, tailLines) + : []; + + // Stop every existing server for this project before starting the new one. + for (const server of this.devServers.findByCwd(project.cwd)) { + this.devServers.stop(server.id); + } + + const devServer = this.startDevServer(projectId, opts.scriptName); + this.logger.info( + "Project", + `retried dev server for ${project.name} (previous: ${previous?.id ?? "none"})`, + ); + return { devServer, previousLogsTail }; + } + // ---- Open flow --------------------------------------------------------- /** @@ -680,6 +818,194 @@ function runScriptCommand( } } +// --------------------------------------------------------------------------- +// Setup-detect helpers +// --------------------------------------------------------------------------- + +const FRAMEWORK_DEFAULT_PORTS: Partial> = { + nextjs: 3000, + vite: 5173, + react: 3000, + vue: 5173, + svelte: 5173, + astro: 4321, + remix: 3000, + node: 3000, +}; + +const ENV_FILE_NAMES = [ + ".env", + ".env.local", + ".env.development", + ".env.development.local", + ".env.production", + ".env.production.local", + ".env.test", + ".env.test.local", +]; + +/** Collect env files that actually exist in `dir`. */ +function detectEnvFiles(dir: string): string[] { + return ENV_FILE_NAMES.filter((f) => existsSync(join(dir, f))); +} + +/** Glob-expand a pnpm/yarn/npm workspaces pattern against `dir`. */ +function expandWorkspaceGlob(dir: string, pattern: string): string[] { + // We only handle the common non-glob forms ("packages/*") and explicit paths + // to avoid pulling in a glob dependency. Patterns ending with `/*` are + // expanded by listing the parent directory. + if (pattern.endsWith("/*")) { + const parent = join(dir, pattern.slice(0, -2)); + if (!existsSync(parent)) return []; + try { + return readdirSync(parent, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => join(parent, e.name)); + } catch { + return []; + } + } + // Treat it as a literal path. + const resolved = join(dir, pattern); + return existsSync(resolved) ? [resolved] : []; +} + +/** Detect monorepo topology and enumerate workspace packages. */ +function detectMonorepo( + dir: string, + pm: PackageManager, +): { isMonorepo: boolean; workspaces: WorkspaceEntry[] } { + const pkg = readPackageJson(dir); + const hasTurbo = existsSync(join(dir, "turbo.json")); + const hasNx = existsSync(join(dir, "nx.json")); + const hasPnpmWorkspace = existsSync(join(dir, "pnpm-workspace.yaml")); + const hasWorkspacesField = + pkg && Array.isArray((pkg as Record).workspaces); + + const isMonorepo = hasTurbo || hasNx || hasPnpmWorkspace || hasWorkspacesField; + if (!isMonorepo) return { isMonorepo: false, workspaces: [] }; + + // Collect raw glob patterns from the config files. + let patterns: string[] = []; + if (hasPnpmWorkspace) { + // Parse pnpm-workspace.yaml minimally: look for "packages:" lines. + try { + const yaml = readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8"); + const pkgLines = yaml + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("-")) + .map((l) => l.replace(/^-\s*['"]?/, "").replace(/['"]?\s*$/, "")); + patterns = pkgLines.length ? pkgLines : ["packages/*"]; + } catch { + patterns = ["packages/*"]; + } + } else if (hasWorkspacesField) { + const ws = (pkg as Record).workspaces; + if (Array.isArray(ws)) patterns = (ws as unknown[]).map(String); + } else { + // turbo/nx without an explicit workspaces field — best-effort guess. + patterns = ["packages/*", "apps/*"]; + } + + const dirs = patterns.flatMap((p) => expandWorkspaceGlob(dir, p)); + const workspaces: WorkspaceEntry[] = []; + for (const wDir of dirs) { + const wPkg = readPackageJson(wDir); + const wScripts: ProjectScript[] = wPkg?.scripts + ? Object.entries(wPkg.scripts).map(([name, command]) => ({ + name, + command: String(command), + })) + : []; + workspaces.push({ + name: + (typeof wPkg?.name === "string" && wPkg.name) || + relative(dir, wDir) || + basename(wDir), + dir: relative(dir, wDir), + framework: detectFramework(wDir, wPkg), + scripts: wScripts, + }); + } + + return { isMonorepo: true, workspaces }; +} + +/** Extract a port from a script command string (--port N or PORT=N). */ +function extractPortFromCommand(command: string): number | null { + const portFlag = /--port[=\s]+(\d{2,5})\b/.exec(command); + if (portFlag) { + const p = Number(portFlag[1]); + if (p >= 1 && p <= 65535) return p; + } + const portEnv = /\bPORT[=\s]+(\d{2,5})\b/.exec(command); + if (portEnv) { + const p = Number(portEnv[1]); + if (p >= 1 && p <= 65535) return p; + } + return null; +} + +/** Best-effort guess at the primary dev server port. */ +function guessPort(framework: ProjectFramework, devCommand: string | null): number | null { + if (devCommand) { + const extracted = extractPortFromCommand(devCommand); + if (extracted !== null) return extracted; + } + return FRAMEWORK_DEFAULT_PORTS[framework] ?? null; +} + +/** Build the prose setup notes string. */ +function buildSetupNotes(input: { + dir: string; + base: ProjectDetection; + envFiles: string[]; + isMonorepo: boolean; + workspaces: WorkspaceEntry[]; + likelyPort: number | null; + devScript: ProjectScript | null; +}): string { + const { dir, base, envFiles, isMonorepo, workspaces, likelyPort, devScript } = input; + const lines: string[] = []; + + lines.push(`Project: ${base.name}`); + lines.push(`Directory: ${dir}`); + lines.push(`Framework: ${base.framework}`); + lines.push(`Package manager: ${base.packageManager}`); + + if (devScript) { + const { command: cmd, args } = runScriptCommand(base.packageManager, devScript.name); + lines.push(`Dev command: ${cmd} ${args.join(" ")} (script: "${devScript.command}")`); + } else if (base.scripts.length > 0) { + lines.push(`No dev/start script found. Available scripts: ${base.scripts.map((s) => s.name).join(", ")}`); + } else { + lines.push("No runnable scripts found in package.json."); + } + + if (likelyPort !== null) { + lines.push(`Likely port: ${likelyPort}`); + } + + if (envFiles.length > 0) { + lines.push(`Env files: ${envFiles.join(", ")}`); + } else { + lines.push("No .env files found."); + } + + if (isMonorepo) { + lines.push(`Monorepo: yes (${workspaces.length} workspace package${workspaces.length !== 1 ? "s" : ""})`); + for (const ws of workspaces) { + const devS = pickDevScript(ws.scripts); + lines.push(` - ${ws.name} (${ws.dir}): framework=${ws.framework}${devS ? `, dev="${devS.command}"` : ""}`); + } + } else { + lines.push("Monorepo: no"); + } + + return lines.join("\n"); +} + function normalizeCwd(cwd: string): string { let p = cwd.trim(); if (p === "~") p = homedir(); diff --git a/packages/desktop/src/main/tools/projectTools.ts b/packages/desktop/src/main/tools/projectTools.ts index 1181699..ac069f5 100644 --- a/packages/desktop/src/main/tools/projectTools.ts +++ b/packages/desktop/src/main/tools/projectTools.ts @@ -185,6 +185,48 @@ export function createProjectTools(deps: ToolDeps): ToolDefinition[] { withProjectErrors(() => okResult(projects.allocatePrewarmed(input))), }); + const projectSetupDetect = defineTool({ + name: "project_setup_detect", + description: + "Extended project inspection for the setup-mode agent flow. Returns everything in project_detect plus: env files present in the root, monorepo topology (workspaces list), best-guess dev-server port, and a prose setupNotes summary. Use this to build or verify a project's run configuration without opening it.", + capabilities: ["read-only"], + inputSchema: z.object({ + cwd: z.string().min(1).describe("Directory to inspect."), + }), + execute: (_ctx, input) => + withProjectErrors(() => okResult(projects.setupDetect(input.cwd))), + }); + + const projectRetryDevServer = defineTool({ + name: "project_retry_dev_server", + description: + "Stop the most recent failed/exited dev server for a project, capture its log tail for diagnosis, and immediately start a fresh one. Use this after inspecting process logs that show a startup failure — it bundles capture→stop→restart into a single call and returns both the new server record and the previous logs tail.", + capabilities: ["starts-process", "accesses-network"], + inputSchema: z.object({ + projectId: z.string(), + scriptName: z + .string() + .optional() + .describe("Override which package.json script to run; defaults to dev/start."), + logTailLines: z + .number() + .int() + .positive() + .max(500) + .optional() + .describe("How many trailing log lines to capture from the previous server (default 50)."), + }), + execute: (_ctx, input) => + withProjectErrors(() => + okResult( + projects.retryDevServer(input.projectId, { + scriptName: input.scriptName, + logTailLines: input.logTailLines, + }), + ), + ), + }); + return [ projectList, projectDetect, @@ -199,6 +241,8 @@ export function createProjectTools(deps: ToolDeps): ToolDefinition[] { projectPrewarm, projectPrewarmStatus, projectAllocate, + projectSetupDetect, + projectRetryDevServer, ]; } diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index f2e4402..af93fe8 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -228,6 +228,13 @@ export const ProjectRunConfigSchema = z.object({ defaultCommandId: z.string().nullable().default(null), /** Extra environment variables injected when running a command. */ env: z.record(z.string(), z.string()).default({}), + /** + * Human-readable notes produced by the setup-detect flow describing how the + * project should be started, any env files found, detected workspace layout, + * etc. Stored here so agents and the UI can surface them alongside the run + * commands without requiring a separate inspect pass. + */ + setupNotes: z.string().optional(), }); export type ProjectRunConfig = z.infer;