feat: allow custom install and start commands (#892)

# Description

Gives the ability to define an `install` and `startup` command when
importing a project, so we can work on a project locally without any
issue.

# Preview

<img width="2256" height="1422" alt="image"
src="https://github.com/user-attachments/assets/2132b1cb-5f71-4b88-84db-8ecc81cf1f66"
/>

---------

Co-authored-by: Will Chen <willchen90@gmail.com>
This commit is contained in:
Olyno
2025-08-18 19:41:22 +02:00
committed by GitHub
parent f72157a443
commit 237017acd9
10 changed files with 693 additions and 13 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE `apps` ADD `install_command` text;--> statement-breakpoint
ALTER TABLE `apps` ADD `start_command` text;

View File

@@ -0,0 +1,524 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a7f4a6e1-2a38-4dc8-a37e-b473b6304bab",
"prevId": "4d1fc225-7395-4d56-8d0d-7f76fed4a8d8",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -71,6 +71,13 @@
"when": 1753473275674,
"tag": "0009_previous_misty_knight",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1755110011615,
"tag": "0010_nappy_fat_cobra",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,6 @@
import path from "path";
import { testSkipIfWindows } from "./helpers/test_helper";
import { expect } from "@playwright/test";
import * as eph from "electron-playwright-helpers";
testSkipIfWindows("import app", async ({ po }) => {
@@ -43,3 +44,58 @@ testSkipIfWindows("import app with AI rules", async ({ po }) => {
await po.snapshotServerDump();
await po.snapshotMessages({ replaceDumpPath: true });
});
testSkipIfWindows("import app with custom commands", async ({ po }) => {
await po.setUp();
await po.page.getByRole("button", { name: "Import App" }).click();
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [path.join(__dirname, "fixtures", "import-app", "minimal")],
});
await po.page.getByRole("button", { name: "Select Folder" }).click();
await po.page
.getByRole("textbox", { name: "Enter new app name" })
.fill("minimal-imported-app");
await po.page.getByRole("button", { name: "Advanced options" }).click();
await po.page.getByPlaceholder("pnpm install").fill("");
await po.page.getByPlaceholder("pnpm dev").fill("npm start");
await expect(po.page.getByRole("button", { name: "Import" })).toBeDisabled();
await expect(
po.page.getByText("Both commands are required when customizing."),
).toBeVisible();
await po.page.getByPlaceholder("pnpm install").fill("npm i");
await expect(po.page.getByRole("button", { name: "Import" })).toBeEnabled();
await expect(
po.page.getByText("Both commands are required when customizing."),
).toHaveCount(0);
await po.page.getByRole("button", { name: "Import" }).click();
});
testSkipIfWindows(
"advanced options: both cleared are valid and use defaults",
async ({ po }) => {
await po.setUp();
await po.page.getByRole("button", { name: "Import App" }).click();
await eph.stubDialog(po.electronApp, "showOpenDialog", {
filePaths: [path.join(__dirname, "fixtures", "import-app", "minimal")],
});
await po.page.getByRole("button", { name: "Select Folder" }).click();
await po.page
.getByRole("textbox", { name: "Enter new app name" })
.fill("both-cleared");
await po.page.getByRole("button", { name: "Advanced options" }).click();
await po.page.getByPlaceholder("pnpm install").fill("");
await po.page.getByPlaceholder("pnpm dev").fill("");
await expect(po.page.getByRole("button", { name: "Import" })).toBeEnabled();
await po.page.getByRole("button", { name: "Import" }).click();
await po.snapshotPreview();
},
);

View File

@@ -0,0 +1 @@
- text: Minimal imported app

View File

@@ -27,6 +27,12 @@ import {
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useSetAtom } from "jotai";
import { useLoadApps } from "@/hooks/useLoadApps";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";
interface ImportAppDialogProps {
isOpen: boolean;
@@ -39,6 +45,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const [customAppName, setCustomAppName] = useState<string>("");
const [nameExists, setNameExists] = useState<boolean>(false);
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const [installCommand, setInstallCommand] = useState("pnpm install");
const [startCommand, setStartCommand] = useState("pnpm dev");
const navigate = useNavigate();
const { streamMessage } = useStreamChat({ hasChatId: false });
const { refreshApps } = useLoadApps();
@@ -89,6 +97,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
return IpcClient.getInstance().importApp({
path: selectedPath,
appName: customAppName,
installCommand: installCommand || undefined,
startCommand: startCommand || undefined,
});
},
onSuccess: async (result) => {
@@ -128,6 +138,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
setHasAiRules(null);
setCustomAppName("");
setNameExists(false);
setInstallCommand("pnpm install");
setStartCommand("pnpm dev");
};
const handleAppNameChange = async (
@@ -140,6 +152,10 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
}
};
const hasInstallCommand = installCommand.trim().length > 0;
const hasStartCommand = startCommand.trim().length > 0;
const commandsValid = hasInstallCommand === hasStartCommand;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
@@ -221,6 +237,41 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-sm ml-2 mb-2">
Install command
</Label>
<Input
value={installCommand}
onChange={(e) => setInstallCommand(e.target.value)}
placeholder="pnpm install"
disabled={importAppMutation.isPending}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm ml-2 mb-2">Start command</Label>
<Input
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
placeholder="pnpm dev"
disabled={importAppMutation.isPending}
/>
</div>
{!commandsValid && (
<p className="text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{hasAiRules === false && (
<Alert className="border-yellow-500/20 text-yellow-500 flex items-start gap-2">
<TooltipProvider>
@@ -264,7 +315,10 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
<Button
onClick={handleImport}
disabled={
!selectedPath || importAppMutation.isPending || nameExists
!selectedPath ||
importAppMutation.isPending ||
nameExists ||
!commandsValid
}
className="min-w-[80px]"
>

View File

@@ -23,6 +23,8 @@ export const apps = sqliteTable("apps", {
vercelProjectName: text("vercel_project_name"),
vercelTeamId: text("vercel_team_id"),
vercelDeploymentUrl: text("vercel_deployment_url"),
installCommand: text("install_command"),
startCommand: text("start_command"),
chatContext: text("chat_context", { mode: "json" }),
});

View File

@@ -83,17 +83,28 @@ async function executeApp({
appId,
event, // Keep event for local-node case
isNeon,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
await executeAppLocalNode({ appPath, appId, event, isNeon });
await executeAppLocalNode({
appPath,
appId,
event,
isNeon,
installCommand,
startCommand,
});
}
async function executeAppLocalNode({
@@ -101,22 +112,28 @@ async function executeAppLocalNode({
appId,
event,
isNeon,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
const spawnedProcess = spawn(
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)",
[],
{
cwd: appPath,
shell: true,
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded
},
);
const defaultCommand =
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
const command = hasCustomCommands
? `${installCommand!.trim()} && ${startCommand!.trim()}`
: defaultCommand;
const spawnedProcess = spawn(command, [], {
cwd: appPath,
shell: true,
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded
});
// Check if process spawned correctly
if (!spawnedProcess.pid) {
@@ -375,6 +392,8 @@ export function registerAppHandlers() {
supabaseProjectId: null,
githubOrg: null,
githubRepo: null,
installCommand: originalApp.installCommand,
startCommand: originalApp.startCommand,
})
.returning();
@@ -511,6 +530,8 @@ export function registerAppHandlers() {
appId,
event,
isNeon: !!app.neonProjectId,
installCommand: app.installCommand,
startCommand: app.startCommand,
});
return;
@@ -646,6 +667,8 @@ export function registerAppHandlers() {
appId,
event,
isNeon: !!app.neonProjectId,
installCommand: app.installCommand,
startCommand: app.startCommand,
}); // This will handle starting either mode
return;

View File

@@ -69,7 +69,12 @@ export function registerImportHandlers() {
"import-app",
async (
_,
{ path: sourcePath, appName }: ImportAppParams,
{
path: sourcePath,
appName,
installCommand,
startCommand,
}: ImportAppParams,
): Promise<ImportAppResult> => {
// Validate the source path exists
try {
@@ -128,6 +133,8 @@ export function registerImportHandlers() {
name: appName,
// Use the name as the path for now
path: appName,
installCommand: installCommand ?? null,
startCommand: startCommand ?? null,
})
.returning();

View File

@@ -96,6 +96,8 @@ export interface App {
vercelProjectName: string | null;
vercelTeamSlug: string | null;
vercelDeploymentUrl: string | null;
installCommand: string | null;
startCommand: string | null;
}
export interface Version {
@@ -226,6 +228,8 @@ export interface ApproveProposalResult {
export interface ImportAppParams {
path: string;
appName: string;
installCommand?: string;
startCommand?: string;
}
export interface CopyAppParams {