Unverified 提交 158b1bcb authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

refactor(migration):install drizzle-kit in user app instead of bundling (#3280)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3280" 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 in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
上级 2c3fade9
...@@ -40,9 +40,6 @@ const ignore = (file: string) => { ...@@ -40,9 +40,6 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/html-to-image")) { if (file.startsWith("/node_modules/html-to-image")) {
return false; return false;
} }
if (file.startsWith("/node_modules/drizzle-kit")) {
return false;
}
if (file.startsWith("/node_modules/better-sqlite3")) { if (file.startsWith("/node_modules/better-sqlite3")) {
return false; return false;
} }
...@@ -124,12 +121,7 @@ const config: ForgeConfig = { ...@@ -124,12 +121,7 @@ const config: ForgeConfig = {
unpackDir: "node_modules/node-pty", unpackDir: "node_modules/node-pty",
}, },
ignore, ignore,
extraResource: [ extraResource: ["node_modules/dugite/git", "node_modules/@vscode"],
"node_modules/dugite/git",
"node_modules/@vscode",
"node_modules/drizzle-kit",
"node_modules/drizzle-orm",
],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/], // ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
}, },
rebuildConfig: { rebuildConfig: {
......
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
XCircle, XCircle,
ChevronDown, ChevronDown,
AlertTriangle, AlertTriangle,
Info,
} from "lucide-react"; } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
...@@ -26,6 +27,7 @@ import { useTranslation } from "react-i18next"; ...@@ -26,6 +27,7 @@ import { useTranslation } from "react-i18next";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
import { useNeon } from "@/hooks/useNeon"; import { useNeon } from "@/hooks/useNeon";
import { queryKeys } from "@/lib/queryKeys";
interface MigrationPanelProps { interface MigrationPanelProps {
appId: number; appId: number;
...@@ -37,8 +39,28 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => { ...@@ -37,8 +39,28 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => {
const { projectInfo, branches } = useNeon(appId); const { projectInfo, branches } = useNeon(appId);
const [showErrorDetails, setShowErrorDetails] = useState(false); const [showErrorDetails, setShowErrorDetails] = useState(false);
const errorDetailsId = useId(); const errorDetailsId = useId();
const queryClient = useQueryClient();
const dependenciesStatus = useQuery({
queryKey: queryKeys.migration.dependenciesStatus({ appId }),
queryFn: () => ipc.migration.dependenciesStatus({ appId }),
staleTime: 0,
refetchOnMount: "always",
});
const depsInstalled = dependenciesStatus.data?.installed;
// Capture the install state at click time so the in-flight label doesn't
// flicker if the status query refetches mid-mutation.
const installingDepsRef = useRef(false);
const invalidateDepsStatus = () =>
queryClient.invalidateQueries({
queryKey: queryKeys.migration.dependenciesStatus({ appId }),
});
const pushMutation = useMutation({ const pushMutation = useMutation({
mutationFn: () => ipc.migration.push({ appId }), mutationFn: () => ipc.migration.push({ appId }),
onSuccess: invalidateDepsStatus,
onError: invalidateDepsStatus,
}); });
const productionBranch = branches.find( const productionBranch = branches.find(
...@@ -110,6 +132,16 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => { ...@@ -110,6 +132,16 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => {
<span>{t("integrations.migration.backupWarning")}</span> <span>{t("integrations.migration.backupWarning")}</span>
</div> </div>
{depsInstalled === false && !pushMutation.isPending && (
<div
role="note"
className="flex items-start gap-2 text-sm text-blue-800 dark:text-blue-200 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3"
>
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t("integrations.migration.installDependenciesNote")}</span>
</div>
)}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
disabled={pushMutation.isPending || isProductionBranchActive} disabled={pushMutation.isPending || isProductionBranchActive}
...@@ -122,7 +154,9 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => { ...@@ -122,7 +154,9 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => {
{pushMutation.isPending ? ( {pushMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t("integrations.migration.migrating")} {installingDepsRef.current
? t("integrations.migration.installingDependencies")
: t("integrations.migration.migrating")}
</> </>
) : ( ) : (
<> <>
...@@ -147,6 +181,7 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => { ...@@ -147,6 +181,7 @@ export const MigrationPanel = ({ appId }: MigrationPanelProps) => {
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
setShowErrorDetails(false); setShowErrorDetails(false);
installingDepsRef.current = depsInstalled === false;
pushMutation.mutate(); pushMutation.mutate();
}} }}
> >
......
...@@ -638,6 +638,8 @@ ...@@ -638,6 +638,8 @@
"descriptionWithBranches": "Copy the schema in {{projectName}} from {{sourceBranchName}} to {{targetBranchName}}.", "descriptionWithBranches": "Copy the schema in {{projectName}} from {{sourceBranchName}} to {{targetBranchName}}.",
"migrateToProduction": "Migrate to Production", "migrateToProduction": "Migrate to Production",
"migrating": "Migrating...", "migrating": "Migrating...",
"installingDependencies": "Installing dependencies, then migrating...",
"installDependenciesNote": "First migration will install drizzle-kit and drizzle-orm. This may take a few minutes.",
"success": "Migration applied successfully.", "success": "Migration applied successfully.",
"errorMessage": "Migration couldn't be applied.", "errorMessage": "Migration couldn't be applied.",
"showDetails": "Show details", "showDetails": "Show details",
......
...@@ -635,6 +635,8 @@ ...@@ -635,6 +635,8 @@
"descriptionWithBranches": "Copie o schema em {{projectName}} de {{sourceBranchName}} para {{targetBranchName}}.", "descriptionWithBranches": "Copie o schema em {{projectName}} de {{sourceBranchName}} para {{targetBranchName}}.",
"migrateToProduction": "Migrar para Produção", "migrateToProduction": "Migrar para Produção",
"migrating": "Migrando...", "migrating": "Migrando...",
"installingDependencies": "Instalando dependências e migrando...",
"installDependenciesNote": "A primeira migração instalará drizzle-kit e drizzle-orm. Isso pode levar alguns minutos.",
"success": "Migração aplicada com sucesso.", "success": "Migração aplicada com sucesso.",
"errorMessage": "Não foi possível aplicar a migração.", "errorMessage": "Não foi possível aplicar a migração.",
"showDetails": "Mostrar detalhes", "showDetails": "Mostrar detalhes",
......
...@@ -635,6 +635,8 @@ ...@@ -635,6 +635,8 @@
"descriptionWithBranches": "将 {{projectName}} 中 {{sourceBranchName}} 的 schema 复制到 {{targetBranchName}}。", "descriptionWithBranches": "将 {{projectName}} 中 {{sourceBranchName}} 的 schema 复制到 {{targetBranchName}}。",
"migrateToProduction": "迁移到生产环境", "migrateToProduction": "迁移到生产环境",
"migrating": "迁移中...", "migrating": "迁移中...",
"installingDependencies": "正在安装依赖并迁移...",
"installDependenciesNote": "首次迁移将安装 drizzle-kit 和 drizzle-orm,可能需要几分钟。",
"success": "迁移已成功应用。", "success": "迁移已成功应用。",
"errorMessage": "无法应用此次迁移。", "errorMessage": "无法应用此次迁移。",
"showDetails": "显示详情", "showDetails": "显示详情",
......
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { eq } from "drizzle-orm";
import { createTypedHandler } from "./base"; import { createTypedHandler } from "./base";
import { migrationContracts } from "../types/migration"; import { migrationContracts } from "../types/migration";
import { import {
...@@ -10,11 +11,17 @@ import { ...@@ -10,11 +11,17 @@ import {
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getAppWithNeonBranch } from "../utils/neon_utils"; import { getAppWithNeonBranch } from "../utils/neon_utils";
import { IS_TEST_BUILD } from "../utils/test_utils"; import { IS_TEST_BUILD } from "../utils/test_utils";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { getDyadAppPath } from "../../paths/paths";
import { gitAdd, gitCommit } from "../utils/git_utils";
import { import {
logger, logger,
getProductionBranchId, getProductionBranchId,
createTempDrizzleConfig, createTempDrizzleConfig,
spawnDrizzleKit, spawnDrizzleKit,
areMigrationDepsInstalled,
installMigrationDeps,
} from "../utils/migration_utils"; } from "../utils/migration_utils";
// ============================================================================= // =============================================================================
...@@ -22,6 +29,32 @@ import { ...@@ -22,6 +29,32 @@ import {
// ============================================================================= // =============================================================================
export function registerMigrationHandlers() { export function registerMigrationHandlers() {
// -------------------------------------------------------------------------
// migration:dependencies-status
// -------------------------------------------------------------------------
createTypedHandler(
migrationContracts.dependenciesStatus,
async (_, params) => {
const { appId } = params;
if (IS_TEST_BUILD) {
return { installed: true };
}
const rows = await db
.select()
.from(apps)
.where(eq(apps.id, appId))
.limit(1);
if (rows.length === 0) {
throw new DyadError(
`App with ID ${appId} not found`,
DyadErrorKind.NotFound,
);
}
const appPath = getDyadAppPath(rows[0].path);
return { installed: await areMigrationDepsInstalled(appPath) };
},
);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// migration:push // migration:push
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
...@@ -91,7 +124,39 @@ export function registerMigrationHandlers() { ...@@ -91,7 +124,39 @@ export function registerMigrationHandlers() {
); );
} }
// 5. Create temp directory with restricted permissions // 5. Ensure drizzle-kit + drizzle-orm are installed in the user's app
const appPath = getDyadAppPath(appData.path);
if (!(await areMigrationDepsInstalled(appPath))) {
logger.info(
`Migration dependencies not installed in ${appPath}; installing now.`,
);
await installMigrationDeps(appPath);
try {
// Stage only the files modified by the dependency install so we don't
// sweep unrelated user changes into the commit.
await gitAdd({ path: appPath, filepath: "package.json" });
for (const lockfile of [
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
]) {
await gitAdd({ path: appPath, filepath: lockfile }).catch(() => {});
}
await gitCommit({
path: appPath,
message: "[dyad] install drizzle-kit and drizzle-orm for migrations",
});
logger.info(`Committed migration dependency install in ${appPath}`);
} catch (err) {
logger.warn(
`Failed to commit migration dependency install. This may happen if the project is not in a git repository, or if there are no changes to commit.`,
err,
);
}
}
// 6. Create temp directory with restricted permissions
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-migration-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-migration-"));
try { try {
...@@ -109,6 +174,7 @@ export function registerMigrationHandlers() { ...@@ -109,6 +174,7 @@ export function registerMigrationHandlers() {
const introspectResult = await spawnDrizzleKit({ const introspectResult = await spawnDrizzleKit({
args: ["introspect", `--config=${introspectConfigPath}`], args: ["introspect", `--config=${introspectConfigPath}`],
cwd: tmpDir, cwd: tmpDir,
appPath,
connectionUri: devUri, connectionUri: devUri,
}); });
...@@ -156,6 +222,7 @@ export function registerMigrationHandlers() { ...@@ -156,6 +222,7 @@ export function registerMigrationHandlers() {
const pushResult = await spawnDrizzleKit({ const pushResult = await spawnDrizzleKit({
args: ["push", "--force", `--config=${pushConfigPath}`], args: ["push", "--force", `--config=${pushConfigPath}`],
cwd: tmpDir, cwd: tmpDir,
appPath,
connectionUri: prodUri, connectionUri: prodUri,
}); });
......
...@@ -18,6 +18,22 @@ export const MigrationPushResponseSchema = z.object({ ...@@ -18,6 +18,22 @@ export const MigrationPushResponseSchema = z.object({
export type MigrationPushResponse = z.infer<typeof MigrationPushResponseSchema>; export type MigrationPushResponse = z.infer<typeof MigrationPushResponseSchema>;
export const MigrationDependenciesStatusParamsSchema = z.object({
appId: z.number(),
});
export type MigrationDependenciesStatusParams = z.infer<
typeof MigrationDependenciesStatusParamsSchema
>;
export const MigrationDependenciesStatusResponseSchema = z.object({
installed: z.boolean(),
});
export type MigrationDependenciesStatusResponse = z.infer<
typeof MigrationDependenciesStatusResponseSchema
>;
// ============================================================================= // =============================================================================
// Migration Contracts // Migration Contracts
// ============================================================================= // =============================================================================
...@@ -28,6 +44,11 @@ export const migrationContracts = { ...@@ -28,6 +44,11 @@ export const migrationContracts = {
input: MigrationPushParamsSchema, input: MigrationPushParamsSchema,
output: MigrationPushResponseSchema, output: MigrationPushResponseSchema,
}), }),
dependenciesStatus: defineContract({
channel: "migration:dependencies-status",
input: MigrationDependenciesStatusParamsSchema,
output: MigrationDependenciesStatusResponseSchema,
}),
} as const; } as const;
// ============================================================================= // =============================================================================
......
import log from "electron-log"; import log from "electron-log";
import { app, utilityProcess } from "electron"; import { utilityProcess } from "electron";
import path from "node:path"; import path from "node:path";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getNeonClient } from "../../neon_admin/neon_management_client"; import { getNeonClient } from "../../neon_admin/neon_management_client";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { IS_TEST_BUILD } from "../utils/test_utils"; import { IS_TEST_BUILD } from "../utils/test_utils";
import { readEffectiveSettings } from "@/main/settings";
import {
ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
buildAddDependencyCommand,
CommandExecutionError,
detectPreferredPackageManager,
ensureSocketFirewallInstalled,
runCommand,
} from "./socket_firewall";
export const logger = log.scope("migration_handlers"); export const logger = log.scope("migration_handlers");
const MIGRATION_DEPS = ["drizzle-kit", "drizzle-orm"] as const;
/** /**
* Finds the production (default) branch for a Neon project. * Finds the production (default) branch for a Neon project.
*/ */
...@@ -36,18 +47,69 @@ export async function getProductionBranchId( ...@@ -36,18 +47,69 @@ export async function getProductionBranchId(
} }
/** /**
* Resolves the path to the drizzle-kit bin.cjs file. * Resolves the path to the drizzle-kit bin.cjs inside the user's app.
*/ */
export function getDrizzleKitPath(): string { export function getDrizzleKitPath(appPath: string): string {
if (!app.isPackaged) { return path.join(appPath, "node_modules", "drizzle-kit", "bin.cjs");
return path.join( }
app.getAppPath(),
"node_modules", export async function areMigrationDepsInstalled(
"drizzle-kit", appPath: string,
"bin.cjs", ): Promise<boolean> {
try {
await fs.access(getDrizzleKitPath(appPath));
await fs.access(path.join(appPath, "node_modules", "drizzle-orm"));
return true;
} catch {
return false;
}
}
export async function installMigrationDeps(appPath: string): Promise<void> {
if (IS_TEST_BUILD) {
return;
}
const settings = await readEffectiveSettings();
let useSocketFirewall = settings.blockUnsafeNpmPackages !== false;
if (useSocketFirewall) {
const sfw = await ensureSocketFirewallInstalled();
if (!sfw.available) {
useSocketFirewall = false;
if (sfw.warningMessage) {
logger.warn(sfw.warningMessage);
}
}
}
const packageManager = await detectPreferredPackageManager();
const command = buildAddDependencyCommand(
[...MIGRATION_DEPS],
packageManager,
useSocketFirewall,
);
logger.info(
`Installing migration deps in ${appPath}: ${command.command} ${command.args.join(" ")}`,
);
try {
await runCommand(command.command, command.args, {
cwd: appPath,
timeoutMs: ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
});
} catch (error) {
const detail =
error instanceof CommandExecutionError
? error.stderr.trim() || error.stdout.trim() || error.message
: error instanceof Error
? error.message
: String(error);
throw new DyadError(
`Failed to install migration dependencies: ${detail}`,
DyadErrorKind.External,
); );
} }
return path.join(process.resourcesPath, "drizzle-kit", "bin.cjs");
} }
/** /**
...@@ -88,11 +150,14 @@ export async function createTempDrizzleConfig({ ...@@ -88,11 +150,14 @@ export async function createTempDrizzleConfig({
export async function spawnDrizzleKit({ export async function spawnDrizzleKit({
args, args,
cwd, cwd,
appPath,
connectionUri, connectionUri,
timeoutMs = 120_000, timeoutMs = 120_000,
}: { }: {
args: string[]; args: string[];
cwd: string; cwd: string;
/** Path to the user's app — drizzle-kit and drizzle-orm resolve from here. */
appPath: string;
/** Passed as DRIZZLE_DATABASE_URL env var so credentials never touch disk. */ /** Passed as DRIZZLE_DATABASE_URL env var so credentials never touch disk. */
connectionUri: string; connectionUri: string;
timeoutMs?: number; timeoutMs?: number;
...@@ -126,15 +191,13 @@ export async function spawnDrizzleKit({ ...@@ -126,15 +191,13 @@ export async function spawnDrizzleKit({
); );
} }
const drizzleKitBin = getDrizzleKitPath(); const drizzleKitBin = getDrizzleKitPath(appPath);
// Create a node_modules symlink in the working directory so that generated // Create a node_modules symlink in the working directory so that generated
// schema files can resolve drizzle-orm and other dependencies through // schema files can resolve drizzle-orm and other dependencies through
// standard Node.js module resolution (walking up to find node_modules), // standard Node.js module resolution (walking up to find node_modules),
// in addition to the NODE_PATH env var set below. // in addition to the NODE_PATH env var set below.
const nodeModulesPath = app.isPackaged const nodeModulesPath = path.join(appPath, "node_modules");
? process.resourcesPath
: path.join(app.getAppPath(), "node_modules");
const symlinkTarget = path.join(cwd, "node_modules"); const symlinkTarget = path.join(cwd, "node_modules");
try { try {
await fs.symlink(nodeModulesPath, symlinkTarget, "junction"); await fs.symlink(nodeModulesPath, symlinkTarget, "junction");
......
...@@ -308,6 +308,15 @@ export const queryKeys = { ...@@ -308,6 +308,15 @@ export const queryKeys = {
repos: ["github", "repos"] as const, repos: ["github", "repos"] as const,
}, },
// ─────────────────────────────────────────────────────────────────────────────
// Migration
// ─────────────────────────────────────────────────────────────────────────────
migration: {
all: ["migration"] as const,
dependenciesStatus: ({ appId }: { appId: number }) =>
["migration", "dependencies-status", appId] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Neon // Neon
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -401,6 +410,7 @@ export type AppQueryKey = ...@@ -401,6 +410,7 @@ export type AppQueryKey =
| QueryKeyOf<(typeof queryKeys.mcp)[keyof typeof queryKeys.mcp]> | QueryKeyOf<(typeof queryKeys.mcp)[keyof typeof queryKeys.mcp]>
| QueryKeyOf<(typeof queryKeys.supabase)[keyof typeof queryKeys.supabase]> | QueryKeyOf<(typeof queryKeys.supabase)[keyof typeof queryKeys.supabase]>
| QueryKeyOf<(typeof queryKeys.github)[keyof typeof queryKeys.github]> | QueryKeyOf<(typeof queryKeys.github)[keyof typeof queryKeys.github]>
| QueryKeyOf<(typeof queryKeys.migration)[keyof typeof queryKeys.migration]>
| QueryKeyOf<(typeof queryKeys.neon)[keyof typeof queryKeys.neon]> | QueryKeyOf<(typeof queryKeys.neon)[keyof typeof queryKeys.neon]>
| QueryKeyOf<(typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars]> | QueryKeyOf<(typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars]>
| QueryKeyOf<(typeof queryKeys.media)[keyof typeof queryKeys.media]>; | QueryKeyOf<(typeof queryKeys.media)[keyof typeof queryKeys.media]>;
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论