From b250a1b4f6f4074dcbef766089162916cc936bea Mon Sep 17 00:00:00 2001 From: Ken Bantoft Date: Thu, 9 Jul 2026 16:06:38 -0400 Subject: [PATCH] Fix crash when saving settings on repo-less Category projects Category projects (grouping-only, no shared git repo) store mainRepoPath as "". Saving Scripts settings called mkdir on a bare ".superset" path relative to cwd, throwing an uncaught ENOENT and crashing the app (#6). Guard config.ts's procedures against an empty mainRepoPath, and surface save errors in the UI instead of failing silently. Also fixes a directory-name mismatch: config.ts hardcoded ".superset" while loadSetupConfig actually reads PROJECT_SUPERSET_DIR_NAME (".ade"), so scripts saved via Settings were never picked up by setup detection. Aligned config.ts and the setup/teardown test fixtures to the shared constant. Fixes #6 Co-Authored-By: Claude Sonnet 5 --- .../src/lib/trpc/routers/config/config.ts | 30 +++++++++++++--- .../routers/workspaces/utils/setup.test.ts | 36 ++++++++++--------- .../routers/workspaces/utils/teardown.test.ts | 34 ++++++++++-------- .../ScriptsEditor/ScriptsEditor.tsx | 4 +++ 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts index 9b74b277..4adf7559 100644 --- a/apps/desktop/src/lib/trpc/routers/config/config.ts +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -10,6 +10,10 @@ import { eq } from "drizzle-orm"; import { MEMORY_SCAFFOLD_ENABLED } from "main/lib/feature-flags"; import { findRealBinary } from "main/lib/agent-setup/utils"; import { localDb } from "main/lib/local-db"; +import { + CONFIG_FILE_NAME, + PROJECT_SUPERSET_DIR_NAME, +} from "shared/constants"; import type { SetupAction, SetupDetectionResult } from "shared/types/config"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -44,6 +48,10 @@ function getRuntimeAvailability(force: boolean): RuntimeAvailability { function hasConfiguredScripts( project: Pick, ) { + if (!project.mainRepoPath) { + return false; + } + const config = loadSetupConfig({ mainRepoPath: project.mainRepoPath, projectId: project.id, @@ -365,12 +373,18 @@ async function detectSetupDefaults( } function getConfigPath(mainRepoPath: string): string { - return join(mainRepoPath, ".superset", "config.json"); + return join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME); } function ensureConfigExists(mainRepoPath: string): string { + if (!mainRepoPath) { + throw new Error( + "This project has no repo path, so it has no setup/teardown scripts.", + ); + } + const configPath = getConfigPath(mainRepoPath); - const supersetDir = join(mainRepoPath, ".superset"); + const supersetDir = join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME); if (!existsSync(configPath)) { // Create .superset directory if it doesn't exist @@ -441,7 +455,7 @@ export const createConfigRouter = () => { .from(projects) .where(eq(projects.id, input.projectId)) .get(); - if (!project) { + if (!project || !project.mainRepoPath) { return null; } return ensureConfigExists(project.mainRepoPath); @@ -456,7 +470,7 @@ export const createConfigRouter = () => { .from(projects) .where(eq(projects.id, input.projectId)) .get(); - if (!project) { + if (!project || !project.mainRepoPath) { return { content: null, exists: false }; } @@ -484,6 +498,14 @@ export const createConfigRouter = () => { if (!project) { throw new Error("Project not found"); } + if (!project.mainRepoPath) { + return { + projectSummary: "", + actions: [], + setupTemplate: [], + signals: {}, + }; + } return await detectSetupDefaults(project.mainRepoPath); }), 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 236009cc..5e68e4b2 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 @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { PROJECTS_DIR_NAME, SUPERSET_DIR_NAME } from "shared/constants"; +import { + PROJECT_SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + SUPERSET_DIR_NAME, +} from "shared/constants"; import { loadSetupConfig } from "./setup"; const TEST_DIR = join(tmpdir(), `superset-test-setup-${process.pid}`); @@ -18,7 +22,7 @@ const USER_CONFIG_DIR = join( describe("loadSetupConfig", () => { beforeEach(() => { - mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true }); + mkdirSync(join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); }); afterEach(() => { @@ -43,7 +47,7 @@ describe("loadSetupConfig", () => { }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(setupConfig), ); @@ -53,7 +57,7 @@ describe("loadSetupConfig", () => { test("returns null for invalid JSON", () => { writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), "{ invalid json", ); @@ -63,7 +67,7 @@ describe("loadSetupConfig", () => { test("validates setup field must be an array", () => { writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ setup: "not-an-array" }), ); @@ -76,13 +80,13 @@ describe("loadSetupConfig", () => { const worktreeConfig = { setup: ["scripts/setup-worktree.sh"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); - mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); + mkdirSync(join(WORKTREE, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); writeFileSync( - join(WORKTREE, ".superset", "config.json"), + join(WORKTREE, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(worktreeConfig), ); @@ -97,7 +101,7 @@ describe("loadSetupConfig", () => { const mainConfig = { setup: ["npm install"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); @@ -115,7 +119,7 @@ describe("loadSetupConfig", () => { const userConfig = { setup: ["custom-setup.sh"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); @@ -136,9 +140,9 @@ describe("loadSetupConfig", () => { const worktreeConfig = { setup: ["worktree-setup.sh"] }; const userConfig = { setup: ["user-override-setup.sh"] }; - mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); + mkdirSync(join(WORKTREE, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); writeFileSync( - join(WORKTREE, ".superset", "config.json"), + join(WORKTREE, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(worktreeConfig), ); @@ -160,7 +164,7 @@ describe("loadSetupConfig", () => { const mainConfig = { setup: ["npm install"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); @@ -175,7 +179,7 @@ describe("loadSetupConfig", () => { const mainConfig = { setup: ["npm install"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); @@ -190,7 +194,7 @@ describe("loadSetupConfig", () => { const userConfig = { setup: [], teardown: [] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); @@ -212,7 +216,7 @@ describe("loadSetupConfig", () => { const mainConfig = { setup: ["npm install"] }; writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify(mainConfig), ); 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 4db5b0c1..c6ec6ddc 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 @@ -8,7 +8,11 @@ import { } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { PROJECTS_DIR_NAME, SUPERSET_DIR_NAME } from "shared/constants"; +import { + PROJECT_SUPERSET_DIR_NAME, + PROJECTS_DIR_NAME, + SUPERSET_DIR_NAME, +} from "shared/constants"; import { runTeardown } from "./teardown"; const TEST_DIR = join(tmpdir(), `superset-test-teardown-${process.pid}`); @@ -25,7 +29,7 @@ const USER_CONFIG_DIR = join( describe("runTeardown", () => { beforeEach(() => { // Create test directories - mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true }); + mkdirSync(join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); mkdirSync(WORKTREE, { recursive: true }); }); @@ -52,7 +56,7 @@ describe("runTeardown", () => { test("returns success when config has no teardown commands", async () => { writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ setup: ["echo setup"] }), ); @@ -66,7 +70,7 @@ describe("runTeardown", () => { test("returns success when teardown array is empty", async () => { writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [] }), ); @@ -84,7 +88,7 @@ describe("runTeardown", () => { const markerFile = join(WORKTREE, "main-repo-config-executed.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "executed" > "${markerFile}"`] }), ); @@ -101,9 +105,9 @@ describe("runTeardown", () => { test("uses worktreePath config when present", async () => { const worktreeMarker = join(WORKTREE, "worktree-config-executed.txt"); - mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); + mkdirSync(join(WORKTREE, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); writeFileSync( - join(WORKTREE, ".superset", "config.json"), + join(WORKTREE, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "executed" > "${worktreeMarker}"`] }), ); @@ -123,13 +127,13 @@ describe("runTeardown", () => { const worktreeMarker = join(WORKTREE, "from-worktree.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "main" > "${mainMarker}"`] }), ); - mkdirSync(join(WORKTREE, ".superset"), { recursive: true }); + mkdirSync(join(WORKTREE, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); writeFileSync( - join(WORKTREE, ".superset", "config.json"), + join(WORKTREE, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "worktree" > "${worktreeMarker}"`] }), ); @@ -146,7 +150,7 @@ describe("runTeardown", () => { test("returns error when teardown command fails", async () => { writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: ["exit 1"] }), ); @@ -162,7 +166,7 @@ describe("runTeardown", () => { test("chains multiple teardown commands with &&", async () => { const testFile = join(WORKTREE, "teardown-test.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "created" > "${testFile}"`, `test -f "${testFile}"`], }), @@ -180,7 +184,7 @@ describe("runTeardown", () => { test("sets environment variables for teardown scripts", async () => { const envFile = join(WORKTREE, "env-test.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [ `echo "$SUPERSET_WORKSPACE_NAME|$SUPERSET_ROOT_PATH" > "${envFile}"`, @@ -204,7 +208,7 @@ describe("runTeardown", () => { const userMarker = join(WORKTREE, "from-user.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "main" > "${mainMarker}"`] }), ); @@ -231,7 +235,7 @@ describe("runTeardown", () => { const mainMarker = join(WORKTREE, "from-main.txt"); writeFileSync( - join(MAIN_REPO, ".superset", "config.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, "config.json"), JSON.stringify({ teardown: [`echo "main" > "${mainMarker}"`] }), ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx index 0b925fde..0c7045e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx @@ -1,4 +1,5 @@ import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { useCallback, useEffect, useRef, useState } from "react"; import { HiArrowTopRightOnSquare, HiDocumentArrowUp } from "react-icons/hi2"; @@ -185,6 +186,9 @@ export function ScriptsEditor({ projectId, className }: ScriptsEditorProps) { utils.config.getConfigContent.invalidate({ projectId }); utils.config.shouldShowSetupCard.invalidate({ projectId }); }, + onError: (error) => { + toast.error(error.message || "Could not save scripts"); + }, }); const handleSetupChange = useCallback((value: string) => {