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;
|
isolationMode?: IsolationMode;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
settings?: ProjectSettings;
|
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 {
|
export interface EnsureProjectForPathResult {
|
||||||
@@ -579,7 +587,9 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
|||||||
if (input.identity?.id) {
|
if (input.identity?.id) {
|
||||||
const byId = await this.getProject(input.identity.id);
|
const byId = await this.getProject(input.identity.id);
|
||||||
if (!byId) {
|
if (!byId) {
|
||||||
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
|
const gitRepository = input.skipGitInit
|
||||||
|
? undefined
|
||||||
|
: await this.ensureGitRepositoryForProjectPath(input.path);
|
||||||
const reattached = await this.registerProject({
|
const reattached = await this.registerProject({
|
||||||
id: input.identity.id,
|
id: input.identity.id,
|
||||||
name: input.name ?? basename(input.path),
|
name: input.name ?? basename(input.path),
|
||||||
@@ -597,7 +607,9 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
|||||||
return { project: byId, reattached: false, outcome: "existing" };
|
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({
|
const registered = await this.registerProject({
|
||||||
name: input.name ?? basename(input.path),
|
name: input.name ?? basename(input.path),
|
||||||
path: input.path,
|
path: input.path,
|
||||||
|
|||||||
@@ -6290,6 +6290,8 @@ export interface ProjectCreateInput {
|
|||||||
cloneUrl?: string;
|
cloneUrl?: string;
|
||||||
workspaceMode?: boolean;
|
workspaceMode?: boolean;
|
||||||
taskPrefix?: string;
|
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;
|
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 { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api";
|
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 { DirectoryPicker } from "./DirectoryPicker";
|
||||||
import { suggestProjectName } from "../utils/projectDetection";
|
import { suggestProjectName } from "../utils/projectDetection";
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ export function SetupWizardModal({
|
|||||||
includeAgentStep = true,
|
includeAgentStep = true,
|
||||||
}: SetupWizardModalProps) {
|
}: SetupWizardModalProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const { confirmWithChoice } = useConfirm();
|
||||||
const helpUrl = "https://discord.gg/ksrfuy7WYR";
|
const helpUrl = "https://discord.gg/ksrfuy7WYR";
|
||||||
/*
|
/*
|
||||||
FNXC:Onboarding 2026-06-22-03:11:
|
FNXC:Onboarding 2026-06-22-03:11:
|
||||||
@@ -211,6 +214,52 @@ export function SetupWizardModal({
|
|||||||
if (!trimmedPath || !trimmedName) return;
|
if (!trimmedPath || !trimmedName) return;
|
||||||
if (state.manualMode === "clone" && !trimmedCloneUrl) 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 }));
|
setState((prev) => ({ ...prev, isRegistering: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -223,6 +272,7 @@ export function SetupWizardModal({
|
|||||||
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
|
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
|
||||||
workspaceMode: state.manualMode === "existing" ? state.workspaceMode : false,
|
workspaceMode: state.manualMode === "existing" ? state.workspaceMode : false,
|
||||||
taskPrefix: state.manualTaskPrefix.trim() || undefined,
|
taskPrefix: state.manualTaskPrefix.trim() || undefined,
|
||||||
|
skipGitInit: skipGitInit || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await registerProject(input);
|
const result = await registerProject(input);
|
||||||
@@ -249,7 +299,7 @@ export function SetupWizardModal({
|
|||||||
error: err instanceof Error ? err.message : "Failed to register project",
|
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 handlePresetSelect = useCallback((presetId: string) => {
|
||||||
const preset = getPresetById(presetId);
|
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") {
|
if (normalizedCloneUrl !== undefined && normalizedGitSetupMode !== undefined && normalizedGitSetupMode !== "clone") {
|
||||||
throw badRequest("cloneUrl can only be provided when gitSetupMode is '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) {
|
if (normalizedGitSetupMode === "clone" && normalizedCloneUrl === undefined) {
|
||||||
throw badRequest("cloneUrl must be a non-empty string when gitSetupMode is 'clone'");
|
throw badRequest("cloneUrl must be a non-empty string when gitSetupMode is 'clone'");
|
||||||
}
|
}
|
||||||
@@ -411,6 +422,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
isolationMode,
|
isolationMode,
|
||||||
nodeId,
|
nodeId,
|
||||||
|
skipGitInit,
|
||||||
});
|
});
|
||||||
const project = ensured.project;
|
const project = ensured.project;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user