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:
7
.changeset/git-missing-project-warning.md
Normal file
7
.changeset/git-missing-project-warning.md
Normal 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."
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user