diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx
index 82036020b34..eb1c5a9e1fb 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx
@@ -26,12 +26,14 @@ import { createPortal } from "react-dom";
import { HiOutlineCog6Tooth } from "react-icons/hi2";
import { useHotkeyDisplay } from "renderer/hotkeys";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
+import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader";
import { DashboardSidebarHelpMenu } from "./components/DashboardSidebarHelpMenu";
import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay";
import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList";
import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection";
import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext";
+import { V2SetupScriptCard } from "./components/V2SetupScriptCard";
import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData";
import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts";
import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider";
@@ -101,6 +103,9 @@ export function DashboardSidebar({
const matchRoute = useMatchRoute();
const settingsHotkey = useHotkeyDisplay("OPEN_SETTINGS").text;
const isSettingsOpen = !!matchRoute({ to: "/settings", fuzzy: true });
+ const { activeHostUrl } = useLocalHostService();
+ const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" });
+ const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null;
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
@@ -130,6 +135,26 @@ export function DashboardSidebar({
.filter((g): g is DashboardSidebarProject => g != null);
}, [groups, projectOrder]);
+ const activeV2Project = useMemo(() => {
+ if (!activeV2WorkspaceId) return null;
+ for (const project of groups) {
+ for (const child of project.children) {
+ if (
+ child.type === "workspace" &&
+ child.workspace.id === activeV2WorkspaceId
+ ) {
+ return project;
+ }
+ if (child.type === "section") {
+ for (const ws of child.section.workspaces) {
+ if (ws.id === activeV2WorkspaceId) return project;
+ }
+ }
+ }
+ }
+ return null;
+ }, [groups, activeV2WorkspaceId]);
+
const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
@@ -204,6 +229,13 @@ export function DashboardSidebar({
{!isCollapsed && }
+ {!isCollapsed && activeV2Project && activeHostUrl && (
+
+ )}
+ s.isDismissed(projectId),
+ );
+ const dismiss = useV2SetupCardDismissalsStore((s) => s.dismiss);
+
+ const { data: shouldShow } = useQuery({
+ queryKey: ["host-config", "shouldShowSetupCard", hostUrl, projectId],
+ queryFn: () =>
+ getHostServiceClientByUrl(hostUrl).config.shouldShowSetupCard.query({
+ projectId,
+ }),
+ refetchOnWindowFocus: true,
+ });
+
+ if (isCollapsed || isDismissed || !shouldShow) return null;
+
+ return (
+
+
+
+ navigate({
+ to: "/settings/projects/$projectId",
+ params: { projectId },
+ })
+ }
+ onDismiss={() => dismiss(projectId)}
+ />
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts
new file mode 100644
index 00000000000..6e034e14506
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts
@@ -0,0 +1 @@
+export { V2SetupScriptCard } from "./V2SetupScriptCard";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
index 6069985f4e4..b73abb2729b 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
@@ -11,6 +11,7 @@ import { IconUploadField } from "./components/IconUploadField";
import { NameSection } from "./components/NameSection";
import { ProjectLocationSection } from "./components/ProjectLocationSection";
import { RepositorySection } from "./components/RepositorySection";
+import { V2ScriptsEditor } from "./components/V2ScriptsEditor";
interface V2ProjectSettingsProps {
projectId: string;
@@ -81,6 +82,15 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) {
/>
+ {activeHostUrl && (
+
+
+
+ )}
+
typeof s === "string")
+ : [];
+ const teardown = Array.isArray(parsed?.teardown)
+ ? parsed.teardown.filter(
+ (s: unknown): s is string => typeof s === "string",
+ )
+ : [];
+ return {
+ setup: setup.join("\n"),
+ teardown: teardown.join("\n"),
+ };
+ } catch {
+ return { setup: "", teardown: "" };
+ }
+}
+
+function toCommandsArray(value: string): string[] {
+ return value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+}
+
+function arraysEqual(a: string[], b: string[]): boolean {
+ return a.length === b.length && a.every((v, i) => v === b[i]);
+}
+
+type SaveStatus = "idle" | "saving" | "saved";
+
+export function V2ScriptsEditor({
+ hostUrl,
+ projectId,
+ className,
+}: V2ScriptsEditorProps) {
+ const queryClient = useQueryClient();
+
+ const configQueryKey = [
+ "host-config",
+ "getConfigContent",
+ hostUrl,
+ projectId,
+ ];
+
+ const { data: configData, isLoading } = useQuery({
+ queryKey: configQueryKey,
+ queryFn: () =>
+ getHostServiceClientByUrl(hostUrl).config.getConfigContent.query({
+ projectId,
+ }),
+ });
+
+ const [setupValue, setSetupValue] = useState("");
+ const [teardownValue, setTeardownValue] = useState("");
+ const [saveStatus, setSaveStatus] = useState("idle");
+ const focusedRef = useRef<"setup" | "teardown" | null>(null);
+ const lastSavedRef = useRef<{ setup: string[]; teardown: string[] }>({
+ setup: [],
+ teardown: [],
+ });
+ const savedTimerRef = useRef(null);
+
+ useEffect(() => {
+ // Don't clobber an in-progress edit when the server-side query refetches.
+ if (focusedRef.current) return;
+ const parsed = parseConfigContent(configData?.content ?? null);
+ setSetupValue(parsed.setup);
+ setTeardownValue(parsed.teardown);
+ lastSavedRef.current = {
+ setup: toCommandsArray(parsed.setup),
+ teardown: toCommandsArray(parsed.teardown),
+ };
+ }, [configData?.content]);
+
+ useEffect(() => {
+ return () => {
+ if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
+ };
+ }, []);
+
+ const updateMutation = useMutation({
+ mutationFn: (input: {
+ projectId: string;
+ setup: string[];
+ teardown: string[];
+ }) => getHostServiceClientByUrl(hostUrl).config.updateConfig.mutate(input),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: configQueryKey });
+ },
+ });
+
+ const flushSave = useCallback(
+ async (next: { setup: string[]; teardown: string[] }) => {
+ if (
+ arraysEqual(next.setup, lastSavedRef.current.setup) &&
+ arraysEqual(next.teardown, lastSavedRef.current.teardown)
+ ) {
+ return;
+ }
+
+ if (savedTimerRef.current) {
+ clearTimeout(savedTimerRef.current);
+ savedTimerRef.current = null;
+ }
+
+ setSaveStatus("saving");
+ try {
+ await updateMutation.mutateAsync({ projectId, ...next });
+ lastSavedRef.current = next;
+ setSaveStatus("saved");
+ savedTimerRef.current = setTimeout(() => {
+ setSaveStatus("idle");
+ savedTimerRef.current = null;
+ }, 2000);
+ } catch (error) {
+ console.error("[v2-scripts/save] failed", error);
+ setSaveStatus("idle");
+ }
+ },
+ [projectId, updateMutation],
+ );
+
+ const handleBlur = useCallback(
+ async (field: "setup" | "teardown") => {
+ focusedRef.current = null;
+
+ const trimmedSetup = setupValue
+ .split("\n")
+ .map((line) => line.trim())
+ .join("\n")
+ .replace(/^\n+|\n+$/g, "");
+ const trimmedTeardown = teardownValue
+ .split("\n")
+ .map((line) => line.trim())
+ .join("\n")
+ .replace(/^\n+|\n+$/g, "");
+
+ if (trimmedSetup !== setupValue) setSetupValue(trimmedSetup);
+ if (trimmedTeardown !== teardownValue) setTeardownValue(trimmedTeardown);
+
+ await flushSave({
+ setup: toCommandsArray(field === "setup" ? trimmedSetup : setupValue),
+ teardown: toCommandsArray(
+ field === "teardown" ? trimmedTeardown : teardownValue,
+ ),
+ });
+ },
+ [flushSave, setupValue, teardownValue],
+ );
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {saveStatus === "saving" && (
+
+
+ Saving…
+
+ )}
+ {saveStatus === "saved" && (
+
+
+ Saved
+
+ )}
+
+
+
+
+
+
+ Setup
+ Teardown
+
+
+ {
+ focusedRef.current = "setup";
+ }}
+ onBlur={() => handleBlur("setup")}
+ />
+
+
+ {
+ focusedRef.current = "teardown";
+ }}
+ onBlur={() => handleBlur("teardown")}
+ />
+
+
+
+ );
+}
+
+interface ScriptFieldProps {
+ field: "setup" | "teardown";
+ description: string;
+ placeholder: string;
+ value: string;
+ onChange: (value: string) => void;
+ onFocus: () => void;
+ onBlur: () => void;
+}
+
+function ScriptField({
+ description,
+ placeholder,
+ value,
+ onChange,
+ onFocus,
+ onBlur,
+}: ScriptFieldProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const importFirstFile = useCallback(
+ async (files: File[]) => {
+ const scriptFile = files.find((file) =>
+ file.name.match(/\.(sh|bash|zsh|command)$/i),
+ );
+ if (!scriptFile) return;
+ try {
+ onChange(await scriptFile.text());
+ } catch (error) {
+ console.error("[v2-scripts/import] failed to read file", error);
+ }
+ },
+ [onChange],
+ );
+
+ return (
+
+
{description}
+
+ {/* biome-ignore lint/a11y/useSemanticElements: drop zone wrapper */}
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(true);
+ }}
+ onDragLeave={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ }}
+ onDrop={async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ await importFirstFile(Array.from(e.dataTransfer.files));
+ }}
+ >
+
+
+
+
{
+ const files = e.target.files ? Array.from(e.target.files) : [];
+ await importFirstFile(files);
+ e.target.value = "";
+ }}
+ />
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/index.ts
new file mode 100644
index 00000000000..530db7ff91d
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/index.ts
@@ -0,0 +1 @@
+export { V2ScriptsEditor } from "./V2ScriptsEditor";
diff --git a/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/index.ts b/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/index.ts
new file mode 100644
index 00000000000..bfb327e869d
--- /dev/null
+++ b/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/index.ts
@@ -0,0 +1 @@
+export { useV2SetupCardDismissalsStore } from "./store";
diff --git a/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/store.ts b/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/store.ts
new file mode 100644
index 00000000000..c80d4d08ba6
--- /dev/null
+++ b/apps/desktop/src/renderer/stores/v2-setup-card-dismissals/store.ts
@@ -0,0 +1,34 @@
+import { create } from "zustand";
+import { devtools, persist } from "zustand/middleware";
+
+interface V2SetupCardDismissalsState {
+ /** Map of v2 projectId → epoch ms when the card was dismissed. */
+ dismissedAt: Record;
+ dismiss: (projectId: string) => void;
+ isDismissed: (projectId: string) => boolean;
+ reset: (projectId: string) => void;
+}
+
+export const useV2SetupCardDismissalsStore =
+ create()(
+ devtools(
+ persist(
+ (set, get) => ({
+ dismissedAt: {},
+ dismiss: (projectId) =>
+ set((state) => ({
+ dismissedAt: { ...state.dismissedAt, [projectId]: Date.now() },
+ })),
+ isDismissed: (projectId) => projectId in get().dismissedAt,
+ reset: (projectId) =>
+ set((state) => {
+ const next = { ...state.dismissedAt };
+ delete next[projectId];
+ return { dismissedAt: next };
+ }),
+ }),
+ { name: "v2-setup-card-dismissals-v1" },
+ ),
+ { name: "V2SetupCardDismissals" },
+ ),
+ );
diff --git a/packages/host-service/src/runtime/setup/config.test.ts b/packages/host-service/src/runtime/setup/config.test.ts
new file mode 100644
index 00000000000..90042a2ac7b
--- /dev/null
+++ b/packages/host-service/src/runtime/setup/config.test.ts
@@ -0,0 +1,322 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ getProjectConfigPath,
+ getResolvedSetupCommands,
+ hasConfiguredScripts,
+ loadSetupConfig,
+} from "./config";
+
+interface Sandbox {
+ repoPath: string;
+ homeDir: string;
+ cleanup: () => void;
+}
+
+function createSandbox(): Sandbox {
+ const root = mkdtempSync(join(tmpdir(), "setup-config-test-"));
+ const repoPath = join(root, "repo");
+ const homeDir = join(root, "home");
+ mkdirSync(repoPath, { recursive: true });
+ mkdirSync(homeDir, { recursive: true });
+ return {
+ repoPath,
+ homeDir,
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+function writeRepoConfig(repoPath: string, content: string | object) {
+ const dir = join(repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ typeof content === "string" ? content : JSON.stringify(content),
+ "utf-8",
+ );
+}
+
+function writeRepoLocalConfig(repoPath: string, content: string | object) {
+ const dir = join(repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.local.json"),
+ typeof content === "string" ? content : JSON.stringify(content),
+ "utf-8",
+ );
+}
+
+function writeUserOverride(
+ homeDir: string,
+ projectId: string,
+ content: object,
+) {
+ const dir = join(homeDir, ".superset", "projects", projectId);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "config.json"), JSON.stringify(content), "utf-8");
+}
+
+const PROJECT_ID = "11111111-1111-1111-1111-111111111111";
+
+describe("loadSetupConfig", () => {
+ let sandbox: Sandbox;
+
+ beforeEach(() => {
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ function load(args: { projectId?: string } = {}) {
+ return loadSetupConfig({
+ repoPath: sandbox.repoPath,
+ projectId: args.projectId ?? PROJECT_ID,
+ homeDir: sandbox.homeDir,
+ });
+ }
+
+ it("returns null when no config sources exist", () => {
+ const result = load();
+ expect(result).toBeNull();
+ });
+
+ it("returns repo config.json when only that source exists", () => {
+ writeRepoConfig(sandbox.repoPath, {
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ });
+
+ const result = load();
+
+ expect(result).toEqual({
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ });
+ });
+
+ it("returns null when repo config.json is malformed JSON", () => {
+ writeRepoConfig(sandbox.repoPath, "{not valid json,,,");
+ const result = load();
+ expect(result).toBeNull();
+ });
+
+ it("rejects mixed-type arrays and treats config as missing", () => {
+ writeRepoConfig(sandbox.repoPath, {
+ setup: [123, "bun install"],
+ teardown: [],
+ });
+
+ const result = load();
+ expect(result).toBeNull();
+ });
+
+ it("rejects when config root is an array (not an object)", () => {
+ writeRepoConfig(sandbox.repoPath, ["bun install"]);
+ const result = load();
+ expect(result).toBeNull();
+ });
+
+ it("user override only sets keys it explicitly defines", () => {
+ writeRepoConfig(sandbox.repoPath, {
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ });
+ writeUserOverride(sandbox.homeDir, PROJECT_ID, {
+ setup: ["bun install --frozen-lockfile"],
+ });
+
+ const result = load();
+
+ expect(result).toEqual({
+ setup: ["bun install --frozen-lockfile"],
+ teardown: ["docker compose down"],
+ });
+ });
+
+ it("ignores user override path when projectId contains a slash", () => {
+ // Path-traversal guard: the loader should refuse to expand the override
+ // path for a projectId that looks like a relative path.
+ writeRepoConfig(sandbox.repoPath, { setup: ["from-repo"] });
+ writeUserOverride(sandbox.homeDir, "../escapee", {
+ setup: ["from-override"],
+ });
+ const result = load({ projectId: "../escapee" });
+ expect(result?.setup).toEqual(["from-repo"]);
+ });
+
+ it("local overlay 'before' prepends to base", () => {
+ writeRepoConfig(sandbox.repoPath, {
+ setup: ["bun install"],
+ });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { before: ["echo before"] },
+ });
+
+ const result = load();
+ expect(result?.setup).toEqual(["echo before", "bun install"]);
+ });
+
+ it("local overlay 'after' appends to base", () => {
+ writeRepoConfig(sandbox.repoPath, { setup: ["bun install"] });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { after: ["echo after"] },
+ });
+
+ const result = load();
+ expect(result?.setup).toEqual(["bun install", "echo after"]);
+ });
+
+ it("local overlay before+after wraps the base", () => {
+ writeRepoConfig(sandbox.repoPath, { setup: ["mid"] });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { before: ["pre"], after: ["post"] },
+ });
+
+ const result = load();
+ expect(result?.setup).toEqual(["pre", "mid", "post"]);
+ });
+
+ it("local overlay as a plain array replaces the base entirely", () => {
+ writeRepoConfig(sandbox.repoPath, {
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: ["only this"],
+ });
+
+ const result = load();
+ expect(result?.setup).toEqual(["only this"]);
+ expect(result?.teardown).toEqual(["docker compose down"]);
+ });
+
+ it("local overlay only takes effect when there is a base config", () => {
+ // loadSetupConfig returns null when no base exists, even if a local
+ // overlay is present — the overlay needs something to overlay onto.
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { before: ["echo x"] },
+ });
+
+ const result = load();
+ expect(result).toBeNull();
+ });
+
+ it("local overlay with invalid before type is rejected silently", () => {
+ writeRepoConfig(sandbox.repoPath, { setup: ["bun install"] });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { before: ["ok", 42] },
+ });
+
+ const result = load();
+ // Invalid local overlay parses to null, so base is returned untouched.
+ expect(result?.setup).toEqual(["bun install"]);
+ });
+
+ it("stacks repo + user override + local overlay in the right order", () => {
+ // repo provides base; user override replaces setup (per-key); local
+ // overlay's `before` then prepends to that merged result.
+ writeRepoConfig(sandbox.repoPath, { setup: ["a"] });
+ writeUserOverride(sandbox.homeDir, PROJECT_ID, { setup: ["b"] });
+ writeRepoLocalConfig(sandbox.repoPath, {
+ setup: { before: ["pre"] },
+ });
+
+ const result = load();
+ expect(result?.setup).toEqual(["pre", "b"]);
+ });
+
+ it("user override with explicit empty array clears base setup", () => {
+ // Load-bearing semantic: mergeBaseConfigs uses `??`, so an empty array
+ // in the override wins over the base. Switching to `||` would silently
+ // let the base fall through.
+ writeRepoConfig(sandbox.repoPath, {
+ setup: ["from-repo"],
+ teardown: ["keep-me"],
+ });
+ writeUserOverride(sandbox.homeDir, PROJECT_ID, { setup: [] });
+
+ const result = load();
+ expect(result?.setup).toEqual([]);
+ expect(result?.teardown).toEqual(["keep-me"]);
+ });
+
+ it("returns the config when only some keys are defined", () => {
+ // Config with no `setup` key at all should still load, with teardown
+ // alone. `setup` stays undefined rather than being defaulted to [].
+ writeRepoConfig(sandbox.repoPath, { teardown: ["docker compose down"] });
+
+ const result = load();
+ expect(result).toEqual({ teardown: ["docker compose down"] });
+ expect(result?.setup).toBeUndefined();
+ });
+
+ it("does not consult any worktree-level config", () => {
+ // The plan promises the worktree is not consulted. Even if a sibling
+ // worktree has its own config.json, loadSetupConfig only reads the
+ // main repoPath.
+ writeRepoConfig(sandbox.repoPath, { setup: ["from-main"] });
+ const fakeWorktree = join(sandbox.repoPath, "..", "fake-worktree");
+ writeRepoConfig(fakeWorktree, { setup: ["from-worktree"] });
+
+ const result = load();
+ expect(result?.setup).toEqual(["from-main"]);
+ });
+});
+
+describe("hasConfiguredScripts", () => {
+ it("returns false for null", () => {
+ expect(hasConfiguredScripts(null)).toBe(false);
+ });
+
+ it("returns false when all arrays are empty", () => {
+ expect(hasConfiguredScripts({ setup: [], teardown: [], run: [] })).toBe(
+ false,
+ );
+ });
+
+ it("returns false when arrays contain only whitespace strings", () => {
+ expect(hasConfiguredScripts({ setup: ["", " "], teardown: ["\n"] })).toBe(
+ false,
+ );
+ });
+
+ it("returns true when setup has any non-empty command", () => {
+ expect(hasConfiguredScripts({ setup: ["bun install"] })).toBe(true);
+ });
+
+ it("returns true when only teardown is set", () => {
+ expect(hasConfiguredScripts({ teardown: ["docker compose down"] })).toBe(
+ true,
+ );
+ });
+
+ it("returns true when only run is set (so the card hides for run-only)", () => {
+ // v2 doesn't expose run, but the loader still considers it "configured"
+ // so the sidebar CTA hides for projects that came over from v1.
+ expect(hasConfiguredScripts({ run: ["bun dev"] })).toBe(true);
+ });
+});
+
+describe("getResolvedSetupCommands", () => {
+ it("returns empty for null config", () => {
+ expect(getResolvedSetupCommands(null)).toEqual([]);
+ });
+
+ it("filters out empty and whitespace-only entries", () => {
+ expect(
+ getResolvedSetupCommands({
+ setup: ["bun install", "", " ", "bun run db:migrate"],
+ }),
+ ).toEqual(["bun install", "bun run db:migrate"]);
+ });
+});
+
+describe("getProjectConfigPath", () => {
+ it("appends .superset/config.json to the repoPath", () => {
+ expect(getProjectConfigPath("/tmp/x")).toBe("/tmp/x/.superset/config.json");
+ });
+});
diff --git a/packages/host-service/src/runtime/setup/config.ts b/packages/host-service/src/runtime/setup/config.ts
new file mode 100644
index 00000000000..74e20cb5eb0
--- /dev/null
+++ b/packages/host-service/src/runtime/setup/config.ts
@@ -0,0 +1,222 @@
+import { existsSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+const PROJECT_SUPERSET_DIR_NAME = ".superset";
+const CONFIG_FILE_NAME = "config.json";
+const LOCAL_CONFIG_FILE_NAME = "config.local.json";
+const SUPERSET_DIR_NAME = ".superset";
+const PROJECTS_DIR_NAME = "projects";
+
+export interface SetupConfig {
+ setup?: string[];
+ teardown?: string[];
+ run?: string[];
+}
+
+interface LocalScriptMerge {
+ before?: string[];
+ after?: string[];
+}
+
+interface LocalSetupConfig {
+ setup?: string[] | LocalScriptMerge;
+ teardown?: string[] | LocalScriptMerge;
+ run?: string[] | LocalScriptMerge;
+}
+
+const SCRIPT_KEYS = ["setup", "teardown", "run"] as const;
+type ScriptKey = (typeof SCRIPT_KEYS)[number];
+
+function isStringArray(value: unknown): value is string[] {
+ return (
+ Array.isArray(value) && value.every((item) => typeof item === "string")
+ );
+}
+
+function readJson(filePath: string): T | null {
+ if (!existsSync(filePath)) return null;
+ try {
+ return JSON.parse(readFileSync(filePath, "utf-8")) as T;
+ } catch (error) {
+ console.error(
+ `Failed to read JSON at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return null;
+ }
+}
+
+function validateSetupConfig(
+ parsed: unknown,
+ source: string,
+): SetupConfig | null {
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return null;
+ }
+ const obj = parsed as Record;
+ const result: SetupConfig = {};
+ for (const key of SCRIPT_KEYS) {
+ const value = obj[key];
+ if (value === undefined) continue;
+ if (!isStringArray(value)) {
+ console.error(
+ `Invalid setup config at ${source}: '${key}' must be an array of strings`,
+ );
+ return null;
+ }
+ result[key] = value;
+ }
+ return result;
+}
+
+function readSetupConfigAt(filePath: string): SetupConfig | null {
+ const parsed = readJson(filePath);
+ if (parsed === null) return null;
+ return validateSetupConfig(parsed, filePath);
+}
+
+function readLocalConfigAt(filePath: string): LocalSetupConfig | null {
+ const parsed = readJson(filePath);
+ if (parsed === null) return null;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return null;
+ }
+ const obj = parsed as Record;
+ const result: LocalSetupConfig = {};
+ for (const key of SCRIPT_KEYS) {
+ const value = obj[key];
+ if (value === undefined) continue;
+ if (isStringArray(value)) {
+ result[key] = value;
+ continue;
+ }
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ const merge = value as Record;
+ if (merge.before !== undefined && !isStringArray(merge.before)) {
+ console.error(
+ `Invalid local config at ${filePath}: '${key}.before' must be an array of strings`,
+ );
+ return null;
+ }
+ if (merge.after !== undefined && !isStringArray(merge.after)) {
+ console.error(
+ `Invalid local config at ${filePath}: '${key}.after' must be an array of strings`,
+ );
+ return null;
+ }
+ result[key] = {
+ before: merge.before as string[] | undefined,
+ after: merge.after as string[] | undefined,
+ };
+ continue;
+ }
+ console.error(
+ `Invalid local config at ${filePath}: '${key}' must be an array or {before,after}`,
+ );
+ return null;
+ }
+ return result;
+}
+
+function mergeBaseConfigs(
+ base: SetupConfig | null,
+ override: SetupConfig | null,
+): SetupConfig | null {
+ if (!base) return override;
+ if (!override) return base;
+ return {
+ setup: override.setup ?? base.setup,
+ teardown: override.teardown ?? base.teardown,
+ run: override.run ?? base.run,
+ };
+}
+
+function applyLocalOverlay(
+ base: SetupConfig,
+ local: LocalSetupConfig,
+): SetupConfig {
+ const result: SetupConfig = { ...base };
+ for (const key of SCRIPT_KEYS) {
+ const localValue = local[key];
+ if (localValue === undefined) continue;
+ if (Array.isArray(localValue)) {
+ result[key] = localValue;
+ } else {
+ const before = localValue.before ?? [];
+ const after = localValue.after ?? [];
+ result[key] = [...before, ...(base[key] ?? []), ...after];
+ }
+ }
+ return result;
+}
+
+export function getProjectConfigPath(repoPath: string): string {
+ return join(repoPath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME);
+}
+
+function getUserOverridePath(
+ projectId: string,
+ homeDir: string,
+): string | null {
+ if (projectId.includes("/") || projectId.includes("\\")) return null;
+ return join(
+ homeDir,
+ SUPERSET_DIR_NAME,
+ PROJECTS_DIR_NAME,
+ projectId,
+ CONFIG_FILE_NAME,
+ );
+}
+
+function getLocalOverlayPath(repoPath: string): string {
+ return join(repoPath, PROJECT_SUPERSET_DIR_NAME, LOCAL_CONFIG_FILE_NAME);
+}
+
+/**
+ * Resolve setup/teardown/run config for a v2 project.
+ *
+ * 1. /.superset/config.json — canonical
+ * 2. ~/.superset/projects//config.json — per-machine override (later wins)
+ * 3. /.superset/config.local.json — overlay with before/after/replace
+ *
+ * Returns null when no source defines anything. Worktrees are not consulted —
+ * the main repo path is the single source of truth.
+ */
+export function loadSetupConfig(args: {
+ repoPath: string;
+ projectId: string;
+ /** Override $HOME for tests. Defaults to `os.homedir()`. */
+ homeDir?: string;
+}): SetupConfig | null {
+ const projectConfig = readSetupConfigAt(getProjectConfigPath(args.repoPath));
+
+ const userOverridePath = getUserOverridePath(
+ args.projectId,
+ args.homeDir ?? homedir(),
+ );
+ const userConfig = userOverridePath
+ ? readSetupConfigAt(userOverridePath)
+ : null;
+
+ const base = mergeBaseConfigs(projectConfig, userConfig);
+ if (!base) return null;
+
+ const local = readLocalConfigAt(getLocalOverlayPath(args.repoPath));
+ return local ? applyLocalOverlay(base, local) : base;
+}
+
+function nonEmptyStrings(value: string[] | undefined): string[] {
+ return (value ?? []).filter((s) => s.trim().length > 0);
+}
+
+export function hasConfiguredScripts(config: SetupConfig | null): boolean {
+ if (!config) return false;
+ for (const key of SCRIPT_KEYS satisfies readonly ScriptKey[]) {
+ if (nonEmptyStrings(config[key]).length > 0) return true;
+ }
+ return false;
+}
+
+export function getResolvedSetupCommands(config: SetupConfig | null): string[] {
+ return nonEmptyStrings(config?.setup);
+}
diff --git a/packages/host-service/src/trpc/router/config/config.test.ts b/packages/host-service/src/trpc/router/config/config.test.ts
new file mode 100644
index 00000000000..242251fe68c
--- /dev/null
+++ b/packages/host-service/src/trpc/router/config/config.test.ts
@@ -0,0 +1,249 @@
+import { Database } from "bun:sqlite";
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
+import * as schema from "../../../db/schema";
+import type { HostServiceContext } from "../../../types";
+import { configRouter } from "./config";
+
+const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle");
+// Valid v4 UUID — zod's .uuid() rejects all-1s.
+const PROJECT_ID = "1f0e8c7e-1234-4abc-8def-0123456789ab";
+
+interface Sandbox {
+ repoPath: string;
+ cleanup: () => void;
+}
+
+function createRepo(): Sandbox {
+ const root = mkdtempSync(join(tmpdir(), "config-router-test-"));
+ const repoPath = join(root, "repo");
+ mkdirSync(repoPath, { recursive: true });
+ return {
+ repoPath,
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+function createCaller(repoPath: string) {
+ const sqlite = new Database(":memory:");
+ const db = drizzle(sqlite, { schema });
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
+ db.insert(schema.projects).values({ id: PROJECT_ID, repoPath }).run();
+ const ctx = { db, isAuthenticated: true } as unknown as HostServiceContext;
+ return configRouter.createCaller(ctx);
+}
+
+describe("configRouter", () => {
+ let sandbox: Sandbox;
+
+ beforeEach(() => {
+ sandbox = createRepo();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ describe("getConfigContent", () => {
+ it("returns null content when config.json doesn't exist", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ const result = await caller.getConfigContent({ projectId: PROJECT_ID });
+ expect(result).toEqual({ content: null, exists: false });
+ });
+
+ it("returns raw content when config.json exists", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ `{"setup":["bun install"]}`,
+ "utf-8",
+ );
+
+ const result = await caller.getConfigContent({ projectId: PROJECT_ID });
+ expect(result.exists).toBe(true);
+ expect(result.content).toBe(`{"setup":["bun install"]}`);
+ });
+
+ it("throws NOT_FOUND when project isn't registered locally", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ await expect(
+ caller.getConfigContent({
+ projectId: "2f0e8c7e-1234-4abc-8def-0123456789ab",
+ }),
+ ).rejects.toThrow(/Project not set up locally/);
+ });
+ });
+
+ describe("updateConfig", () => {
+ it("creates .superset/config.json on first save", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: ["bun install"],
+ teardown: [],
+ });
+
+ const configPath = join(sandbox.repoPath, ".superset", "config.json");
+ expect(existsSync(configPath)).toBe(true);
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
+ expect(parsed).toEqual({ setup: ["bun install"], teardown: [] });
+ });
+
+ it("preserves the existing run array on subsequent saves", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ JSON.stringify({
+ setup: ["old"],
+ teardown: ["old-down"],
+ run: ["bun run dev"],
+ }),
+ "utf-8",
+ );
+
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ });
+
+ const parsed = JSON.parse(
+ readFileSync(join(dir, "config.json"), "utf-8"),
+ );
+ expect(parsed).toEqual({
+ setup: ["bun install"],
+ teardown: ["docker compose down"],
+ run: ["bun run dev"],
+ });
+ });
+
+ it("preserves unrelated top-level keys (forward compatibility)", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ JSON.stringify({
+ setup: [],
+ teardown: [],
+ somethingNew: { nested: true },
+ }),
+ "utf-8",
+ );
+
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: ["x"],
+ teardown: [],
+ });
+
+ const parsed = JSON.parse(
+ readFileSync(join(dir, "config.json"), "utf-8"),
+ );
+ expect(parsed.somethingNew).toEqual({ nested: true });
+ expect(parsed.setup).toEqual(["x"]);
+ });
+
+ it("overwrites a malformed config.json with a fresh shape", async () => {
+ // Documents the current behavior: a corrupt file is silently replaced.
+ // Surfaced in the smoke-test list as a known-but-accepted edge case.
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "config.json"), "{not valid json,,,", "utf-8");
+
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: ["new"],
+ teardown: [],
+ });
+
+ const parsed = JSON.parse(
+ readFileSync(join(dir, "config.json"), "utf-8"),
+ );
+ expect(parsed).toEqual({ setup: ["new"], teardown: [] });
+ });
+ });
+
+ describe("shouldShowSetupCard", () => {
+ it("returns true when no config exists", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ expect(await caller.shouldShowSetupCard({ projectId: PROJECT_ID })).toBe(
+ true,
+ );
+ });
+
+ it("returns false once setup is non-empty", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: ["bun install"],
+ teardown: [],
+ });
+ expect(await caller.shouldShowSetupCard({ projectId: PROJECT_ID })).toBe(
+ false,
+ );
+ });
+
+ it("returns true when config exists but all arrays are empty", async () => {
+ const caller = createCaller(sandbox.repoPath);
+ await caller.updateConfig({
+ projectId: PROJECT_ID,
+ setup: [],
+ teardown: [],
+ });
+ expect(await caller.shouldShowSetupCard({ projectId: PROJECT_ID })).toBe(
+ true,
+ );
+ });
+
+ it("returns false when only teardown is defined (setup key absent)", async () => {
+ // Different from "all-empty arrays": here there's no setup key at all.
+ // Card should still hide because teardown counts as configured.
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ JSON.stringify({ teardown: ["docker compose down"] }),
+ "utf-8",
+ );
+
+ expect(await caller.shouldShowSetupCard({ projectId: PROJECT_ID })).toBe(
+ false,
+ );
+ });
+
+ it("returns false when only the run key is set", async () => {
+ // updateConfig doesn't accept run, but a v1-imported project might
+ // already have one — the card should hide for those too.
+ const caller = createCaller(sandbox.repoPath);
+ const dir = join(sandbox.repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "config.json"),
+ JSON.stringify({ setup: [], teardown: [], run: ["bun run dev"] }),
+ "utf-8",
+ );
+
+ expect(await caller.shouldShowSetupCard({ projectId: PROJECT_ID })).toBe(
+ false,
+ );
+ });
+ });
+});
diff --git a/packages/host-service/src/trpc/router/config/config.ts b/packages/host-service/src/trpc/router/config/config.ts
new file mode 100644
index 00000000000..4f266e5de9d
--- /dev/null
+++ b/packages/host-service/src/trpc/router/config/config.ts
@@ -0,0 +1,125 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname } from "node:path";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+import { projects } from "../../../db/schema";
+import {
+ getProjectConfigPath,
+ hasConfiguredScripts,
+ loadSetupConfig,
+ type SetupConfig,
+} from "../../../runtime/setup/config";
+import type { HostServiceContext } from "../../../types";
+import { protectedProcedure, router } from "../../index";
+
+const projectIdInput = z.object({ projectId: z.string().uuid() });
+
+const stringArray = z.array(z.string());
+
+function requireProject(
+ ctx: HostServiceContext,
+ projectId: string,
+): { id: string; repoPath: string } {
+ const row = ctx.db.query.projects
+ .findFirst({ where: eq(projects.id, projectId) })
+ .sync();
+ if (!row || !row.repoPath) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Project not set up locally: ${projectId}`,
+ });
+ }
+ return { id: row.id, repoPath: row.repoPath };
+}
+
+export const configRouter = router({
+ /**
+ * Decide whether the v2 sidebar setup-script CTA should show for a project.
+ * Returns true only when no source (main repo, user override, local overlay)
+ * defines any setup/teardown/run commands. Renderer also gates on a
+ * client-side dismissal store, so this only answers "is config empty".
+ */
+ shouldShowSetupCard: protectedProcedure
+ .input(projectIdInput)
+ .query(({ ctx, input }) => {
+ const project = requireProject(ctx, input.projectId);
+ const config = loadSetupConfig({
+ repoPath: project.repoPath,
+ projectId: project.id,
+ });
+ return !hasConfiguredScripts(config);
+ }),
+
+ /**
+ * Read the canonical config file. Returns null content when the file is
+ * absent — the editor renders an empty form in that case and creates the
+ * file on first save via updateConfig.
+ */
+ getConfigContent: protectedProcedure
+ .input(projectIdInput)
+ .query(({ ctx, input }) => {
+ const project = requireProject(ctx, input.projectId);
+ const configPath = getProjectConfigPath(project.repoPath);
+ if (!existsSync(configPath)) {
+ return { content: null as string | null, exists: false };
+ }
+ try {
+ return {
+ content: readFileSync(configPath, "utf-8") as string | null,
+ exists: true,
+ };
+ } catch (error) {
+ console.error(
+ `[config.getConfigContent] failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return { content: null as string | null, exists: false };
+ }
+ }),
+
+ /**
+ * Write setup/teardown to the project's config.json, preserving any other
+ * existing top-level keys (notably `run`, which v2 doesn't expose yet).
+ */
+ updateConfig: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ setup: stringArray,
+ teardown: stringArray,
+ }),
+ )
+ .mutation(({ ctx, input }) => {
+ const project = requireProject(ctx, input.projectId);
+ const configPath = getProjectConfigPath(project.repoPath);
+ mkdirSync(dirname(configPath), { recursive: true });
+
+ let existing: Record = {};
+ if (existsSync(configPath)) {
+ try {
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ existing = parsed as Record;
+ }
+ } catch {
+ existing = {};
+ }
+ }
+
+ const merged: SetupConfig & Record = {
+ ...existing,
+ setup: input.setup,
+ teardown: input.teardown,
+ };
+
+ try {
+ writeFileSync(configPath, JSON.stringify(merged, null, 2), "utf-8");
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to write config: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ }
+ return { success: true as const };
+ }),
+});
diff --git a/packages/host-service/src/trpc/router/config/index.ts b/packages/host-service/src/trpc/router/config/index.ts
new file mode 100644
index 00000000000..8001e9adaef
--- /dev/null
+++ b/packages/host-service/src/trpc/router/config/index.ts
@@ -0,0 +1 @@
+export { configRouter } from "./config";
diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts
index 6bcff364d32..fd890b778bb 100644
--- a/packages/host-service/src/trpc/router/router.ts
+++ b/packages/host-service/src/trpc/router/router.ts
@@ -4,6 +4,7 @@ import { attachmentsRouter } from "./attachments";
import { authRouter } from "./auth";
import { chatRouter } from "./chat";
import { cloudRouter } from "./cloud";
+import { configRouter } from "./config";
import { filesystemRouter } from "./filesystem";
import { gitRouter } from "./git";
import { githubRouter } from "./github";
@@ -28,6 +29,7 @@ export const appRouter = router({
health: healthRouter,
host: hostRouter,
chat: chatRouter,
+ config: configRouter,
filesystem: filesystemRouter,
git: gitRouter,
github: githubRouter,
diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.test.ts
new file mode 100644
index 00000000000..30b4c178c65
--- /dev/null
+++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { resolveInitialCommand } from "./setup-terminal";
+
+const PROJECT_ID = "11111111-1111-1111-1111-111111111111";
+
+interface Sandbox {
+ repoPath: string;
+ homeDir: string;
+ cleanup: () => void;
+}
+
+function createSandbox(): Sandbox {
+ const root = mkdtempSync(join(tmpdir(), "setup-terminal-test-"));
+ const repoPath = join(root, "repo");
+ const homeDir = join(root, "home");
+ mkdirSync(repoPath, { recursive: true });
+ mkdirSync(homeDir, { recursive: true });
+ return {
+ repoPath,
+ homeDir,
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+function writeConfig(repoPath: string, content: object) {
+ const dir = join(repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "config.json"), JSON.stringify(content), "utf-8");
+}
+
+function writeFallbackScript(repoPath: string) {
+ const dir = join(repoPath, ".superset");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "setup.sh"), "#!/bin/bash\necho hi\n", "utf-8");
+}
+
+describe("resolveInitialCommand", () => {
+ let sandbox: Sandbox;
+
+ beforeEach(() => {
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ function resolve() {
+ return resolveInitialCommand({
+ repoPath: sandbox.repoPath,
+ projectId: PROJECT_ID,
+ homeDir: sandbox.homeDir,
+ });
+ }
+
+ it("returns null when no config and no fallback script exist", () => {
+ expect(resolve()).toBeNull();
+ });
+
+ it("joins multi-line setup commands with ' && '", () => {
+ writeConfig(sandbox.repoPath, {
+ setup: ["bun install", "bun run db:migrate"],
+ });
+ expect(resolve()).toBe("bun install && bun run db:migrate");
+ });
+
+ it("returns the single command when setup has only one line", () => {
+ writeConfig(sandbox.repoPath, { setup: ["bun install"] });
+ expect(resolve()).toBe("bun install");
+ });
+
+ it("falls back to bash /.superset/setup.sh when config is empty", () => {
+ writeConfig(sandbox.repoPath, { setup: [], teardown: [] });
+ writeFallbackScript(sandbox.repoPath);
+
+ const cmd = resolve();
+ expect(cmd).toBe(
+ `bash '${join(sandbox.repoPath, ".superset", "setup.sh")}'`,
+ );
+ });
+
+ it("falls back to setup.sh when no config.json exists at all", () => {
+ writeFallbackScript(sandbox.repoPath);
+ const cmd = resolve();
+ expect(cmd).toBe(
+ `bash '${join(sandbox.repoPath, ".superset", "setup.sh")}'`,
+ );
+ });
+
+ it("config setup wins over the fallback script", () => {
+ writeConfig(sandbox.repoPath, { setup: ["bun install"] });
+ writeFallbackScript(sandbox.repoPath);
+ expect(resolve()).toBe("bun install");
+ });
+
+ it("ignores teardown when resolving the setup command", () => {
+ writeConfig(sandbox.repoPath, {
+ setup: [],
+ teardown: ["docker compose down"],
+ });
+ expect(resolve()).toBeNull();
+ });
+
+ it("filters whitespace-only setup entries", () => {
+ writeConfig(sandbox.repoPath, {
+ setup: ["", " ", "bun install", "\n"],
+ });
+ expect(resolve()).toBe("bun install");
+ });
+
+ it("escapes single quotes in fallback path", () => {
+ const sandboxWithQuote = createSandbox();
+ try {
+ const trickyRepo = join(sandboxWithQuote.repoPath, "it's a repo");
+ writeFallbackScript(trickyRepo);
+ const cmd = resolveInitialCommand({
+ repoPath: trickyRepo,
+ projectId: PROJECT_ID,
+ homeDir: sandboxWithQuote.homeDir,
+ });
+ expect(cmd).toContain("'\\''");
+ // Verify the escape sequence wraps the single quote correctly.
+ expect(cmd).toBe(
+ `bash '${trickyRepo.replace("'", "'\\''")}/.superset/setup.sh'`,
+ );
+ } finally {
+ sandboxWithQuote.cleanup();
+ }
+ });
+
+ it("does not consult worktree-level config (uses main repoPath)", () => {
+ writeConfig(sandbox.repoPath, { setup: ["from-main"] });
+ // A sibling worktree directory with its own config should be ignored.
+ const sibling = join(sandbox.repoPath, "..", "sibling-worktree");
+ writeConfig(sibling, { setup: ["from-worktree"] });
+
+ expect(resolve()).toBe("from-main");
+ });
+});
diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts
index 9edd312b0d9..72468969779 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts
@@ -1,19 +1,62 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
+import { eq } from "drizzle-orm";
+import { projects, workspaces } from "../../../../db/schema";
+import {
+ getResolvedSetupCommands,
+ loadSetupConfig,
+} from "../../../../runtime/setup/config";
import { createTerminalSessionInternal } from "../../../../terminal/terminal";
import type { HostServiceContext } from "../../../../types";
import type { TerminalDescriptor } from "./types";
-export async function startSetupTerminalIfPresent(args: {
+interface StartSetupTerminalArgs {
ctx: HostServiceContext;
workspaceId: string;
- worktreePath: string;
-}): Promise<{
+}
+
+interface StartSetupTerminalResult {
terminal: TerminalDescriptor | null;
warning: string | null;
-}> {
- const setupScriptPath = join(args.worktreePath, ".superset", "setup.sh");
- if (!existsSync(setupScriptPath)) {
+}
+
+/**
+ * Resolve and start the workspace-creation setup terminal, if any.
+ *
+ * Source order:
+ * 1. Configured `setup` array from `.superset/config.json` (+ user override
+ * and `config.local.json` overlay) — joined with ` && ` so failures
+ * short-circuit.
+ * 2. Fallback: `bash /.superset/setup.sh` against the main repo
+ * (NOT the worktree — worktrees skip gitignored files, the main repo is
+ * authoritative). Scripts that need the canonical `.superset/` dir read
+ * `$SUPERSET_ROOT_PATH`, injected by the v2 terminal env builder.
+ *
+ * No-op when neither source resolves to anything runnable.
+ */
+export async function startSetupTerminalIfPresent(
+ args: StartSetupTerminalArgs,
+): Promise {
+ const row = args.ctx.db
+ .select({
+ worktreePath: workspaces.worktreePath,
+ repoPath: projects.repoPath,
+ projectId: workspaces.projectId,
+ })
+ .from(workspaces)
+ .innerJoin(projects, eq(projects.id, workspaces.projectId))
+ .where(eq(workspaces.id, args.workspaceId))
+ .get();
+
+ if (!row || !row.worktreePath || !row.repoPath) {
+ return { terminal: null, warning: null };
+ }
+
+ const initialCommand = resolveInitialCommand({
+ repoPath: row.repoPath,
+ projectId: row.projectId,
+ });
+ if (!initialCommand) {
return { terminal: null, warning: null };
}
@@ -23,7 +66,7 @@ export async function startSetupTerminalIfPresent(args: {
workspaceId: args.workspaceId,
db: args.ctx.db,
eventBus: args.ctx.eventBus,
- initialCommand: `bash ${singleQuote(setupScriptPath)}`,
+ initialCommand,
});
if ("error" in result) {
return {
@@ -42,6 +85,27 @@ export async function startSetupTerminalIfPresent(args: {
};
}
+/** Exported for tests. Resolves the initial command for the setup terminal. */
+export function resolveInitialCommand(args: {
+ repoPath: string;
+ projectId: string;
+ /** Override $HOME for tests. */
+ homeDir?: string;
+}): string | null {
+ const config = loadSetupConfig(args);
+ const commands = getResolvedSetupCommands(config);
+ if (commands.length > 0) {
+ return commands.join(" && ");
+ }
+
+ const fallbackScript = join(args.repoPath, ".superset", "setup.sh");
+ if (existsSync(fallbackScript)) {
+ return `bash ${singleQuote(fallbackScript)}`;
+ }
+
+ return null;
+}
+
/** POSIX single-quote escape: safe for any path passed through a shell. */
function singleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
index f9998a16c5b..0339a1b623d 100644
--- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts
+++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
@@ -785,27 +785,18 @@ export const workspacesRouter = router({
const terminalsResult: Array<{ terminalId: string; label?: string }> = [];
if (!alreadyExists) {
- // worktreePath is set in the !alreadyExists branches above.
- const setupWorktreePath = ctx.db.query.workspaces
- .findFirst({
- where: eq(workspaces.id, workspaceRow.id),
- })
- .sync()?.worktreePath;
- if (setupWorktreePath) {
- const { terminal, warning } = await startSetupTerminalIfPresent({
- ctx,
- workspaceId: workspaceRow.id,
- worktreePath: setupWorktreePath,
+ const { terminal, warning } = await startSetupTerminalIfPresent({
+ ctx,
+ workspaceId: workspaceRow.id,
+ });
+ if (warning) {
+ console.warn(`[workspaces.create] setup warning: ${warning}`);
+ }
+ if (terminal) {
+ terminalsResult.push({
+ terminalId: terminal.id,
+ label: terminal.label,
});
- if (warning) {
- console.warn(`[workspaces.create] setup warning: ${warning}`);
- }
- if (terminal) {
- terminalsResult.push({
- terminalId: terminal.id,
- label: terminal.label,
- });
- }
}
}
diff --git a/plans/20260505-setup-teardown-scripts-v2.md b/plans/20260505-setup-teardown-scripts-v2.md
new file mode 100644
index 00000000000..8713398e6c7
--- /dev/null
+++ b/plans/20260505-setup-teardown-scripts-v2.md
@@ -0,0 +1,228 @@
+# Setup/Teardown Scripts for v2 Projects
+
+Status: plan. v2-only — v1 code paths must not change.
+
+## Goal
+
+Build the v2 equivalent of the v1 project-settings UI for editing
+`.superset/config.json` setup and teardown scripts, and make v2 workspace
+creation honor the configured `setup` array (today it only checks for a
+literal `/.superset/setup.sh`).
+
+## Scope rule
+
+Touch only v2 surfaces. Do not modify the v1 ScriptsEditor, the v1
+electronTrpc `config` router, the v1 `setup-script-card`, or any v1 callsite.
+v1 stays exactly as it is on `main`. Per project memory: v1 desktop UI is
+sunset, prefer v2-first fixes.
+
+## Outcome target
+
+| Surface | Before | After |
+|---|---|---|
+| v2 project settings → Scripts editor | missing | mounted, talks to host-service |
+| v2 sidebar → Setup-scripts CTA | missing | new card, dismissable per-project |
+| v2 workspace creation → setup terminal | reads `/.superset/setup.sh` only | resolves config.json + override + overlay, falls back to `/.superset/setup.sh` |
+| `/.superset/` copy from main | required (`copySupersetConfigToWorktree`) | not needed; main repo is single source of truth |
+
+## Architecture
+
+```
+renderer (v2 only)
+ V2ScriptsEditor ──▶ host-service.config.{getConfigContent, updateConfig}
+ │
+ ▼
+ loadSetupConfig({ repoPath, projectId })
+ │
+ ┌─────────┴─────────┐
+ ▼ ▼
+ .superset/config.json ~/.superset/projects//config.json
+ │
+ └──── + .superset/config.local.json (overlay)
+```
+
+The host-service is the authoritative path for v2 — it owns the v2 project's
+`repoPath` and does its own filesystem I/O, which means it works correctly for
+any host-service location (local or remote-via-relay), not just when
+host-service runs on the same machine as Electron.
+
+Both the v2 editor and the v2 workspace creation runner go through the same
+host-service config loader, so what the user types in the editor is what
+actually runs on workspace creation — no second source of truth.
+
+## Implementation plan
+
+### 1. Host-service config loader
+
+`packages/host-service/src/runtime/setup/config.ts` — `loadSetupConfig` +
+`hasConfiguredScripts`.
+
+Resolution order (later wins for keys it explicitly defines):
+
+1. `/.superset/config.json` — canonical, written by the editor.
+2. `~/.superset/projects//config.json` — per-machine user override.
+
+Then `/.superset/config.local.json` applies as an overlay with
+before/after/replace semantics per key.
+
+Validates element types up-front (rejects `[123, "ok"]`). Returns `null` if no
+source exists. Generic `readJson()` handles read+parse+log; shape validators
+sit on top.
+
+Does **not** read `/.superset/config.json` (v1 did) — the
+worktree no longer holds a separate copy, so the main repo is the single
+source of truth across all worktrees.
+
+### 2. Host-service config router
+
+`packages/host-service/src/trpc/router/config/config.ts` — new tRPC router
+exposing three procedures, all keyed on a v2 `projectId`:
+
+- `getConfigContent({ projectId })` → `{ content: string | null, exists }` — reads
+ `/.superset/config.json` raw.
+- `updateConfig({ projectId, setup, teardown })` → writes the file, preserving
+ any existing top-level keys (including `run`) via spread.
+- `shouldShowSetupCard({ projectId })` → `boolean` — uses `loadSetupConfig` so
+ the card hides correctly when configuration comes from the user override or
+ local overlay, not just `config.json`.
+
+Register under `config:` in `packages/host-service/src/trpc/router/router.ts`.
+
+### 3. Host-service setup terminal
+
+`packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts`.
+
+Rewrite `startSetupTerminalIfPresent` to resolve an `initialCommand`:
+
+1. If the resolved `setup` array is non-empty, run the commands joined with
+ `&&` so a failure short-circuits.
+2. Else fall back to `bash /.superset/setup.sh` (resolved against the
+ main repo, **not** the worktree).
+3. Else no-op.
+
+Terminal `cwd` stays the worktree; `$SUPERSET_ROOT_PATH` (already injected by
+the v2 terminal env builder) exposes the main repo path so scripts can reach
+the canonical `.superset/` dir without it being copied into worktrees.
+
+Drop the unused `worktreePath` arg on the public function and replace the two
+sequential `select` calls with a single `workspaces ⨝ projects` join.
+
+Simplify the caller in `packages/host-service/src/trpc/router/workspaces/workspaces.ts`:
+drop the redundant pre-lookup of `setupWorktreePath` (the helper does its own
+lookup and no-ops gracefully).
+
+### 4. v2 ScriptsEditor
+
+New component family at
+`apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/`:
+
+- `V2ScriptsEditor({ hostUrl, projectId })` — uses raw `useQuery` /
+ `useMutation` against `getHostServiceClientByUrl(hostUrl).config.*` (matching
+ how `V2ProjectSettings` already talks to the host-service for `project.get`).
+
+Behavior:
+
+- Save **on blur only** (no debounce while typing).
+- Trim **on blur** (so newlines typed mid-edit aren't dropped).
+- Multi-line textareas → multi-element arrays
+ (`split('\n').map(trim).filter(Boolean)`) — the runner does `.join(' && ')`,
+ so collapsing into one newline-separated string would silently change failure
+ semantics.
+- No-op skip when the trimmed value matches the last saved snapshot.
+- Server-sync guard: while a textarea is focused, server data won't clobber
+ in-progress edits.
+- Two tabs: Setup, Teardown. No Run tab in v1.x — v2 has no equivalent of v1's
+ `getResolvedRunCommands` hotkey-triggered runner. The editor doesn't send
+ `run` in payloads; the host server preserves any existing on-disk value via
+ its conditional spread.
+
+Visual:
+
+- Section heading + description matching the v2 `SettingsSection` style used
+ by `NameSection`, `RepositorySection`, etc. (smaller `text-sm font-medium`
+ heading, not the large `text-base font-semibold` used by v1).
+- Inline save status next to the heading ("Saving…" amber dot, "Saved" emerald
+ check, fades after 2 s).
+- Shared `@superset/ui/textarea` component for the editor.
+- Drag-drop overlay for `.sh` files (subtle ring rather than heavy border).
+- "Import file" button in the corner (`h-7`, ghost variant).
+- "Docs" link (`h-7`, ghost variant) opening
+ `EXTERNAL_LINKS.SETUP_TEARDOWN_SCRIPTS`.
+
+Mount in `V2ProjectSettings.tsx` after the Appearance section, gated on
+`activeHostUrl`.
+
+### 5. v2 SetupScriptCard
+
+New component at
+`apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/`.
+
+`V2SetupScriptCard({ hostUrl, projectId, projectName, isCollapsed })` —
+`SidebarCard` wrapper that hides itself unless:
+
+- The user is viewing a v2 workspace (so a project context exists).
+- `host-service.config.shouldShowSetupCard({ projectId })` returns true.
+- The card hasn't been dismissed for that v2 project.
+
+Action: navigate to `/settings/projects/$projectId` (this route already
+detects v2 vs v1 and renders `V2ProjectSettings`).
+
+Dismissal stored client-side in
+`apps/desktop/src/renderer/stores/v2-setup-card-dismissals/` — small
+zustand+persist store keyed by v2 projectId. Per-machine UI state, no server
+roundtrip.
+
+Mount in `DashboardSidebar.tsx` between `DashboardSidebarPortsList` and the
+settings/help footer. Active project computed from
+`useMatchRoute({ to: '/v2-workspace/$workspaceId' })` plus a lookup through
+`groups` (the existing dashboard sidebar data).
+
+## Things deliberately NOT done
+
+- **No `copySupersetConfigToWorktree` equivalent.** Worktrees stay clean; main
+ repo is the canonical source. Scripts that need to reach repo-tracked
+ `.superset/` files use `$SUPERSET_ROOT_PATH`. Edits in the settings UI take
+ effect on the next workspace creation immediately, instead of being frozen
+ at each worktree's creation time.
+
+- **No worktree-level `config.json` read** (v1 had it). Reading from the
+ worktree would re-introduce the drift bug v1 had. User-level + local overlay
+ still cover per-machine customization.
+
+- **No Run tab.** v2 has no equivalent of v1's `getResolvedRunCommands`
+ hotkey-triggered runner. When that lands, add a Run tab and wire it up.
+
+- **No changes to v1.** The v1 ScriptsEditor, v1 electronTrpc `config` router,
+ v1 SetupScriptCard, and all v1 callsites stay exactly as they are on main.
+
+## Test plan
+
+- [ ] Open a v2 project's settings → "Scripts" section appears between
+ Appearance and Delete; edits to Setup/Teardown persist to
+ `/.superset/config.json`.
+- [ ] Open a v1 project's settings → editor still works exactly as before;
+ v1 codepath untouched.
+- [ ] Type into a textarea, press Enter to add a newline at the end → blur the
+ textarea → newline is trimmed and a save fires once.
+- [ ] Type a change and immediately switch tabs → the change saves on blur
+ (not while typing).
+- [ ] Type and revert to original → blur fires no network request.
+- [ ] Drag a `.sh` file onto a textarea → contents replace the value; blur
+ saves it.
+- [ ] Open a v2 project whose config already has a non-empty `run` array →
+ run value is preserved on subsequent saves of setup/teardown.
+- [ ] Configure setup commands via the editor → create a new v2 workspace →
+ setup terminal opens and runs the commands joined with `&&`.
+- [ ] Project has no `config.json` but has `/.superset/setup.sh` →
+ new v2 workspace runs `bash /.superset/setup.sh` with the worktree
+ as cwd.
+- [ ] Project has no scripts of any kind → no setup terminal opens.
+- [ ] Edit setup commands while a v2 workspace is mid-creation → only future
+ workspaces pick up the change (in-flight one keeps its snapshot).
+- [ ] On a v2 project with no scripts, the `V2SetupScriptCard` shows in the
+ sidebar; clicking "Configure" lands on the v2 project settings page.
+- [ ] Dismiss the card → it stays dismissed across reloads for that project,
+ shows again on a different project.
+- [ ] Add a `.superset/config.local.json` with `setup.before` → the prepended
+ commands run first; the canonical setup runs after; the card is hidden
+ because configured scripts now exist via the overlay.