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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-18 00:08:56 -07:00
parent 7bf83bb458
commit c7b1529ef8
6 changed files with 179 additions and 4 deletions

View File

@@ -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."

View File

@@ -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<CentralCoreEvents> {
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<CentralCoreEvents> {
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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<Record<string, unknown>>()),
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<void> {
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(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
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(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
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(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
await fillAndSubmit();
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ skipGitInit: undefined }));
});
expect(mockConfirmWithChoice).not.toHaveBeenCalled();
});
});

View File

@@ -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;