From c7b1529ef8a17dc8c65cfedb198bf4149b486965 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 00:08:56 -0700 Subject: [PATCH] feat(dashboard,core): warn before project creation when git is missing Registering a project on a git-less host used to fail after submission with a raw spawn error. The setup wizard now probes gitCli up front and offers: open the Git downloads (picked up without restart thanks to the stale-PATH resolver), create the project anyway without a git repo (new skipGitInit passthrough, rejected for clone mode), or cancel. Co-Authored-By: Claude Fable 5 --- .changeset/git-missing-project-warning.md | 7 ++ packages/core/src/central-core.ts | 16 +++- packages/dashboard/app/api/legacy.ts | 2 + .../app/components/SetupWizardModal.tsx | 54 ++++++++++- .../SetupWizardModal.git-missing.test.tsx | 92 +++++++++++++++++++ .../src/routes/register-project-routes.ts | 12 +++ 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 .changeset/git-missing-project-warning.md create mode 100644 packages/dashboard/app/components/__tests__/SetupWizardModal.git-missing.test.tsx diff --git a/.changeset/git-missing-project-warning.md b/.changeset/git-missing-project-warning.md new file mode 100644 index 0000000000..a821455382 --- /dev/null +++ b/.changeset/git-missing-project-warning.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Project creation now warns up front when Git is missing, with the choice to open the Git downloads or create the project without a git repo. +category: feature +dev: "SetupWizardModal probes gitCli before registering and shows a three-way ConfirmDialog (create anyway / open downloads / cancel; clone mode offers install-only). New skipGitInit passthrough: ProjectCreateInput → POST /api/projects (rejected for clone mode) → EnsureProjectForPathInput → ensureProjectForPath skips ensureGitRepositoryForProjectPath." diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index c04ff57cd0..f8ee1f1af0 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -175,6 +175,14 @@ export interface EnsureProjectForPathInput { isolationMode?: IsolationMode; nodeId?: string; settings?: ProjectSettings; + /* + FNXC:ProjectSetup 2026-07-18-04:30: + Operator-confirmed "create anyway without a git repo" when git is not + installed on the host. Skips ensureGitRepositoryForProjectPath entirely so + registration succeeds on a plain directory; the repo can be initialized + later once git exists. + */ + skipGitInit?: boolean; } export interface EnsureProjectForPathResult { @@ -579,7 +587,9 @@ export class CentralCore extends EventEmitter { if (input.identity?.id) { const byId = await this.getProject(input.identity.id); if (!byId) { - const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path); + const gitRepository = input.skipGitInit + ? undefined + : await this.ensureGitRepositoryForProjectPath(input.path); const reattached = await this.registerProject({ id: input.identity.id, name: input.name ?? basename(input.path), @@ -597,7 +607,9 @@ export class CentralCore extends EventEmitter { return { project: byId, reattached: false, outcome: "existing" }; } - const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path); + const gitRepository = input.skipGitInit + ? undefined + : await this.ensureGitRepositoryForProjectPath(input.path); const registered = await this.registerProject({ name: input.name ?? basename(input.path), path: input.path, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 1594408419..e43e805aa4 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6290,6 +6290,8 @@ export interface ProjectCreateInput { cloneUrl?: string; workspaceMode?: boolean; taskPrefix?: string; + /** Confirmed "create anyway without a git repo" when git is missing on the host (never valid for clone mode). */ + skipGitInit?: boolean; } export type DockerNodeConfigInfo = DockerNodeConfig; diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index 0420a8731b..09adcda150 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -3,7 +3,9 @@ import { lazy, Suspense, useState, useCallback, useMemo, useRef, useEffect, type import { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api"; -import { createAgent, registerProject, detectWorkspace } from "../api"; +import { createAgent, registerProject, detectWorkspace, fetchAuthStatus } from "../api"; +import { useConfirm } from "../hooks/useConfirm"; +import { openExternalUrl } from "../utils/open-external"; import { DirectoryPicker } from "./DirectoryPicker"; import { suggestProjectName } from "../utils/projectDetection"; @@ -85,6 +87,7 @@ export function SetupWizardModal({ includeAgentStep = true, }: SetupWizardModalProps) { const { t } = useTranslation("app"); + const { confirmWithChoice } = useConfirm(); const helpUrl = "https://discord.gg/ksrfuy7WYR"; /* FNXC:Onboarding 2026-06-22-03:11: @@ -211,6 +214,52 @@ export function SetupWizardModal({ if (!trimmedPath || !trimmedName) return; if (state.manualMode === "clone" && !trimmedCloneUrl) return; + /* + FNXC:ProjectSetup 2026-07-18-04:30: + Registering a project on a host without git previously failed AFTER submission with a raw + spawn error. Probe git up front and warn with an explicit choice: open the git downloads, + or create the project anyway without a git repo (skipGitInit). Clone mode cannot proceed + without git, so its dialog only offers the install link. The probe is best-effort — if the + status call itself fails, registration proceeds and server-side errors still surface. + */ + let skipGitInit = false; + try { + const { gitCli } = await fetchAuthStatus(); + if (gitCli && !gitCli.available) { + if (state.manualMode === "clone") { + const choice = await confirmWithChoice({ + title: t("setup.gitMissingTitle", "Git is not installed"), + message: t( + "setup.gitMissingCloneMessage", + "Cloning a repository requires Git on the Fusion host, and Git was not found. Install Git, then try again — Fusion picks it up without a restart.", + ), + confirmLabel: t("setup.gitMissingOpenDownloads", "Open Git downloads"), + cancelLabel: t("setup.cancel", "Cancel"), + }); + if (choice === "primary") openExternalUrl(gitCli.installUrl ?? "https://git-scm.com/downloads"); + return; + } + const choice = await confirmWithChoice({ + title: t("setup.gitMissingTitle", "Git is not installed"), + message: t( + "setup.gitMissingMessage", + "Git was not found on the Fusion host. Fusion projects normally live in a git repository so agents can branch, commit, and merge work. You can install Git first (Fusion picks it up without a restart), or create the project anyway without a git repository.", + ), + confirmLabel: t("setup.gitMissingCreateAnyway", "Create anyway without Git"), + tertiaryLabel: t("setup.gitMissingOpenDownloads", "Open Git downloads"), + cancelLabel: t("setup.cancel", "Cancel"), + }); + if (choice === "tertiary") { + openExternalUrl(gitCli.installUrl ?? "https://git-scm.com/downloads"); + return; + } + if (choice !== "primary") return; + skipGitInit = true; + } + } catch { + // Probe failure must not block registration. + } + setState((prev) => ({ ...prev, isRegistering: true, error: null })); try { @@ -223,6 +272,7 @@ export function SetupWizardModal({ cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined, workspaceMode: state.manualMode === "existing" ? state.workspaceMode : false, taskPrefix: state.manualTaskPrefix.trim() || undefined, + skipGitInit: skipGitInit || undefined, }; const result = await registerProject(input); @@ -249,7 +299,7 @@ export function SetupWizardModal({ error: err instanceof Error ? err.message : "Failed to register project", })); } - }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix]); + }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix, confirmWithChoice, t]); const handlePresetSelect = useCallback((presetId: string) => { const preset = getPresetById(presetId); diff --git a/packages/dashboard/app/components/__tests__/SetupWizardModal.git-missing.test.tsx b/packages/dashboard/app/components/__tests__/SetupWizardModal.git-missing.test.tsx new file mode 100644 index 0000000000..14b6dec88a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SetupWizardModal.git-missing.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SetupWizardModal } from "../SetupWizardModal"; + +/* +FNXC:ProjectSetup 2026-07-18-04:30: +Registering a project on a host without git used to fail AFTER submission with +a raw spawn error. The wizard must warn up front with an explicit choice: +open the git downloads, or create anyway without a git repo (skipGitInit). +*/ + +const mockRegisterProject = vi.fn(); +const mockFetchAuthStatus = vi.fn(); +const mockConfirmWithChoice = vi.fn(); + +vi.mock("../../api", async (importOriginal) => ({ + ...(await importOriginal>()), + registerProject: (...args: unknown[]) => mockRegisterProject(...args), + fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), + detectWorkspace: vi.fn().mockResolvedValue({ repos: [], isWorkspace: false }), + createAgent: vi.fn(), +})); + +vi.mock("../../hooks/useConfirm", () => ({ + useConfirm: () => ({ + confirm: vi.fn(), + confirmWithChoice: (...args: unknown[]) => mockConfirmWithChoice(...args), + }), +})); + +vi.mock("../../hooks/useNodes", () => ({ + useNodes: () => ({ nodes: [], loading: false }), +})); + +async function fillAndSubmit(): Promise { + fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { + target: { value: "/tmp/demo-project" }, + }); + const nameInput = document.getElementById("project-name") as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Demo" } }); + fireEvent.click(screen.getByRole("button", { name: /register project/i })); +} + +describe("SetupWizardModal git-missing warning", () => { + beforeEach(() => { + mockRegisterProject.mockReset().mockResolvedValue({ id: "proj_1", name: "Demo", path: "/tmp/demo-project" }); + mockFetchAuthStatus.mockReset(); + mockConfirmWithChoice.mockReset(); + }); + + it("creates anyway with skipGitInit when the operator confirms", async () => { + mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: false, installUrl: "https://git-scm.com/downloads" } }); + mockConfirmWithChoice.mockResolvedValue("primary"); + + render(); + await fillAndSubmit(); + + await waitFor(() => { + expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ skipGitInit: true })); + }); + expect(mockConfirmWithChoice).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringMatching(/git is not installed/i) }), + ); + }); + + it("opens the git downloads and aborts on the tertiary choice", async () => { + mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: false, installUrl: "https://git-scm.com/downloads" } }); + mockConfirmWithChoice.mockResolvedValue("tertiary"); + const windowOpen = vi.spyOn(window, "open").mockReturnValue(null); + + render(); + await fillAndSubmit(); + + await waitFor(() => { + expect(windowOpen).toHaveBeenCalledWith("https://git-scm.com/downloads", "_blank"); + }); + expect(mockRegisterProject).not.toHaveBeenCalled(); + windowOpen.mockRestore(); + }); + + it("registers without skipGitInit and without a dialog when git is available", async () => { + mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: true, version: "2.50.0", installUrl: "https://git-scm.com/downloads" } }); + + render(); + await fillAndSubmit(); + + await waitFor(() => { + expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ skipGitInit: undefined })); + }); + expect(mockConfirmWithChoice).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index 4f80725f30..de753c1b28 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -313,6 +313,17 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { if (normalizedCloneUrl !== undefined && normalizedGitSetupMode !== undefined && normalizedGitSetupMode !== "clone") { throw badRequest("cloneUrl can only be provided when gitSetupMode is 'clone'"); } + + /* + FNXC:ProjectSetup 2026-07-18-04:30: + skipGitInit is the dashboard's confirmed "create anyway without a git + repo" choice when git is missing on the host. Never valid for clone mode + (cloning requires git by definition). + */ + const skipGitInit = req.body?.skipGitInit === true; + if (skipGitInit && normalizedGitSetupMode === "clone") { + throw badRequest("skipGitInit cannot be combined with clone mode"); + } if (normalizedGitSetupMode === "clone" && normalizedCloneUrl === undefined) { throw badRequest("cloneUrl must be a non-empty string when gitSetupMode is 'clone'"); } @@ -411,6 +422,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { name: normalizedName, isolationMode, nodeId, + skipGitInit, }); const project = ensured.project;