feat(FN-2394): add Fusion reinstall flow for Pi extensions

- Add dashboard route and server handling to reinstall the Fusion Pi extension
- Introduce a client API helper and wire a reinstall action into PiExtensionsManager
- Polish action spacing and document Fusion reinstall recovery in the CLI README
- Expand route, API, and component tests including reinstall refresh stability coverage
- Add a changeset for @runfusion/fusion patch release
This commit is contained in:
Fusion
2026-04-24 05:05:11 -07:00
committed by gsxdsm
parent 0ebe20e487
commit f078a4e946
11 changed files with 244 additions and 31 deletions

View File

@@ -55,6 +55,7 @@ import {
fetchPiSettings,
updatePiSettings,
installPiPackage,
reinstallFusionPiPackage,
fetchPiExtensions,
updatePiExtensions,
fetchProjectTasks,
@@ -5962,6 +5963,45 @@ describe("installPiPackage", () => {
});
});
describe("reinstallFusionPiPackage", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST to /api/pi-settings/reinstall-fusion", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { success: true, source: "npm:@runfusion/fusion" })
);
const result = await reinstallFusionPiPackage();
expect(result).toEqual({ success: true, source: "npm:@runfusion/fusion" });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/pi-settings/reinstall-fusion", expect.objectContaining({
method: "POST",
}));
});
it("forwards projectId query parameter when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { success: true, source: "npm:@runfusion/fusion" })
);
await reinstallFusionPiPackage("proj-123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/pi-settings/reinstall-fusion?projectId=proj-123", expect.objectContaining({
method: "POST",
}));
});
it("throws API error message on failure", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Reinstall failed" }, 500));
await expect(reinstallFusionPiPackage()).rejects.toThrow("Reinstall failed");
});
});
describe("fetchPiExtensions", () => {
const originalFetch = globalThis.fetch;

View File

@@ -657,6 +657,13 @@ export async function installPiPackage(source: string): Promise<{ success: boole
});
}
/** Reinstall Fusion's bundled pi package and ensure it remains in global Pi settings. */
export async function reinstallFusionPiPackage(projectId?: string): Promise<{ success: boolean; source: string }> {
return api<{ success: boolean; source: string }>(withProjectId("/pi-settings/reinstall-fusion", projectId), {
method: "POST",
});
}
export async function uploadAttachment(id: string, file: File, projectId?: string): Promise<TaskAttachment> {
const formData = new FormData();
formData.append("file", file);

View File

@@ -26,7 +26,7 @@ import {
RefreshCw,
Trash2,
} from "lucide-react";
import { fetchPiSettings, updatePiSettings, installPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api";
import { fetchPiSettings, updatePiSettings, installPiPackage, reinstallFusionPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
interface PiExtensionsManagerProps {
@@ -67,6 +67,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
const [settings, setSettings] = useState<PiSettings | null>(null);
const [loading, setLoading] = useState(true);
const [installing, setInstalling] = useState(false);
const [reinstallingFusion, setReinstallingFusion] = useState(false);
const [newSource, setNewSource] = useState("");
const [expandedPackages, setExpandedPackages] = useState<Set<number>>(new Set());
@@ -154,6 +155,19 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
}
};
const handleReinstallFusion = async () => {
try {
setReinstallingFusion(true);
await reinstallFusionPiPackage(projectId);
await Promise.all([loadSettings(), loadExtensions()]);
addToast("Fusion skill reinstalled successfully", "success");
} catch (err) {
addToast(`Failed to reinstall Fusion skill: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setReinstallingFusion(false);
}
};
const handleRemovePackage = async (sourceToRemove: string) => {
if (!settings) return;
@@ -263,6 +277,15 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
{installing ? "Installing…" : "Add"}
</button>
</div>
<div className="pi-ext-add-form-row">
<button
className="btn"
onClick={handleReinstallFusion}
disabled={reinstallingFusion}
>
{reinstallingFusion ? "Reinstalling Fusion…" : "Reinstall Fusion skill"}
</button>
</div>
</div>
{/* Package list */}

View File

@@ -7,6 +7,7 @@ import { PiExtensionsManager } from "../PiExtensionsManager";
const mockFetchPiSettings = vi.fn();
const mockUpdatePiSettings = vi.fn();
const mockInstallPiPackage = vi.fn();
const mockReinstallFusionPiPackage = vi.fn();
const mockFetchPiExtensions = vi.fn();
const mockUpdatePiExtensions = vi.fn();
@@ -53,6 +54,7 @@ vi.mock("../../api", () => ({
fetchPiSettings: (...args: unknown[]) => mockFetchPiSettings(...args),
updatePiSettings: (...args: unknown[]) => mockUpdatePiSettings(...args),
installPiPackage: (...args: unknown[]) => mockInstallPiPackage(...args),
reinstallFusionPiPackage: (...args: unknown[]) => mockReinstallFusionPiPackage(...args),
fetchPiExtensions: (...args: unknown[]) => mockFetchPiExtensions(...args),
updatePiExtensions: (...args: unknown[]) => mockUpdatePiExtensions(...args),
}));
@@ -91,6 +93,7 @@ describe("PiExtensionsManager", () => {
// Default mock for fetchPiExtensions to return empty settings
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockUpdatePiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockReinstallFusionPiPackage.mockResolvedValue({ success: true, source: "npm:@runfusion/fusion" });
});
describe("Rendering", () => {
@@ -113,6 +116,15 @@ describe("PiExtensionsManager", () => {
});
});
it("renders reinstall Fusion skill action", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
});
it("renders package list with source badges", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render(<PiExtensionsManager addToast={addToast} />);
@@ -268,6 +280,55 @@ describe("PiExtensionsManager", () => {
});
});
describe("Reinstall Fusion action", () => {
it("calls reinstall API and refreshes settings and extensions on success", async () => {
mockFetchPiSettings.mockResolvedValue(mockPiSettings);
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockReinstallFusionPiPackage.mockResolvedValueOnce({ success: true, source: "npm:@runfusion/fusion" });
render(<PiExtensionsManager addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
const settingsCallsBefore = mockFetchPiSettings.mock.calls.length;
const extensionCallsBefore = mockFetchPiExtensions.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Reinstall Fusion skill" }));
await waitFor(() => {
expect(mockReinstallFusionPiPackage).toHaveBeenCalledWith("proj-123");
});
await waitFor(() => {
expect(mockFetchPiSettings.mock.calls.length).toBeGreaterThan(settingsCallsBefore);
expect(mockFetchPiExtensions.mock.calls.length).toBeGreaterThan(extensionCallsBefore);
});
expect(addToast).toHaveBeenCalledWith("Fusion skill reinstalled successfully", "success");
});
it("shows error toast and resets loading state when reinstall fails", async () => {
mockFetchPiSettings.mockResolvedValue(mockPiSettings);
mockReinstallFusionPiPackage.mockRejectedValueOnce(new Error("Boom"));
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
await userEvent.click(screen.getByRole("button", { name: "Reinstall Fusion skill" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to reinstall Fusion skill: Boom", "error");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
});
});
describe("Remove package", () => {
it("calls updatePiSettings with package removed from list", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);

View File

@@ -36549,6 +36549,9 @@ html .column.drag-over * {
}
.pi-ext-add-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}