feat(FN-2310): merge fusion/fn-2310
This commit is contained in:
@@ -2799,6 +2799,32 @@ describe("registerProject", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes cloneUrl when cloning during registration", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT));
|
||||
|
||||
await registerProject({
|
||||
name: "Test Project",
|
||||
path: "/path/to/new/project",
|
||||
isolationMode: "child-process",
|
||||
nodeId: "node-1",
|
||||
cloneUrl: "https://github.com/runfusion/fusion.git",
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Test Project",
|
||||
path: "/path/to/new/project",
|
||||
isolationMode: "child-process",
|
||||
nodeId: "node-1",
|
||||
cloneUrl: "https://github.com/runfusion/fusion.git",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterProject", () => {
|
||||
|
||||
@@ -4300,6 +4300,7 @@ export interface ProjectCreateInput {
|
||||
path: string;
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
nodeId?: string;
|
||||
cloneUrl?: string;
|
||||
}
|
||||
|
||||
/** Node information returned by node endpoints */
|
||||
|
||||
@@ -15,10 +15,13 @@ export interface SetupWizardModalProps {
|
||||
}
|
||||
|
||||
type WizardStep = "manual" | "complete";
|
||||
type ManualSetupMode = "existing" | "clone";
|
||||
|
||||
interface WizardState {
|
||||
step: WizardStep;
|
||||
manualMode: ManualSetupMode;
|
||||
manualPath: string;
|
||||
manualCloneUrl: string;
|
||||
manualName: string;
|
||||
manualIsolationMode: "in-process" | "child-process";
|
||||
manualNodeId: string;
|
||||
@@ -40,7 +43,9 @@ export function SetupWizardModal({
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [state, setState] = useState<WizardState>({
|
||||
step: "manual",
|
||||
manualMode: "existing",
|
||||
manualPath: "",
|
||||
manualCloneUrl: "",
|
||||
manualName: "",
|
||||
manualIsolationMode: "in-process",
|
||||
manualNodeId: "",
|
||||
@@ -71,16 +76,22 @@ export function SetupWizardModal({
|
||||
}, []);
|
||||
|
||||
const handleManualRegister = useCallback(async () => {
|
||||
if (!state.manualPath || !state.manualName) return;
|
||||
const trimmedPath = state.manualPath.trim();
|
||||
const trimmedName = state.manualName.trim();
|
||||
const trimmedCloneUrl = state.manualCloneUrl.trim();
|
||||
|
||||
if (!trimmedPath || !trimmedName) return;
|
||||
if (state.manualMode === "clone" && !trimmedCloneUrl) return;
|
||||
|
||||
setState((prev) => ({ ...prev, isRegistering: true, error: null }));
|
||||
|
||||
try {
|
||||
const input: ProjectCreateInput = {
|
||||
name: state.manualName,
|
||||
path: state.manualPath,
|
||||
name: trimmedName,
|
||||
path: trimmedPath,
|
||||
isolationMode: state.manualIsolationMode,
|
||||
nodeId: state.manualNodeId || undefined,
|
||||
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
|
||||
};
|
||||
|
||||
const result = await registerProject(input);
|
||||
@@ -98,7 +109,7 @@ export function SetupWizardModal({
|
||||
error: err instanceof Error ? err.message : "Failed to register project",
|
||||
}));
|
||||
}
|
||||
}, [state.manualPath, state.manualName, state.manualIsolationMode, state.manualNodeId, onProjectRegistered]);
|
||||
}, [state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, onProjectRegistered]);
|
||||
|
||||
const handleSetAuthToken = useCallback(() => {
|
||||
const token = authTokenInput.trim();
|
||||
@@ -116,6 +127,16 @@ export function SetupWizardModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isExistingMode = state.manualMode === "existing";
|
||||
const isCloneMode = state.manualMode === "clone";
|
||||
const hasPath = state.manualPath.trim().length > 0;
|
||||
const hasName = state.manualName.trim().length > 0;
|
||||
const hasCloneUrl = state.manualCloneUrl.trim().length > 0;
|
||||
const isRegisterDisabled = state.isRegistering
|
||||
|| !hasPath
|
||||
|| !hasName
|
||||
|| (isCloneMode && !hasCloneUrl);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open setup-wizard-overlay" role="dialog" aria-modal="true" aria-labelledby="wizard-title">
|
||||
<div className="modal setup-wizard-modal">
|
||||
@@ -171,20 +192,66 @@ export function SetupWizardModal({
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<p className="welcome-text">
|
||||
Let's set up your first project. Browse to your project directory or type the path manually.
|
||||
Let's set up your first project. Register an existing directory or clone a git repository into a destination folder, then register it.
|
||||
</p>
|
||||
|
||||
<fieldset className="setup-wizard-mode-switch" aria-label="Project setup mode">
|
||||
<legend>Setup Mode</legend>
|
||||
<label
|
||||
className={`setup-wizard-mode-option${isExistingMode ? " selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="setup-mode"
|
||||
value="existing"
|
||||
checked={isExistingMode}
|
||||
onChange={() => setState((prev) => ({ ...prev, manualMode: "existing", error: null }))}
|
||||
/>
|
||||
<span>Use Existing Directory</span>
|
||||
</label>
|
||||
<label
|
||||
className={`setup-wizard-mode-option${isCloneMode ? " selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="setup-mode"
|
||||
value="clone"
|
||||
checked={isCloneMode}
|
||||
onChange={() => setState((prev) => ({ ...prev, manualMode: "clone", error: null }))}
|
||||
/>
|
||||
<span>Clone Git Repository</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{isCloneMode && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-clone-url">Repository URL</label>
|
||||
<input
|
||||
id="project-clone-url"
|
||||
type="text"
|
||||
value={state.manualCloneUrl}
|
||||
onChange={(e) => setState((prev) => ({ ...prev, manualCloneUrl: e.target.value }))}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Fusion will run git clone into the destination directory, then register that cloned folder.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-path">Project Directory</label>
|
||||
<label htmlFor="project-path">{isCloneMode ? "Destination Directory" : "Project Directory"}</label>
|
||||
<DirectoryPicker
|
||||
value={state.manualPath}
|
||||
onChange={handlePathChange}
|
||||
nodeId={state.manualNodeId || undefined}
|
||||
localNodeId={localNodeId}
|
||||
placeholder="/path/to/your/project"
|
||||
placeholder={isCloneMode ? "/path/for/new-clone" : "/path/to/your/project"}
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Select or type the absolute path to your project
|
||||
{isCloneMode
|
||||
? "Select or type an absolute destination path. Fusion will clone into this directory."
|
||||
: "Select or type the absolute path to your project"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -199,6 +266,11 @@ export function SetupWizardModal({
|
||||
}
|
||||
placeholder="my-project"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
{isCloneMode
|
||||
? "By default this follows the destination folder name unless you edit it."
|
||||
: "By default this follows the selected directory name unless you edit it."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="setup-wizard-advanced">
|
||||
@@ -351,7 +423,7 @@ export function SetupWizardModal({
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleManualRegister}
|
||||
disabled={state.isRegistering || !state.manualPath || !state.manualName}
|
||||
disabled={isRegisterDisabled}
|
||||
>
|
||||
{state.isRegistering ? (
|
||||
<>
|
||||
|
||||
@@ -149,6 +149,145 @@ describe("SetupWizardModal", () => {
|
||||
expect(registerBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("existing-directory submit payload is unchanged", async () => {
|
||||
mockRegisterProject.mockResolvedValueOnce({
|
||||
id: "proj_existing",
|
||||
name: "existing-project",
|
||||
path: "/existing/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
|
||||
target: { value: "/existing/project" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Register Project"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
name: "project",
|
||||
path: "/existing/project",
|
||||
isolationMode: "in-process",
|
||||
nodeId: undefined,
|
||||
cloneUrl: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("clone mode renders repository url input and destination picker", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
|
||||
|
||||
expect(screen.getByLabelText("Repository URL")).toBeDefined();
|
||||
expect(screen.getByPlaceholderText("/path/for/new-clone")).toBeDefined();
|
||||
expect(screen.getByText(/Fusion will run git clone into the destination directory/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("clone mode submit sends cloneUrl payload", async () => {
|
||||
mockRegisterProject.mockResolvedValueOnce({
|
||||
id: "proj_clone",
|
||||
name: "fusion",
|
||||
path: "/tmp/fusion",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), {
|
||||
target: { value: "https://github.com/runfusion/fusion.git" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), {
|
||||
target: { value: "/tmp/fusion" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Register Project"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
name: "fusion",
|
||||
path: "/tmp/fusion",
|
||||
isolationMode: "in-process",
|
||||
nodeId: undefined,
|
||||
cloneUrl: "https://github.com/runfusion/fusion.git",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("register button disabled/enabled logic is mode-aware", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const registerBtn = screen.getByText("Register Project").closest("button")!;
|
||||
expect(registerBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), {
|
||||
target: { value: "/tmp/repo" },
|
||||
});
|
||||
expect(registerBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), {
|
||||
target: { value: "https://github.com/runfusion/fusion.git" },
|
||||
});
|
||||
expect(registerBtn.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Use Existing Directory"));
|
||||
expect(registerBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-suggested name updates until manually edited", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
|
||||
const destinationInput = screen.getByPlaceholderText("/path/for/new-clone");
|
||||
|
||||
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion" } });
|
||||
expect(nameInput.value).toBe("fusion");
|
||||
|
||||
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-next" } });
|
||||
expect(nameInput.value).toBe("fusion-next");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "custom-name" } });
|
||||
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-final" } });
|
||||
expect(nameInput.value).toBe("custom-name");
|
||||
});
|
||||
|
||||
it("shows error state on registration failure", async () => {
|
||||
mockRegisterProject.mockRejectedValueOnce(new Error("Path does not exist"));
|
||||
|
||||
|
||||
@@ -21709,6 +21709,52 @@ html .column.drag-over * {
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
margin: 0 0 var(--space-lg);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.setup-wizard-mode-switch legend {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-option input {
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-option:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-option.selected {
|
||||
border-color: var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--todo) 25%, transparent);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
@@ -21965,6 +22011,10 @@ html .column.drag-over * {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.setup-wizard-mode-switch {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-options {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
@@ -21983,6 +22033,8 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.setup-wizard-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding:
|
||||
var(--space-sm)
|
||||
var(--space-md)
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
// Mock node:fs for route handler tests that check path existence
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock node:fs/promises access function for path validation
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
// Use vi.hoisted() for mock functions that need to be accessible in hoisted vi.mock calls
|
||||
const {
|
||||
mockFsAccess,
|
||||
mockFsStat,
|
||||
mockFsReaddir,
|
||||
mockFsMkdir,
|
||||
mockFsRm,
|
||||
mockExecFile,
|
||||
mockListProjects,
|
||||
mockGetProject,
|
||||
mockRegisterProject,
|
||||
@@ -41,6 +33,15 @@ const {
|
||||
mockGetNode,
|
||||
mockEnsureMemoryFileWithBackend,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFsAccess: vi.fn().mockResolvedValue(undefined),
|
||||
mockFsStat: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })),
|
||||
mockFsReaddir: vi.fn().mockResolvedValue([]),
|
||||
mockFsMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockFsRm: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecFile: vi.fn((_file, _args, optsOrCallback, maybeCallback) => {
|
||||
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
|
||||
callback?.(null, "", "");
|
||||
}),
|
||||
mockListProjects: vi.fn().mockResolvedValue([]),
|
||||
mockGetProject: vi.fn().mockResolvedValue(null),
|
||||
mockRegisterProject: vi.fn().mockResolvedValue({
|
||||
@@ -94,6 +95,36 @@ const {
|
||||
mockEnsureMemoryFileWithBackend: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
// Mock node:fs for route handler tests that check path existence
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock node:fs/promises for path validation and clone behavior checks
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
access: mockFsAccess,
|
||||
stat: mockFsStat,
|
||||
readdir: mockFsReaddir,
|
||||
mkdir: mockFsMkdir,
|
||||
rm: mockFsRm,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
execFile: mockExecFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
@@ -563,6 +594,16 @@ class MockStoreForRoutes extends EventEmitter {
|
||||
describe("POST /api/projects route handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
mockFsStat.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
mockFsReaddir.mockResolvedValue([]);
|
||||
mockFsMkdir.mockResolvedValue(undefined);
|
||||
mockFsRm.mockResolvedValue(undefined);
|
||||
mockExecFile.mockImplementation((_file, _args, optsOrCallback, maybeCallback) => {
|
||||
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
|
||||
// Reset mocks to default values for route handler tests
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj_test123",
|
||||
@@ -668,6 +709,136 @@ describe("POST /api/projects route handler", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as any).status).toBe("active");
|
||||
});
|
||||
|
||||
it("clones and registers when cloneUrl is provided", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "fn-2310-clone-"));
|
||||
const bareRepo = join(tempRoot, "remote.git");
|
||||
const cloneDestination = join(tempRoot, "cloned-project");
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", "--bare", bareRepo]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Cloned Project",
|
||||
path: cloneDestination,
|
||||
cloneUrl: bareRepo,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
name: "Cloned Project",
|
||||
path: cloneDestination,
|
||||
isolationMode: "in-process",
|
||||
nodeId: undefined,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns clone failure and skips registration when git clone fails", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
mockExecFile.mockImplementation((_file, _args, optsOrCallback, maybeCallback) => {
|
||||
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
|
||||
const cloneError = Object.assign(new Error("git exited with code 128"), { stderr: "fatal: repository not found" });
|
||||
callback?.(cloneError, "", "fatal: repository not found");
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Broken Clone",
|
||||
path: "/tmp/broken-clone",
|
||||
cloneUrl: "https://github.com/runfusion/missing.git",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as { error?: string }).error).toContain("Git clone failed");
|
||||
expect(mockRegisterProject).not.toHaveBeenCalled();
|
||||
expect(mockFsRm).toHaveBeenCalledWith("/tmp/broken-clone", { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects clone mode when destination directory is non-empty", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
mockFsStat.mockResolvedValue({ isDirectory: () => true } as import("node:fs").Stats);
|
||||
mockFsReaddir.mockResolvedValue(["README.md"]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Existing Destination",
|
||||
path: "/tmp/existing-destination",
|
||||
cloneUrl: "https://github.com/runfusion/fusion.git",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as { error?: string }).error).toContain("Clone destination must be empty");
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(mockRegisterProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects clone mode when cloneUrl is blank", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Blank Clone Url",
|
||||
path: "/tmp/blank-clone-url",
|
||||
cloneUrl: " ",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as { error?: string }).error).toContain("cloneUrl must be a non-empty string");
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects clone mode destination path with null-byte input", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Invalid Destination",
|
||||
path: "/tmp/bad\u0000path",
|
||||
cloneUrl: "https://github.com/runfusion/fusion.git",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as { error?: string }).error).toContain("path cannot contain null bytes");
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects route handler", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as fsPromises from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline as streamPipeline } from "node:stream/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { resolve, sep, join } from "node:path";
|
||||
import { resolve, sep, join, dirname, isAbsolute } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as nodeFs from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
@@ -15559,13 +15559,19 @@ async function persistImportedSkills(
|
||||
/**
|
||||
* POST /api/projects
|
||||
* Register a new project.
|
||||
* Body: { name: string, path: string, isolationMode?: "in-process" | "child-process" }
|
||||
* Body: {
|
||||
* name: string,
|
||||
* path: string,
|
||||
* isolationMode?: "in-process" | "child-process",
|
||||
* nodeId?: string,
|
||||
* cloneUrl?: string
|
||||
* }
|
||||
* Returns: RegisteredProject
|
||||
*/
|
||||
router.post("/projects", async (req, res) => {
|
||||
try {
|
||||
const { name, path, isolationMode = "in-process", nodeId } = req.body;
|
||||
|
||||
const { name, path, isolationMode = "in-process", nodeId, cloneUrl } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
throw badRequest("name is required and must be a non-empty string");
|
||||
}
|
||||
@@ -15575,29 +15581,122 @@ async function persistImportedSkills(
|
||||
if (!["in-process", "child-process"].includes(isolationMode)) {
|
||||
throw badRequest("isolationMode must be 'in-process' or 'child-process'");
|
||||
}
|
||||
|
||||
// Check if path exists and has .fusion/ directory (async to avoid blocking event loop)
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
throw badRequest("Project path does not exist");
|
||||
|
||||
const normalizedName = name.trim();
|
||||
const normalizedPath = path.trim();
|
||||
let normalizedCloneUrl: string | undefined;
|
||||
|
||||
if (normalizedPath.includes("\0")) {
|
||||
throw badRequest("path cannot contain null bytes");
|
||||
}
|
||||
if (!isAbsolute(normalizedPath)) {
|
||||
throw badRequest("path must be an absolute path");
|
||||
}
|
||||
|
||||
if (cloneUrl !== undefined) {
|
||||
if (typeof cloneUrl !== "string") {
|
||||
throw badRequest("cloneUrl must be a non-empty string when provided");
|
||||
}
|
||||
|
||||
const trimmedCloneUrl = cloneUrl.trim();
|
||||
if (trimmedCloneUrl.length === 0) {
|
||||
throw badRequest("cloneUrl must be a non-empty string when provided");
|
||||
}
|
||||
if (trimmedCloneUrl.includes("\0")) {
|
||||
throw badRequest("cloneUrl cannot contain null bytes");
|
||||
}
|
||||
|
||||
normalizedCloneUrl = trimmedCloneUrl;
|
||||
}
|
||||
|
||||
const isCloneMode = normalizedCloneUrl !== undefined;
|
||||
let destinationCreatedForClone = false;
|
||||
|
||||
if (!isCloneMode) {
|
||||
// Existing-directory mode: path must already exist.
|
||||
try {
|
||||
await access(normalizedPath);
|
||||
} catch {
|
||||
throw badRequest("Project path does not exist");
|
||||
}
|
||||
} else {
|
||||
// Clone mode: parent directory must exist.
|
||||
const destinationParent = dirname(normalizedPath);
|
||||
try {
|
||||
await access(destinationParent);
|
||||
} catch {
|
||||
throw badRequest("Clone destination parent directory does not exist");
|
||||
}
|
||||
|
||||
// Destination must either not exist yet, or be an empty directory.
|
||||
let destinationExists = false;
|
||||
try {
|
||||
const destinationStats = await stat(normalizedPath);
|
||||
destinationExists = true;
|
||||
if (!destinationStats.isDirectory()) {
|
||||
throw badRequest("Clone destination must be a directory path");
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (destinationExists) {
|
||||
const entries = await readdir(normalizedPath);
|
||||
if (entries.length > 0) {
|
||||
throw badRequest("Clone destination must be empty");
|
||||
}
|
||||
} else {
|
||||
await mkdir(normalizedPath, { recursive: false });
|
||||
destinationCreatedForClone = true;
|
||||
}
|
||||
|
||||
const cloneSource = normalizedCloneUrl;
|
||||
if (!cloneSource) {
|
||||
throw badRequest("cloneUrl must be a non-empty string when provided");
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("git", ["clone", cloneSource, normalizedPath], {
|
||||
timeout: 90_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
} catch (cloneError) {
|
||||
if (destinationCreatedForClone) {
|
||||
try {
|
||||
await rm(normalizedPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
const cloneErrorInfo = cloneError as Error & { stderr?: string; stdout?: string };
|
||||
const details = [cloneErrorInfo.stderr, cloneErrorInfo.stdout, cloneErrorInfo.message]
|
||||
.find((value) => typeof value === "string" && value.trim().length > 0)
|
||||
?.toString()
|
||||
.trim();
|
||||
throw badRequest(`Git clone failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
let hasFusionDir = false;
|
||||
const fusionDirPath = join(path, ".fusion");
|
||||
const fusionDirPath = join(normalizedPath, ".fusion");
|
||||
try {
|
||||
await access(fusionDirPath);
|
||||
hasFusionDir = true;
|
||||
} catch {
|
||||
hasFusionDir = false;
|
||||
}
|
||||
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: name.trim(),
|
||||
path: path.trim(),
|
||||
name: normalizedName,
|
||||
path: normalizedPath,
|
||||
isolationMode,
|
||||
nodeId,
|
||||
});
|
||||
@@ -15606,7 +15705,7 @@ async function persistImportedSkills(
|
||||
const activeProject = await central.updateProject(project.id, { status: "active" });
|
||||
|
||||
// Bootstrap memory files (non-blocking, non-fatal)
|
||||
ensureMemoryFileWithBackend(path.trim()).catch(() => {
|
||||
ensureMemoryFileWithBackend(normalizedPath).catch(() => {
|
||||
// Memory bootstrap failure is non-fatal - project registration succeeded
|
||||
});
|
||||
|
||||
@@ -15636,9 +15735,11 @@ async function persistImportedSkills(
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("already registered") ? 409
|
||||
: (err instanceof Error ? err.message : String(err)).includes("Duplicate path") ? 409
|
||||
: 500;
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("already registered")
|
||||
? 409
|
||||
: (err instanceof Error ? err.message : String(err)).includes("Duplicate path")
|
||||
? 409
|
||||
: 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user