diff --git a/apps/desktop/electron/api.ts b/apps/desktop/electron/api.ts
index d8a4645..3de3cfe 100644
--- a/apps/desktop/electron/api.ts
+++ b/apps/desktop/electron/api.ts
@@ -95,7 +95,7 @@ export async function listWorkflows() {
groups: workflowConfig.groups,
workflowConfig,
taskInputFields: deriveTaskInputFields(raw),
- worktree: raw.worktree || { enabled: false, files: [] },
+ worktree: raw.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
});
} catch {}
}
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index 9b3d433..fb5e994 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -1,5 +1,6 @@
import { spawn } from "child_process";
import { app, BrowserWindow, ipcMain } from "electron";
+import { mkdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { killAllChildren } from "../../../packages/core-lib/claude";
@@ -46,6 +47,12 @@ let mainWindow;
const __dirname = dirname(fileURLToPath(import.meta.url));
const rendererDevServerUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererDevServerUrl);
+const userDataDirOverride = process.env.DEV_WORKFLOW_USER_DATA_DIR;
+
+if (userDataDirOverride) {
+ mkdirSync(userDataDirOverride, { recursive: true });
+ app.setPath("userData", userDataDirOverride);
+}
function spawnDetached(command, args) {
return new Promise((resolve, reject) => {
@@ -189,8 +196,9 @@ function registerIpcHandlers() {
}
app.whenReady().then(async () => {
- setRuntimeStorageDir(app.getPath("userData"));
- setRuntimeBaseDir(join(app.getPath("userData"), "tasks"));
+ const userDataDir = app.getPath("userData");
+ setRuntimeStorageDir(userDataDir);
+ setRuntimeBaseDir(join(userDataDir, "tasks"));
registerIpcHandlers();
try {
await createWindow();
diff --git a/apps/desktop/local-server-controllers/workflowController.ts b/apps/desktop/local-server-controllers/workflowController.ts
index cee4bf9..58ff471 100644
--- a/apps/desktop/local-server-controllers/workflowController.ts
+++ b/apps/desktop/local-server-controllers/workflowController.ts
@@ -211,7 +211,7 @@ router.get("/workflows", async (req, res) => {
groups: workflowConfig.groups,
workflowConfig,
taskInputFields: deriveTaskInputFields(raw),
- worktree: raw.worktree || { enabled: false, files: [] },
+ worktree: raw.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
});
} catch {}
}
diff --git a/apps/desktop/renderer/src/WorkflowEditor.test.tsx b/apps/desktop/renderer/src/WorkflowEditor.test.tsx
index 7031989..14d1d44 100644
--- a/apps/desktop/renderer/src/WorkflowEditor.test.tsx
+++ b/apps/desktop/renderer/src/WorkflowEditor.test.tsx
@@ -1,5 +1,6 @@
-import { render, screen, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, test, vi } from "vitest";
+import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
desktopApi: {},
@@ -48,10 +49,12 @@ const workflow = {
nodePositions: {},
},
worktree: {
- enabled: false,
- files: [],
+ enabled: true,
+ files: [".env"],
customFiles: [],
removeOnComplete: false,
+ useCustomSetupScript: false,
+ setupScript: "",
},
steps: [
{
@@ -85,6 +88,10 @@ function renderWorkflowEditor(filename = "default-codex.json") {
}
describe("WorkflowEditor", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
beforeEach(() => {
sessionStorage.clear();
for (const key of Object.keys(mocks.desktopApi)) {
@@ -106,4 +113,43 @@ describe("WorkflowEditor", () => {
});
expect(screen.getByDisplayValue("Default Codex")).toBeInTheDocument();
});
+
+ test("uses the built-in worktree setup until custom setup is enabled", async () => {
+ const user = userEvent.setup();
+ renderWorkflowEditor();
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Default Codex")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Workflow Setup" }));
+
+ expect(screen.getByText(".env")).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText("pnpm install")).not.toBeInTheDocument();
+
+ const customSetup = screen.getByText("Use custom setup script").closest("label");
+ expect(customSetup).not.toBeNull();
+ await user.click(within(customSetup).getByRole("checkbox"));
+
+ expect(screen.queryByText(".env")).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText("pnpm install")).toBeInTheDocument();
+ });
+
+ test("requires a setup script before saving when custom setup is enabled", async () => {
+ const user = userEvent.setup();
+ renderWorkflowEditor();
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Default Codex")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Workflow Setup" }));
+ const customSetup = screen.getByText("Use custom setup script").closest("label");
+ expect(customSetup).not.toBeNull();
+ await user.click(within(customSetup).getByRole("checkbox"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(await screen.findByText("Setup script is required when custom setup is enabled.")).toBeInTheDocument();
+ expect(mocks.desktopApi.updateWorkflow).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/desktop/renderer/src/WorkflowEditor.tsx b/apps/desktop/renderer/src/WorkflowEditor.tsx
index c75f759..d237739 100644
--- a/apps/desktop/renderer/src/WorkflowEditor.tsx
+++ b/apps/desktop/renderer/src/WorkflowEditor.tsx
@@ -227,6 +227,8 @@ function createDefaultWorkflow({ includeStartStep = true } = {}) {
files: [...COMMON_WORKTREE_FILES],
customFiles: [],
removeOnComplete: false,
+ useCustomSetupScript: false,
+ setupScript: "",
},
steps: includeStartStep
? [{
@@ -371,6 +373,7 @@ function normalizeWorkflow(raw) {
...(raw?.worktree || {}),
files: Array.from(new Set(worktreeFiles)),
customFiles,
+ useCustomSetupScript: raw?.worktree?.useCustomSetupScript === true,
},
};
return relinkSteps(workflow);
@@ -1157,6 +1160,10 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
setError(t("editor.phaseRequired"));
return;
}
+ if (dsl.worktree?.enabled && dsl.worktree?.useCustomSetupScript && !String(dsl.worktree.setupScript || "").trim()) {
+ setError("Setup script is required when custom setup is enabled.");
+ return;
+ }
setSaving(true);
setError("");
try {
@@ -1237,13 +1244,6 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
-
-
-
Runtime
-
LangGraph StateGraph
-
-
DSL
-
)}
{workflow.worktree.enabled && (
+
+
+
+ )}
+ {workflow.worktree.enabled && workflow.worktree.useCustomSetupScript !== true && (
{displayedWorktreeFiles.map((file) => (
)}
- {workflow.worktree.enabled && (
+ {workflow.worktree.enabled && workflow.worktree.useCustomSetupScript !== true && (
@@ -1311,6 +1323,17 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
)}
+ {workflow.worktree.enabled && workflow.worktree.useCustomSetupScript === true && (
+
+
+ )}
diff --git a/apps/desktop/renderer/src/lib/i18n.ts b/apps/desktop/renderer/src/lib/i18n.ts
index 3c8430f..03e24aa 100644
--- a/apps/desktop/renderer/src/lib/i18n.ts
+++ b/apps/desktop/renderer/src/lib/i18n.ts
@@ -28,6 +28,7 @@ export const messages = {
"home.gitWorktreeHint": "A sibling worktree will be created before the workflow starts.",
"home.gitWorktreeRemoveHint": "It will be removed automatically when the task is completed.",
"home.gitWorktreeFiles": "Migrated files: {files}",
+ "home.gitWorktreeCustomSetup": "Custom setup script will run instead of the built-in setup.",
"home.worktreeName": "Branch / Worktree Name",
"home.worktreeNamePlaceholder": "Leave blank to generate automatically",
"home.worktreeNameHint": "Branch keeps slashes, and the worktree folder uses hyphens.",
@@ -340,6 +341,7 @@ export const messages = {
"home.gitWorktreeHint": "工作流启动前会先创建一个同级 worktree。",
"home.gitWorktreeRemoveHint": "任务完成后会自动移除。",
"home.gitWorktreeFiles": "迁移文件:{files}",
+ "home.gitWorktreeCustomSetup": "会运行自定义 setup script,而不是系统内置 setup。",
"home.worktreeName": "Branch / Worktree 名称",
"home.worktreeNamePlaceholder": "留空时自动生成",
"home.worktreeNameHint": "branch 保留斜杠,worktree 文件夹会使用连字符。",
diff --git a/apps/desktop/renderer/src/lib/worktree-config.test.ts b/apps/desktop/renderer/src/lib/worktree-config.test.ts
new file mode 100644
index 0000000..40a3e55
--- /dev/null
+++ b/apps/desktop/renderer/src/lib/worktree-config.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "vitest";
+import { normalizeWorktreeConfig } from "../../../../../packages/core-lib/worktree";
+
+describe("normalizeWorktreeConfig", () => {
+ test("uses built-in setup when custom setup is not enabled", () => {
+ const config = normalizeWorktreeConfig({
+ enabled: true,
+ files: [".env"],
+ useCustomSetupScript: false,
+ setupScript: "pnpm install",
+ });
+
+ expect(config.files).toEqual([".env"]);
+ expect(config.useCustomSetupScript).toBe(false);
+ expect(config.setupScript).toBe("");
+ });
+
+ test("requires a custom script when custom setup is enabled", () => {
+ expect(() => normalizeWorktreeConfig({
+ enabled: true,
+ useCustomSetupScript: true,
+ setupScript: " ",
+ })).toThrow("git worktree custom setup script is required");
+ });
+
+ test("keeps the custom script when custom setup is enabled", () => {
+ const config = normalizeWorktreeConfig({
+ enabled: true,
+ files: [".env"],
+ useCustomSetupScript: true,
+ setupScript: " pnpm install ",
+ });
+
+ expect(config.useCustomSetupScript).toBe(true);
+ expect(config.setupScript).toBe("pnpm install");
+ });
+});
diff --git a/apps/desktop/renderer/src/pages/HomePage.tsx b/apps/desktop/renderer/src/pages/HomePage.tsx
index 0bec5d6..8d547ff 100644
--- a/apps/desktop/renderer/src/pages/HomePage.tsx
+++ b/apps/desktop/renderer/src/pages/HomePage.tsx
@@ -235,7 +235,11 @@ function StartWorkflowModal({ workflows, workFolders, defaultFolder, defaultWork
{t("home.gitWorktreeRemoveHint")}
)}
- {worktreeConfig.files.length > 0 && (
+ {worktreeConfig.useCustomSetupScript ? (
+
+ {t("home.gitWorktreeCustomSetup")}
+
+ ) : worktreeConfig.files.length > 0 && (
{t("home.gitWorktreeFiles", { files: worktreeConfig.files.join(", ") })}
diff --git a/apps/desktop/renderer/vite.config.ts b/apps/desktop/renderer/vite.config.ts
index 4bc376a..22e73a5 100644
--- a/apps/desktop/renderer/vite.config.ts
+++ b/apps/desktop/renderer/vite.config.ts
@@ -2,6 +2,9 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
+const localServerPort = process.env.LOCAL_SERVER_PORT || process.env.PORT || "3000";
+const localServerUrl = `http://localhost:${localServerPort}`;
+
export default defineConfig({
base: "./",
plugins: [react(), tailwindcss()],
@@ -11,9 +14,9 @@ export default defineConfig({
},
server: {
proxy: {
- "/api": "http://localhost:3000",
+ "/api": localServerUrl,
"/ws": {
- target: "http://localhost:3000",
+ target: localServerUrl,
ws: true,
},
},
diff --git a/apps/desktop/server.ts b/apps/desktop/server.ts
index a661ec4..3caf773 100644
--- a/apps/desktop/server.ts
+++ b/apps/desktop/server.ts
@@ -12,7 +12,7 @@ import { activeWorkflows } from "../../packages/core-lib/claude";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
-const PORT = process.env.PORT || 3000;
+const PORT = process.env.LOCAL_SERVER_PORT || process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
diff --git a/package.json b/package.json
index 9187548..65ff955 100644
--- a/package.json
+++ b/package.json
@@ -7,18 +7,19 @@
"main": "apps/desktop/electron/main.ts",
"packageManager": "pnpm@10.33.2",
"scripts": {
- "dev:renderer": "pnpm --dir apps/desktop/renderer dev --host 127.0.0.1 --port 5173 --strictPort",
+ "dev:renderer": "pnpm --dir apps/desktop/renderer dev --host 127.0.0.1 --port ${RENDERER_PORT:-5173} --strictPort",
"prepare:electron": "tsc -p tsconfig.preload.json",
- "dev:electron": "pnpm run prepare:electron && wait-on http://127.0.0.1:5173 && ELECTRON_RENDERER_URL=http://127.0.0.1:5173 NODE_OPTIONS='--import tsx' electronmon .",
- "dev:desktop-ui": "concurrently -k --names \"renderer,electron\" --prefix name --prefix-colors \"blue,magenta\" \"pnpm exec tsx scripts/run-if-port-free.ts 5173 pnpm dev:renderer\" \"pnpm dev:electron\"",
- "dev:desktop": "concurrently -k --names \"desktop,server,connector\" --prefix name --prefix-colors \"green,blue,cyan\" \"pnpm run dev:desktop-ui\" \"pnpm exec tsx scripts/run-if-port-free.ts 3000 pnpm run dev:server\" \"pnpm run dev:connector\"",
+ "dev:electron": "pnpm run prepare:electron && wait-on http://127.0.0.1:${RENDERER_PORT:-5173} && ELECTRON_RENDERER_URL=http://127.0.0.1:${RENDERER_PORT:-5173} NODE_OPTIONS='--import tsx' electronmon .",
+ "dev:desktop-ui": "concurrently -k --names \"renderer,electron\" --prefix name --prefix-colors \"blue,magenta\" \"pnpm exec tsx scripts/run-if-port-free.ts ${RENDERER_PORT:-5173} pnpm dev:renderer\" \"pnpm dev:electron\"",
+ "dev:desktop": "concurrently -k --names \"desktop,server,connector\" --prefix name --prefix-colors \"green,blue,cyan\" \"pnpm run dev:desktop-ui\" \"pnpm exec tsx scripts/run-if-port-free.ts ${LOCAL_SERVER_PORT:-3000} pnpm run dev:server\" \"pnpm run dev:connector\"",
"dev:backend": "pnpm exec tsx watch apps/backend/src/server.ts",
"dev:mobile": "cd apps/mobile && flutter run",
"dev:server": "pnpm exec tsx apps/desktop/server.ts",
"dev:connector": "pnpm exec tsx apps/desktop/connector/src/index.ts",
- "dev:desktop-stack": "concurrently -k --names \"server,backend,connector\" --prefix name --prefix-colors \"blue,magenta,cyan\" \"pnpm exec tsx scripts/run-if-port-free.ts 3000 pnpm run dev:server\" \"pnpm exec tsx scripts/run-if-port-free.ts 8787 pnpm run dev:backend\" \"pnpm run dev:connector\"",
- "dev:mobile-stack": "concurrently -k --names \"server,backend,connector\" --prefix name --prefix-colors \"blue,magenta,cyan\" \"pnpm exec tsx scripts/run-if-port-free.ts 3000 pnpm run dev:server\" \"pnpm exec tsx scripts/run-if-port-free.ts 8787 pnpm run dev:backend\" \"pnpm run dev:connector\"",
- "dev:all": "concurrently -k --names \"desktop,backend\" --prefix name --prefix-colors \"green,magenta\" \"pnpm run dev:desktop\" \"pnpm exec tsx scripts/run-if-port-free.ts 8787 pnpm run dev:backend\"",
+ "dev:desktop-stack": "concurrently -k --names \"server,backend,connector\" --prefix name --prefix-colors \"blue,magenta,cyan\" \"pnpm exec tsx scripts/run-if-port-free.ts ${LOCAL_SERVER_PORT:-3000} pnpm run dev:server\" \"pnpm exec tsx scripts/run-if-port-free.ts ${BACKEND_PORT:-8787} pnpm run dev:backend\" \"pnpm run dev:connector\"",
+ "dev:mobile-stack": "concurrently -k --names \"server,backend,connector\" --prefix name --prefix-colors \"blue,magenta,cyan\" \"pnpm exec tsx scripts/run-if-port-free.ts ${LOCAL_SERVER_PORT:-3000} pnpm run dev:server\" \"pnpm exec tsx scripts/run-if-port-free.ts ${BACKEND_PORT:-8787} pnpm run dev:backend\" \"pnpm run dev:connector\"",
+ "dev:all": "concurrently -k --names \"desktop,backend\" --prefix name --prefix-colors \"green,magenta\" \"pnpm run dev:desktop\" \"pnpm exec tsx scripts/run-if-port-free.ts ${BACKEND_PORT:-8787} pnpm run dev:backend\"",
+ "dev:desktop:instance": "pnpm exec tsx scripts/dev-desktop-instance.ts",
"build:desktop": "pnpm run prepare:electron && pnpm --dir apps/desktop/renderer build",
"package:desktop": "pnpm build:desktop && electron-forge package",
"make:desktop": "pnpm build:desktop && electron-forge make",
diff --git a/packages/core-lib/langgraph-runtime/dsl.ts b/packages/core-lib/langgraph-runtime/dsl.ts
index eb272cf..f0e8cdd 100644
--- a/packages/core-lib/langgraph-runtime/dsl.ts
+++ b/packages/core-lib/langgraph-runtime/dsl.ts
@@ -99,6 +99,8 @@ function normalizeWorktree(rawWorktree) {
files: [],
customFiles: [],
removeOnComplete: false,
+ useCustomSetupScript: false,
+ setupScript: "",
};
}
if (!isObject(rawWorktree)) throw new Error("worktree must be an object");
@@ -107,6 +109,8 @@ function normalizeWorktree(rawWorktree) {
files: normalizeOptionalStringArray(rawWorktree.files, "worktree.files"),
customFiles: normalizeOptionalStringArray(rawWorktree.customFiles, "worktree.customFiles"),
removeOnComplete: rawWorktree.removeOnComplete === true,
+ useCustomSetupScript: rawWorktree.useCustomSetupScript === true,
+ setupScript: normalizeOptionalString(rawWorktree.setupScript),
};
}
diff --git a/packages/core-lib/worktree.ts b/packages/core-lib/worktree.ts
index 757e716..0ce9f18 100644
--- a/packages/core-lib/worktree.ts
+++ b/packages/core-lib/worktree.ts
@@ -15,6 +15,19 @@ function execGit(args, cwd) {
});
}
+function execShell(command, cwd) {
+ return new Promise((resolvePromise, rejectPromise) => {
+ execFile(process.env.SHELL || "/bin/sh", ["-lc", command], { cwd }, (error, stdout, stderr) => {
+ if (error) {
+ const message = stderr?.trim() || stdout?.trim() || error.message;
+ rejectPromise(new Error(message));
+ return;
+ }
+ resolvePromise((stdout || "").trim());
+ });
+ });
+}
+
function sanitizeNamePart(value, fallback) {
const normalized = String(value || "")
.toLowerCase()
@@ -121,7 +134,12 @@ export function normalizeWorktreeConfig(worktree) {
const files = normalizeMigrationFiles(worktree?.files);
const customFiles = normalizeMigrationFiles(worktree?.customFiles);
const removeOnComplete = worktree?.removeOnComplete !== undefined ? Boolean(worktree.removeOnComplete) : false;
- return { enabled, files, customFiles, removeOnComplete };
+ const useCustomSetupScript = worktree?.useCustomSetupScript === true;
+ const setupScript = useCustomSetupScript && typeof worktree?.setupScript === "string" ? worktree.setupScript.trim() : "";
+ if (useCustomSetupScript && !setupScript) {
+ throw new Error("git worktree custom setup script is required");
+ }
+ return { enabled, files, customFiles, removeOnComplete, useCustomSetupScript, setupScript };
}
export async function prepareWorktree({ repoRoot, taskId, worktree, worktreeName }) {
@@ -154,24 +172,28 @@ export async function prepareWorktree({ repoRoot, taskId, worktree, worktreeName
const skippedFiles = [];
try {
- for (const configuredPath of worktreeConfig.files) {
- const { normalizedPath, sourcePath } = assertSafeRelativePath(sourceRoot, configuredPath);
- const exists = await pathExists(sourcePath);
- if (!exists) {
- skippedFiles.push(normalizedPath);
- continue;
- }
-
- const sourceStat = await stat(sourcePath);
- const targetPath = join(worktreePath, normalizedPath);
- if (sourceStat.isDirectory()) {
- await mkdir(dirname(targetPath), { recursive: true });
- await cp(sourcePath, targetPath, { recursive: true, errorOnExist: false, force: true });
- } else {
- await mkdir(dirname(targetPath), { recursive: true });
- await cp(sourcePath, targetPath, { errorOnExist: false, force: true });
+ if (worktreeConfig.setupScript) {
+ await execShell(worktreeConfig.setupScript, worktreePath);
+ } else {
+ for (const configuredPath of worktreeConfig.files) {
+ const { normalizedPath, sourcePath } = assertSafeRelativePath(sourceRoot, configuredPath);
+ const exists = await pathExists(sourcePath);
+ if (!exists) {
+ skippedFiles.push(normalizedPath);
+ continue;
+ }
+
+ const sourceStat = await stat(sourcePath);
+ const targetPath = join(worktreePath, normalizedPath);
+ if (sourceStat.isDirectory()) {
+ await mkdir(dirname(targetPath), { recursive: true });
+ await cp(sourcePath, targetPath, { recursive: true, errorOnExist: false, force: true });
+ } else {
+ await mkdir(dirname(targetPath), { recursive: true });
+ await cp(sourcePath, targetPath, { errorOnExist: false, force: true });
+ }
+ migratedFiles.push(normalizedPath);
}
- migratedFiles.push(normalizedPath);
}
} catch (err) {
await removeWorktree({ enabled: true, sourceRoot, rootPath: worktreePath, branchName }, { force: true });
@@ -187,6 +209,7 @@ export async function prepareWorktree({ repoRoot, taskId, worktree, worktreeName
migratedFiles,
skippedFiles,
removeOnComplete: worktreeConfig.removeOnComplete,
+ setupScript: worktreeConfig.setupScript,
};
}
diff --git a/packages/core-models/config.ts b/packages/core-models/config.ts
index 68fcfaa..c9b7457 100644
--- a/packages/core-models/config.ts
+++ b/packages/core-models/config.ts
@@ -11,6 +11,10 @@ let runtimeStorageDir = "";
export const LEGACY_WORKFLOW_DIR = join(PROJECT_ROOT, "workflows");
function getDefaultDesktopUserDataDir() {
+ if (process.env.DEV_WORKFLOW_USER_DATA_DIR) {
+ return process.env.DEV_WORKFLOW_USER_DATA_DIR;
+ }
+
switch (process.platform) {
case "darwin":
return join(os.homedir(), "Library", "Application Support", "dev-Workflow");
diff --git a/packages/core-models/workflow.ts b/packages/core-models/workflow.ts
index 9a1ce31..3d37fa6 100644
--- a/packages/core-models/workflow.ts
+++ b/packages/core-models/workflow.ts
@@ -171,7 +171,7 @@ export function getWorkflowConfigShape(workflow = WORKFLOW) {
rejectTargets: {},
conditionRoutes: {},
taskInputFields: [],
- worktree: { enabled: false, files: [], customFiles: [], removeOnComplete: false },
+ worktree: { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
};
}
@@ -228,7 +228,7 @@ export function getWorkflowConfigShape(workflow = WORKFLOW) {
rejectTargets,
conditionRoutes,
taskInputFields: deriveTaskInputFields(workflow),
- worktree: workflow.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false },
+ worktree: workflow.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
};
}
diff --git a/scripts/dev-desktop-instance.ts b/scripts/dev-desktop-instance.ts
new file mode 100644
index 0000000..8359cec
--- /dev/null
+++ b/scripts/dev-desktop-instance.ts
@@ -0,0 +1,116 @@
+import { spawn } from "child_process";
+import { join } from "path";
+import { mkdirSync } from "fs";
+
+type Options = {
+ name: string;
+ portOffset: number;
+};
+
+const BASE_RENDERER_PORT = 5173;
+const BASE_LOCAL_SERVER_PORT = 3000;
+const BASE_BACKEND_PORT = 8787;
+
+function printUsage() {
+ console.log("usage: pnpm dev:desktop:instance --name [--port-offset ]");
+}
+
+function normalizeName(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function parseArgs(argv: string[]): Options {
+ let name = "";
+ let portOffset = Number(process.env.PORT_OFFSET || "0");
+
+ for (let index = 0; index < argv.length; index++) {
+ const arg = argv[index];
+ if (arg === "--help" || arg === "-h") {
+ printUsage();
+ process.exit(0);
+ }
+ if (arg === "--name") {
+ name = argv[++index] || "";
+ continue;
+ }
+ if (arg.startsWith("--name=")) {
+ name = arg.slice("--name=".length);
+ continue;
+ }
+ if (arg === "--port-offset") {
+ portOffset = Number(argv[++index] || "");
+ continue;
+ }
+ if (arg.startsWith("--port-offset=")) {
+ portOffset = Number(arg.slice("--port-offset=".length));
+ continue;
+ }
+ throw new Error(`unknown argument: ${arg}`);
+ }
+
+ const normalizedName = normalizeName(name);
+ if (!normalizedName) {
+ throw new Error("--name is required");
+ }
+ if (!Number.isInteger(portOffset) || portOffset < 0) {
+ throw new Error("--port-offset must be a non-negative integer");
+ }
+
+ return { name: normalizedName, portOffset };
+}
+
+async function main() {
+ const { name, portOffset } = parseArgs(process.argv.slice(2));
+ const rendererPort = BASE_RENDERER_PORT + portOffset;
+ const localServerPort = BASE_LOCAL_SERVER_PORT + portOffset;
+ const backendPort = BASE_BACKEND_PORT + portOffset;
+ const userDataDir = join(process.cwd(), ".dev-instances", name);
+
+ mkdirSync(userDataDir, { recursive: true });
+
+ const env = {
+ ...process.env,
+ RENDERER_PORT: String(rendererPort),
+ LOCAL_SERVER_PORT: String(localServerPort),
+ PORT: String(localServerPort),
+ BACKEND_PORT: String(backendPort),
+ ELECTRON_RENDERER_URL: `http://127.0.0.1:${rendererPort}`,
+ LOCAL_WORKFLOW_API_BASE: `http://127.0.0.1:${localServerPort}/api`,
+ LOCAL_WORKFLOW_WS_URL: `ws://127.0.0.1:${localServerPort}/ws`,
+ CLOUD_BACKEND_WS_URL: `ws://127.0.0.1:${backendPort}/ws/desktop`,
+ DEVICE_ID: `desktop-${name}`,
+ DEVICE_NAME: `Desktop ${name}`,
+ DEV_WORKFLOW_INSTANCE_NAME: name,
+ DEV_WORKFLOW_USER_DATA_DIR: userDataDir,
+ };
+
+ console.log(`[dev-instance] name: ${name}`);
+ console.log(`[dev-instance] renderer: http://127.0.0.1:${rendererPort}`);
+ console.log(`[dev-instance] local server: http://127.0.0.1:${localServerPort}`);
+ console.log(`[dev-instance] backend: http://127.0.0.1:${backendPort}`);
+ console.log(`[dev-instance] user data: ${userDataDir}`);
+
+ const child = spawn("pnpm", ["run", "dev:all"], {
+ env,
+ stdio: "inherit",
+ shell: false,
+ });
+
+ child.on("exit", (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 0);
+ });
+}
+
+main().catch((error) => {
+ console.error(error?.message || error);
+ printUsage();
+ process.exit(1);
+});