feat(FN-2310): merge fusion/fn-2310

This commit is contained in:
gsxdsm
2026-04-24 03:12:56 -07:00
parent 8a0885ad48
commit 159e40b42d
7 changed files with 608 additions and 46 deletions

View File

@@ -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", () => {

View File

@@ -4300,6 +4300,7 @@ export interface ProjectCreateInput {
path: string;
isolationMode?: "in-process" | "child-process";
nodeId?: string;
cloneUrl?: string;
}
/** Node information returned by node endpoints */

View File

@@ -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&apos;s set up your first project. Browse to your project directory or type the path manually.
Let&apos;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 ? (
<>

View File

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

View File

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

View File

@@ -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", () => {

View File

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