diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index e736f92441b..35beaae21fa 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -147,6 +147,7 @@ function handleExistingWorktree({ const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: existingWorktree.path, + projectName: project.name, }); return { @@ -252,6 +253,7 @@ async function handleNewWorktree({ const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath, + projectName: project.name, }); return { @@ -394,6 +396,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: orphanedWorktree.path, + projectName: project.name, }); return { workspace, @@ -472,6 +475,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath, + projectName: project.name, }); return { @@ -663,6 +667,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: worktree.path, + projectName: project.name, }); track("workspace_opened", { @@ -775,6 +780,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: existingWorktree.path, + projectName: project.name, }); track("workspace_opened", { @@ -831,6 +837,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: input.worktreePath, + projectName: project.name, }); track("workspace_created", { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index a150bb9a455..2d1b66ffb94 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -202,11 +202,12 @@ export const createDeleteProcedures = () => { worktree = getWorktree(workspace.worktreeId); if (worktree && project && existsSync(worktree.path)) { - teardownPromise = runTeardown( - project.mainRepoPath, - worktree.path, - workspace.name, - ); + teardownPromise = runTeardown({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + workspaceName: workspace.name, + projectName: project.name, + }); } else { console.warn( `[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktree ? existsSync(worktree.path) : "N/A"}`, @@ -424,11 +425,12 @@ export const createDeleteProcedures = () => { ); if (exists) { - const teardownResult = await runTeardown( - project.mainRepoPath, - worktree.path, - worktree.branch, - ); + const teardownResult = await runTeardown({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + workspaceName: worktree.branch, + projectName: project.name, + }); if (!teardownResult.success) { return { success: false, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts index 9e6393b57b1..0e380dc3e57 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts @@ -118,6 +118,7 @@ export const createInitProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, worktreePath: relations.worktree?.path, + projectName: project.name, }); const defaultPreset = getDefaultPreset(); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts index 9d557482dd9..af4f1e3b593 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts @@ -1,11 +1,20 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; +import { PROJECTS_DIR_NAME, SUPERSET_DIR_NAME } from "shared/constants"; import { loadSetupConfig } from "./setup"; const TEST_DIR = join(__dirname, ".test-tmp"); const MAIN_REPO = join(TEST_DIR, "main-repo"); const WORKTREE = join(TEST_DIR, "worktree"); +const PROJECT_NAME = "test-project"; +const USER_CONFIG_DIR = join( + homedir(), + SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + PROJECT_NAME, +); describe("loadSetupConfig", () => { beforeEach(() => { @@ -13,10 +22,14 @@ describe("loadSetupConfig", () => { }); afterEach(() => { - // Clean up + // Clean up test dir if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }); } + // Clean up user override dir + if (existsSync(USER_CONFIG_DIR)) { + rmSync(USER_CONFIG_DIR, { recursive: true, force: true }); + } }); test("returns null when config.json does not exist", () => { @@ -96,4 +109,120 @@ describe("loadSetupConfig", () => { }); expect(config).toEqual(mainConfig); }); + + test("user override takes priority over main repo config", () => { + const mainConfig = { setup: ["npm install"] }; + const userConfig = { setup: ["custom-setup.sh"] }; + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify(mainConfig), + ); + + mkdirSync(USER_CONFIG_DIR, { recursive: true }); + writeFileSync( + join(USER_CONFIG_DIR, "config.json"), + JSON.stringify(userConfig), + ); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + projectName: PROJECT_NAME, + }); + expect(config).toEqual(userConfig); + }); + + test("user override takes priority over worktree config", () => { + const worktreeConfig = { setup: ["worktree-setup.sh"] }; + const userConfig = { setup: ["user-override-setup.sh"] }; + + mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); + writeFileSync( + join(WORKTREE, ".superset", "config.json"), + JSON.stringify(worktreeConfig), + ); + + mkdirSync(USER_CONFIG_DIR, { recursive: true }); + writeFileSync( + join(USER_CONFIG_DIR, "config.json"), + JSON.stringify(userConfig), + ); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + projectName: PROJECT_NAME, + }); + expect(config).toEqual(userConfig); + }); + + test("falls back to worktree/main when no user override exists", () => { + const mainConfig = { setup: ["npm install"] }; + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify(mainConfig), + ); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + projectName: PROJECT_NAME, + }); + expect(config).toEqual(mainConfig); + }); + + test("works when projectName is not provided (backwards compat)", () => { + const mainConfig = { setup: ["npm install"] }; + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify(mainConfig), + ); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + }); + expect(config).toEqual(mainConfig); + }); + + test("user override with empty arrays skips setup", () => { + const mainConfig = { setup: ["npm install"] }; + const userConfig = { setup: [], teardown: [] }; + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify(mainConfig), + ); + + mkdirSync(USER_CONFIG_DIR, { recursive: true }); + writeFileSync( + join(USER_CONFIG_DIR, "config.json"), + JSON.stringify(userConfig), + ); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + projectName: PROJECT_NAME, + }); + expect(config).toEqual(userConfig); + expect(config?.setup).toEqual([]); + }); + + test("falls back to main repo when user override has invalid JSON", () => { + const mainConfig = { setup: ["npm install"] }; + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify(mainConfig), + ); + + mkdirSync(USER_CONFIG_DIR, { recursive: true }); + writeFileSync(join(USER_CONFIG_DIR, "config.json"), "{ invalid json"); + + const config = loadSetupConfig({ + mainRepoPath: MAIN_REPO, + projectName: PROJECT_NAME, + }); + expect(config).toEqual(mainConfig); + }); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts index d21b934fc33..8038171487e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -1,6 +1,12 @@ import { cpSync, existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; -import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import { + CONFIG_FILE_NAME, + PROJECT_SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + SUPERSET_DIR_NAME, +} from "shared/constants"; import type { SetupConfig } from "shared/types"; /** @@ -27,13 +33,7 @@ export function copySupersetConfigToWorktree( } } -function readConfigFromPath(basePath: string): SetupConfig | null { - const configPath = join( - basePath, - PROJECT_SUPERSET_DIR_NAME, - CONFIG_FILE_NAME, - ); - +function readConfigFile(configPath: string): SetupConfig | null { if (!existsSync(configPath)) { return null; } @@ -59,17 +59,66 @@ function readConfigFromPath(basePath: string): SetupConfig | null { } } +function readConfigFromPath(basePath: string): SetupConfig | null { + return readConfigFile( + join(basePath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME), + ); +} + +/** + * Resolves setup/teardown config with a three-tier priority: + * 1. User override: ~/.superset/projects//config.json + * 2. Worktree: /.superset/config.json + * 3. Main repo: /.superset/config.json + * + * First config found wins entirely (no merging between levels). + */ export function loadSetupConfig({ mainRepoPath, worktreePath, + projectName, }: { mainRepoPath: string; worktreePath?: string; + projectName?: string; }): SetupConfig | null { + // 1. Check user-level override (~/.superset/projects//config.json) + if ( + projectName && + !projectName.includes("/") && + !projectName.includes("\\") + ) { + const userConfigPath = join( + homedir(), + SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + projectName, + CONFIG_FILE_NAME, + ); + const config = readConfigFile(userConfigPath); + if (config) { + console.log(`[setup] Using user override config from ${userConfigPath}`); + return config; + } + } + + // 2. Check worktree-specific config if (worktreePath) { const config = readConfigFromPath(worktreePath); - if (config) return config; + if (config) { + console.log( + `[setup] Using worktree config from ${join(worktreePath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME)}`, + ); + return config; + } } - return readConfigFromPath(mainRepoPath); + // 3. Fall back to main repo config + const config = readConfigFromPath(mainRepoPath); + if (config) { + console.log( + `[setup] Using main repo config from ${join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME)}`, + ); + } + return config; } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.test.ts index a6f0258bc15..83a606a75f3 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.test.ts @@ -6,12 +6,21 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; +import { PROJECTS_DIR_NAME, SUPERSET_DIR_NAME } from "shared/constants"; import { runTeardown } from "./teardown"; const TEST_DIR = join(__dirname, ".test-tmp-teardown"); const MAIN_REPO = join(TEST_DIR, "main-repo"); const WORKTREE = join(TEST_DIR, "worktree"); +const PROJECT_NAME = "test-teardown-project"; +const USER_CONFIG_DIR = join( + homedir(), + SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + PROJECT_NAME, +); describe("runTeardown", () => { beforeEach(() => { @@ -25,10 +34,18 @@ describe("runTeardown", () => { if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }); } + // Clean up user override dir + if (existsSync(USER_CONFIG_DIR)) { + rmSync(USER_CONFIG_DIR, { recursive: true, force: true }); + } }); test("returns success when no config exists", async () => { - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); @@ -39,7 +56,11 @@ describe("runTeardown", () => { JSON.stringify({ setup: ["echo setup"] }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); }); @@ -49,7 +70,11 @@ describe("runTeardown", () => { JSON.stringify({ teardown: [] }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); }); @@ -63,32 +88,37 @@ describe("runTeardown", () => { JSON.stringify({ teardown: [`echo "executed" > "${markerFile}"`] }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); expect(existsSync(markerFile)).toBe(true); expect(readFileSync(markerFile, "utf-8").trim()).toBe("executed"); }); - test("ignores config in worktreePath", async () => { - // Put a config ONLY in worktree that would create a marker file + test("uses worktreePath config when present", async () => { const worktreeMarker = join(WORKTREE, "worktree-config-executed.txt"); mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); writeFileSync( join(WORKTREE, ".superset", "config.json"), - JSON.stringify({ teardown: [`echo "wrong" > "${worktreeMarker}"`] }), + JSON.stringify({ teardown: [`echo "executed" > "${worktreeMarker}"`] }), ); - // Main repo has no config, so nothing should execute - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); - // The worktree config should NOT have been read/executed - expect(existsSync(worktreeMarker)).toBe(false); + expect(existsSync(worktreeMarker)).toBe(true); + expect(readFileSync(worktreeMarker, "utf-8").trim()).toBe("executed"); }); - test("uses mainRepoPath config even when worktreePath has different config", async () => { - // Both locations have config - only mainRepoPath should be used + test("prefers worktreePath config over mainRepoPath config", async () => { const mainMarker = join(WORKTREE, "from-main.txt"); const worktreeMarker = join(WORKTREE, "from-worktree.txt"); @@ -103,11 +133,15 @@ describe("runTeardown", () => { JSON.stringify({ teardown: [`echo "worktree" > "${worktreeMarker}"`] }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); - expect(existsSync(mainMarker)).toBe(true); - expect(existsSync(worktreeMarker)).toBe(false); + expect(existsSync(worktreeMarker)).toBe(true); + expect(existsSync(mainMarker)).toBe(false); }); test("returns error when teardown command fails", async () => { @@ -116,7 +150,11 @@ describe("runTeardown", () => { JSON.stringify({ teardown: ["exit 1"] }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); @@ -130,7 +168,11 @@ describe("runTeardown", () => { }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "test-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + }); expect(result.success).toBe(true); expect(existsSync(testFile)).toBe(true); }); @@ -146,10 +188,62 @@ describe("runTeardown", () => { }), ); - const result = await runTeardown(MAIN_REPO, WORKTREE, "my-workspace"); + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "my-workspace", + }); expect(result.success).toBe(true); const content = readFileSync(envFile, "utf-8").trim(); expect(content).toBe(`my-workspace|${MAIN_REPO}`); }); + + test("reads from user override when projectName is provided", async () => { + const mainMarker = join(WORKTREE, "from-main.txt"); + const userMarker = join(WORKTREE, "from-user.txt"); + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ teardown: [`echo "main" > "${mainMarker}"`] }), + ); + + mkdirSync(USER_CONFIG_DIR, { recursive: true }); + writeFileSync( + join(USER_CONFIG_DIR, "config.json"), + JSON.stringify({ teardown: [`echo "user" > "${userMarker}"`] }), + ); + + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + projectName: PROJECT_NAME, + }); + + expect(result.success).toBe(true); + expect(existsSync(userMarker)).toBe(true); + expect(readFileSync(userMarker, "utf-8").trim()).toBe("user"); + expect(existsSync(mainMarker)).toBe(false); + }); + + test("falls back to mainRepoPath when no user override exists", async () => { + const mainMarker = join(WORKTREE, "from-main.txt"); + + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ teardown: [`echo "main" > "${mainMarker}"`] }), + ); + + const result = await runTeardown({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + workspaceName: "test-workspace", + projectName: PROJECT_NAME, + }); + + expect(result.success).toBe(true); + expect(existsSync(mainMarker)).toBe(true); + expect(readFileSync(mainMarker, "utf-8").trim()).toBe("main"); + }); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts index 4db51591fd1..54c5936bf82 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts @@ -11,12 +11,18 @@ export interface TeardownResult { output?: string; } -export async function runTeardown( - mainRepoPath: string, - worktreePath: string, - workspaceName: string, -): Promise { - const config = loadSetupConfig({ mainRepoPath }); +export async function runTeardown({ + mainRepoPath, + worktreePath, + workspaceName, + projectName, +}: { + mainRepoPath: string; + worktreePath: string; + workspaceName: string; + projectName?: string; +}): Promise { + const config = loadSetupConfig({ mainRepoPath, worktreePath, projectName }); if (!config?.teardown || config.teardown.length === 0) { console.log( diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 931fbf87347..2eadec60637 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -32,6 +32,7 @@ export const PROTOCOL_SCHEME = // Project-level directory name (always .superset, not conditional) export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; +export const PROJECTS_DIR_NAME = "projects"; export const CONFIG_FILE_NAME = "config.json"; export const PORTS_FILE_NAME = "ports.json"; diff --git a/apps/docs/content/docs/setup-teardown-scripts.mdx b/apps/docs/content/docs/setup-teardown-scripts.mdx index 1347e20d3d3..370925c30ee 100644 --- a/apps/docs/content/docs/setup-teardown-scripts.mdx +++ b/apps/docs/content/docs/setup-teardown-scripts.mdx @@ -45,8 +45,43 @@ Commands run sequentially in the workspace directory. } ``` +## User Overrides + +Override project setup/teardown scripts without modifying the repo by placing a `config.json` in your home directory: + +``` +~/.superset/projects//config.json +``` + +Where `` matches your project's name in Superset (the same name used in `~/.superset/worktrees//`). + +### Priority Order + +Config is resolved in this order (first found wins, for both setup and teardown): +1. `~/.superset/projects//config.json` — user override +2. `/.superset/config.json` — worktree-specific +3. `/.superset/config.json` — project default + +No merging occurs between levels—the first config found is used entirely. + +### Examples + +**Custom setup script:** +```json +{ + "setup": ["~/.superset/projects/my-project/setup.sh"], + "teardown": ["~/.superset/projects/my-project/teardown.sh"] +} +``` + +**Skip setup entirely:** +```json +{ "setup": [], "teardown": [] } +``` + ## Tips - Keep setup fast—runs every workspace creation - Commit `.superset/` to share with team - Use shell scripts for complex logic: `"setup": ["./.superset/setup.sh"]` +- Use user overrides to customize scripts without creating git noise