Unverified 提交 300f0ac2 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

feat: DyadError kinds and PostHog IPC exception filtering (#3063)

## Summary - Add `DyadError` / `DyadErrorKind` in `src/errors/dyad_error.ts` for classified failures (validation, not found, auth, precondition, conflict, user cancel, rate limit, etc.). - Extend `shouldFilterTelemetryException` so filtered kinds are not sent as PostHog `$exception` events; preserve legacy filters (RateLimitError 429, exact Supabase message set). - `safe_handle` rethrows `DyadError` unchanged; typed handler invalid input uses `Validation`. - Migrate many IPC/main/git/supabase/local-agent call sites from `throw new Error` to `DyadError` with appropriate kinds. - Document in `AGENTS.md`, `rules/dyad-errors.md`, and `.cursor/rules/ipc.mdc`. ## Test plan - `npm run ts`, `npm run lint`, `npm test` (946 tests) passed locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Made with [Cursor](https://cursor.com) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3063" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 cb446418
......@@ -67,6 +67,7 @@ The pattern involves a client-side React hook interacting with main process IPC
* Contains the core business logic, interacting with databases (e.g., `db`), file system (`fs`), or other main-process services (e.g., `git`).
* **Error Handling (Crucial):**
* **Handlers MUST `throw new Error("Descriptive error message")` when an operation fails or an invalid state is encountered.** This is the preferred pattern over returning objects like `{ success: false, errorMessage: "..." }`.
* For **non-bug** failures (validation, not found, auth, user refusal), use **`DyadError`** with **`DyadErrorKind`** (`src/errors/dyad_error.ts`) so PostHog does not treat them as `$exception` floods — see `rules/dyad-errors.md`.
* **Concurrency (If Applicable):**
* For operations that modify shared resources related to a specific entity (like an `appId`), use a locking mechanism (e.g., `withLock(appId, async () => { ... })`) to prevent race conditions.
......
......@@ -11,6 +11,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi
| File | Read when... |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [rules/electron-ipc.md](rules/electron-ipc.md) | Adding/modifying IPC endpoints, handlers, React Query hooks, or renderer-to-main communication |
| [rules/dyad-errors.md](rules/dyad-errors.md) | Classifying IPC/main errors with `DyadError` / `DyadErrorKind` and PostHog exception filtering |
| [rules/local-agent-tools.md](rules/local-agent-tools.md) | Adding/modifying local agent tools, tool flags (`modifiesState`), or read-only/plan-only guards |
| [rules/e2e-testing.md](rules/e2e-testing.md) | Writing or debugging E2E tests (Playwright, Base UI radio clicks, Lexical editor, test fixtures) |
| [rules/git-workflow.md](rules/git-workflow.md) | Pushing branches, creating PRs, or dealing with fork/upstream remotes |
......@@ -87,6 +88,7 @@ This is the only supported way to type-check the project. It uses the correct co
- This is an Electron application with a secure IPC boundary.
- Frontend is a React app that uses TanStack Router (not Next.js or React Router).
- Data fetching/mutations should be handled with TanStack Query when touching IPC-backed endpoints.
- Main-process IPC errors that are **not bugs** (validation, missing entities, auth, user refusal, etc.) should be thrown as **`DyadError`** with a **`DyadErrorKind`** so they can be excluded from PostHog exception telemetry. See [rules/dyad-errors.md](rules/dyad-errors.md).
## Verifying your changes
......
# DyadError and telemetry
Use `DyadError` from `src/errors/dyad_error.ts` when throwing from **main process / IPC handlers** (or code only called from there) for failures that are **not product bugs**: validation, missing entities, auth/setup prerequisites, user refusal, conflicts, rate limits, etc.
## API
- **`DyadErrorKind`** — enum classifying the failure.
- **`new DyadError(message, kind)`**`error.name` is `"DyadError"`; use `error.kind` for branching.
- **`isDyadError(error)`** — type guard.
## Telemetry (PostHog `$exception`)
`sendTelemetryException` in `src/ipc/utils/telemetry.ts` calls `shouldFilterTelemetryException`, which **does not send** exceptions for:
| Kind | Use for |
| --------------- | ----------------------------------------------------------------------------------- |
| `Validation` | Invalid input, limits, malformed URLs, Zod-style client mistakes surfaced as errors |
| `NotFound` | App/chat/plan/file missing, stale IDs |
| `Auth` | Not signed in, missing token, GitHub not linked |
| `Precondition` | Wrong state for the operation (e.g. feature not installed, sandbox/path rules) |
| `Conflict` | Duplicates, git working-tree conflicts, push rejected — user/environment fixable |
| `UserCancelled` | User declined a tool or similar explicit refusal |
| `RateLimited` | Quota / 429-style limits (also see legacy `RateLimitError` handling) |
**Always sent** (actionable or unknown): `External`, `Internal`, `Unknown`.
Prefer **`DyadError`** over growing `FILTERED_EXCEPTION_MESSAGES` in `telemetry.ts` when the failure is stable and classified.
## IPC handlers
- **`createTypedHandler` / `createLoggedTypedHandler`** rethrow the original error after telemetry — `DyadError` is preserved.
- **`createLoggedHandler` (`safe_handle.ts`)** rethrows `DyadError` unchanged so the renderer keeps `instanceof DyadError`.
## Migration
Most IPC/main paths and shared utilities (`git_utils`, Supabase admin, local agent tools, etc.) now use **`DyadError`** with an appropriate kind. Remaining `throw new Error(...)` are usually **dynamic** messages (`throw new Error(err.message || …)`), **multi-line** throws, or **renderer** code where telemetry filtering is less critical.
**Do not** import `DyadError` inside preload (`src/preload.ts`) without verifying the preload bundle; preload continues to use plain `Error` for invalid channels.
**Legacy:** `FILTERED_EXCEPTION_MESSAGES` and `RateLimitError` (429) handling in `telemetry.ts` remain for any plain `Error` paths not yet migrated.
## Automation pitfalls
- When auto-inserting `import { DyadError, DyadErrorKind } from "@/errors/dyad_error"`, **never** place it inside another `import { ... }` block — it must be its own import statement or TypeScript fails with “Identifier expected” at the next line.
- Automated line-based migrations must **not** match strings inside **test fixtures** (e.g. template literals that embed sample source code); that can inject imports into fake file content.
......@@ -71,6 +71,7 @@ writeSettings({
## Handler expectations
- Handlers should `throw new Error("...")` on failure instead of returning `{ success: false }` style payloads.
- For **non-bug** failures (validation, not found, auth, user refusal, etc.), prefer `DyadError` with the right `DyadErrorKind` so PostHog does not flood with `$exception` events — see [rules/dyad-errors.md](dyad-errors.md).
- Use `createTypedHandler(contract, handler)` which validates inputs at runtime via Zod.
## React Query key factory
......
......@@ -16,6 +16,7 @@ import fs from "node:fs";
import { db } from "../db";
import { cleanFullResponse } from "../ipc/utils/cleanFullResponse";
import { gitAdd, gitRemove, gitCommit } from "../ipc/utils/git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// Mock fs with default export
vi.mock("node:fs", async () => {
......@@ -723,7 +724,7 @@ describe("processFullResponse", () => {
it("should handle file system errors gracefully", async () => {
// Set up the mock to throw an error on mkdirSync
vi.mocked(fs.mkdirSync).mockImplementationOnce(() => {
throw new Error("Mock filesystem error");
throw new DyadError("Mock filesystem error", DyadErrorKind.Internal);
});
const response = `<dyad-write path="src/error-file.js">This will fail</dyad-write>`;
......
......@@ -302,6 +302,7 @@ vi.mock("@/ipc/handlers/compaction/compaction_handler", () => ({
// ============================================================================
import { handleLocalAgentStream } from "@/pro/main/ipc/handlers/local_agent/local_agent_handler";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// ============================================================================
// Tests
......@@ -1378,7 +1379,7 @@ describe("handleLocalAgentStream", () => {
yield { type: "text-delta", text: "Partial response" };
abortController.abort();
// This will not be processed due to abort
throw new Error("Simulated abort error");
throw new DyadError("Simulated abort error", DyadErrorKind.Internal);
})(),
response: Promise.resolve({ messages: [] }),
};
......
......@@ -10,6 +10,7 @@ import {
} from "@/main/settings";
import { getUserDataPath } from "@/paths/paths";
import { UserSettings } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// Mock dependencies
vi.mock("node:fs");
......@@ -442,7 +443,7 @@ describe("readSettings", () => {
it("should return default settings when file read fails", () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockImplementation(() => {
throw new Error("File read error");
throw new DyadError("File read error", DyadErrorKind.External);
});
const result = readSettings();
......@@ -524,7 +525,7 @@ describe("readSettings", () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
mockSafeStorage.decryptString.mockImplementation(() => {
throw new Error("Decryption failed");
throw new DyadError("Decryption failed", DyadErrorKind.External);
});
const result = readSettings();
......
import { describe, expect, it } from "vitest";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { shouldFilterTelemetryException } from "@/ipc/utils/telemetry";
describe("shouldFilterTelemetryException", () => {
......@@ -35,4 +36,33 @@ describe("shouldFilterTelemetryException", () => {
),
).toBe(false);
});
it("filters DyadError kinds that are non-actionable for telemetry", () => {
expect(
shouldFilterTelemetryException(
new DyadError("bad input", DyadErrorKind.Validation),
),
).toBe(true);
expect(
shouldFilterTelemetryException(
new DyadError("missing", DyadErrorKind.NotFound),
),
).toBe(true);
});
it("does not filter DyadError Internal, External, or Unknown", () => {
expect(
shouldFilterTelemetryException(
new DyadError("bug", DyadErrorKind.Internal),
),
).toBe(false);
expect(
shouldFilterTelemetryException(
new DyadError("upstream", DyadErrorKind.External),
),
).toBe(false);
expect(
shouldFilterTelemetryException(new DyadError("?", DyadErrorKind.Unknown)),
).toBe(false);
});
});
......@@ -4,6 +4,7 @@ import { app } from "electron";
import * as crypto from "crypto";
import log from "electron-log";
import Database from "better-sqlite3";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("backup_manager");
......@@ -167,7 +168,10 @@ export class BackupManager {
} catch (cleanupError) {
logger.error("Failed to clean up backup directory:", cleanupError);
}
throw new Error(`Backup creation failed: ${error}`);
throw new DyadError(
`Backup creation failed: ${error}`,
DyadErrorKind.External,
);
}
}
......@@ -263,7 +267,10 @@ export class BackupManager {
logger.info(`Deleted backup: ${backupName}`);
} catch (error) {
logger.error(`Failed to delete backup ${backupName}:`, error);
throw new Error(`Failed to delete backup: ${error}`);
throw new DyadError(
`Failed to delete backup: ${error}`,
DyadErrorKind.External,
);
}
}
......
/**
* Classified application errors for IPC/main-process code.
* Use {@link DyadError} with a {@link DyadErrorKind} so telemetry can ignore
* high-volume, non-actionable failures (see `shouldFilterTelemetryException`).
*/
export enum DyadErrorKind {
Validation = "validation",
NotFound = "not_found",
Auth = "auth",
Precondition = "precondition",
Conflict = "conflict",
UserCancelled = "user_cancelled",
RateLimited = "rate_limited",
/** Upstream failures; reported to PostHog by default unless you add finer metadata later. */
External = "external",
/** Bugs, invariant violations, unexpected failures — always reported. */
Internal = "internal",
/** Unclassified; treated as reportable until call sites are migrated. */
Unknown = "unknown",
}
const TELEMETRY_FILTERED_KINDS: ReadonlySet<DyadErrorKind> = new Set([
DyadErrorKind.Validation,
DyadErrorKind.NotFound,
DyadErrorKind.Auth,
DyadErrorKind.Precondition,
DyadErrorKind.Conflict,
DyadErrorKind.UserCancelled,
DyadErrorKind.RateLimited,
]);
/**
* Returns true if this kind should not be sent to PostHog as an `$exception` event.
*/
export function isDyadErrorKindFilteredFromTelemetry(
kind: DyadErrorKind,
): boolean {
return TELEMETRY_FILTERED_KINDS.has(kind);
}
export class DyadError extends Error {
readonly kind: DyadErrorKind;
constructor(message: string, kind: DyadErrorKind) {
super(message);
this.name = "DyadError";
this.kind = kind;
}
}
export function isDyadError(error: unknown): error is DyadError {
return error instanceof DyadError;
}
......@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { ipc, type ProblemReport } from "@/ipc/types";
import { useSettings } from "./useSettings";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useCheckProblems(appId: number | null) {
const { settings } = useSettings();
......@@ -14,7 +15,7 @@ export function useCheckProblems(appId: number | null) {
queryKey: queryKeys.problems.byApp({ appId }),
queryFn: async (): Promise<ProblemReport> => {
if (!appId) {
throw new Error("App ID is required");
throw new DyadError("App ID is required", DyadErrorKind.Validation);
}
return ipc.misc.checkProblems({ appId });
},
......
......@@ -3,6 +3,7 @@ import { ipc } from "@/ipc/types";
import { useSetAtom } from "jotai";
import { activeCheckoutCounterAtom } from "@/store/appAtoms";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
interface CheckoutVersionVariables {
appId: number;
......@@ -18,7 +19,10 @@ export function useCheckoutVersion() {
mutationFn: async ({ appId, versionId }) => {
if (appId === null) {
// Should be caught by UI logic before calling, but as a safeguard.
throw new Error("App ID is null, cannot checkout version.");
throw new DyadError(
"App ID is null, cannot checkout version.",
DyadErrorKind.External,
);
}
setActiveCheckouts((prev) => prev + 1); // Increment counter
try {
......
......@@ -3,6 +3,7 @@ import { ipc } from "@/ipc/types";
import type { CreateAppParams, CreateAppResult } from "@/ipc/types";
import { showError } from "@/lib/toast";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useCreateApp() {
const queryClient = useQueryClient();
......@@ -10,7 +11,7 @@ export function useCreateApp() {
const mutation = useMutation<CreateAppResult, Error, CreateAppParams>({
mutationFn: async (params: CreateAppParams) => {
if (!params.name.trim()) {
throw new Error("App name is required");
throw new DyadError("App name is required", DyadErrorKind.Validation);
}
return ipc.app.createApp(params);
......
import { ipc, type BranchResult } from "@/ipc/types";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useCurrentBranch(appId: number | null) {
const {
......@@ -13,7 +14,10 @@ export function useCurrentBranch(appId: number | null) {
if (appId === null) {
// This case should ideally be handled by the `enabled` option
// but as a safeguard, and to ensure queryFn always has a valid appId if called.
throw new Error("appId is null, cannot fetch current branch.");
throw new DyadError(
"appId is null, cannot fetch current branch.",
DyadErrorKind.External,
);
}
return ipc.version.getCurrentBranch({ appId });
},
......
......@@ -6,6 +6,7 @@ import {
} from "@/ipc/types";
import { showError } from "@/lib/toast";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useCustomLanguageModelProvider() {
const queryClient = useQueryClient();
......@@ -15,13 +16,22 @@ export function useCustomLanguageModelProvider() {
params: CreateCustomLanguageModelProviderParams,
): Promise<LanguageModelProvider> => {
if (!params.id.trim()) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
if (!params.name.trim()) {
throw new Error("Provider name is required");
throw new DyadError(
"Provider name is required",
DyadErrorKind.Validation,
);
}
if (!params.apiBaseUrl.trim()) {
throw new Error("API base URL is required");
throw new DyadError(
"API base URL is required",
DyadErrorKind.Validation,
);
}
return ipc.languageModel.createCustomProvider({
......@@ -47,13 +57,22 @@ export function useCustomLanguageModelProvider() {
params: CreateCustomLanguageModelProviderParams,
): Promise<LanguageModelProvider> => {
if (!params.id.trim()) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
if (!params.name.trim()) {
throw new Error("Provider name is required");
throw new DyadError(
"Provider name is required",
DyadErrorKind.Validation,
);
}
if (!params.apiBaseUrl.trim()) {
throw new Error("API base URL is required");
throw new DyadError(
"API base URL is required",
DyadErrorKind.Validation,
);
}
return ipc.languageModel.editCustomProvider({
......@@ -77,7 +96,10 @@ export function useCustomLanguageModelProvider() {
const deleteProviderMutation = useMutation({
mutationFn: async (providerId: string): Promise<void> => {
if (!providerId) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
return ipc.languageModel.deleteCustomProvider({ providerId });
......
......@@ -4,6 +4,7 @@ import { showError } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useAtomValue } from "jotai";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
interface RenameBranchParams {
appId: number;
......@@ -18,13 +19,22 @@ export function useRenameBranch() {
const mutation = useMutation<void, Error, RenameBranchParams>({
mutationFn: async (params: RenameBranchParams) => {
if (params.appId === null || params.appId === undefined) {
throw new Error("App ID is required to rename a branch.");
throw new DyadError(
"App ID is required to rename a branch.",
DyadErrorKind.Validation,
);
}
if (!params.oldBranchName) {
throw new Error("Old branch name is required.");
throw new DyadError(
"Old branch name is required.",
DyadErrorKind.Validation,
);
}
if (!params.newBranchName) {
throw new Error("New branch name is required.");
throw new DyadError(
"New branch name is required.",
DyadErrorKind.Validation,
);
}
await ipc.app.renameBranch(params);
},
......
import { useQuery } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useSecurityReview(appId: number | null) {
return useQuery({
queryKey: queryKeys.securityReview.byApp({ appId }),
queryFn: async () => {
if (!appId) {
throw new Error("App ID is required");
throw new DyadError("App ID is required", DyadErrorKind.Validation);
}
return ipc.security.getLatestSecurityReview(appId);
},
......
import { ipc, type UncommittedFile } from "@/ipc/types";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export type { UncommittedFile };
......@@ -13,7 +14,10 @@ export function useUncommittedFiles(appId: number | null) {
queryKey: queryKeys.uncommittedFiles.byApp({ appId }),
queryFn: async (): Promise<UncommittedFile[]> => {
if (appId === null) {
throw new Error("appId is null, cannot fetch uncommitted files.");
throw new DyadError(
"appId is null, cannot fetch uncommitted files.",
DyadErrorKind.Conflict,
);
}
return ipc.git.getUncommittedFiles({ appId });
},
......
......@@ -7,6 +7,7 @@ import { chatMessagesByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { toast } from "sonner";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function useVersions(appId: number | null) {
const [, setVersionsAtom] = useAtom(versionsListAtom);
......@@ -55,7 +56,7 @@ export function useVersions(appId: number | null) {
}) => {
const currentAppId = appId;
if (currentAppId === null) {
throw new Error("App ID is null");
throw new DyadError("App ID is null", DyadErrorKind.External);
}
return ipc.version.revertVersion({
appId: currentAppId,
......
......@@ -15,6 +15,7 @@ import {
} from "../utils/app_env_var_utils";
import { createTypedHandler } from "./base";
import { miscContracts } from "../types/misc";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function registerAppEnvVarsHandlers() {
// Handler to get app environment variables
......@@ -25,7 +26,7 @@ export function registerAppEnvVarsHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -60,7 +61,7 @@ export function registerAppEnvVarsHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......
......@@ -67,6 +67,7 @@ import {
MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS,
} from "../utils/ripgrep_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
......@@ -503,7 +504,10 @@ RUN npm install -g pnpm
await fsPromises.writeFile(dockerfilePath, dockerfileContent, "utf-8");
} catch (error) {
logger.error(`Failed to create Dockerfile for app ${appId}:`, error);
throw new Error(`Failed to create Dockerfile: ${error}`);
throw new DyadError(
`Failed to create Dockerfile: ${error}`,
DyadErrorKind.External,
);
}
}
......@@ -802,7 +806,10 @@ export function registerAppHandlers() {
const appPath = params.name;
const fullAppPath = getDyadAppPath(appPath);
if (fs.existsSync(fullAppPath)) {
throw new Error(`App already exists at: ${fullAppPath}`);
throw new DyadError(
`App already exists at: ${fullAppPath}`,
DyadErrorKind.Conflict,
);
}
// Create a new app
const [app] = await db
......@@ -862,7 +869,10 @@ export function registerAppHandlers() {
});
if (existingApp) {
throw new Error(`An app named "${newAppName}" already exists.`);
throw new DyadError(
`An app named "${newAppName}" already exists.`,
DyadErrorKind.Conflict,
);
}
// 2. Find the original app
......@@ -871,7 +881,7 @@ export function registerAppHandlers() {
});
if (!originalApp) {
throw new Error("Original app not found.");
throw new DyadError("Original app not found.", DyadErrorKind.NotFound);
}
const originalAppPath = getDyadAppPath(originalApp.path);
......@@ -892,7 +902,10 @@ export function registerAppHandlers() {
);
} catch (error) {
logger.error("Failed to copy app directory:", error);
throw new Error("Failed to copy app directory.");
throw new DyadError(
"Failed to copy app directory.",
DyadErrorKind.External,
);
}
if (!withHistory) {
......@@ -935,7 +948,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
// Get app files
......@@ -1001,7 +1014,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -1009,11 +1022,11 @@ export function registerAppHandlers() {
// Check if the path is within the app directory (security check)
if (!fullPath.startsWith(appPath)) {
throw new Error("Invalid file path");
throw new DyadError("Invalid file path", DyadErrorKind.Validation);
}
if (!fs.existsSync(fullPath)) {
throw new Error("File not found");
throw new DyadError("File not found", DyadErrorKind.NotFound);
}
try {
......@@ -1021,7 +1034,7 @@ export function registerAppHandlers() {
return contents;
} catch (error) {
logger.error(`Error reading file ${filePath} for app ${appId}:`, error);
throw new Error("Failed to read file");
throw new DyadError("Failed to read file", DyadErrorKind.External);
}
});
......@@ -1060,7 +1073,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
logger.debug(`Starting app ${appId} in path ${app.path}`);
......@@ -1088,7 +1101,10 @@ export function registerAppHandlers() {
) {
runningApps.delete(appId);
}
throw new Error(`Failed to run app ${appId}: ${error.message}`);
throw new DyadError(
`Failed to run app ${appId}: ${error.message}`,
DyadErrorKind.External,
);
}
});
});
......@@ -1136,7 +1152,10 @@ export function registerAppHandlers() {
);
// Attempt cleanup even if an error occurred during the stop process
removeAppIfCurrentProcess(appId, process);
throw new Error(`Failed to stop app ${appId}: ${error.message}`);
throw new DyadError(
`Failed to stop app ${appId}: ${error.message}`,
DyadErrorKind.External,
);
}
});
});
......@@ -1167,7 +1186,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -1240,7 +1259,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -1248,7 +1267,7 @@ export function registerAppHandlers() {
// Check if the path is within the app directory (security check)
if (!fullPath.startsWith(appPath)) {
throw new Error("Invalid file path");
throw new DyadError("Invalid file path", DyadErrorKind.Validation);
}
if (app.neonProjectId && app.neonDevelopmentBranchId) {
......@@ -1283,7 +1302,10 @@ export function registerAppHandlers() {
}
} catch (error: any) {
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
throw new Error(`Failed to write file: ${error.message}`);
throw new DyadError(
`Failed to write file: ${error.message}`,
DyadErrorKind.External,
);
}
if (app.supabaseProjectId) {
......@@ -1346,7 +1368,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
// Stop the app if it's running
......@@ -1370,7 +1392,10 @@ export function registerAppHandlers() {
// Note: Associated chats will cascade delete
} catch (error: any) {
logger.error(`Error deleting app ${appId} from database:`, error);
throw new Error(`Failed to delete app from database: ${error.message}`);
throw new DyadError(
`Failed to delete app from database: ${error.message}`,
DyadErrorKind.External,
);
}
// Delete app files
......@@ -1398,7 +1423,10 @@ export function registerAppHandlers() {
.limit(1);
if (result.length === 0) {
throw new Error(`App with ID ${appId} not found.`);
throw new DyadError(
`App with ID ${appId} not found.`,
DyadErrorKind.NotFound,
);
}
const currentIsFavorite = result[0].isFavorite;
......@@ -1423,7 +1451,10 @@ export function registerAppHandlers() {
`Error in add-to-favorite handler for app ID ${appId}:`,
error,
);
throw new Error(`Failed to toggle favorite status: ${error.message}`);
throw new DyadError(
`Failed to toggle favorite status: ${error.message}`,
DyadErrorKind.External,
);
}
});
});
......@@ -1438,7 +1469,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const pathChanged = appPath !== app.path;
......@@ -1471,7 +1502,10 @@ export function registerAppHandlers() {
});
if (nameConflict && nameConflict.id !== appId) {
throw new Error(`An app with the name '${appName}' already exists`);
throw new DyadError(
`An app with the name '${appName}' already exists`,
DyadErrorKind.Conflict,
);
}
// If the current path is absolute, preserve the directory and only change the folder name
......@@ -1493,7 +1527,10 @@ export function registerAppHandlers() {
}
if (hasPathConflict) {
throw new Error(`An app with the path '${newAppPath}' already exists`);
throw new DyadError(
`An app with the path '${newAppPath}' already exists`,
DyadErrorKind.Conflict,
);
}
// Stop the app if it's running
......@@ -1516,7 +1553,10 @@ export function registerAppHandlers() {
try {
// Check if destination directory already exists
if (fs.existsSync(newAppPath)) {
throw new Error(`Destination path '${newAppPath}' already exists`);
throw new DyadError(
`Destination path '${newAppPath}' already exists`,
DyadErrorKind.Conflict,
);
}
// Create parent directory if it doesn't exist
......@@ -1547,7 +1587,10 @@ export function registerAppHandlers() {
);
}
}
throw new Error(`Failed to move app files: ${error.message}`);
throw new DyadError(
`Failed to move app files: ${error.message}`,
DyadErrorKind.External,
);
}
try {
......@@ -1596,7 +1639,10 @@ export function registerAppHandlers() {
}
logger.error(`Error updating app ${appId} in database:`, error);
throw new Error(`Failed to update app in database: ${error.message}`);
throw new DyadError(
`Failed to update app in database: ${error.message}`,
DyadErrorKind.External,
);
}
});
});
......@@ -1666,7 +1712,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -1676,7 +1722,10 @@ export function registerAppHandlers() {
// Check if the old branch exists
const branches = await gitListBranches({ path: appPath });
if (!branches.includes(oldBranchName)) {
throw new Error(`Branch '${oldBranchName}' not found.`);
throw new DyadError(
`Branch '${oldBranchName}' not found.`,
DyadErrorKind.NotFound,
);
}
// Check if the new branch name already exists
......@@ -1712,18 +1761,27 @@ export function registerAppHandlers() {
createTypedHandler(appContracts.respondToAppInput, async (_, params) => {
const { appId, response } = params;
if (response !== "y" && response !== "n") {
throw new Error(`Invalid response: ${response}`);
throw new DyadError(
`Invalid response: ${response}`,
DyadErrorKind.Validation,
);
}
const appInfo = runningApps.get(appId);
if (!appInfo) {
throw new Error(`App ${appId} is not running`);
throw new DyadError(
`App ${appId} is not running`,
DyadErrorKind.External,
);
}
const { process } = appInfo;
if (!process.stdin) {
throw new Error(`App ${appId} process has no stdin available`);
throw new DyadError(
`App ${appId} process has no stdin available`,
DyadErrorKind.External,
);
}
try {
......@@ -1732,7 +1790,10 @@ export function registerAppHandlers() {
logger.debug(`Sent response '${response}' to app ${appId} stdin`);
} catch (error: any) {
logger.error(`Error sending response to app ${appId}:`, error);
throw new Error(`Failed to send response to app: ${error.message}`);
throw new DyadError(
`Failed to send response to app: ${error.message}`,
DyadErrorKind.External,
);
}
});
......@@ -1748,7 +1809,7 @@ export function registerAppHandlers() {
});
if (!appRecord) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(appRecord.path);
......@@ -1887,7 +1948,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const trimmedInstall = installCommand?.trim() || null;
......@@ -1915,11 +1976,17 @@ export function registerAppHandlers() {
const { appId, parentDirectory } = params;
if (!parentDirectory) {
throw new Error("No destination folder provided.");
throw new DyadError(
"No destination folder provided.",
DyadErrorKind.External,
);
}
if (!path.isAbsolute(parentDirectory)) {
throw new Error("Please select an absolute destination folder.");
throw new DyadError(
"Please select an absolute destination folder.",
DyadErrorKind.External,
);
}
const normalizedParentDir = path.normalize(parentDirectory);
......@@ -1930,7 +1997,7 @@ export function registerAppHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const currentResolvedPath = getDyadAppPath(app.path);
......@@ -1993,7 +2060,10 @@ export function registerAppHandlers() {
await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before moving:`, error);
throw new Error(`Failed to stop app before moving: ${error.message}`);
throw new DyadError(
`Failed to stop app before moving: ${error.message}`,
DyadErrorKind.External,
);
}
}
......@@ -2045,7 +2115,10 @@ export function registerAppHandlers() {
`Error moving app files from ${currentResolvedPath} to ${nextResolvedPath}:`,
error,
);
throw new Error(`Failed to move app files: ${error.message}`);
throw new DyadError(
`Failed to move app files: ${error.message}`,
DyadErrorKind.External,
);
}
});
});
......
......@@ -10,6 +10,7 @@ import path from "node:path";
import { spawn } from "node:child_process";
import { gitAddAll, gitCommit } from "../utils/git_utils";
import { simpleSpawn } from "../utils/simpleSpawn";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export const logger = log.scope("app_upgrade_handlers");
const handle = createLoggedHandler(logger);
......@@ -36,7 +37,10 @@ async function getApp(appId: number) {
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with id ${appId} not found`);
throw new DyadError(
`App with id ${appId} not found`,
DyadErrorKind.NotFound,
);
}
return app;
}
......@@ -103,7 +107,10 @@ async function applyComponentTagger(appPath: string) {
} else if (fs.existsSync(viteConfigPathJs)) {
viteConfigPath = viteConfigPathJs;
} else {
throw new Error("Could not find vite.config.js or vite.config.ts");
throw new DyadError(
"Could not find vite.config.js or vite.config.ts",
DyadErrorKind.External,
);
}
let content = await fs.promises.readFile(viteConfigPath, "utf-8");
......@@ -273,7 +280,7 @@ export function registerAppUpgradeHandlers() {
"execute-app-upgrade",
async (_, { appId, upgradeId }: { appId: number; upgradeId: string }) => {
if (!upgradeId) {
throw new Error("upgradeId is required");
throw new DyadError("upgradeId is required", DyadErrorKind.Validation);
}
const app = await getApp(appId);
......@@ -284,7 +291,10 @@ export function registerAppUpgradeHandlers() {
} else if (upgradeId === "capacitor") {
await applyCapacitor({ appName: app.name, appPath });
} else {
throw new Error(`Unknown upgrade id: ${upgradeId}`);
throw new DyadError(
`Unknown upgrade id: ${upgradeId}`,
DyadErrorKind.External,
);
}
},
);
......
import { ipcMain, IpcMainInvokeEvent } from "electron";
import { z } from "zod";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type { IpcContract } from "../contracts/core";
import { sendTelemetryException } from "../utils/telemetry";
......@@ -35,7 +36,10 @@ export function createTypedHandler<
const errorMessage = parsed.error.issues
.map((e) => `${e.path.join(".")}: ${e.message}`)
.join("; ");
throw new Error(`[${contract.channel}] Invalid input: ${errorMessage}`);
throw new DyadError(
`[${contract.channel}] Invalid input: ${errorMessage}`,
DyadErrorKind.Validation,
);
}
let result: z.infer<TOutput>;
......@@ -98,8 +102,9 @@ export function createLoggedTypedHandler(logger: {
const errorMessage = parsed.error.issues
.map((e) => `${e.path.join(".")}: ${e.message}`)
.join("; ");
const error = new Error(
const error = new DyadError(
`[${contract.channel}] Invalid input: ${errorMessage}`,
DyadErrorKind.Validation,
);
logger.error(`[${contract.channel}] Invalid input`, error);
throw error;
......
......@@ -9,6 +9,7 @@ import { simpleSpawn } from "../utils/simpleSpawn";
import { IS_TEST_BUILD } from "../utils/test_utils";
import { createTypedHandler } from "./base";
import { capacitorContracts } from "../types/capacitor";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("capacitor_handlers");
......@@ -17,7 +18,10 @@ async function getApp(appId: number) {
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with id ${appId} not found`);
throw new DyadError(
`App with id ${appId} not found`,
DyadErrorKind.NotFound,
);
}
return app;
}
......@@ -60,7 +64,10 @@ export function registerCapacitorHandlers() {
const appPath = getDyadAppPath(app.path);
if (!isCapacitorInstalled(appPath)) {
throw new Error("Capacitor is not installed in this app");
throw new DyadError(
"Capacitor is not installed in this app",
DyadErrorKind.Precondition,
);
}
await simpleSpawn({
......@@ -87,7 +94,10 @@ export function registerCapacitorHandlers() {
const appPath = getDyadAppPath(app.path);
if (!isCapacitorInstalled(appPath)) {
throw new Error("Capacitor is not installed in this app");
throw new DyadError(
"Capacitor is not installed in this app",
DyadErrorKind.Precondition,
);
}
if (IS_TEST_BUILD) {
......@@ -109,7 +119,10 @@ export function registerCapacitorHandlers() {
const appPath = getDyadAppPath(app.path);
if (!isCapacitorInstalled(appPath)) {
throw new Error("Capacitor is not installed in this app");
throw new DyadError(
"Capacitor is not installed in this app",
DyadErrorKind.Precondition,
);
}
if (IS_TEST_BUILD) {
......
......@@ -4,6 +4,7 @@ import { desc, eq, and, like } from "drizzle-orm";
import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getDyadAppPath } from "../../paths/paths";
import { getCurrentCommitHash } from "../utils/git_utils";
import { createTypedHandler } from "./base";
......@@ -22,7 +23,7 @@ export function registerChatHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
let initialCommitHash = null;
......@@ -66,7 +67,7 @@ export function registerChatHandlers() {
});
if (!chat) {
throw new Error("Chat not found");
throw new DyadError("Chat not found", DyadErrorKind.NotFound);
}
return {
......
......@@ -31,6 +31,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
CodebaseFile,
extractCodebase,
......@@ -251,7 +252,10 @@ export function registerChatStreamHandlers() {
});
if (!chat) {
throw new Error(`Chat not found: ${req.chatId}`);
throw new DyadError(
`Chat not found: ${req.chatId}`,
DyadErrorKind.NotFound,
);
}
// Handle redo option: remove the most recent messages if needed
......@@ -561,7 +565,10 @@ ${componentSnippet}
});
if (!updatedChat) {
throw new Error(`Chat not found: ${req.chatId}`);
throw new DyadError(
`Chat not found: ${req.chatId}`,
DyadErrorKind.NotFound,
);
}
// Send the messages right away so that the loading state is shown for the message.
......@@ -1975,7 +1982,11 @@ async function getMcpTools(event: IpcMainInvokeEvent): Promise<ToolSet> {
inputPreview,
});
if (!ok) throw new Error(`User declined running tool ${key}`);
if (!ok)
throw new DyadError(
`User declined running tool ${key}`,
DyadErrorKind.UserCancelled,
);
const res = await mcpTool.execute(args, execCtx);
return typeof res === "string" ? res : JSON.stringify(res);
......
......@@ -13,6 +13,7 @@ import log from "electron-log";
import { getDyadAppPath } from "@/paths/paths";
import { extractCodebase } from "@/utils/codebase";
import { validateChatContext } from "../utils/context_paths_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("context_paths_handlers");
const handle = createLoggedHandler(logger);
......@@ -28,11 +29,11 @@ export function registerContextPathsHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
if (!app.path) {
throw new Error("App path not set");
throw new DyadError("App path not set", DyadErrorKind.Precondition);
}
const appPath = getDyadAppPath(app.path);
......
......@@ -6,6 +6,7 @@ import { gitClone, getCurrentCommitHash } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { getTemplateOrThrow } from "../utils/template_utils";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("createFromTemplate");
......@@ -27,7 +28,10 @@ export async function createFromTemplate({
const template = await getTemplateOrThrow(templateId);
if (!template.githubUrl) {
throw new Error(`Template ${templateId} has no GitHub URL`);
throw new DyadError(
`Template ${templateId} has no GitHub URL`,
DyadErrorKind.External,
);
}
const repoCachePath = await cloneRepo(template.githubUrl);
await copyRepoToApp(repoCachePath, fullAppPath);
......@@ -36,10 +40,16 @@ export async function createFromTemplate({
async function cloneRepo(repoUrl: string): Promise<string> {
const url = new URL(repoUrl);
if (url.protocol !== "https:") {
throw new Error("Repository URL must use HTTPS.");
throw new DyadError(
"Repository URL must use HTTPS.",
DyadErrorKind.External,
);
}
if (url.hostname !== "github.com") {
throw new Error("Repository URL must be a github.com URL.");
throw new DyadError(
"Repository URL must be a github.com URL.",
DyadErrorKind.Validation,
);
}
// Pathname will be like "/org/repo" or "/org/repo.git"
......@@ -97,7 +107,10 @@ async function cloneRepo(repoUrl: string): Promise<string> {
const commitData = await response.json();
const remoteSha = commitData.sha;
if (!remoteSha) {
throw new Error("SHA not found in GitHub API response.");
throw new DyadError(
"SHA not found in GitHub API response.",
DyadErrorKind.NotFound,
);
}
logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
......
......@@ -25,6 +25,7 @@ import {
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths";
import { validateChatContext } from "../utils/context_paths_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// Shared function to get system debug info
async function getSystemDebugInfo({
......@@ -312,7 +313,10 @@ export function registerDebugHandlers() {
});
if (!chatRecord) {
throw new Error(`Chat with ID ${chatId} not found`);
throw new DyadError(
`Chat with ID ${chatId} not found`,
DyadErrorKind.NotFound,
);
}
// Get app data from database
......@@ -321,7 +325,10 @@ export function registerDebugHandlers() {
});
if (!app) {
throw new Error(`App with ID ${chatRecord.appId} not found`);
throw new DyadError(
`App with ID ${chatRecord.appId} not found`,
DyadErrorKind.NotFound,
);
}
// Query custom providers, custom models, and MCP servers in parallel
......@@ -455,7 +462,10 @@ export function registerDebugHandlers() {
const image = await win.capturePage();
// Validate image
if (!image || image.isEmpty()) {
throw new Error("Failed to capture screenshot");
throw new DyadError(
"Failed to capture screenshot",
DyadErrorKind.External,
);
}
// Write the image to the clipboard
clipboard.writeImage(image);
......
......@@ -5,6 +5,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { executeAddDependency } from "../processors/executeAddDependency";
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("dependency_handlers");
const handle = createLoggedHandler(logger);
......@@ -27,7 +28,7 @@ export function registerDependencyHandlers() {
});
if (!chat) {
throw new Error(`Chat ${chatId} not found`);
throw new DyadError(`Chat ${chatId} not found`, DyadErrorKind.NotFound);
}
// Get the app using the appId from the chat
......@@ -36,7 +37,10 @@ export function registerDependencyHandlers() {
});
if (!app) {
throw new Error(`App for chat ${chatId} not found`);
throw new DyadError(
`App for chat ${chatId} not found`,
DyadErrorKind.NotFound,
);
}
const message = [...foundMessages]
......
import { IpcMainInvokeEvent } from "electron";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { readSettings } from "../../main/settings";
import {
gitMergeAbort,
......@@ -44,7 +45,7 @@ async function handleAbortMerge(
{ appId }: GitBranchAppIdParams,
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
await gitMergeAbort({ path: appPath });
......@@ -58,11 +59,14 @@ async function handleFetchFromGithub(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const appPath = getDyadAppPath(app.path);
......@@ -80,10 +84,16 @@ async function handleCreateBranch(
): Promise<void> {
// Validate branch name
if (!branch || branch.length === 0 || branch.length > 255) {
throw new Error("Branch name must be between 1 and 255 characters");
throw new DyadError(
"Branch name must be between 1 and 255 characters",
DyadErrorKind.Validation,
);
}
if (!/^[a-zA-Z0-9/_.-]+$/.test(branch) || /\.\./.test(branch)) {
throw new Error("Branch name contains invalid characters");
throw new DyadError(
"Branch name contains invalid characters",
DyadErrorKind.Validation,
);
}
if (
branch.startsWith("-") ||
......@@ -94,10 +104,10 @@ async function handleCreateBranch(
branch.endsWith("/") ||
branch.includes("@{")
) {
throw new Error("Invalid branch name");
throw new DyadError("Invalid branch name", DyadErrorKind.Validation);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
await gitCreateBranch({
......@@ -112,7 +122,7 @@ export async function handleDeleteBranch(
{ appId, branch }: GitBranchParams,
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
// Check if branch exists locally
......@@ -136,8 +146,9 @@ export async function handleDeleteBranch(
`Failed to list remote branches while checking for branch '${branch}' to delete.`,
error,
);
throw new Error(
throw new DyadError(
`Branch '${branch}' does not exist locally and remote branches could not be checked. Please try again later.`,
DyadErrorKind.Conflict,
);
}
......@@ -151,12 +162,14 @@ export async function handleDeleteBranch(
// Branch only exists remotely - inform user they need to delete it on GitHub
if (app.githubOrg && app.githubRepo) {
throw new Error(
throw new DyadError(
`Branch '${branch}' only exists on the remote. To delete it, please delete the branch on GitHub directly. Visit https://github.com/${app.githubOrg}/${app.githubRepo}/branches to manage remote branches.`,
DyadErrorKind.Conflict,
);
}
throw new Error(
throw new DyadError(
`Branch '${branch}' only exists on the remote and cannot be deleted locally. Please delete it from your remote Git hosting provider.`,
DyadErrorKind.Conflict,
);
}
}
......@@ -166,7 +179,7 @@ async function handleSwitchBranch(
{ appId, branch }: GitBranchParams,
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
// Check for merge or rebase in progress before attempting to switch
......@@ -203,9 +216,10 @@ async function handleSwitchBranch(
lowerMessage.includes("would be overwritten") ||
lowerMessage.includes("please commit or stash")
) {
throw new Error(
throw new DyadError(
`Failed to switch branch: uncommitted changes detected. ` +
"Please commit or stash your changes manually and try again.",
DyadErrorKind.Conflict,
);
}
throw checkoutError;
......@@ -225,7 +239,7 @@ async function handleRenameBranch(
{ appId, oldBranch, newBranch }: RenameGitBranchParams,
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
// Check if we're renaming the current branch BEFORE renaming to avoid race conditions
......@@ -250,10 +264,10 @@ async function handleRenameBranch(
}
}
// Custom error class for merge conflicts
class MergeConflictError extends Error {
// Custom error class for merge conflicts (name kept for UI checks)
class MergeConflictError extends DyadError {
constructor(message: string) {
super(message);
super(message, DyadErrorKind.Conflict);
this.name = "MergeConflictError";
}
}
......@@ -263,7 +277,7 @@ async function handleMergeBranch(
{ appId, branch }: GitBranchParams,
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
// Check if branch exists locally, if not, check if it's a remote branch
......@@ -308,9 +322,10 @@ async function handleMergeBranch(
lowerMessage.includes("would be overwritten") ||
lowerMessage.includes("please commit or stash")
) {
throw new Error(
throw new DyadError(
`Failed to merge branch: uncommitted changes detected. ` +
"Please commit or stash your changes manually and try again.",
DyadErrorKind.Conflict,
);
}
......@@ -324,7 +339,7 @@ async function handleListLocalBranches(
{ appId }: GitBranchAppIdParams,
): Promise<{ branches: string[]; current: string | null }> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
const branches = await gitListBranches({ path: appPath });
......@@ -337,7 +352,7 @@ async function handleListRemoteBranches(
{ appId, remote = "origin" }: { appId: number; remote?: string },
): Promise<string[]> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
const branches = await gitListRemoteBranches({ path: appPath, remote });
......@@ -349,7 +364,7 @@ async function handleGetUncommittedFiles(
{ appId }: GitBranchAppIdParams,
): Promise<UncommittedFile[]> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
return getGitUncommittedFilesWithStatus({ path: appPath });
......@@ -360,7 +375,7 @@ async function handleCommitChanges(
{ appId, message }: { appId: number; message: string },
): Promise<string> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path);
return withLock(appId, async () => {
......@@ -397,11 +412,14 @@ async function handlePullFromGithub(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const appPath = getDyadAppPath(app.path);
const currentBranch = await gitCurrentBranch({ path: appPath });
......
......@@ -38,6 +38,7 @@ import { withLock } from "../utils/lock_utils";
import { createTypedHandler } from "./base";
import { githubContracts } from "../types/github";
import type { CloneRepoParams, CloneRepoResult } from "../types/github";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("github_handlers");
......@@ -136,7 +137,7 @@ export async function prepareLocalBranch({
}) {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
const targetBranch = branch || "main";
......@@ -424,7 +425,10 @@ async function pollForAccessToken(event: IpcMainInvokeEvent) {
break;
}
} else {
throw new Error(`Unknown response structure: ${JSON.stringify(data)}`);
throw new DyadError(
`Unknown response structure: ${JSON.stringify(data)}`,
DyadErrorKind.External,
);
}
} catch (error) {
logger.error("Error polling for GitHub access token:", error);
......@@ -551,7 +555,7 @@ async function handleListGithubRepos(): Promise<
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
// Fetch user's repositories
......@@ -579,6 +583,7 @@ async function handleListGithubRepos(): Promise<
private: repo.private,
}));
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to list repos:", err);
throw new Error(err.message || "Failed to list GitHub repositories.");
}
......@@ -594,7 +599,7 @@ async function handleGetRepoBranches(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
// Fetch repository branches
......@@ -621,6 +626,7 @@ async function handleGetRepoBranches(
commit: { sha: branch.commit.sha },
}));
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to get repo branches:", err);
throw new Error(err.message || "Failed to get repository branches.");
}
......@@ -685,7 +691,7 @@ async function handleCreateRepo(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
// If org is empty, create for the authenticated user
let owner = org;
......@@ -790,7 +796,7 @@ async function handleConnectToExistingRepo(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
// Verify the repository exists and user has access
......@@ -827,6 +833,7 @@ async function handleConnectToExistingRepo(
// Store org, repo, and branch in the app's DB row
await updateAppGithubRepo({ appId, org: owner, repo, branch });
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to connect to existing repo:", err);
throw new Error(err.message || "Failed to connect to existing repository.");
}
......@@ -849,13 +856,16 @@ async function handlePushToGithub(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
// Get app info from DB
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const appPath = getDyadAppPath(app.path);
const branch = app.githubBranch || "main";
......@@ -961,11 +971,14 @@ async function handleRebaseFromGithub(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const appPath = getDyadAppPath(app.path);
const branch = app.githubBranch || "main";
......@@ -1035,12 +1048,15 @@ async function handleListCollaborators(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const response = await fetch(
......@@ -1066,6 +1082,7 @@ async function handleListCollaborators(
permissions: c.permissions,
}));
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to list collaborators:", err);
throw new Error(err.message || "Failed to list collaborators.");
}
......@@ -1079,10 +1096,13 @@ async function handleInviteCollaborator(
// Validate username
const trimmedUsername = username.trim();
if (!trimmedUsername) {
throw new Error("Username cannot be empty.");
throw new DyadError("Username cannot be empty.", DyadErrorKind.External);
}
if (trimmedUsername.length > 39) {
throw new Error("GitHub username cannot exceed 39 characters.");
throw new DyadError(
"GitHub username cannot exceed 39 characters.",
DyadErrorKind.Validation,
);
}
// Single character usernames must be alphanumeric only
if (trimmedUsername.length === 1) {
......@@ -1103,12 +1123,15 @@ async function handleInviteCollaborator(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
// GitHub API to add a collaborator (sends an invitation)
......@@ -1134,6 +1157,7 @@ async function handleInviteCollaborator(
);
}
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to invite collaborator:", err);
throw new Error(err.message || "Failed to invite collaborator.");
}
......@@ -1147,12 +1171,15 @@ async function handleRemoveCollaborator(
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
throw new DyadError("Not authenticated with GitHub.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
throw new DyadError(
"App is not linked to a GitHub repo.",
DyadErrorKind.Precondition,
);
}
const response = await fetch(
......@@ -1174,6 +1201,7 @@ async function handleRemoveCollaborator(
);
}
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[GitHub Handler] Failed to remove collaborator:", err);
throw new Error(err.message || "Failed to remove collaborator.");
}
......@@ -1203,7 +1231,7 @@ async function handleDisconnectGithubRepo(
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
// Update app in database to remove GitHub repo, org, and branch
......
......@@ -11,6 +11,7 @@ import {
import { createTypedHandler } from "./base";
import { helpContracts } from "../types/help";
import { resolveBuiltinModelAlias } from "../shared/remote_language_model_catalog";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("help-bot");
......@@ -24,7 +25,10 @@ export function registerHelpBotHandlers() {
const { sessionId, message } = params;
try {
if (!sessionId || !message?.trim()) {
throw new Error("Missing sessionId or message");
throw new DyadError(
"Missing sessionId or message",
DyadErrorKind.External,
);
}
// Clear any existing active streams (only one session at a time)
......
......@@ -15,6 +15,7 @@ import { eq } from "drizzle-orm";
import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("image_generation_handlers");
......@@ -45,14 +46,17 @@ export function registerImageGenerationHandlers() {
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required for image generation");
throw new DyadError(
"Dyad Pro API key is required for image generation",
DyadErrorKind.Auth,
);
}
const app = await db.query.apps.findFirst({
where: eq(apps.id, params.targetAppId),
});
if (!app) {
throw new Error("Target app not found");
throw new DyadError("Target app not found", DyadErrorKind.NotFound);
}
const systemPrompt = THEME_SYSTEM_PROMPTS[params.themeMode];
......@@ -86,9 +90,15 @@ export function registerImageGenerationHandlers() {
} catch (error) {
activeControllers.delete(requestId);
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Image generation cancelled or timed out.");
throw new DyadError(
"Image generation cancelled or timed out.",
DyadErrorKind.UserCancelled,
);
}
throw new Error("Failed to connect to image generation service.");
throw new DyadError(
"Failed to connect to image generation service.",
DyadErrorKind.External,
);
} finally {
clearTimeout(timeoutId);
}
......@@ -108,12 +118,18 @@ export function registerImageGenerationHandlers() {
const parsed = ImageGenerationApiResponseSchema.safeParse(rawData);
if (!parsed.success) {
logger.error("Invalid image generation response:", parsed.error);
throw new Error("Invalid response from image generation service");
throw new DyadError(
"Invalid response from image generation service",
DyadErrorKind.External,
);
}
const imageData = parsed.data.data[0];
if (!imageData?.b64_json && !imageData?.url) {
throw new Error("No image data returned from generation service");
throw new DyadError(
"No image data returned from generation service",
DyadErrorKind.External,
);
}
// Prepare image data before acquiring lock (network I/O outside lock)
......@@ -121,12 +137,18 @@ export function registerImageGenerationHandlers() {
if (imageData.b64_json) {
imageBuffer = Buffer.from(imageData.b64_json, "base64");
if (imageBuffer.byteLength > MAX_IMAGE_SIZE) {
throw new Error("Decoded image exceeds maximum allowed size");
throw new DyadError(
"Decoded image exceeds maximum allowed size",
DyadErrorKind.Validation,
);
}
} else if (imageData.url) {
const imageUrl = new URL(imageData.url);
if (imageUrl.protocol !== "https:") {
throw new Error("Image URL must use HTTPS");
throw new DyadError(
"Image URL must use HTTPS",
DyadErrorKind.External,
);
}
const dlController = new AbortController();
const dlTimeout = setTimeout(
......@@ -144,19 +166,28 @@ export function registerImageGenerationHandlers() {
}
const arrayBuffer = await imgResponse.arrayBuffer();
if (arrayBuffer.byteLength > MAX_IMAGE_SIZE) {
throw new Error("Downloaded image exceeds maximum allowed size");
throw new DyadError(
"Downloaded image exceeds maximum allowed size",
DyadErrorKind.Validation,
);
}
imageBuffer = Buffer.from(arrayBuffer);
} catch (dlError) {
if (dlError instanceof Error && dlError.name === "AbortError") {
throw new Error("Image download timed out. Please try again.");
throw new DyadError(
"Image download timed out. Please try again.",
DyadErrorKind.External,
);
}
throw dlError;
} finally {
clearTimeout(dlTimeout);
}
} else {
throw new Error("Unexpected image response format");
throw new DyadError(
"Unexpected image response format",
DyadErrorKind.External,
);
}
// Save to app's media folder under lock (consistent with media CRUD handlers)
......
......@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
import { ImportAppParams, ImportAppResult } from "@/ipc/types";
import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitCommit, gitAdd, gitInit } from "../utils/git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger);
......@@ -88,7 +89,10 @@ export function registerImportHandlers() {
try {
await fs.access(sourcePath);
} catch {
throw new Error("Source folder does not exist");
throw new DyadError(
"Source folder does not exist",
DyadErrorKind.NotFound,
);
}
// Determine the app path based on skipCopy
......
......@@ -20,6 +20,7 @@ import {
} from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { IpcMainInvokeEvent } from "electron";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("language_model_handlers");
const handle = createLoggedHandler(logger);
......@@ -42,15 +43,24 @@ export function registerLanguageModelHandlers() {
// Validation
if (!id) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
if (!name) {
throw new Error("Provider name is required");
throw new DyadError(
"Provider name is required",
DyadErrorKind.Validation,
);
}
if (!apiBaseUrl) {
throw new Error("API base URL is required");
throw new DyadError(
"API base URL is required",
DyadErrorKind.Validation,
);
}
// Check if a provider with this ID already exists
......@@ -61,7 +71,10 @@ export function registerLanguageModelHandlers() {
.get();
if (existingProvider) {
throw new Error(`A provider with ID "${id}" already exists`);
throw new DyadError(
`A provider with ID "${id}" already exists`,
DyadErrorKind.Conflict,
);
}
// Insert the new provider
......@@ -101,20 +114,32 @@ export function registerLanguageModelHandlers() {
// Validation
if (!apiName) {
throw new Error("Model API name is required");
throw new DyadError(
"Model API name is required",
DyadErrorKind.Validation,
);
}
if (!displayName) {
throw new Error("Model display name is required");
throw new DyadError(
"Model display name is required",
DyadErrorKind.Validation,
);
}
if (!providerId) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
// Check if provider exists
const providers = await getLanguageModelProviders();
const provider = providers.find((p) => p.id === providerId);
if (!provider) {
throw new Error(`Provider with ID "${providerId}" not found`);
throw new DyadError(
`Provider with ID "${providerId}" not found`,
DyadErrorKind.NotFound,
);
}
// Insert the new model
......@@ -138,13 +163,22 @@ export function registerLanguageModelHandlers() {
const { id, name, apiBaseUrl, envVarName } = params;
if (!id) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
if (!name) {
throw new Error("Provider name is required");
throw new DyadError(
"Provider name is required",
DyadErrorKind.Validation,
);
}
if (!apiBaseUrl) {
throw new Error("API base URL is required");
throw new DyadError(
"API base URL is required",
DyadErrorKind.Validation,
);
}
// Check if the provider being edited exists
......@@ -155,7 +189,10 @@ export function registerLanguageModelHandlers() {
.get();
if (!existingProvider) {
throw new Error(`Provider with ID "${id}" not found`);
throw new DyadError(
`Provider with ID "${id}" not found`,
DyadErrorKind.NotFound,
);
}
// Use transaction to ensure atomicity when updating provider and potentially its models
......@@ -175,7 +212,10 @@ export function registerLanguageModelHandlers() {
.run();
if (updateResult.changes === 0) {
throw new Error(`Failed to update provider with ID "${id}"`);
throw new DyadError(
`Failed to update provider with ID "${id}"`,
DyadErrorKind.External,
);
}
return {
......@@ -201,7 +241,10 @@ export function registerLanguageModelHandlers() {
// Validation
if (!apiName) {
throw new Error("Model API name (modelId) is required");
throw new DyadError(
"Model API name (modelId) is required",
DyadErrorKind.Validation,
);
}
logger.info(
......@@ -237,7 +280,10 @@ export function registerLanguageModelHandlers() {
`Handling delete-custom-model for ${providerId} / ${modelApiName}`,
);
if (!providerId || !modelApiName) {
throw new Error("Provider ID and Model API Name are required.");
throw new DyadError(
"Provider ID and Model API Name are required.",
DyadErrorKind.External,
);
}
logger.info(
`Attempting to delete custom model ${modelApiName} for provider ${providerId}`,
......@@ -246,10 +292,16 @@ export function registerLanguageModelHandlers() {
const providers = await getLanguageModelProviders();
const provider = providers.find((p) => p.id === providerId);
if (!provider) {
throw new Error(`Provider with ID "${providerId}" not found`);
throw new DyadError(
`Provider with ID "${providerId}" not found`,
DyadErrorKind.NotFound,
);
}
if (provider.type === "local") {
throw new Error("Local models cannot be deleted");
throw new DyadError(
"Local models cannot be deleted",
DyadErrorKind.External,
);
}
const result = db
.delete(language_models)
......@@ -286,7 +338,10 @@ export function registerLanguageModelHandlers() {
// Validation
if (!providerId) {
throw new Error("Provider ID is required");
throw new DyadError(
"Provider ID is required",
DyadErrorKind.Validation,
);
}
logger.info(
......@@ -349,15 +404,24 @@ export function registerLanguageModelHandlers() {
params: { providerId: string },
): Promise<LanguageModel[]> => {
if (!params || typeof params.providerId !== "string") {
throw new Error("Invalid parameters: providerId (string) is required.");
throw new DyadError(
"Invalid parameters: providerId (string) is required.",
DyadErrorKind.Validation,
);
}
const providers = await getLanguageModelProviders();
const provider = providers.find((p) => p.id === params.providerId);
if (!provider) {
throw new Error(`Provider with ID "${params.providerId}" not found`);
throw new DyadError(
`Provider with ID "${params.providerId}" not found`,
DyadErrorKind.NotFound,
);
}
if (provider.type === "local") {
throw new Error("Local models cannot be fetched");
throw new DyadError(
"Local models cannot be fetched",
DyadErrorKind.External,
);
}
return getLanguageModels({ providerId: params.providerId });
},
......
......@@ -3,6 +3,7 @@ import { LM_STUDIO_BASE_URL } from "../utils/lm_studio_utils";
import { createTypedHandler } from "./base";
import { languageModelContracts } from "../types/language-model";
import type { LocalModel } from "../types/language-model";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("lmstudio_handler");
......@@ -24,7 +25,10 @@ export async function fetchLMStudioModels(): Promise<{ models: LocalModel[] }> {
`${LM_STUDIO_BASE_URL}/api/v0/models`,
);
if (!modelsResponse.ok) {
throw new Error("Failed to fetch models from LM Studio");
throw new DyadError(
"Failed to fetch models from LM Studio",
DyadErrorKind.External,
);
}
const modelsJson = await modelsResponse.json();
const downloadedModels = modelsJson.data as LMStudioModel[];
......
......@@ -2,6 +2,7 @@ import log from "electron-log";
import { createTypedHandler } from "./base";
import { languageModelContracts } from "../types/language-model";
import type { LocalModel } from "../types/language-model";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("ollama_handler");
......@@ -60,7 +61,10 @@ export async function fetchOllamaModels(): Promise<{ models: LocalModel[] }> {
try {
const response = await fetch(`${getOllamaApiUrl()}/api/tags`);
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.statusText}`);
throw new DyadError(
`Failed to fetch model: ${response.statusText}`,
DyadErrorKind.External,
);
}
const data = await response.json();
......@@ -93,7 +97,10 @@ export async function fetchOllamaModels(): Promise<{ models: LocalModel[] }> {
"Could not connect to Ollama. Make sure it's running at http://localhost:11434",
);
}
throw new Error("Failed to fetch models from Ollama");
throw new DyadError(
"Failed to fetch models from Ollama",
DyadErrorKind.External,
);
}
}
......
......@@ -13,6 +13,7 @@ import fs from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("media_handlers");
......@@ -81,11 +82,11 @@ async function withMediaLock<T>(
function assertSafeFileName(fileName: string): void {
if (!fileName || fileName.trim().length === 0) {
throw new Error("File name is required");
throw new DyadError("File name is required", DyadErrorKind.Validation);
}
if (fileName !== path.basename(fileName)) {
throw new Error("Invalid file name");
throw new DyadError("Invalid file name", DyadErrorKind.Validation);
}
if (
......@@ -95,7 +96,7 @@ function assertSafeFileName(fileName: string): void {
fileName === ".." ||
INVALID_FILE_NAME_CHARS.test(fileName)
) {
throw new Error("Invalid file name");
throw new DyadError("Invalid file name", DyadErrorKind.Validation);
}
}
......@@ -103,7 +104,7 @@ function assertSafeBaseName(baseName: string): string {
const trimmed = baseName.trim();
if (!trimmed) {
throw new Error("New image name is required");
throw new DyadError("New image name is required", DyadErrorKind.Validation);
}
if (
......@@ -113,7 +114,7 @@ function assertSafeBaseName(baseName: string): string {
trimmed === ".." ||
INVALID_FILE_NAME_CHARS.test(trimmed)
) {
throw new Error("Invalid image name");
throw new DyadError("Invalid image name", DyadErrorKind.Validation);
}
return trimmed;
......@@ -123,7 +124,10 @@ function assertSupportedMediaExtension(fileName: string): string {
const extension = path.extname(fileName).toLowerCase();
if (!SUPPORTED_MEDIA_EXTENSIONS.includes(extension)) {
throw new Error("Unsupported media file extension");
throw new DyadError(
"Unsupported media file extension",
DyadErrorKind.Validation,
);
}
return extension;
......@@ -145,7 +149,7 @@ async function getAppOrThrow(appId: number) {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
return app;
......@@ -186,7 +190,10 @@ export function registerMediaHandlers() {
assertSafeFileName(destinationFileName);
if (destinationFileName === params.fileName) {
throw new Error("New image name must be different from current name");
throw new DyadError(
"New image name must be different from current name",
DyadErrorKind.Validation,
);
}
const destinationPath = safeJoin(
......@@ -199,7 +206,10 @@ export function registerMediaHandlers() {
const isCaseOnlyRename =
destinationFileName.toLowerCase() === params.fileName.toLowerCase();
if (!isCaseOnlyRename && fs.existsSync(destinationPath)) {
throw new Error("A media file with that name already exists");
throw new DyadError(
"A media file with that name already exists",
DyadErrorKind.Conflict,
);
}
try {
......@@ -238,7 +248,10 @@ export function registerMediaHandlers() {
createTypedHandler(mediaContracts.moveMediaFile, async (_, params) => {
if (params.sourceAppId === params.targetAppId) {
throw new Error("Source and target apps must be different");
throw new DyadError(
"Source and target apps must be different",
DyadErrorKind.Validation,
);
}
await withMediaLock([params.sourceAppId, params.targetAppId], async () => {
......@@ -250,7 +263,7 @@ export function registerMediaHandlers() {
const sourcePath = getMediaFilePath(sourceAppPath, params.fileName);
if (!fs.existsSync(sourcePath)) {
throw new Error("Media file not found");
throw new DyadError("Media file not found", DyadErrorKind.NotFound);
}
await ensureDyadGitignored(targetAppPath);
......
......@@ -14,6 +14,7 @@ import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { EndpointType } from "@neondatabase/api-client";
import { retryOnLocked } from "../utils/retryOnLocked";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export const logger = log.scope("neon_handlers");
......@@ -44,7 +45,10 @@ export function registerNeonHandlers() {
);
if (!response.data.project) {
throw new Error("Failed to create project: No project data returned.");
throw new DyadError(
"Failed to create project: No project data returned.",
DyadErrorKind.External,
);
}
const project = response.data.project;
......@@ -113,12 +117,18 @@ export function registerNeonHandlers() {
.limit(1);
if (app.length === 0) {
throw new Error(`App with ID ${appId} not found`);
throw new DyadError(
`App with ID ${appId} not found`,
DyadErrorKind.NotFound,
);
}
const appData = app[0];
if (!appData.neonProjectId) {
throw new Error(`No Neon project found for app ${appId}`);
throw new DyadError(
`No Neon project found for app ${appId}`,
DyadErrorKind.External,
);
}
const neonClient = await getNeonClient();
......@@ -130,7 +140,10 @@ export function registerNeonHandlers() {
);
if (!projectResponse.data.project) {
throw new Error("Failed to get project: No project data returned.");
throw new DyadError(
"Failed to get project: No project data returned.",
DyadErrorKind.External,
);
}
const project = projectResponse.data.project;
......@@ -141,7 +154,10 @@ export function registerNeonHandlers() {
});
if (!branchesResponse.data.branches) {
throw new Error("Failed to get branches: No branch data returned.");
throw new DyadError(
"Failed to get branches: No branch data returned.",
DyadErrorKind.External,
);
}
// Map branches to our format
......
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function slugify(text: string): string {
const result = text
.toLowerCase()
......@@ -17,7 +18,7 @@ export function buildFrontmatter(meta: Record<string, string>): string {
export function validatePlanId(planId: string): void {
if (!/^[a-z0-9-]+$/.test(planId)) {
throw new Error("Invalid plan ID");
throw new DyadError("Invalid plan ID", DyadErrorKind.Validation);
}
}
......
......@@ -15,6 +15,7 @@ import {
parsePlanFile,
} from "./planUtils";
import { ensureDyadGitignored } from "./gitignoreUtils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("plan_handlers");
......@@ -62,7 +63,10 @@ export function registerPlanHandlers() {
raw = await fs.promises.readFile(filePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Plan not found: ${planId}`);
throw new DyadError(
`Plan not found: ${planId}`,
DyadErrorKind.NotFound,
);
}
throw err;
}
......@@ -145,7 +149,10 @@ export function registerPlanHandlers() {
await fs.promises.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Plan not found: ${planId}`);
throw new DyadError(
`Plan not found: ${planId}`,
DyadErrorKind.NotFound,
);
}
throw err;
}
......
......@@ -7,6 +7,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { spawn } from "child_process";
import { gitCommit, gitAdd } from "../utils/git_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("portal_handlers");
const handle = createLoggedHandler(logger);
......@@ -16,7 +17,10 @@ async function getApp(appId: number) {
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with id ${appId} not found`);
throw new DyadError(
`App with id ${appId} not found`,
DyadErrorKind.NotFound,
);
}
return app;
}
......@@ -125,7 +129,10 @@ export function registerPortalHandlers() {
return { output: migrationOutput };
} catch (gitError) {
logger.error(`Migration created but failed to commit: ${gitError}`);
throw new Error(`Migration created but failed to commit: ${gitError}`);
throw new DyadError(
`Migration created but failed to commit: ${gitError}`,
DyadErrorKind.External,
);
}
},
);
......
......@@ -6,6 +6,7 @@ import { getDyadAppPath } from "@/paths/paths";
import log from "electron-log";
import { createTypedHandler } from "./base";
import { miscContracts } from "../types/misc";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("problems_handlers");
......@@ -18,7 +19,10 @@ export function registerProblemsHandlers() {
});
if (!app) {
throw new Error(`App not found: ${params.appId}`);
throw new DyadError(
`App not found: ${params.appId}`,
DyadErrorKind.NotFound,
);
}
const appPath = getDyadAppPath(app.path);
......
......@@ -4,6 +4,7 @@ import { prompts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { promptContracts } from "../types/prompts";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const _logger = log.scope("prompt_handlers");
......@@ -24,7 +25,10 @@ export function registerPromptHandlers() {
createTypedHandler(promptContracts.create, async (_, params) => {
const { title, content, description, slug } = params;
if (!title || !content) {
throw new Error("Title and content are required");
throw new DyadError(
"Title and content are required",
DyadErrorKind.External,
);
}
const result = db
.insert(prompts)
......
......@@ -3,6 +3,7 @@ import fetch from "node-fetch";
import { IS_TEST_BUILD } from "../utils/test_utils";
import { createTypedHandler } from "./base";
import { systemContracts } from "../types/system";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("release_note_handlers");
......@@ -13,7 +14,10 @@ export function registerReleaseNoteHandlers() {
const { version } = params;
if (!version || typeof version !== "string") {
throw new Error("Invalid version provided");
throw new DyadError(
"Invalid version provided",
DyadErrorKind.Validation,
);
}
// For E2E tests, we don't want to check for release notes
......
import { ipcMain, IpcMainInvokeEvent } from "electron";
import log from "electron-log";
import { DyadError } from "@/errors/dyad_error";
import { sendTelemetryException } from "../utils/telemetry";
import { IS_TEST_BUILD } from "../utils/test_utils";
......@@ -24,6 +25,10 @@ export function createLoggedHandler(logger: log.LogFunctions) {
error,
);
sendTelemetryException(error, { ipc_channel: channel });
// Preserve DyadError so telemetry classification stay consistent.
if (error instanceof DyadError) {
throw error;
}
throw new Error(`[${channel}] ${error}`);
}
},
......
......@@ -4,13 +4,14 @@ import { eq, and, like, desc } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { securityContracts } from "../types/security";
import type { SecurityFinding } from "../types/security";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export function registerSecurityHandlers() {
createTypedHandler(
securityContracts.getLatestSecurityReview,
async (_, appId) => {
if (!appId) {
throw new Error("App ID is required");
throw new DyadError("App ID is required", DyadErrorKind.Validation);
}
// Query for the most recent message with security findings
......@@ -34,14 +35,20 @@ export function registerSecurityHandlers() {
.limit(1);
if (result.length === 0) {
throw new Error("No security review found for this app");
throw new DyadError(
"No security review found for this app",
DyadErrorKind.NotFound,
);
}
const message = result[0];
const findings = parseSecurityFindings(message.content);
if (findings.length === 0) {
throw new Error("No security review found for this app");
throw new DyadError(
"No security review found for this app",
DyadErrorKind.NotFound,
);
}
return {
......
......@@ -4,6 +4,7 @@ import path from "node:path";
import { createLoggedHandler } from "./safe_handle";
import { IS_TEST_BUILD } from "../utils/test_utils";
import { isFileWithinAnyDyadMediaDir } from "../utils/media_path_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("shell_handlers");
const handle = createLoggedHandler(logger);
......@@ -36,7 +37,7 @@ const ALLOWED_MEDIA_EXTENSIONS = new Set([
export function registerShellHandlers() {
handle("open-external-url", async (_event, url: string) => {
if (!url) {
throw new Error("No URL provided.");
throw new DyadError("No URL provided.", DyadErrorKind.External);
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new Error("Attempted to open invalid or non-http URL: " + url);
......@@ -53,7 +54,7 @@ export function registerShellHandlers() {
handle("show-item-in-folder", async (_event, fullPath: string) => {
// Validate that a path was provided
if (!fullPath) {
throw new Error("No file path provided.");
throw new DyadError("No file path provided.", DyadErrorKind.External);
}
shell.showItemInFolder(fullPath);
......@@ -62,7 +63,7 @@ export function registerShellHandlers() {
handle("open-file-path", async (_event, fullPath: string) => {
if (!fullPath) {
throw new Error("No file path provided.");
throw new DyadError("No file path provided.", DyadErrorKind.External);
}
// Security: only allow opening files within .dyad/media subdirectories.
......@@ -71,7 +72,10 @@ export function registerShellHandlers() {
// App paths may be under the default dyad-apps base directory (normal) or
// at an external location (imported with skipCopy).
if (!isFileWithinAnyDyadMediaDir(fullPath)) {
throw new Error("Can only open files within .dyad/media directories.");
throw new DyadError(
"Can only open files within .dyad/media directories.",
DyadErrorKind.External,
);
}
const resolvedPath = path.resolve(fullPath);
......@@ -86,7 +90,10 @@ export function registerShellHandlers() {
const result = await shell.openPath(resolvedPath);
if (result) {
// shell.openPath returns an error string if it fails, empty string on success
throw new Error(`Failed to open file: ${result}`);
throw new DyadError(
`Failed to open file: ${result}`,
DyadErrorKind.External,
);
}
logger.debug("Opened file:", resolvedPath);
});
......
......@@ -16,6 +16,7 @@ import { createTestOnlyLoggedHandler } from "./safe_handle";
import { safeSend } from "../utils/safe_sender";
import { readSettings, writeSettings } from "../../main/settings";
import { supabaseContracts } from "../types/supabase";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("supabase_handlers");
const testOnlyHandle = createTestOnlyLoggedHandler(logger);
......@@ -70,7 +71,10 @@ export function registerSupabaseHandlers() {
const organizations = { ...settings.supabase?.organizations };
if (!organizations[organizationSlug]) {
throw new Error(`Supabase organization ${organizationSlug} not found`);
throw new DyadError(
`Supabase organization ${organizationSlug} not found`,
DyadErrorKind.NotFound,
);
}
delete organizations[organizationSlug];
......@@ -159,7 +163,10 @@ export function registerSupabaseHandlers() {
typeof response.error === "string"
? response.error
: JSON.stringify(response.error);
throw new Error(`Failed to fetch logs: ${errorMsg}`);
throw new DyadError(
`Failed to fetch logs: ${errorMsg}`,
DyadErrorKind.External,
);
}
const rawLogs = response.result || [];
......
......@@ -26,6 +26,7 @@ import { readSettings } from "@/main/settings";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("token_count_handlers");
......@@ -46,7 +47,10 @@ export function registerTokenCountHandlers() {
});
if (!chat) {
throw new Error(`Chat not found: ${req.chatId}`);
throw new DyadError(
`Chat not found: ${req.chatId}`,
DyadErrorKind.NotFound,
);
}
// Prepare message history for token counting
......
......@@ -2,6 +2,7 @@ import log from "electron-log";
import fetch from "node-fetch";
import { createTypedHandler } from "./base";
import { systemContracts } from "../types/system";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("upload_handlers");
......@@ -12,12 +13,18 @@ export function registerUploadHandlers() {
// Validate the signed URL
if (!url || typeof url !== "string" || !url.startsWith("https://")) {
throw new Error("Invalid signed URL provided");
throw new DyadError(
"Invalid signed URL provided",
DyadErrorKind.Validation,
);
}
// Validate content type
if (!contentType || typeof contentType !== "string") {
throw new Error("Invalid content type provided");
throw new DyadError(
"Invalid content type provided",
DyadErrorKind.Validation,
);
}
// Perform the upload to the signed URL
......
......@@ -23,6 +23,7 @@ import {
VercelProject,
VercelDeployment,
} from "../types/vercel";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("vercel_handlers");
......@@ -123,10 +124,13 @@ async function getDefaultTeamId(token: string): Promise<string> {
return data.teams[0].id;
}
throw new Error("No teams found for this user");
throw new DyadError("No teams found for this user", DyadErrorKind.NotFound);
} catch (error) {
logger.error("Error getting default team ID:", error);
throw new Error("Failed to get team information");
throw new DyadError(
"Failed to get team information",
DyadErrorKind.External,
);
}
}
......@@ -198,7 +202,7 @@ async function handleSaveVercelToken(
logger.debug("Saving Vercel access token");
if (!token || token.trim() === "") {
throw new Error("Access token is required.");
throw new DyadError("Access token is required.", DyadErrorKind.Auth);
}
try {
......@@ -219,7 +223,10 @@ async function handleSaveVercelToken(
logger.log("Successfully saved Vercel access token.");
} catch (error: any) {
logger.error("Error saving Vercel token:", error);
throw new Error(`Failed to save access token: ${error.message}`);
throw new DyadError(
`Failed to save access token: ${error.message}`,
DyadErrorKind.Auth,
);
}
}
......@@ -229,13 +236,16 @@ async function handleListVercelProjects(): Promise<VercelProject[]> {
const settings = readSettings();
const accessToken = settings.vercelAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with Vercel.");
throw new DyadError("Not authenticated with Vercel.", DyadErrorKind.Auth);
}
const response = await getVercelProjects(accessToken);
if (!response.projects) {
throw new Error("Failed to retrieve projects from Vercel.");
throw new DyadError(
"Failed to retrieve projects from Vercel.",
DyadErrorKind.External,
);
}
return response.projects.map((project) => ({
......@@ -244,6 +254,7 @@ async function handleListVercelProjects(): Promise<VercelProject[]> {
framework: project.framework || null,
}));
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[Vercel Handler] Failed to list projects:", err);
throw new Error(err.message || "Failed to list Vercel projects.");
}
......@@ -292,7 +303,7 @@ async function handleCreateProject(
const settings = readSettings();
const accessToken = settings.vercelAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with Vercel.");
throw new DyadError("Not authenticated with Vercel.", DyadErrorKind.Auth);
}
try {
......@@ -301,7 +312,7 @@ async function handleCreateProject(
// Get app details to determine the framework
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) {
throw new Error("App not found.");
throw new DyadError("App not found.", DyadErrorKind.NotFound);
}
// Check if app has GitHub repository configured
......@@ -331,7 +342,10 @@ async function handleCreateProject(
},
});
if (!projectData.id) {
throw new Error("Failed to create project: No project ID returned.");
throw new DyadError(
"Failed to create project: No project ID returned.",
DyadErrorKind.External,
);
}
// Get the default team ID
......@@ -383,6 +397,7 @@ async function handleCreateProject(
// Don't throw here - project creation was successful, deployment failure is non-critical
}
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[Vercel Handler] Failed to create project:", err);
throw new Error(err.message || "Failed to create Vercel project.");
}
......@@ -397,7 +412,7 @@ async function handleConnectToExistingProject(
const settings = readSettings();
const accessToken = settings.vercelAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with Vercel.");
throw new DyadError("Not authenticated with Vercel.", DyadErrorKind.Auth);
}
logger.info(
......@@ -411,7 +426,10 @@ async function handleConnectToExistingProject(
);
if (!projectData) {
throw new Error("Project not found. Please check the project ID.");
throw new DyadError(
"Project not found. Please check the project ID.",
DyadErrorKind.NotFound,
);
}
// Get the default team ID
......@@ -430,6 +448,7 @@ async function handleConnectToExistingProject(
logger.info(`Successfully connected to Vercel project: ${projectData.id}`);
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error(
"[Vercel Handler] Failed to connect to existing project:",
err,
......@@ -447,12 +466,15 @@ async function handleGetVercelDeployments(
const settings = readSettings();
const accessToken = settings.vercelAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with Vercel.");
throw new DyadError("Not authenticated with Vercel.", DyadErrorKind.Auth);
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.vercelProjectId) {
throw new Error("App is not linked to a Vercel project.");
throw new DyadError(
"App is not linked to a Vercel project.",
DyadErrorKind.Precondition,
);
}
logger.info(
......@@ -468,7 +490,10 @@ async function handleGetVercelDeployments(
});
if (!deploymentsResponse.deployments) {
throw new Error("Failed to retrieve deployments from Vercel.");
throw new DyadError(
"Failed to retrieve deployments from Vercel.",
DyadErrorKind.External,
);
}
// Find the most recent READY production deployment and update the stored URL
......@@ -500,6 +525,7 @@ async function handleGetVercelDeployments(
readyState: deployment.readyState || "unknown",
}));
} catch (err: any) {
if (err instanceof DyadError) throw err;
logger.error("[Vercel Handler] Failed to get deployments:", err);
throw new Error(err.message || "Failed to get Vercel deployments.");
}
......@@ -516,7 +542,7 @@ async function handleDisconnectVercelProject(
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
// Update app in database to remove Vercel project info
......
......@@ -32,6 +32,7 @@ import {
} from "../utils/app_env_var_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { retryOnLocked } from "../utils/retryOnLocked";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("version_handlers");
......@@ -124,14 +125,14 @@ export function registerVersionHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
// Return appropriate result if the app is not a git repo
if (!fs.existsSync(path.join(appPath, ".git"))) {
throw new Error("Not a git repository");
throw new DyadError("Not a git repository", DyadErrorKind.External);
}
try {
......@@ -142,7 +143,10 @@ export function registerVersionHandlers() {
};
} catch (error: any) {
logger.error(`Error getting current branch for app ${appId}:`, error);
throw new Error(`Failed to get current branch: ${error.message}`);
throw new DyadError(
`Failed to get current branch: ${error.message}`,
DyadErrorKind.External,
);
}
});
......@@ -156,7 +160,7 @@ export function registerVersionHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
......@@ -290,7 +294,10 @@ export function registerVersionHandlers() {
const preserveBranchId = response.data.branch.parent_id;
if (!preserveBranchId) {
throw new Error("Preserve branch ID not found");
throw new DyadError(
"Preserve branch ID not found",
DyadErrorKind.NotFound,
);
}
logger.info(
`Deleting preserve branch ${preserveBranchId} for app ${appId}`,
......@@ -373,7 +380,7 @@ export function registerVersionHandlers() {
});
if (!app) {
throw new Error("App not found");
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
if (
......
......@@ -29,6 +29,7 @@ import {
hasStagedChanges,
} from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { writeMigrationFile } from "../utils/file_utils";
import {
getDyadWriteTags,
......@@ -133,9 +134,10 @@ export async function processFullResponseActions(
});
} catch (error) {
logger.error("Error creating Neon branch at current version:", error);
throw new Error(
throw new DyadError(
"Could not create Neon branch; database versioning functionality is not working: " +
error,
DyadErrorKind.External,
);
}
}
......
import log from "electron-log";
import { z } from "zod";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type {
LanguageModel,
LanguageModelProvider,
......@@ -327,8 +328,9 @@ async function fetchRemoteCatalog(): Promise<BuiltinLanguageModelCatalog | null>
});
if (!response.ok) {
throw new Error(
throw new DyadError(
`Failed to fetch language model catalog: ${response.status} ${response.statusText}`,
DyadErrorKind.External,
);
}
......
......@@ -8,6 +8,7 @@ import { EnvVar } from "@/ipc/types";
import path from "path";
import fs from "fs";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("app_env_var_utils");
......@@ -94,7 +95,10 @@ export async function readPostgresUrlFromEnvFile({
(envVar) => envVar.key === "POSTGRES_URL",
)?.value;
if (!postgresUrl) {
throw new Error("POSTGRES_URL not found in .env.local");
throw new DyadError(
"POSTGRES_URL not found in .env.local",
DyadErrorKind.NotFound,
);
}
return postgresUrl;
}
......
......@@ -11,6 +11,7 @@ import {
isSharedServerModule,
extractFunctionNameFromPath,
} from "../../supabase_admin/supabase_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("copy_file_utils");
......@@ -64,7 +65,10 @@ export async function executeCopyFile({
const toFullPath = safeJoin(appPath, to);
if (!fs.existsSync(fromFullPath)) {
throw new Error(`Source file does not exist: ${from}`);
throw new DyadError(
`Source file does not exist: ${from}`,
DyadErrorKind.NotFound,
);
}
// Security: resolve symlinks and re-validate that paths remain within bounds.
......
......@@ -5,6 +5,7 @@ import type {
} from "@ai-sdk/provider";
import type { LanguageModel } from "ai";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("fallback_model");
......@@ -128,7 +129,10 @@ class FallbackModel implements LanguageModelV3 {
constructor(settings: FallbackSettings) {
// Validate settings
if (!settings.models || settings.models.length === 0) {
throw new Error("At least one model must be provided in settings.models");
throw new DyadError(
"At least one model must be provided in settings.models",
DyadErrorKind.Validation,
);
}
this.settings = settings;
......@@ -154,15 +158,21 @@ class FallbackModel implements LanguageModelV3 {
private getUnderlyingModel(): LanguageModelV3 {
const model = this.settings.models[this.currentModelIndex];
if (!model) {
throw new Error(`Model at index ${this.currentModelIndex} not found`);
throw new DyadError(
`Model at index ${this.currentModelIndex} not found`,
DyadErrorKind.Internal,
);
}
// The model is either a string (GatewayModelId) or LanguageModelV2/V3
// In this fallback context, we only support actual model instances
if (typeof model === "string") {
throw new Error("String model IDs are not supported in fallback model");
throw new DyadError(
"String model IDs are not supported in fallback model",
DyadErrorKind.External,
);
}
if (model.specificationVersion !== "v3") {
throw new Error("Model is not a v3 model");
throw new DyadError("Model is not a v3 model", DyadErrorKind.External);
}
return model;
}
......@@ -249,7 +259,10 @@ class FallbackModel implements LanguageModelV3 {
}
async doGenerate(): Promise<any> {
throw new Error("doGenerate is not supported for fallback model");
throw new DyadError(
"doGenerate is not supported for fallback model",
DyadErrorKind.External,
);
}
async doStream(options: LanguageModelV3CallOptions): Promise<StreamResult> {
......
......@@ -28,6 +28,7 @@ import { LM_STUDIO_BASE_URL } from "./lm_studio_utils";
import { createOllamaProvider } from "./ollama_provider";
import { getOllamaApiUrl } from "../handlers/local_model_ollama_handler";
import { createFallback } from "./fallback_ai_model";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const dyadEngineUrl = process.env.DYAD_ENGINE_URL;
......@@ -60,7 +61,10 @@ export async function getModelClient(
const providerConfig = allProviders.find((p) => p.id === model.provider);
if (!providerConfig) {
throw new Error(`Configuration not found for provider: ${model.provider}`);
throw new DyadError(
`Configuration not found for provider: ${model.provider}`,
DyadErrorKind.NotFound,
);
}
// Handle Dyad Pro override
......@@ -122,7 +126,10 @@ export async function getModelClient(
(p) => p.id === "openrouter",
);
if (!openRouterProvider) {
throw new Error("OpenRouter provider not found");
throw new DyadError(
"OpenRouter provider not found",
DyadErrorKind.NotFound,
);
}
return {
modelClient: {
......@@ -225,7 +232,10 @@ async function getProModelClient({
(candidate) => candidate !== null,
);
if (validModels.length === 0) {
throw new Error("No auto-mode models could be resolved from the catalog");
throw new DyadError(
"No auto-mode models could be resolved from the catalog",
DyadErrorKind.External,
);
}
return {
......@@ -488,7 +498,10 @@ function getRegularModelClient(
};
}
// If it's not a known ID and not type 'custom', it's unsupported
throw new Error(`Unsupported model provider: ${model.provider}`);
throw new DyadError(
`Unsupported model provider: ${model.provider}`,
DyadErrorKind.Validation,
);
}
}
}
......@@ -14,6 +14,7 @@ import { readSettings } from "../../main/settings";
import log from "electron-log";
import { normalizePath } from "../../../shared/normalizePath";
import type { UncommittedFile, UncommittedFileStatus } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("git_utils");
/**
......@@ -128,12 +129,18 @@ import type {
} from "../git_types";
/**
* Helper function that wraps exec and throws an error if the exit code is non-zero
* Helper function that wraps exec and throws an error if the exit code is non-zero.
*
* Defaults to {@link DyadErrorKind.External} so unexpected failures (network, permissions,
* corrupted repos) surface in telemetry. Use {@link DyadErrorKind.Conflict} only when the
* dominant failure mode is genuinely merge/working-tree conflict (callers that detect
* conflict state often rethrow {@link GitConflictError} instead).
*/
async function execOrThrow(
args: string[],
path: string,
errorMessage?: string,
kind: DyadErrorKind = DyadErrorKind.External,
): Promise<void> {
const result = await execGit(args, path);
if (result.exitCode !== 0) {
......@@ -141,7 +148,7 @@ async function execOrThrow(
const error = errorMessage
? `${errorMessage}. ${errorDetails}`
: `Git command failed: ${args.join(" ")}. ${errorDetails}`;
throw new Error(error);
throw new DyadError(error, kind);
}
}
......@@ -221,8 +228,9 @@ export async function getCurrentCommitHash({
if (settings.enableNativeGit) {
const result = await execGit(["rev-parse", ref], path);
if (result.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to resolve ref '${ref}': ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
return result.stdout.trim();
......@@ -245,7 +253,10 @@ export async function isGitStatusClean({
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(`Failed to get status: ${result.stderr}`);
throw new DyadError(
`Failed to get status: ${result.stderr}`,
DyadErrorKind.Conflict,
);
}
// If output is empty, working directory is clean (no changes)
......@@ -269,8 +280,9 @@ export async function hasStagedChanges({
// git diff --cached --quiet exits with 1 if there are staged changes, 0 if none
const result = await execGit(["diff", "--cached", "--quiet"], path);
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(
throw new DyadError(
`Failed to check staged changes: ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
return result.exitCode === 1;
......@@ -299,8 +311,9 @@ export async function gitCommit({
// Get the new commit hash
const result = await execGit(["rev-parse", "HEAD"], path);
if (result.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get commit hash: ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
return result.stdout.trim();
......@@ -341,8 +354,9 @@ export async function gitStageToRevert({
// Get the current HEAD commit hash
const currentHeadResult = await execGit(["rev-parse", "HEAD"], path);
if (currentHeadResult.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get current commit: ${currentHeadResult.stderr.trim() || currentHeadResult.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
......@@ -356,12 +370,16 @@ export async function gitStageToRevert({
// Safety: refuse to run if the work-tree isn't clean.
const statusResult = await execGit(["status", "--porcelain"], path);
if (statusResult.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get status: ${statusResult.stderr.trim() || statusResult.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
if (statusResult.stdout.trim() !== "") {
throw new Error("Cannot revert: working tree has uncommitted changes.");
throw new DyadError(
"Cannot revert: working tree has uncommitted changes.",
DyadErrorKind.Conflict,
);
}
// Reset the working directory and index to match the target commit state
......@@ -510,9 +528,10 @@ export async function gitReset({ path }: GitBaseParams): Promise<void> {
// For isomorphic-git, resetting the index is complex and not directly supported
// This is a fallback - in practice, this should rarely be needed when native git is disabled
// If needed, users can manually reset via command line or enable native git
throw new Error(
throw new DyadError(
"gitReset: Resetting the staging area is not fully supported when native git is disabled. " +
"Please enable native git or manually unstage files using 'git reset HEAD'.",
DyadErrorKind.Precondition,
);
}
}
......@@ -564,8 +583,9 @@ export async function getGitUncommittedFiles({
if (settings.enableNativeGit) {
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
return result.stdout
......@@ -592,8 +612,9 @@ export async function getGitUncommittedFilesWithStatus({
if (settings.enableNativeGit) {
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
return result.stdout
......@@ -711,7 +732,7 @@ export async function gitListBranches({
const result = await execGit(["branch", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
throw new DyadError(result.stderr.toString(), DyadErrorKind.Conflict);
}
// Parse output:
// e.g. "* main\n feature/login"
......@@ -738,7 +759,7 @@ export async function gitListRemoteBranches({
const result = await execGit(["branch", "-r", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
throw new DyadError(result.stderr.toString(), DyadErrorKind.Conflict);
}
// Parse output:
// e.g. " origin/main\n origin/feature/login\n upstream/develop"
......@@ -778,7 +799,7 @@ export async function gitRenameBranch({
// git branch -m oldBranch newBranch
const result = await execGit(["branch", "-m", oldBranch, newBranch], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
throw new DyadError(result.stderr.toString(), DyadErrorKind.Conflict);
}
} else {
// isomorphic-git does not have a renameBranch function.
......@@ -847,7 +868,7 @@ export async function gitClone({
const result = await execGit(args, ".");
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
throw new DyadError(result.stderr.toString(), DyadErrorKind.Conflict);
}
} else {
// isomorphic-git version
......@@ -879,7 +900,7 @@ export async function gitSetRemoteUrl({
// Validate remoteUrl to prevent argument injection attacks
// URLs starting with "-" could be interpreted as command-line options
if (remoteUrl.startsWith("-")) {
throw new Error("Invalid remote URL");
throw new DyadError("Invalid remote URL", DyadErrorKind.Validation);
}
if (settings.enableNativeGit) {
......@@ -899,11 +920,17 @@ export async function gitSetRemoteUrl({
);
if (updateResult.exitCode !== 0) {
throw new Error(`Failed to update remote: ${updateResult.stderr}`);
throw new DyadError(
`Failed to update remote: ${updateResult.stderr}`,
DyadErrorKind.Conflict,
);
}
} else if (result.exitCode !== 0) {
// Handle other errors
throw new Error(`Failed to add remote: ${result.stderr}`);
throw new DyadError(
`Failed to add remote: ${result.stderr}`,
DyadErrorKind.Conflict,
);
}
} catch (error: any) {
logger.error("Error setting up remote:", error);
......@@ -949,12 +976,18 @@ export async function gitPush({
const result = await execGit(args, path);
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString();
throw new Error(`Git push failed: ${errorMsg}`);
throw new DyadError(
`Git push failed: ${errorMsg}`,
DyadErrorKind.Conflict,
);
}
return;
} catch (error: any) {
logger.error("Error during git push:", error);
throw new Error(`Git push failed: ${error.message}`);
throw new DyadError(
`Git push failed: ${error.message}`,
DyadErrorKind.Conflict,
);
}
}
......@@ -964,9 +997,10 @@ export async function gitPush({
"gitPush: 'forceWithLease' requested but not supported when native git is disabled. " +
"Rejecting push to prevent unsafe force operation.",
);
throw new Error(
throw new DyadError(
"gitPush: 'forceWithLease' is not supported when native git is disabled. " +
"Falling back to plain force could overwrite remote commits. Enable native git.",
DyadErrorKind.Precondition,
);
}
await git.push({
......@@ -989,8 +1023,9 @@ export async function gitPush({
export async function gitRebaseAbort({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
throw new DyadError(
"Rebase controls require native Git. Enable native Git in settings.",
DyadErrorKind.Precondition,
);
}
......@@ -1002,8 +1037,9 @@ export async function gitRebaseContinue({
}: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
throw new DyadError(
"Rebase controls require native Git. Enable native Git in settings.",
DyadErrorKind.Precondition,
);
}
......@@ -1026,8 +1062,9 @@ export async function gitRebase({
}): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
throw new DyadError(
"Rebase requires native Git. Enable native Git in settings.",
DyadErrorKind.Precondition,
);
}
......@@ -1044,8 +1081,9 @@ export async function gitRebase({
export async function gitMergeAbort({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
throw new DyadError(
"Merge abort requires native Git. Enable native Git in settings.",
DyadErrorKind.Precondition,
);
}
......@@ -1060,8 +1098,9 @@ export async function gitCurrentBranch({
// Dugite version
const result = await execGit(["branch", "--show-current"], path);
if (result.exitCode !== 0) {
throw new Error(
throw new DyadError(
`Failed to get current branch: ${result.stderr.trim() || result.stdout.trim()}`,
DyadErrorKind.Conflict,
);
}
const branch = result.stdout.trim() || null;
......@@ -1113,7 +1152,7 @@ export async function gitIsIgnored({
if (result.exitCode === 1) return false;
// Other exit codes are actual errors
throw new Error(result.stderr.toString());
throw new DyadError(result.stderr.toString(), DyadErrorKind.Conflict);
} else {
// isomorphic-git version
return await git.isIgnored({
......@@ -1142,7 +1181,7 @@ export async function gitLogNative(
const logResult = await execGit(logArgs, path);
if (logResult.exitCode !== 0) {
throw new Error(logResult.stderr.toString());
throw new DyadError(logResult.stderr.toString(), DyadErrorKind.Conflict);
}
const output = logResult.stdout.toString().trim();
......@@ -1202,19 +1241,30 @@ export async function gitFetch({
}
}
// Custom error function for git conflicts
/** Merge/pull conflicts — `name` kept for UI checks (e.g. GitHubConnector). */
class GitConflictErrorImpl extends DyadError {
constructor(message: string) {
super(message, DyadErrorKind.Conflict);
this.name = "GitConflictError";
}
}
export function GitConflictError(message: string): Error {
const error = new Error(message);
error.name = "GitConflictError";
return error;
return new GitConflictErrorImpl(message);
}
/** Blocked git operation due to repo state (merge/rebase in progress, etc.). */
class GitStateErrorImpl extends DyadError {
readonly code: string;
constructor(message: string, code: string) {
super(message, DyadErrorKind.Precondition);
this.name = "GitStateError";
this.code = code;
}
}
// Custom error function for git operations with structured error codes
export function GitStateError(message: string, code: string): Error {
const error = new Error(message);
error.name = "GitStateError";
(error as any).code = code;
return error;
return new GitStateErrorImpl(message, code);
}
// Error codes for git state errors
......@@ -1357,9 +1407,10 @@ export async function gitCreateBranch({
// isomorphic-git: branch creation uses the current HEAD; it does not honor "from"
// in the same way as native `git branch <name> <from>`.
if (from !== "HEAD") {
throw new Error(
throw new DyadError(
`gitCreateBranch: 'from' is not supported when native git is disabled (from=${from}). ` +
`Branches would be created from HEAD instead.`,
DyadErrorKind.Precondition,
);
}
await git.branch({
......@@ -1405,7 +1456,10 @@ export async function gitGetMergeConflicts({
exitCode: number;
};
if (result.exitCode !== 0) {
throw new Error(`Failed to get merge conflicts: ${result.stderr}`);
throw new DyadError(
`Failed to get merge conflicts: ${result.stderr}`,
DyadErrorKind.Conflict,
);
}
return result.stdout
.toString()
......@@ -1414,8 +1468,9 @@ export async function gitGetMergeConflicts({
.filter((s) => s.length > 0);
}
//throw error("gitGetMergeConflicts requires native Git. Enable native Git in settings.");
throw new Error(
throw new DyadError(
"Git conflict detection requires native Git. Enable native Git in settings.",
DyadErrorKind.Precondition,
);
}
......
......@@ -7,6 +7,7 @@ import {
} from "@ai-sdk/provider-utils";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getExtraProviderOptions } from "./thinking_utils";
import { DYAD_INTERNAL_REQUEST_ID_HEADER } from "./provider_options";
import type { UserSettings } from "../../lib/schemas";
......@@ -279,8 +280,9 @@ export async function transcribeWithDyadEngine(
if (!response.ok) {
const errorText = await response.text();
throw new Error(
throw new DyadError(
`Dyad Engine transcription failed: ${response.status} ${response.statusText} - ${errorText}`,
DyadErrorKind.External,
);
}
const data = (await response.json()) as { text: string };
......
......@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
class McpManager {
private static _instance: McpManager;
......@@ -43,7 +44,10 @@ class McpManager {
},
});
} else {
throw new Error(`Unsupported MCP transport: ${s.transport}`);
throw new DyadError(
`Unsupported MCP transport: ${s.transport}`,
DyadErrorKind.Validation,
);
}
const client = await createMCPClient({
transport,
......
......@@ -7,6 +7,7 @@ import { neon } from "@neondatabase/serverless";
import log from "electron-log";
import { getNeonClient } from "@/neon_admin/neon_management_client";
import { getCurrentCommitHash } from "./git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("neon_timestamp_utils");
......@@ -28,7 +29,10 @@ async function getLastUpdatedTimestampFromNeon({
return current_timestamp;
} catch (error) {
logger.error("Error retrieving timestamp from Neon:", error);
throw new Error(`Failed to retrieve timestamp from Neon: ${error}`);
throw new DyadError(
`Failed to retrieve timestamp from Neon: ${error}`,
DyadErrorKind.External,
);
}
}
......@@ -52,11 +56,17 @@ export async function storeDbTimestampAtCurrentVersion({
});
if (!app) {
throw new Error(`App with ID ${appId} not found`);
throw new DyadError(
`App with ID ${appId} not found`,
DyadErrorKind.NotFound,
);
}
if (!app.neonProjectId || !app.neonDevelopmentBranchId) {
throw new Error(`App with ID ${appId} has no Neon project or branch`);
throw new DyadError(
`App with ID ${appId} has no Neon project or branch`,
DyadErrorKind.External,
);
}
// 2. Get the current commit hash
......
import path from "node:path";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { normalizePath } from "../../../shared/normalizePath";
/**
......@@ -18,26 +19,30 @@ export function safeJoin(basePath: string, ...paths: string[]): string {
// Check if any of the path segments are absolute paths (which would be unsafe)
for (const pathSegment of normalizedPaths) {
if (path.isAbsolute(pathSegment)) {
throw new Error(
throw new DyadError(
`Unsafe path: joining "${paths.join(", ")}" with base "${basePath}" would escape the base directory`,
DyadErrorKind.Validation,
);
}
// Also check for home directory shortcuts which are effectively absolute
if (pathSegment.startsWith("~/")) {
throw new Error(
throw new DyadError(
`Unsafe path: joining "${paths.join(", ")}" with base "${basePath}" would escape the base directory`,
DyadErrorKind.Validation,
);
}
// Check for Windows-style absolute paths (C:\, D:\, etc.)
if (/^[A-Za-z]:[/\\]/.test(pathSegment)) {
throw new Error(
throw new DyadError(
`Unsafe path: joining "${paths.join(", ")}" with base "${basePath}" would escape the base directory`,
DyadErrorKind.Validation,
);
}
// Check for UNC paths (\\server\share)
if (pathSegment.startsWith("\\\\")) {
throw new Error(
throw new DyadError(
`Unsafe path: joining "${paths.join(", ")}" with base "${basePath}" would escape the base directory`,
DyadErrorKind.Validation,
);
}
}
......@@ -55,8 +60,9 @@ export function safeJoin(basePath: string, ...paths: string[]): string {
// If relativePath starts with ".." or is absolute, then resolvedJoinedPath is outside basePath
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(
throw new DyadError(
`Unsafe path: joining "${paths.join(", ")}" with base "${basePath}" would escape the base directory`,
DyadErrorKind.Validation,
);
}
......
......@@ -4,6 +4,7 @@ import { Worker } from "worker_threads";
import path from "path";
import { findAvailablePort } from "./port_utils";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("start_proxy_server");
......@@ -17,7 +18,10 @@ export async function startProxy(
} = {},
) {
if (!/^https?:\/\//.test(targetOrigin))
throw new Error("startProxy: targetOrigin must be absolute http/https URL");
throw new DyadError(
"startProxy: targetOrigin must be absolute http/https URL",
DyadErrorKind.Validation,
);
const port = await findAvailablePort(50_000, 60_000);
logger.info("Found available port", port);
const {
......
import { BrowserWindow } from "electron";
import log from "electron-log";
import {
DyadError,
isDyadErrorKindFilteredFromTelemetry,
} from "@/errors/dyad_error";
import { TelemetryEventPayload } from "@/ipc/types";
const logger = log.scope("telemetry");
......@@ -53,6 +57,10 @@ export function sendTelemetryException(
}
export function shouldFilterTelemetryException(error: unknown): boolean {
if (error instanceof DyadError) {
return isDyadErrorKindFilteredFromTelemetry(error.kind);
}
if (
error instanceof Error &&
error.name === "RateLimitError" &&
......
......@@ -3,6 +3,7 @@ import { readSettings, writeSettings } from "../main/settings";
import { Api, createApiClient } from "@neondatabase/api-client";
import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("neon_management_client");
......@@ -35,7 +36,10 @@ export async function refreshNeonToken(): Promise<void> {
}
if (!refreshToken) {
throw new Error("Neon refresh token not found. Please authenticate first.");
throw new DyadError(
"Neon refresh token not found. Please authenticate first.",
DyadErrorKind.Auth,
);
}
try {
......@@ -53,7 +57,10 @@ export async function refreshNeonToken(): Promise<void> {
);
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.statusText}`);
throw new DyadError(
`Token refresh failed: ${response.statusText}`,
DyadErrorKind.External,
);
}
const {
......@@ -182,7 +189,10 @@ export async function getNeonClient(): Promise<Api<unknown>> {
const expiresIn = settings.neon?.expiresIn;
if (!neonAccessToken) {
throw new Error("Neon access token not found. Please authenticate first.");
throw new DyadError(
"Neon access token not found. Please authenticate first.",
DyadErrorKind.Auth,
);
}
// Check if token needs refreshing
......@@ -193,7 +203,10 @@ export async function getNeonClient(): Promise<Api<unknown>> {
const newAccessToken = updatedSettings.neon?.accessToken?.value;
if (!newAccessToken) {
throw new Error("Failed to refresh Neon access token");
throw new DyadError(
"Failed to refresh Neon access token",
DyadErrorKind.Auth,
);
}
return createApiClient({
......@@ -223,14 +236,20 @@ export async function getNeonOrganizationId(): Promise<string> {
!response.data?.organizations ||
response.data.organizations.length === 0
) {
throw new Error("No organizations found for this Neon account");
throw new DyadError(
"No organizations found for this Neon account",
DyadErrorKind.NotFound,
);
}
// Return the first organization ID
return response.data.organizations[0].id;
} catch (error) {
logger.error("Error fetching Neon organizations:", error);
throw new Error("Failed to fetch Neon organizations");
throw new DyadError(
"Failed to fetch Neon organizations",
DyadErrorKind.External,
);
}
}
......
......@@ -81,6 +81,7 @@ import {
} from "@/ipc/handlers/compaction/compaction_handler";
import { getPostCompactionMessages } from "@/ipc/handlers/compaction/compaction_utils";
import { DEFAULT_MAX_TOOL_CALL_STEPS } from "@/constants/settings_constants";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("local_agent_handler");
const PLANNING_QUESTIONNAIRE_TOOL_NAME = "planning_questionnaire";
......@@ -347,7 +348,10 @@ export async function handleLocalAgentStream(
const initialChat = await loadChat();
if (!initialChat || !initialChat.app) {
throw new Error(`Chat not found: ${req.chatId}`);
throw new DyadError(
`Chat not found: ${req.chatId}`,
DyadErrorKind.NotFound,
);
}
let chat = initialChat;
......
......@@ -11,6 +11,7 @@ import {
import { deployAllSupabaseFunctions } from "../../../../../../supabase_admin/supabase_utils";
import { readSettings } from "../../../../../../main/settings";
import type { AgentContext } from "../tools/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("file_operations");
......@@ -101,6 +102,9 @@ export async function commitAllChanges(
};
} catch (error) {
logger.error(`Failed to commit changes: ${error}`);
throw new Error(`Failed to commit changes: ${error}`);
throw new DyadError(
`Failed to commit changes: ${error}`,
DyadErrorKind.External,
);
}
}
......@@ -45,6 +45,7 @@ import {
} from "./tools/types";
import { AgentToolConsent } from "@/lib/schemas";
import { getSupabaseClientCode } from "@/supabase_admin/supabase_context";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// Combined tool definitions array
export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool,
......@@ -251,7 +252,10 @@ export async function requireAgentToolConsent(
if (current === "always") return true;
if (current === "never")
throw new Error("Should not ask for consent for a tool marked as 'never'");
throw new DyadError(
"Should not ask for consent for a tool marked as 'never'",
DyadErrorKind.Internal,
);
// Ask renderer for a decision via event bridge
const requestId = `agent:${params.toolName}:${crypto.randomUUID()}`;
......@@ -330,7 +334,10 @@ function convertToolResultForAiSdk(
if (typeof result === "string") {
return { type: "text", value: result };
}
throw new Error(`Unsupported tool result type: ${typeof result}`);
throw new DyadError(
`Unsupported tool result type: ${typeof result}`,
DyadErrorKind.Internal,
);
}
export interface BuildAgentToolSetOptions {
......@@ -456,7 +463,10 @@ export function buildAgentToolSet(
inputPreview: tool.getConsentPreview?.(processedArgs) ?? null,
});
if (!allowed) {
throw new Error(`User denied permission for ${tool.name}`);
throw new DyadError(
`User denied permission for ${tool.name}`,
DyadErrorKind.UserCancelled,
);
}
// Track file edit tool usage before execution to capture all attempts
......
......@@ -4,6 +4,7 @@ import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { db } from "../../../../../../db";
import { messages } from "../../../../../../db/schema";
import { executeAddDependency } from "@/ipc/processors/executeAddDependency";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const addDependencySchema = z.object({
packages: z.array(z.string()).describe("Array of package names to install"),
......@@ -33,7 +34,10 @@ export const addDependencyTool: ToolDefinition<
: undefined;
if (!message) {
throw new Error("Message not found for adding dependencies");
throw new DyadError(
"Message not found for adding dependencies",
DyadErrorKind.NotFound,
);
}
await executeAddDependency({
......
......@@ -8,6 +8,7 @@ import {
} from "./types";
import { extractCodebase } from "../../../../../../utils/codebase";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("code_search");
......@@ -44,8 +45,9 @@ async function callCodeSearch(
if (!response.ok) {
const errorText = await response.text();
throw new Error(
throw new DyadError(
`Code search failed: ${response.status} ${response.statusText} - ${errorText}`,
DyadErrorKind.External,
);
}
......
......@@ -10,6 +10,7 @@ import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("delete_file");
......@@ -50,8 +51,9 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
normalizedPath === "./" ||
normalizedPath === ""
) {
throw new Error(
throw new DyadError(
`Refusing to delete project root for path: "${args.path}"`,
DyadErrorKind.Validation,
);
}
......
......@@ -10,6 +10,7 @@ import {
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const readFile = fs.promises.readFile;
const logger = log.scope("edit_file");
......@@ -167,7 +168,10 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
// Read original file content
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
throw new DyadError(
`File does not exist: ${args.path}`,
DyadErrorKind.NotFound,
);
}
const originalContent = await readFile(fullFilePath, "utf8");
......
......@@ -5,6 +5,7 @@
import { readSettings } from "@/main/settings";
import type { AgentContext } from "./types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
export const DYAD_ENGINE_URL =
process.env.DYAD_ENGINE_URL ?? "https://engine.dyad.sh/v1";
......@@ -33,7 +34,7 @@ export async function engineFetch(
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required");
throw new DyadError("Dyad Pro API key is required", DyadErrorKind.Auth);
}
const { headers: extraHeaders, ...restOptions } = options;
......
......@@ -3,6 +3,7 @@ import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { executeSupabaseSql } from "../../../../../../supabase_admin/supabase_management_client";
import { writeMigrationFile } from "../../../../../../ipc/utils/file_utils";
import { readSettings } from "../../../../../../main/settings";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const executeSqlSchema = z.object({
query: z.string().describe("The SQL query to execute"),
......@@ -33,7 +34,10 @@ export const executeSqlTool: ToolDefinition<z.infer<typeof executeSqlSchema>> =
execute: async (args, ctx: AgentContext) => {
if (!ctx.supabaseProjectId) {
throw new Error("Supabase is not connected to this app");
throw new DyadError(
"Supabase is not connected to this app",
DyadErrorKind.Precondition,
);
}
const sqlResult = await executeSupabaseSql({
......
......@@ -2,6 +2,7 @@ import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext } from "./types";
import { safeSend } from "@/ipc/utils/safe_sender";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("exit_plan");
......@@ -54,7 +55,10 @@ export const exitPlanTool: ToolDefinition<z.infer<typeof exitPlanSchema>> = {
execute: async (args, ctx: AgentContext) => {
if (!args.confirmation) {
throw new Error("User must confirm the plan before exiting plan mode");
throw new DyadError(
"User must confirm the plan before exiting plan mode",
DyadErrorKind.Precondition,
);
}
logger.log("Exiting plan mode, transitioning to implementation");
......
......@@ -12,6 +12,7 @@ import {
import { engineFetch } from "./engine_fetch";
import { DYAD_MEDIA_DIR_NAME } from "@/ipc/utils/media_path_utils";
import { ImageGenerationApiResponseSchema } from "@/ipc/types/image_generation";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("generate_image");
......@@ -68,7 +69,10 @@ async function callGenerateImage(
const data = ImageGenerationApiResponseSchema.parse(await response.json());
if (!data.data || data.data.length === 0) {
throw new Error("Image generation returned no results");
throw new DyadError(
"Image generation returned no results",
DyadErrorKind.External,
);
}
return data.data[0];
......@@ -93,12 +97,18 @@ async function saveGeneratedImage(
} else if (imageData.url) {
const response = await fetch(imageData.url);
if (!response.ok) {
throw new Error(`Failed to download generated image: ${response.status}`);
throw new DyadError(
`Failed to download generated image: ${response.status}`,
DyadErrorKind.External,
);
}
const arrayBuffer = await response.arrayBuffer();
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
} else {
throw new Error("Image generation returned no image data");
throw new DyadError(
"Image generation returned no image data",
DyadErrorKind.External,
);
}
return relativePath;
......
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlContent } from "./types";
import { getSupabaseProjectInfo } from "../../../../../../supabase_admin/supabase_context";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const getSupabaseProjectInfoSchema = z.object({
includeDbFunctions: z
......@@ -25,7 +26,10 @@ export const getSupabaseProjectInfoTool: ToolDefinition<
execute: async (args, ctx: AgentContext) => {
if (!ctx.supabaseProjectId) {
throw new Error("Supabase is not connected to this app");
throw new DyadError(
"Supabase is not connected to this app",
DyadErrorKind.Precondition,
);
}
ctx.onXmlStream(
......
......@@ -6,6 +6,7 @@ import {
escapeXmlContent,
} from "./types";
import { getSupabaseTableSchema } from "../../../../../../supabase_admin/supabase_context";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const getSupabaseTableSchemaSchema = z.object({
tableName: z
......@@ -33,7 +34,10 @@ export const getSupabaseTableSchemaTool: ToolDefinition<
execute: async (args, ctx: AgentContext) => {
if (!ctx.supabaseProjectId) {
throw new Error("Supabase is not connected to this app");
throw new DyadError(
"Supabase is not connected to this app",
DyadErrorKind.Precondition,
);
}
const tableAttr = args.tableName
......
import path from "node:path";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
/**
* Resolve and validate that `directory` stays within `appPath`.
......@@ -17,8 +18,9 @@ export function resolveDirectoryWithinAppPath(params: {
// Disallow any ".." path segment (even if the resolved path would remain within root).
// This makes path traversal attempts explicit and avoids surprising "a/../b" style inputs.
if (/(^|[\\/])\.\.([\\/]|$)/.test(params.directory)) {
throw new Error(
throw new DyadError(
`Invalid directory path: "${params.directory}" contains ".." path traversal segment`,
DyadErrorKind.Validation,
);
}
......@@ -51,8 +53,9 @@ export function resolveDirectoryWithinAppPath(params: {
!pathImpl.isAbsolute(relForCheck));
if (!isWithinRoot) {
throw new Error(
throw new DyadError(
`Invalid directory path: "${params.directory}" escapes the project directory`,
DyadErrorKind.Validation,
);
}
......
......@@ -2,6 +2,7 @@ import fs from "node:fs";
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const readFile = fs.promises.readFile;
......@@ -82,7 +83,10 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
const fullFilePath = safeJoin(ctx.appPath, args.path);
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
throw new DyadError(
`File does not exist: ${args.path}`,
DyadErrorKind.NotFound,
);
}
const content = await readFile(fullFilePath, "utf8");
......
......@@ -5,6 +5,7 @@ import { chats } from "@/db/schema";
import { eq } from "drizzle-orm";
import { getLogs } from "@/lib/log_store";
import type { ConsoleEntry } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const readLogsSchema = z.object({
type: z
......@@ -125,7 +126,7 @@ ${summary}
});
if (!chat || !chat.app) {
throw new Error("Chat or app not found.");
throw new DyadError("Chat or app not found.", DyadErrorKind.NotFound);
}
const appId = chat.app.id;
......
......@@ -17,6 +17,7 @@ import {
isSharedServerModule,
} from "@/supabase_admin/supabase_utils";
import { sendTelemetryEvent } from "@/ipc/utils/telemetry";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("search_replace");
......@@ -91,7 +92,10 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
execute: async (args, ctx: AgentContext) => {
// Validate old_string !== new_string
if (args.old_string === args.new_string) {
throw new Error("old_string and new_string must be different");
throw new DyadError(
"old_string and new_string must be different",
DyadErrorKind.Validation,
);
}
const fullFilePath = safeJoin(ctx.appPath, args.file_path);
......@@ -102,7 +106,10 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
}
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.file_path}`);
throw new DyadError(
`File does not exist: ${args.file_path}`,
DyadErrorKind.NotFound,
);
}
const original = await fs.promises.readFile(fullFilePath, "utf8");
......
import { z } from "zod";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { ToolDefinition, AgentContext, Todo } from "./types";
import { saveTodos, deleteTodos } from "../todo_persistence";
......@@ -126,8 +127,9 @@ export const updateTodosTool: ToolDefinition<
} else {
// New todo - require all fields
if (todo.content === undefined || todo.status === undefined) {
throw new Error(
throw new DyadError(
`New todo with id "${todo.id}" must have content and status defined`,
DyadErrorKind.Validation,
);
}
existingTodosMap.set(todo.id, todo as Todo);
......@@ -138,8 +140,9 @@ export const updateTodosTool: ToolDefinition<
// Replace mode: require all fields
for (const todo of args.todos) {
if (todo.content === undefined || todo.status === undefined) {
throw new Error(
throw new DyadError(
`Todo with id "${todo.id}" must have content and status defined when merge is false`,
DyadErrorKind.Validation,
);
}
}
......
......@@ -7,6 +7,7 @@ import {
isImageTooLarge,
MAX_IMAGE_DIMENSION,
} from "./image_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("web_crawl");
......@@ -130,15 +131,24 @@ export const webCrawlTool: ToolDefinition<z.infer<typeof webCrawlSchema>> = {
const result = await callWebCrawl(args.url, ctx);
if (!result) {
throw new Error("Web crawl returned no results");
throw new DyadError(
"Web crawl returned no results",
DyadErrorKind.External,
);
}
if (!result.markdown) {
throw new Error("No content available from web crawl");
throw new DyadError(
"No content available from web crawl",
DyadErrorKind.External,
);
}
if (!result.screenshot) {
throw new Error("No screenshot available from web crawl");
throw new DyadError(
"No screenshot available from web crawl",
DyadErrorKind.External,
);
}
logger.log(`Web crawl completed for URL: ${args.url}`);
......
......@@ -2,6 +2,7 @@ import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, escapeXmlContent, AgentContext } from "./types";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("web_fetch");
......@@ -10,7 +11,7 @@ function validateHttpUrl(url: string): void {
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
throw new DyadError(`Invalid URL: ${url}`, DyadErrorKind.Validation);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
......@@ -109,7 +110,10 @@ export const webFetchTool: ToolDefinition<z.infer<typeof webFetchSchema>> = {
const result = await callWebFetch(args.url, ctx);
if (!result) {
throw new Error("Web fetch returned no results");
throw new DyadError(
"Web fetch returned no results",
DyadErrorKind.NotFound,
);
}
// Combine markdown from all pages
......@@ -118,7 +122,10 @@ export const webFetchTool: ToolDefinition<z.infer<typeof webFetchSchema>> = {
.join("\n\n---\n\n");
if (!allContent) {
throw new Error("No content available from web fetch");
throw new DyadError(
"No content available from web fetch",
DyadErrorKind.NotFound,
);
}
logger.log(
......
......@@ -7,6 +7,7 @@ import {
escapeXmlContent,
} from "./types";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("web_search");
......@@ -71,7 +72,10 @@ function parseSSEEvents(
if (json.error) {
const errorMessage =
json.error.message || json.error.type || "Unknown SSE error";
throw new Error(`Web search SSE error: ${errorMessage}`);
throw new DyadError(
`Web search SSE error: ${errorMessage}`,
DyadErrorKind.External,
);
}
// OpenAI-style SSE format: { choices: [{ delta: { content: "..." } }] }
......@@ -117,7 +121,10 @@ async function callWebSearchSSE(
}
if (!response.body) {
throw new Error("Web search response has no body");
throw new DyadError(
"Web search response has no body",
DyadErrorKind.External,
);
}
const reader = response.body.getReader();
......@@ -172,7 +179,10 @@ export const webSearchTool: ToolDefinition<z.infer<typeof webSearchSchema>> = {
const result = await callWebSearchSSE(args.query, ctx);
if (!result) {
throw new Error("Web search returned no results");
throw new DyadError(
"Web search returned no results",
DyadErrorKind.External,
);
}
// Write final result to UI and DB with dyad-web-search wrapper
......
......@@ -33,6 +33,7 @@ import {
getThemeGenerationModelOptions,
resolveBuiltinModelAlias,
} from "@/ipc/shared/remote_language_model_catalog";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger);
......@@ -337,23 +338,35 @@ export function registerThemesHandlers() {
// Validate name
if (!trimmedName) {
throw new Error("Theme name is required");
throw new DyadError("Theme name is required", DyadErrorKind.Validation);
}
if (trimmedName.length > 100) {
throw new Error("Theme name must be less than 100 characters");
throw new DyadError(
"Theme name must be less than 100 characters",
DyadErrorKind.Validation,
);
}
// Validate description
if (trimmedDescription && trimmedDescription.length > 500) {
throw new Error("Theme description must be less than 500 characters");
throw new DyadError(
"Theme description must be less than 500 characters",
DyadErrorKind.Validation,
);
}
// Validate prompt
if (!trimmedPrompt) {
throw new Error("Theme prompt is required");
throw new DyadError(
"Theme prompt is required",
DyadErrorKind.Validation,
);
}
if (trimmedPrompt.length > 50000) {
throw new Error("Theme prompt must be less than 50,000 characters");
throw new DyadError(
"Theme prompt must be less than 50,000 characters",
DyadErrorKind.Validation,
);
}
// Check for duplicate theme name (case-insensitive)
......@@ -407,17 +420,23 @@ export function registerThemesHandlers() {
});
if (!currentTheme) {
throw new Error("Theme not found");
throw new DyadError("Theme not found", DyadErrorKind.NotFound);
}
// Validate and sanitize name if provided
if (params.name !== undefined) {
const trimmedName = params.name.trim();
if (!trimmedName) {
throw new Error("Theme name is required");
throw new DyadError(
"Theme name is required",
DyadErrorKind.Validation,
);
}
if (trimmedName.length > 100) {
throw new Error("Theme name must be less than 100 characters");
throw new DyadError(
"Theme name must be less than 100 characters",
DyadErrorKind.Validation,
);
}
// Check for duplicate theme name (case-insensitive), excluding current theme
......@@ -438,7 +457,10 @@ export function registerThemesHandlers() {
if (params.description !== undefined) {
const trimmedDescription = params.description.trim();
if (trimmedDescription.length > 500) {
throw new Error("Theme description must be less than 500 characters");
throw new DyadError(
"Theme description must be less than 500 characters",
DyadErrorKind.Validation,
);
}
updateData.description = trimmedDescription || null;
}
......@@ -447,10 +469,16 @@ export function registerThemesHandlers() {
if (params.prompt !== undefined) {
const trimmedPrompt = params.prompt.trim();
if (!trimmedPrompt) {
throw new Error("Theme prompt is required");
throw new DyadError(
"Theme prompt is required",
DyadErrorKind.Validation,
);
}
if (trimmedPrompt.length > 50000) {
throw new Error("Theme prompt must be less than 50,000 characters");
throw new DyadError(
"Theme prompt must be less than 50,000 characters",
DyadErrorKind.Validation,
);
}
updateData.prompt = trimmedPrompt;
}
......@@ -463,7 +491,7 @@ export function registerThemesHandlers() {
const theme = result[0];
if (!theme) {
throw new Error("Theme not found");
throw new DyadError("Theme not found", DyadErrorKind.NotFound);
}
return {
......@@ -493,7 +521,7 @@ export function registerThemesHandlers() {
// Validate base64 data
if (!data || typeof data !== "string") {
throw new Error("Invalid image data");
throw new DyadError("Invalid image data", DyadErrorKind.Validation);
}
// Validate and extract extension
......@@ -512,7 +540,10 @@ export function registerThemesHandlers() {
// Validate size (base64 to bytes approximation)
const sizeInBytes = (data.length * 3) / 4;
if (sizeInBytes > 10 * 1024 * 1024) {
throw new Error("Image size exceeds 10MB limit");
throw new DyadError(
"Image size exceeds 10MB limit",
DyadErrorKind.Validation,
);
}
// Ensure temp directory exists
......@@ -550,7 +581,10 @@ export function registerThemesHandlers() {
// File might already be deleted (ENOENT), that's okay
// But other errors (permissions, etc.) should be reported
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error("Failed to cleanup temporary image file");
throw new DyadError(
"Failed to cleanup temporary image file",
DyadErrorKind.External,
);
}
}
}
......@@ -586,21 +620,30 @@ Modern dark theme with purple accents for testing.
// Validate inputs - image paths are required
if (params.imagePaths.length === 0) {
throw new Error("Please upload at least one image to generate a theme");
throw new DyadError(
"Please upload at least one image to generate a theme",
DyadErrorKind.External,
);
}
if (params.imagePaths.length > 5) {
throw new Error("Maximum 5 images allowed");
throw new DyadError("Maximum 5 images allowed", DyadErrorKind.External);
}
// Validate keywords length
if (params.keywords.length > 500) {
throw new Error("Keywords must be less than 500 characters");
throw new DyadError(
"Keywords must be less than 500 characters",
DyadErrorKind.Validation,
);
}
// Validate generation mode
if (!["inspired", "high-fidelity"].includes(params.generationMode)) {
throw new Error("Invalid generation mode");
throw new DyadError(
"Invalid generation mode",
DyadErrorKind.Validation,
);
}
// Validate and map model selection
......@@ -725,7 +768,10 @@ Modern theme extracted from website for testing.
try {
parsedUrl = new URL(params.url);
} catch {
throw new Error("Invalid URL format. Please enter a valid URL.");
throw new DyadError(
"Invalid URL format. Please enter a valid URL.",
DyadErrorKind.Validation,
);
}
// Only allow HTTP/HTTPS protocols (security: prevent file://, javascript://, etc.)
......@@ -748,17 +794,26 @@ Modern theme extracted from website for testing.
/\.local$/i,
];
if (blockedPatterns.some((p) => p.test(hostname))) {
throw new Error("Cannot crawl internal network addresses.");
throw new DyadError(
"Cannot crawl internal network addresses.",
DyadErrorKind.External,
);
}
// Validate keywords length
if (params.keywords.length > 500) {
throw new Error("Keywords must be less than 500 characters");
throw new DyadError(
"Keywords must be less than 500 characters",
DyadErrorKind.Validation,
);
}
// Validate generation mode
if (!["inspired", "high-fidelity"].includes(params.generationMode)) {
throw new Error("Invalid generation mode");
throw new DyadError(
"Invalid generation mode",
DyadErrorKind.Validation,
);
}
// Validate and map model selection
......@@ -772,7 +827,7 @@ Modern theme extracted from website for testing.
// Get API key for Dyad Engine
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required");
throw new DyadError("Dyad Pro API key is required", DyadErrorKind.Auth);
}
// Crawl the website
......
......@@ -28,6 +28,7 @@ import {
analyzeComponent,
} from "../../utils/visual_editing_utils";
import { normalizePath } from "../../../../../shared/normalizePath";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
// Client allows 7.5 MB raw; base64 expands by ~4/3 plus data URL prefix
const MAX_IMAGE_SIZE = Math.ceil((7.5 * 1024 * 1024) / 3) * 4 + 100; // ~10,485,860
......@@ -49,7 +50,10 @@ export function registerVisualEditingHandlers() {
});
if (!app) {
throw new Error(`App not found: ${appId}`);
throw new DyadError(
`App not found: ${appId}`,
DyadErrorKind.NotFound,
);
}
const appPath = getDyadAppPath(app.path);
......@@ -226,7 +230,10 @@ export function registerVisualEditingHandlers() {
});
if (!app) {
throw new Error(`App not found: ${appId}`);
throw new DyadError(
`App not found: ${appId}`,
DyadErrorKind.NotFound,
);
}
const appPath = getDyadAppPath(app.path);
......
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { retryWithRateLimit } from "@/ipc/utils/retryWithRateLimit";
import { getSupabaseClient } from "./supabase_management_client";
......@@ -26,12 +27,16 @@ async function getPublishableKey({
`Get API keys for ${projectId}`,
);
} catch (error) {
throw new Error(
throw new DyadError(
`Failed to fetch API keys for Supabase project "${projectId}". This could be due to: 1) Invalid project ID, 2) Network connectivity issues, or 3) Supabase API unavailability. Original error: ${error instanceof Error ? error.message : String(error)}`,
DyadErrorKind.External,
);
}
if (!keys) {
throw new Error("No keys found for Supabase project " + projectId);
throw new DyadError(
"No keys found for Supabase project " + projectId,
DyadErrorKind.NotFound,
);
}
const publishableKey = keys.find(
(key) =>
......@@ -39,8 +44,9 @@ async function getPublishableKey({
);
if (!publishableKey) {
throw new Error(
throw new DyadError(
"No publishable key found for project. Make sure you are connected to the correct Supabase account and project. See https://dyad.sh/docs/integrations/supabase#no-publishable-keys",
DyadErrorKind.NotFound,
);
}
return publishableKey.api_key;
......
......@@ -14,6 +14,7 @@ import {
RateLimitError,
retryWithRateLimit,
} from "../ipc/utils/retryWithRateLimit";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const fsPromises = fs.promises;
......@@ -114,8 +115,9 @@ export async function refreshSupabaseToken(): Promise<void> {
}
if (!refreshToken) {
throw new Error(
throw new DyadError(
"Supabase refresh token not found. Please authenticate first.",
DyadErrorKind.Auth,
);
}
......@@ -133,8 +135,9 @@ export async function refreshSupabaseToken(): Promise<void> {
);
if (!response.ok) {
throw new Error(
throw new DyadError(
`Supabase token refresh failed. Try going to Settings to disconnect Supabase and then reconnect to Supabase. Error status: ${response.statusText}`,
DyadErrorKind.External,
);
}
......@@ -183,8 +186,9 @@ export async function getSupabaseClient({
const expiresIn = settings.supabase?.expiresIn;
if (!supabaseAccessToken) {
throw new Error(
throw new DyadError(
"Supabase access token not found. Please authenticate first.",
DyadErrorKind.Auth,
);
}
......@@ -196,7 +200,10 @@ export async function getSupabaseClient({
const newAccessToken = updatedSettings.supabase?.accessToken?.value;
if (!newAccessToken) {
throw new Error("Failed to refresh Supabase access token");
throw new DyadError(
"Failed to refresh Supabase access token",
DyadErrorKind.Auth,
);
}
return new SupabaseManagementAPI({
......@@ -236,8 +243,9 @@ async function refreshSupabaseTokenForOrganization(
const org = settings.supabase?.organizations?.[organizationSlug];
if (!org) {
throw new Error(
throw new DyadError(
`Supabase organization ${organizationSlug} not found. Please authenticate first.`,
DyadErrorKind.Auth,
);
}
......@@ -247,8 +255,9 @@ async function refreshSupabaseTokenForOrganization(
const refreshToken = org.refreshToken?.value;
if (!refreshToken) {
throw new Error(
throw new DyadError(
"Supabase refresh token not found. Please authenticate first.",
DyadErrorKind.Auth,
);
}
......@@ -265,8 +274,9 @@ async function refreshSupabaseTokenForOrganization(
);
if (!response.ok) {
throw new Error(
throw new DyadError(
`Supabase token refresh failed. Try going to Settings to disconnect Supabase and then reconnect. Error status: ${response.statusText}`,
DyadErrorKind.External,
);
}
......@@ -319,15 +329,17 @@ export async function getSupabaseClientForOrganization(
const org = settings.supabase?.organizations?.[organizationSlug];
if (!org) {
throw new Error(
throw new DyadError(
`Supabase organization ${organizationSlug} not found. Please authenticate first.`,
DyadErrorKind.Auth,
);
}
const accessToken = org.accessToken?.value;
if (!accessToken) {
throw new Error(
throw new DyadError(
`Supabase access token not found for organization ${organizationSlug}. Please authenticate first.`,
DyadErrorKind.Auth,
);
}
......@@ -343,8 +355,9 @@ export async function getSupabaseClientForOrganization(
const newAccessToken = updatedOrg?.accessToken?.value;
if (!newAccessToken) {
throw new Error(
throw new DyadError(
`Failed to refresh Supabase access token for organization ${organizationSlug}`,
DyadErrorKind.Auth,
);
}
......@@ -715,7 +728,10 @@ export async function listSupabaseBranches({
logger.info(
`Branches not available for project ${supabaseProjectId} (403 Forbidden - likely free tier)`,
);
throw new Error("Branches are only supported for Supabase paid customers");
throw new DyadError(
"Branches are only supported for Supabase paid customers",
DyadErrorKind.Precondition,
);
}
if (response.status !== 200) {
......@@ -898,8 +914,9 @@ async function collectFunctionFiles({
}
if (!functionDirectory) {
throw new Error(
throw new DyadError(
`Unable to locate directory for Supabase function ${functionName}`,
DyadErrorKind.NotFound,
);
}
......@@ -908,8 +925,9 @@ async function collectFunctionFiles({
try {
await fsPromises.access(indexPath);
} catch {
throw new Error(
throw new DyadError(
`Supabase function ${functionName} is missing an index.ts entrypoint`,
DyadErrorKind.Validation,
);
}
......
......@@ -8,6 +8,7 @@ import {
listSupabaseFunctions,
type DeployedFunctionResponse,
} from "./supabase_management_client";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("supabase_utils");
......@@ -57,8 +58,9 @@ export function extractFunctionNameFromPath(filePath: string): string {
const match = normalized.match(/^supabase\/functions\/([^/]+)/);
if (!match) {
throw new Error(
throw new DyadError(
`Invalid Supabase function path: ${filePath}. Expected format: supabase/functions/{functionName}/...`,
DyadErrorKind.Validation,
);
}
......@@ -66,8 +68,9 @@ export function extractFunctionNameFromPath(filePath: string): string {
// Exclude _shared and other special directories
if (functionName.startsWith("_")) {
throw new Error(
throw new DyadError(
`Invalid Supabase function path: ${filePath}. Function names starting with "_" are reserved for special directories.`,
DyadErrorKind.Validation,
);
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论