Unverified 提交 3eb2c5e8 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

feat: add app commands configuration in Configure panel (#2433)

Add the ability to configure install and start commands in the Configure panel after an app has been created. Previously these were only configurable when first importing an app. Changes: - Add updateAppCommands IPC contract and handler - Add AppCommandsSection component in ConfigurePanel - Allow users to view, edit, and clear custom commands - Include validation requiring both commands when customizing - Add E2E tests for the new feature https://claude.ai/code/session_01RHmPUNG7Bdw9MC1DKQJX8g <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2433"> <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 --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it introduces a new IPC write path that persists user-provided command strings and affects how apps will run, though guarded by client/server validation and locking. > > **Overview** > Adds an **App Commands** card to `ConfigurePanel` that lets users view default vs custom install/start commands, edit them with client-side validation (both required), cancel edits, and clear back to defaults. > > Introduces a new IPC contract `updateAppCommands` plus a main-process handler that trims inputs, enforces *both-or-neither* semantics, updates the `apps` record under `withLock`, and triggers UI refresh/toasts. > > Adds Playwright E2E coverage for configure/edit/clear flows, validation behavior, and canceling edits. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3f0abe3b8f9ed2483ed27d537dd874e4caa66fcf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Let users configure custom install and start commands from the Configure panel after an app is created. Defaults to “pnpm install && pnpm dev” when unset, and both fields are required when customizing. - **New Features** - Configure, edit, or clear custom install/start commands in ConfigurePanel. - Added updateAppCommands IPC contract and handler to persist commands. - E2E tests cover happy path, validation (both required), cancel, and clear flows. - **Bug Fixes** - Prevent overwriting edits on refetch; fix custom commands check (AND), and update default-state message. - Enforce server-side validation (both commands set together or both cleared) and remove sensitive command content from logs. <sup>Written for commit 3f0abe3b8f9ed2483ed27d537dd874e4caa66fcf. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 edef1c18
import { expect } from "@playwright/test";
import { test } from "./helpers/test_helper";
test("configure app commands", async ({ po }) => {
// Create an app first
await po.sendPrompt("tc=1");
// Navigate to configure panel
await po.selectPreviewMode("configure");
// Verify default state - no custom commands
await expect(
po.page.getByText("Using default install and start commands"),
).toBeVisible();
// Click to configure custom commands
await po.page.getByTestId("configure-app-commands").click();
// Fill in custom install command
await po.page.getByTestId("install-command-input").click();
await po.page.getByTestId("install-command-input").fill("npm install");
// Fill in custom start command
await po.page.getByTestId("start-command-input").click();
await po.page.getByTestId("start-command-input").fill("npm run dev");
// Save the commands
await po.page.getByTestId("save-app-commands").click();
// Verify success toast
await po.waitForToastWithText("App commands saved");
// Verify the commands are displayed
await expect(po.page.getByTestId("current-install-command")).toHaveText(
"npm install",
);
await expect(po.page.getByTestId("current-start-command")).toHaveText(
"npm run dev",
);
// Test editing existing commands
await po.page.getByTestId("edit-app-commands").click();
// Update the commands
await po.page.getByTestId("install-command-input").fill("pnpm install");
await po.page.getByTestId("start-command-input").fill("pnpm dev --port 3001");
// Save the updated commands
await po.page.getByTestId("save-app-commands").click();
// Verify the updated commands are displayed
await expect(po.page.getByTestId("current-install-command")).toHaveText(
"pnpm install",
);
await expect(po.page.getByTestId("current-start-command")).toHaveText(
"pnpm dev --port 3001",
);
// Test clearing commands
await po.page.getByTestId("clear-app-commands").click();
// Verify commands are cleared and default message is shown again
await expect(
po.page.getByText("Using default install and start commands"),
).toBeVisible();
});
test("app commands validation - both required", async ({ po }) => {
// Create an app first
await po.sendPrompt("tc=1");
// Navigate to configure panel
await po.selectPreviewMode("configure");
// Click to configure custom commands
await po.page.getByTestId("configure-app-commands").click();
// Fill in only install command (leaving start command empty)
await po.page.getByTestId("install-command-input").fill("npm install");
// Verify validation message appears
await expect(
po.page.getByText("Both commands are required when customizing."),
).toBeVisible();
// Verify save button is disabled (via the validation state)
const saveButton = po.page.getByTestId("save-app-commands");
await expect(saveButton).toBeDisabled();
// Now fill in both commands
await po.page.getByTestId("start-command-input").fill("npm run dev");
// Validation message should disappear
await expect(
po.page.getByText("Both commands are required when customizing."),
).not.toBeVisible();
// Save button should be enabled
await expect(saveButton).toBeEnabled();
});
test("app commands - cancel editing", async ({ po }) => {
// Create an app first
await po.sendPrompt("tc=1");
// Navigate to configure panel
await po.selectPreviewMode("configure");
// Click to configure custom commands
await po.page.getByTestId("configure-app-commands").click();
// Fill in commands
await po.page.getByTestId("install-command-input").fill("npm install");
await po.page.getByTestId("start-command-input").fill("npm run dev");
// Cancel instead of saving
await po.page.getByTestId("cancel-edit-app-commands").click();
// Verify we're back to default state (commands were not saved)
await expect(
po.page.getByText("Using default install and start commands"),
).toBeVisible();
});
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Button } from "@/components/ui/button";
......@@ -18,6 +18,7 @@ import {
X,
HelpCircle,
ArrowRight,
Terminal,
} from "lucide-react";
import { showError, showSuccess } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
......@@ -26,6 +27,258 @@ import { useNavigate } from "@tanstack/react-router";
import { NeonConfigure } from "./NeonConfigure";
import { queryKeys } from "@/lib/queryKeys";
const AppCommandsTitle = () => (
<div className="flex items-center gap-2">
<Terminal size={18} className="text-muted-foreground" />
<span className="text-lg font-semibold">App Commands</span>
<Tooltip>
<TooltipTrigger>
<HelpCircle size={16} className="text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Configure custom install and start commands for your app.
<br />
Leave empty to use the default pnpm commands.
</p>
</TooltipContent>
</Tooltip>
</div>
);
const AppCommandsSection = ({
selectedAppId,
}: {
selectedAppId: number | null;
}) => {
const queryClient = useQueryClient();
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const [isEditing, setIsEditing] = useState(false);
// Query to get app details including commands
const { data: app, isLoading: isLoadingApp } = useQuery({
queryKey: queryKeys.apps.detail({ appId: selectedAppId }),
queryFn: async () => {
if (!selectedAppId) return null;
return await ipc.app.getApp(selectedAppId);
},
enabled: !!selectedAppId,
});
// Sync local state with app data when it changes (but not during editing)
useEffect(() => {
if (app && !isEditing) {
setInstallCommand(app.installCommand || "");
setStartCommand(app.startCommand || "");
}
}, [app, isEditing]);
// Mutation to update commands
const updateCommandsMutation = useMutation({
mutationFn: async ({
installCmd,
startCmd,
}: {
installCmd: string;
startCmd: string;
}) => {
if (!selectedAppId) throw new Error("No app selected");
return await ipc.app.updateAppCommands({
appId: selectedAppId,
installCommand: installCmd.trim() || null,
startCommand: startCmd.trim() || null,
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.apps.detail({ appId: selectedAppId }),
});
showSuccess("App commands saved");
setIsEditing(false);
},
onError: (error) => {
showError(`Failed to save app commands: ${error}`);
},
});
const handleSave = useCallback(() => {
updateCommandsMutation.mutate({
installCmd: installCommand,
startCmd: startCommand,
});
}, [installCommand, startCommand, updateCommandsMutation]);
const handleCancel = useCallback(() => {
// Reset to original values
setInstallCommand(app?.installCommand || "");
setStartCommand(app?.startCommand || "");
setIsEditing(false);
}, [app]);
const handleClear = useCallback(() => {
updateCommandsMutation.mutate({
installCmd: "",
startCmd: "",
});
}, [updateCommandsMutation]);
if (!selectedAppId) {
return null;
}
if (isLoadingApp) {
return (
<Card>
<CardHeader>
<CardTitle>
<AppCommandsTitle />
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4">
<div className="text-sm text-muted-foreground">
Loading app commands...
</div>
</div>
</CardContent>
</Card>
);
}
const hasCustomCommands = app?.installCommand && app?.startCommand;
const hasInstallCommand = installCommand.trim().length > 0;
const hasStartCommand = startCommand.trim().length > 0;
const commandsValid = hasInstallCommand === hasStartCommand;
return (
<Card>
<CardHeader>
<CardTitle>
<AppCommandsTitle />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isEditing ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="install-command">Install Command</Label>
<Input
id="install-command"
data-testid="install-command-input"
placeholder="pnpm install"
value={installCommand}
onChange={(e) => setInstallCommand(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="start-command">Start Command</Label>
<Input
id="start-command"
data-testid="start-command-input"
placeholder="pnpm dev"
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
/>
</div>
{!commandsValid && (
<p className="text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
<div className="flex gap-2">
<Button
data-testid="save-app-commands"
onClick={handleSave}
size="sm"
disabled={updateCommandsMutation.isPending || !commandsValid}
>
<Save size={14} />
{updateCommandsMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
data-testid="cancel-edit-app-commands"
onClick={handleCancel}
variant="outline"
size="sm"
>
<X size={14} />
Cancel
</Button>
</div>
</div>
) : hasCustomCommands ? (
<div className="space-y-3">
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div>
<span className="text-xs text-muted-foreground">
Install Command
</span>
<p
data-testid="current-install-command"
className="font-mono text-sm truncate"
>
{app.installCommand}
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">
Start Command
</span>
<p
data-testid="current-start-command"
className="font-mono text-sm truncate"
>
{app.startCommand}
</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
data-testid="edit-app-commands"
onClick={() => setIsEditing(true)}
variant="outline"
size="sm"
>
<Edit2 size={14} />
Edit
</Button>
<Button
data-testid="clear-app-commands"
onClick={handleClear}
variant="outline"
size="sm"
disabled={updateCommandsMutation.isPending}
>
<Trash2 size={14} />
Clear
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Using default install and start commands
</p>
<Button
data-testid="configure-app-commands"
onClick={() => setIsEditing(true)}
variant="outline"
className="w-full"
>
<Plus size={14} />
Configure Custom Commands
</Button>
</div>
)}
</CardContent>
</Card>
);
};
const EnvironmentVariablesTitle = () => (
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">Environment Variables</span>
......@@ -397,8 +650,10 @@ export const ConfigurePanel = () => {
</CardContent>
</Card>
{/* App Commands Configuration */}
<AppCommandsSection selectedAppId={selectedAppId} />
{/* Neon Database Configuration */}
{/* Neon Connector */}
<div className="grid grid-cols-1 gap-6">
<NeonConfigure />
</div>
......
......@@ -1834,6 +1834,40 @@ export function registerAppHandlers() {
},
);
createTypedHandler(appContracts.updateAppCommands, async (_, params) => {
const { appId, installCommand, startCommand } = params;
return withLock(appId, async () => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const trimmedInstall = installCommand?.trim() || null;
const trimmedStart = startCommand?.trim() || null;
// Both commands must be provided together, or both must be null
if ((trimmedInstall === null) !== (trimmedStart === null)) {
throw new Error(
"Both install and start commands are required when customizing",
);
}
await db
.update(apps)
.set({
installCommand: trimmedInstall,
startCommand: trimmedStart,
})
.where(eq(apps.id, appId));
logger.info(`Updated commands for app ${appId}`);
});
});
createTypedHandler(appContracts.changeAppLocation, async (_, params) => {
const { appId, parentDirectory } = params;
......
......@@ -222,6 +222,15 @@ export const AddToFavoriteResultSchema = z.object({
isFavorite: z.boolean(),
});
/**
* Schema for update app commands params.
*/
export const UpdateAppCommandsParamsSchema = z.object({
appId: z.number(),
installCommand: z.string().nullable(),
startCommand: z.string().nullable(),
});
/**
* Schema for select app location params.
*/
......@@ -366,6 +375,12 @@ export const appContracts = {
input: z.string(),
output: z.array(AppSearchResultSchema),
}),
updateAppCommands: defineContract({
channel: "update-app-commands",
input: UpdateAppCommandsParamsSchema,
output: z.void(),
}),
} as const;
// =============================================================================
......@@ -403,3 +418,6 @@ export type ChangeAppLocationResult = z.infer<
export type ListAppsResponse = z.infer<typeof ListAppsResponseSchema>;
export type RenameBranchParams = z.infer<typeof RenameBranchParamsSchema>;
export type AppSearchResult = z.infer<typeof AppSearchResultSchema>;
export type UpdateAppCommandsParams = z.infer<
typeof UpdateAppCommandsParamsSchema
>;
......@@ -105,6 +105,7 @@ export type {
ChangeAppLocationResult,
ListAppsResponse,
RenameBranchParams,
UpdateAppCommandsParams,
} from "./app";
// Chat types
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论