FN-6386: add one-click update installation
Add a dashboard update action that installs available Fusion releases directly from the UI. - Add an install-update API route backed by npm global install with bin-collision retry handling. - Add Update now controls, loading/error/success states, and responsive styling to the update banner and settings modal. - Extend localization, documentation, changeset coverage, and update-check tests for the new install flow. Files changed: .changeset/fn-6386-update-now-button.md | 5 + docs/dashboard-guide.md | 4 + docs/settings-reference.md | 2 + packages/dashboard/app/api/legacy.ts | 13 ++ .../dashboard/app/components/SettingsModal.css | 38 +++++- .../dashboard/app/components/SettingsModal.tsx | 96 ++++++++++++-- .../app/components/UpdateAvailableBanner.css | 46 +++++++ .../app/components/UpdateAvailableBanner.tsx | 103 ++++++++++++--- .../components/__tests__/SettingsModal.test.tsx | 79 +++++++++++ .../__tests__/UpdateAvailableBanner.test.tsx | 57 +++++++- .../components/__tests__/settings-mobile.test.tsx | 19 +++ .../src/__tests__/update-check-route.test.ts | 78 ++++++++++- .../dashboard/src/__tests__/update-check.test.ts | 45 +++++++ .../src/routes/register-update-check-routes.ts | 25 +++- packages/dashboard/src/update-check.ts | 93 +++++++++++++ packages/i18n/locales/en/app.json | 14 +- packages/i18n/src/resources.d.ts | 146 ++++++++++++++++++++- 17 files changed, 819 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-6386 Fusion-Task-Lineage: bcccf253-c0f1-493e-b849-b189a65d1479
This commit is contained in:
5
.changeset/fn-6386-update-now-button.md
Normal file
5
.changeset/fn-6386-update-now-button.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a one-click dashboard Update now action for installing available Fusion updates.
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations.
|
||||
|
||||
## Dashboard Updates
|
||||
|
||||
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart.
|
||||
|
||||
## Browser Navigation
|
||||
|
||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||
|
||||
@@ -171,6 +171,8 @@ Disable daily update checks globally:
|
||||
fn settings set updateCheckEnabled false
|
||||
```
|
||||
|
||||
When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Settings
|
||||
|
||||
@@ -812,6 +812,19 @@ export function refreshUpdateCheck(projectId?: string): Promise<UpdateCheckRespo
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateInstallResponse {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
updated: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function installUpdate(projectId?: string): Promise<UpdateInstallResponse> {
|
||||
return api<UpdateInstallResponse>(withProjectId("/update-check/install", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface RemoteSettings {
|
||||
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
||||
remoteTailscaleEnabled: boolean;
|
||||
|
||||
@@ -184,6 +184,10 @@
|
||||
row-gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.settings-update-result {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.settings-footer-help-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -229,6 +233,10 @@
|
||||
}
|
||||
|
||||
.settings-update-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -242,7 +250,7 @@
|
||||
}
|
||||
|
||||
.settings-update-result--error {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-update-result-link {
|
||||
@@ -261,6 +269,34 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.settings-update-now-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-height: calc(var(--space-lg) + var(--space-sm));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-update-now-btn:disabled {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.settings-update-now-btn svg.spinning {
|
||||
animation: settings-update-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.settings-update-install-status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-update-install-status--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-update-install-status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@keyframes settings-update-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
normalizeMergeAdvanceAutoSyncMode,
|
||||
} from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
||||
import { splitSettingsSave } from "./settings/save-split";
|
||||
import type { SectionSaveHandler } from "./settings/sections/context";
|
||||
import { AppearanceSection } from "./settings/sections/AppearanceSection";
|
||||
@@ -686,6 +686,8 @@ export function SettingsModal({
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
||||
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
|
||||
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
|
||||
const gitHubStarCount = useGitHubStarCount();
|
||||
const [starClicked, markStarClicked] = useStarClickedFlag();
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
@@ -963,6 +965,7 @@ export function SettingsModal({
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
setUpdateCheckLoading(true);
|
||||
setUpdateInstallResult(null);
|
||||
|
||||
try {
|
||||
const result = await checkForUpdates();
|
||||
@@ -972,7 +975,7 @@ export function SettingsModal({
|
||||
addToast(result.error, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error) || "Failed to check for updates";
|
||||
const message = getErrorMessage(error) || t("settings.general.updateCheckFailed", "Failed to check for updates");
|
||||
setUpdateCheckResult({
|
||||
currentVersion: appVersion ?? "unknown",
|
||||
latestVersion: null,
|
||||
@@ -983,7 +986,37 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setUpdateCheckLoading(false);
|
||||
}
|
||||
}, [addToast, appVersion]);
|
||||
}, [addToast, appVersion, t]);
|
||||
|
||||
const handleInstallUpdate = useCallback(async () => {
|
||||
setUpdateInstallLoading(true);
|
||||
setUpdateInstallResult(null);
|
||||
|
||||
try {
|
||||
const result = await installUpdate(projectId);
|
||||
setUpdateInstallResult(result);
|
||||
|
||||
if (result.error) {
|
||||
addToast(result.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.updated) {
|
||||
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
|
||||
setUpdateInstallResult({
|
||||
currentVersion: updateCheckResult?.currentVersion ?? appVersion ?? "unknown",
|
||||
latestVersion: updateCheckResult?.latestVersion ?? null,
|
||||
updated: false,
|
||||
error: message,
|
||||
});
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setUpdateInstallLoading(false);
|
||||
}
|
||||
}, [addToast, appVersion, projectId, t, updateCheckResult]);
|
||||
|
||||
const renderUpdateCheckResultContent = useCallback(() => {
|
||||
if (!updateCheckResult) {
|
||||
@@ -995,23 +1028,58 @@ export function SettingsModal({
|
||||
}
|
||||
|
||||
if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) {
|
||||
const installSucceeded = updateInstallResult?.updated === true;
|
||||
const installError = updateInstallResult?.error;
|
||||
|
||||
return (
|
||||
<>
|
||||
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
|
||||
<a
|
||||
href="https://runfusion.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="settings-update-result-link"
|
||||
>
|
||||
{t("settings.general.learnMore", "Learn more")}
|
||||
</a>
|
||||
<span>
|
||||
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
|
||||
<a
|
||||
href="https://runfusion.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="settings-update-result-link"
|
||||
>
|
||||
{t("settings.general.learnMore", "Learn more")}
|
||||
</a>
|
||||
</span>
|
||||
{installSucceeded ? (
|
||||
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
|
||||
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
|
||||
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm settings-update-now-btn"
|
||||
onClick={() => {
|
||||
void handleInstallUpdate();
|
||||
}}
|
||||
disabled={updateInstallLoading}
|
||||
>
|
||||
{updateInstallLoading ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="spinning" aria-hidden="true" />
|
||||
{t("settings.general.updating", "Updating…")}
|
||||
</>
|
||||
) : (
|
||||
t("settings.general.updateNow", "Update now")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{installError && (
|
||||
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
|
||||
{t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return t("settings.general.upToDate", "You're up to date ✓");
|
||||
}, [updateCheckResult]);
|
||||
}, [handleInstallUpdate, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
|
||||
|
||||
// Load auth status when the authentication section is active
|
||||
const loadAuthStatus = useCallback(async () => {
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.update-available-banner__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-available-banner__text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
@@ -39,6 +46,39 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.update-available-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.update-available-banner__update-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.update-available-banner__update-btn:disabled {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.update-available-banner__update-btn svg.spinning {
|
||||
animation: update-available-banner-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.update-available-banner__install-status {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-available-banner__install-status--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.update-available-banner__install-status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.update-available-banner__dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -61,6 +101,12 @@
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
@keyframes update-available-banner-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.update-available-banner {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import "./UpdateAvailableBanner.css";
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { installUpdate } from "../api";
|
||||
import type { UpdateInstallResponse } from "../api";
|
||||
|
||||
interface UpdateAvailableBannerProps {
|
||||
latestVersion: string;
|
||||
@@ -10,29 +14,86 @@ interface UpdateAvailableBannerProps {
|
||||
|
||||
export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }: UpdateAvailableBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [installLoading, setInstallLoading] = useState(false);
|
||||
const [installResult, setInstallResult] = useState<UpdateInstallResponse | null>(null);
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setInstallLoading(true);
|
||||
setInstallResult(null);
|
||||
|
||||
try {
|
||||
setInstallResult(await installUpdate());
|
||||
} catch (error) {
|
||||
setInstallResult({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
|
||||
});
|
||||
} finally {
|
||||
setInstallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installSucceeded = installResult?.updated === true;
|
||||
const installError = installResult?.error;
|
||||
|
||||
return (
|
||||
<div className="update-available-banner" role="status" aria-live="polite">
|
||||
<p className="update-available-banner__text">
|
||||
<Trans
|
||||
i18nKey="app:updateBanner.message"
|
||||
defaults="Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run <code>fn update</code> for an installed CLI, or pull this source checkout."
|
||||
values={{ latestVersion, currentVersion }}
|
||||
components={{ code: <code /> }}
|
||||
/>{" "}
|
||||
<a
|
||||
className="update-available-banner__link"
|
||||
href="https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("updateBanner.releaseNotes", "Release notes")}
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a className="update-available-banner__link" href="https://runfusion.ai" target="_blank" rel="noreferrer">
|
||||
{t("updateBanner.learnMore", "Learn more")}
|
||||
</a>
|
||||
</p>
|
||||
<div className="update-available-banner__content">
|
||||
<p className="update-available-banner__text">
|
||||
<Trans
|
||||
i18nKey="app:updateBanner.message"
|
||||
defaults="Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run <code>fn update</code> for an installed CLI, or pull this source checkout."
|
||||
values={{ latestVersion, currentVersion }}
|
||||
components={{ code: <code /> }}
|
||||
/>{" "}
|
||||
<a
|
||||
className="update-available-banner__link"
|
||||
href="https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("updateBanner.releaseNotes", "Release notes")}
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a className="update-available-banner__link" href="https://runfusion.ai" target="_blank" rel="noreferrer">
|
||||
{t("updateBanner.learnMore", "Learn more")}
|
||||
</a>
|
||||
</p>
|
||||
<div className="update-available-banner__actions">
|
||||
{installSucceeded ? (
|
||||
<span className="update-available-banner__install-status update-available-banner__install-status--success" aria-live="polite">
|
||||
{t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
|
||||
version: installResult.latestVersion ?? latestVersion,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm update-available-banner__update-btn"
|
||||
onClick={() => {
|
||||
void handleInstallUpdate();
|
||||
}}
|
||||
disabled={installLoading}
|
||||
>
|
||||
{installLoading ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="spinning" aria-hidden="true" />
|
||||
{t("updateBanner.updating", "Updating…")}
|
||||
</>
|
||||
) : (
|
||||
t("updateBanner.updateNow", "Update now")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{installError && (
|
||||
<span className="update-available-banner__install-status update-available-banner__install-status--error" aria-live="polite">
|
||||
{t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="update-available-banner__dismiss touch-target"
|
||||
|
||||
@@ -46,6 +46,7 @@ const mockFetchGitRemotesDetailed = vi.fn();
|
||||
const mockFetchProjects = vi.fn();
|
||||
const mockFetchDashboardHealth = vi.fn();
|
||||
const mockCheckForUpdates = vi.fn();
|
||||
const mockInstallUpdate = vi.fn();
|
||||
const mockFetchRemoteSettings = vi.fn();
|
||||
const mockUpdateRemoteSettings = vi.fn();
|
||||
const mockFetchRemoteStatus = vi.fn();
|
||||
@@ -107,6 +108,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
|
||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
||||
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
|
||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
||||
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
||||
@@ -638,6 +640,7 @@ describe("SettingsModal", () => {
|
||||
}));
|
||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||
mockCheckForUpdates.mockResolvedValue(undefined);
|
||||
mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true });
|
||||
mockFetchRemoteSettings.mockResolvedValue({
|
||||
settings: {
|
||||
remoteActiveProvider: null,
|
||||
@@ -1803,6 +1806,82 @@ describe("SettingsModal", () => {
|
||||
|
||||
expect(await screen.findByText(/v2.0.0 available/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute("href", "https://runfusion.ai");
|
||||
expect(screen.getByRole("button", { name: "Update now" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides update-now when update check is up-to-date or errored", async () => {
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
updateAvailable: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
expect(await screen.findByText("You're up to date ✓")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
error: "registry unavailable",
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
expect(await screen.findByText("registry unavailable")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("installs update from the footer and renders restart hint", async () => {
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
mockInstallUpdate.mockResolvedValueOnce({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Update now" }));
|
||||
|
||||
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables update-now and shows inline errors while installing", async () => {
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean; error?: string }) => void) | undefined;
|
||||
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}));
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
|
||||
const updateNow = await screen.findByRole("button", { name: "Update now" });
|
||||
fireEvent.click(updateNow);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Updating…" }).querySelector(".spinning")).not.toBeNull();
|
||||
|
||||
resolveInstall?.({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: false, error: "install failed" });
|
||||
|
||||
expect(await screen.findByText("Update failed: install failed")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Update now" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables button while checking", async () => {
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
|
||||
|
||||
const mockInstallUpdate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
||||
};
|
||||
});
|
||||
|
||||
describe("UpdateAvailableBanner", () => {
|
||||
beforeEach(() => {
|
||||
mockInstallUpdate.mockReset();
|
||||
mockInstallUpdate.mockResolvedValue({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
|
||||
});
|
||||
it("renders version information with release notes and learn more links", () => {
|
||||
render(
|
||||
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />,
|
||||
@@ -49,4 +67,39 @@ describe("UpdateAvailableBanner", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables update-now while installing and then shows restart hint", async () => {
|
||||
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean }) => void) | undefined;
|
||||
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}));
|
||||
|
||||
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
|
||||
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
|
||||
|
||||
resolveInstall?.({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
|
||||
|
||||
expect(await screen.findByText("Updated to v0.7.0 — restart Fusion to apply")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows install errors inline without removing retry button", async () => {
|
||||
mockInstallUpdate.mockResolvedValueOnce({
|
||||
currentVersion: "0.6.0",
|
||||
latestVersion: "0.7.0",
|
||||
updated: false,
|
||||
error: "permission denied",
|
||||
});
|
||||
|
||||
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
|
||||
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText("Update failed: permission denied")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Update now" })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,8 @@ vi.mock("../../api", () => ({
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
})),
|
||||
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
|
||||
checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })),
|
||||
installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })),
|
||||
fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
|
||||
fetchWorkflows: vi.fn(() => Promise.resolve([])),
|
||||
@@ -224,6 +226,23 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expect(updateButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps update-now button reachable from the mobile footer", async () => {
|
||||
mockSettingsViewport(true);
|
||||
const user = userEvent.setup();
|
||||
const { container, findByRole, findByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const modalActions = container.querySelector(".modal-actions");
|
||||
expect(modalActions).toBeTruthy();
|
||||
|
||||
await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" }));
|
||||
const updateNow = await findByRole("button", { name: "Update now" });
|
||||
expect((modalActions as HTMLElement).contains(updateNow)).toBe(true);
|
||||
|
||||
await user.click(updateNow);
|
||||
expect(await findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("excludes research sections from mobile picker when researchView is disabled", async () => {
|
||||
mockSettingsViewport(true);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6,7 +6,29 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { get as performGet } from "../test-request.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
|
||||
const updateCheckMocks = vi.hoisted(() => ({
|
||||
performUpdateCheck: vi.fn(),
|
||||
performUpdateInstall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../update-check.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
|
||||
return {
|
||||
...actual,
|
||||
performUpdateCheck: updateCheckMocks.performUpdateCheck,
|
||||
performUpdateInstall: updateCheckMocks.performUpdateInstall,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
resolveGlobalDir: () => "/tmp/fusion-update-check-route-test",
|
||||
};
|
||||
});
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PACKAGE_VERSION = (() => {
|
||||
@@ -67,10 +89,64 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
updateCheckMocks.performUpdateCheck.mockReset();
|
||||
updateCheckMocks.performUpdateInstall.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("POST /api/update-check/install", () => {
|
||||
it("installs when a newer version is available", async () => {
|
||||
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: 123,
|
||||
});
|
||||
updateCheckMocks.performUpdateInstall.mockResolvedValueOnce({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updated: true,
|
||||
});
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performRequest(app, "POST", "/api/update-check/install");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCheckMocks.performUpdateCheck).toHaveBeenCalledWith(expect.any(String), CLI_PACKAGE_VERSION, {
|
||||
force: true,
|
||||
});
|
||||
expect(updateCheckMocks.performUpdateInstall).toHaveBeenCalledWith(CLI_PACKAGE_VERSION, "99.0.0", {
|
||||
fusionDir: expect.any(String),
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns updated=false without installing when already up to date", async () => {
|
||||
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: CLI_PACKAGE_VERSION,
|
||||
updateAvailable: false,
|
||||
lastChecked: 123,
|
||||
});
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performRequest(app, "POST", "/api/update-check/install");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCheckMocks.performUpdateInstall).not.toHaveBeenCalled();
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: CLI_PACKAGE_VERSION,
|
||||
updated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/updates/check", () => {
|
||||
it("returns updateAvailable=true when npm has a newer version", async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearUpdateCheckCache,
|
||||
performUpdateCheck,
|
||||
performUpdateInstall,
|
||||
readCachedUpdateCheck,
|
||||
ttlForFrequency,
|
||||
__resetStartupRefreshFlag,
|
||||
@@ -161,6 +162,50 @@ describe("update-check", () => {
|
||||
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
|
||||
});
|
||||
|
||||
it("performUpdateInstall installs latest and clears the update-check cache", async () => {
|
||||
const cachePath = join(fusionDir, "update-check.json");
|
||||
await writeFile(cachePath, JSON.stringify({ ok: true }), "utf-8");
|
||||
const execFake = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", {
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
expect(existsSync(cachePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("performUpdateInstall retries once with --force for legacy bin collisions", async () => {
|
||||
const collision = Object.assign(new Error("EEXIST: file already exists, /usr/local/bin/fn"), {
|
||||
stderr: "runfusion.ai legacy bin collision",
|
||||
});
|
||||
const execFake = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(collision)
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||
|
||||
expect(execFake).toHaveBeenCalledTimes(2);
|
||||
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@latest", expect.any(Object));
|
||||
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@latest", expect.any(Object));
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
});
|
||||
|
||||
it("performUpdateInstall returns an error result for non-collision install failures", async () => {
|
||||
const execFake = vi.fn().mockRejectedValue(Object.assign(new Error("npm unavailable"), { stderr: "registry down" }));
|
||||
|
||||
await expect(performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir })).resolves.toEqual({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updated: false,
|
||||
error: "registry down",
|
||||
});
|
||||
expect(execFake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("frequency", () => {
|
||||
beforeEach(() => {
|
||||
__resetStartupRefreshFlag();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveGlobalDir } from "@fusion/core";
|
||||
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
|
||||
import { clearUpdateCheckCache, performUpdateCheck, performUpdateInstall } from "../update-check.js";
|
||||
import { getCliPackageVersion } from "../cli-package-version.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
@@ -44,4 +44,27 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
rethrowAsApiError(error, "Failed to refresh update check");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-check/install", async (_req, res) => {
|
||||
try {
|
||||
const fusionDir = resolveGlobalDir();
|
||||
const updateCheck = await performUpdateCheck(fusionDir, cliPackageVersion, {
|
||||
force: true,
|
||||
});
|
||||
|
||||
if (!updateCheck.updateAvailable || !updateCheck.latestVersion) {
|
||||
res.json({
|
||||
currentVersion: updateCheck.currentVersion,
|
||||
latestVersion: updateCheck.latestVersion,
|
||||
updated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, { fusionDir });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to install update");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { resolveGlobalDir } from "@fusion/core";
|
||||
|
||||
const CACHE_FILENAME = "update-check.json";
|
||||
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
|
||||
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
|
||||
const FORCE_INSTALL_COMMAND = "npm install --force -g @runfusion/fusion@latest";
|
||||
const INSTALL_TIMEOUT_MS = 120_000;
|
||||
const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/** Allowed update-check cadences from GlobalSettings. */
|
||||
export type UpdateCheckFrequency = "manual" | "on-startup" | "daily" | "weekly";
|
||||
@@ -18,6 +26,20 @@ export type UpdateCheckResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateInstallResult = {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
updated: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ExecInstall = (
|
||||
command: string,
|
||||
options: { timeout: number; maxBuffer: number },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
type InstallError = Error & { stdout?: string; stderr?: string };
|
||||
|
||||
/**
|
||||
* Cache TTL in ms for the given frequency. Frequencies that don't expire by
|
||||
* elapsed time (`manual`, `on-startup`) return Infinity — those modes rely on
|
||||
@@ -63,6 +85,32 @@ function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBinCollisionInstallError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
const message = [installError?.message, installError?.stderr, installError?.stdout]
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
.join("\n");
|
||||
|
||||
const hasBinHint = /\/(fn|fusion)\b|runfusion\.ai/i.test(message);
|
||||
if (!hasBinHint) return false;
|
||||
|
||||
return /EEXIST|ENOENT|File exists/i.test(message);
|
||||
}
|
||||
|
||||
function getInstallErrorMessage(error: unknown): string {
|
||||
const installError = error as InstallError;
|
||||
const stderr = typeof installError?.stderr === "string" ? installError.stderr.trim() : "";
|
||||
if (stderr.length > 0) return stderr;
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getInstallOptions(): { timeout: number; maxBuffer: number } {
|
||||
return {
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: INSTALL_MAX_BUFFER,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidResult(value: unknown): value is UpdateCheckResult {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
@@ -103,6 +151,51 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
|
||||
await rm(getCachePath(fusionDir), { force: true });
|
||||
}
|
||||
|
||||
export async function performUpdateInstall(
|
||||
currentVersion: string,
|
||||
latestVersion: string | null,
|
||||
options: { exec?: ExecInstall; fusionDir?: string } = {},
|
||||
): Promise<UpdateInstallResult> {
|
||||
const runExec = options.exec ?? execAsync;
|
||||
const fusionDir = options.fusionDir ?? resolveGlobalDir();
|
||||
|
||||
try {
|
||||
await runExec(INSTALL_COMMAND, getInstallOptions());
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isBinCollisionInstallError(error)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallErrorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await runExec(FORCE_INSTALL_COMMAND, getInstallOptions());
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: true,
|
||||
};
|
||||
} catch (forceError) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallErrorMessage(forceError),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function performUpdateCheck(
|
||||
fusionDir: string,
|
||||
currentVersion: string,
|
||||
|
||||
@@ -4981,6 +4981,13 @@
|
||||
"learnMore": "Learn more",
|
||||
"settingsSaved": "Settings saved",
|
||||
"updateAvailablePrefix": "v{{version}} available",
|
||||
"updateCheckFailed": "Failed to check for updates",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||
"updateNow": "Update now",
|
||||
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
||||
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
|
||||
"updating": "Updating…",
|
||||
"upToDate": "You're up to date ✓"
|
||||
},
|
||||
"header": {
|
||||
@@ -6622,7 +6629,12 @@
|
||||
"dismissLabel": "Dismiss update notice",
|
||||
"learnMore": "Learn more",
|
||||
"message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.",
|
||||
"releaseNotes": "Release notes"
|
||||
"releaseNotes": "Release notes",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||
"updateNow": "Update now",
|
||||
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"usage": {
|
||||
"configureAuthHint": "Configure authentication in Settings to see usage data.",
|
||||
|
||||
146
packages/i18n/src/resources.d.ts
vendored
146
packages/i18n/src/resources.d.ts
vendored
@@ -566,6 +566,7 @@ export default interface Resources {
|
||||
"last24h": "Last 24h",
|
||||
"last7d": "Last 7 days",
|
||||
"lastHeartbeat": "Last heartbeat",
|
||||
"lastHeartbeatAt": "Last: {{time}}",
|
||||
"latestRunLabel": "Latest run",
|
||||
"layoutAuto": "Auto",
|
||||
"layoutAutoAria": "Automatic layout",
|
||||
@@ -658,6 +659,7 @@ export default interface Resources {
|
||||
"next": "Next",
|
||||
"nextExpected": "Next expected",
|
||||
"nextHeartbeat": "Next heartbeat in {{elapsed}}",
|
||||
"nextHeartbeatAt": "Next: {{time}}",
|
||||
"noActiveAssignment": "No active assignment",
|
||||
"noActiveEligible": "No active agents eligible to pause",
|
||||
"noActivityYet": "No activity yet",
|
||||
@@ -1133,6 +1135,7 @@ export default interface Resources {
|
||||
"needsInput": "needs input"
|
||||
},
|
||||
"typeLabel": {
|
||||
"cliAgent": "CLI Agent",
|
||||
"milestoneInterview": "Milestone Interview",
|
||||
"missionInterview": "Mission Interview",
|
||||
"planning": "Planning",
|
||||
@@ -1346,6 +1349,27 @@ export default interface Resources {
|
||||
"stateVersionMismatch": "Version mismatch",
|
||||
"succeededDuration": "Install succeeded in {{duration}}s"
|
||||
},
|
||||
"cliTerminal": {
|
||||
"adapterSettings": "Adapter settings",
|
||||
"advance": "Advance",
|
||||
"advancePrompt": "This session looks idle — advance to review?",
|
||||
"mobileInputPlaceholder": "Type to send to the session…",
|
||||
"mobileKeyArrowDown": "Cursor down",
|
||||
"mobileKeyArrowLeft": "Cursor left",
|
||||
"mobileKeyArrowRight": "Cursor right",
|
||||
"mobileKeyArrowUp": "Cursor up",
|
||||
"mobileKeyCtrl": "Sticky Ctrl modifier",
|
||||
"mobileKeyCtrlC": "Send Ctrl-C",
|
||||
"mobileKeyEsc": "Send Escape",
|
||||
"mobileKeyTab": "Send Tab",
|
||||
"mobileSend": "Send",
|
||||
"notYet": "Not yet",
|
||||
"postureBaseline": "Baseline",
|
||||
"postureResolved": "Resolved posture",
|
||||
"readOnly": "Read-only",
|
||||
"replayEnded": "Session ended",
|
||||
"replayIdle": "Session idle"
|
||||
},
|
||||
"column": {
|
||||
"actionsAriaLabel": "{{columnLabel}} column actions",
|
||||
"actionsTitle": "Column actions",
|
||||
@@ -1631,12 +1655,19 @@ export default interface Resources {
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Directory browser",
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"closeBrowser": "Close directory browser",
|
||||
"createFolder": "New folder",
|
||||
"createFolderAria": "Create new folder",
|
||||
"createFolderConfirm": "Create",
|
||||
"createFolderError": "Folder name cannot contain path separators or '..'",
|
||||
"createFolderTitle": "Create folder",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "Hide hidden",
|
||||
"hideHiddenAria": "Hide hidden directories",
|
||||
"hideHiddenTitle": "Hide hidden",
|
||||
"loading": "Loading…",
|
||||
"newFolderPlaceholder": "Folder name",
|
||||
"noSubdirs": "No subdirectories",
|
||||
"openBrowser": "Browse directories",
|
||||
"parentDir": "Go to parent directory",
|
||||
@@ -4293,8 +4324,10 @@ export default interface Resources {
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"addCustom": "Add Custom Provider",
|
||||
"apiKeyKeepPlaceholder": "Leave blank to keep current key",
|
||||
"apiKeyLabel": "API key",
|
||||
"apiTypeAnthropic": "Anthropic-compatible",
|
||||
"apiTypeGoogle": "Google Generative AI",
|
||||
"apiTypeInvalid": "API type is invalid.",
|
||||
"apiTypeLabel": "API type",
|
||||
"apiTypeOpenAi": "OpenAI-compatible",
|
||||
@@ -4878,6 +4911,16 @@ export default interface Resources {
|
||||
"valueLabel": "Value"
|
||||
},
|
||||
"sessionBanner": {
|
||||
"cli": {
|
||||
"advance": "Advance",
|
||||
"authFailed": "CLI authentication failed",
|
||||
"cancelTask": "Cancel task",
|
||||
"reauthenticate": "Re-authenticate",
|
||||
"relaunch": "Relaunch fresh",
|
||||
"resumeExhausted": "Couldn't resume the session",
|
||||
"retry": "Retry",
|
||||
"userExited": "Agent exited before completing"
|
||||
},
|
||||
"dismissAll": "Dismiss all",
|
||||
"dismissItem": "Dismiss {{title}}",
|
||||
"failed": "Failed",
|
||||
@@ -4893,7 +4936,10 @@ export default interface Resources {
|
||||
"headerErrorSingular_other": "",
|
||||
"regionLabel": "AI sessions needing input or failed",
|
||||
"resume": "Resume",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"typeLabel": {
|
||||
"cliAgent": "CLI Agent"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"actions": {
|
||||
@@ -4948,6 +4994,34 @@ export default interface Resources {
|
||||
"backupNow": "Backup Now",
|
||||
"creating": "Creating…"
|
||||
},
|
||||
"cliAgents": {
|
||||
"adapterLabel": "Adapter",
|
||||
"approveFailed": "Failed to record autonomy approval",
|
||||
"approvedNote": "Elevated autonomy is approved for this project.",
|
||||
"autonomy": {
|
||||
"default": "Default (request approvals)",
|
||||
"elevated": "Elevated (bypass approvals)"
|
||||
},
|
||||
"autonomyHelp": "Elevated autonomy requires a per-project approval before the agent can launch.",
|
||||
"autonomyLabel": "Autonomy mode",
|
||||
"commandHelp": "Path or name of the binary to launch. A non-default value is treated as privileged and requires autonomy approval.",
|
||||
"commandLabel": "Command override",
|
||||
"description": "Per-adapter launch configuration for CLI coding agents driven in engine-owned terminals.",
|
||||
"elevatedConfirmAction": "Approve elevated autonomy",
|
||||
"elevatedConfirmBody": "Elevated autonomy lets this CLI agent bypass per-step approvals (e.g. --dangerously-skip-permissions). It can modify files and run commands without pausing. Approve only if you trust this adapter for this project.",
|
||||
"elevatedConfirmTitle": "Approve elevated autonomy?",
|
||||
"envHelp": "Comma-separated variable NAMES forwarded from the parent process. Service credentials (FUSION_*) are always excluded.",
|
||||
"envLabel": "Environment variable additions",
|
||||
"extraArgsHelp": "Appended after the adapter's computed arguments (space-separated). Bypass flags here are detected and gated.",
|
||||
"extraArgsLabel": "Extra arguments",
|
||||
"heading": "CLI Agents",
|
||||
"saveFailed": "Failed to save CLI agent settings",
|
||||
"tier": {
|
||||
"generic": "generic",
|
||||
"hybrid": "hybrid",
|
||||
"native": "native"
|
||||
}
|
||||
},
|
||||
"closeModal": "Close conflict modal",
|
||||
"conflictModalTitle": "Resolve Settings Conflicts",
|
||||
"footer": {
|
||||
@@ -4958,7 +5032,14 @@ export default interface Resources {
|
||||
"learnMore": "Learn more",
|
||||
"settingsSaved": "Settings saved",
|
||||
"upToDate": "You're up to date ✓",
|
||||
"updateAvailablePrefix": "v{{version}} available"
|
||||
"updateAvailablePrefix": "v{{version}} available",
|
||||
"updateCheckFailed": "Failed to check for updates",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||
"updateNow": "Update now",
|
||||
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
||||
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"header": {
|
||||
"discord": "Discord"
|
||||
@@ -5002,11 +5083,19 @@ export default interface Resources {
|
||||
"presetNameRequired": "Preset name is required",
|
||||
"savePreset": "Save preset"
|
||||
},
|
||||
"movedStub": {
|
||||
"modelLanes": "Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
|
||||
"openWorkflowSettings": "Open workflow settings",
|
||||
"reviewVerification": "Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
|
||||
"stepExecution": "Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
|
||||
"summarizerModelInline": "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it."
|
||||
},
|
||||
"nav": {
|
||||
"aria": {
|
||||
"global": "Global setting",
|
||||
"project": "Project setting"
|
||||
},
|
||||
"cliAgents": "CLI Agents",
|
||||
"tooltip": {
|
||||
"global": "Shared across all projects",
|
||||
"project": "Specific to this project"
|
||||
@@ -6039,6 +6128,7 @@ export default interface Resources {
|
||||
},
|
||||
"tabs": {
|
||||
"changes": "Changes",
|
||||
"chat": "Chat",
|
||||
"comments": "Comments",
|
||||
"definition": "Definition",
|
||||
"documents": "Documents",
|
||||
@@ -6048,8 +6138,12 @@ export default interface Resources {
|
||||
"review": "Review",
|
||||
"routing": "Routing",
|
||||
"stats": "Stats",
|
||||
"terminal": "Terminal",
|
||||
"workflow": "Workflow"
|
||||
},
|
||||
"terminal": {
|
||||
"loading": "Loading terminal…"
|
||||
},
|
||||
"timedDuration": "Timed duration",
|
||||
"timestamps": {
|
||||
"ariaLabel": "Task timestamps",
|
||||
@@ -6295,6 +6389,10 @@ export default interface Resources {
|
||||
"branchProgressTitle": "Parallel branches in progress",
|
||||
"cancelMove": "Cancel Move",
|
||||
"clearSelection": "Clear selection",
|
||||
"cliNeedsAttention": "Needs attention",
|
||||
"cliNeedsAttentionTitle": "The CLI agent needs your attention",
|
||||
"cliWaitingOnInput": "Waiting on input",
|
||||
"cliWaitingOnInputTitle": "The CLI agent is waiting for your input",
|
||||
"closeIssue": "Close Issue",
|
||||
"collapse": "Collapse",
|
||||
"createFailed": "Failed to create task",
|
||||
@@ -6554,7 +6652,12 @@ export default interface Resources {
|
||||
"dismissLabel": "Dismiss update notice",
|
||||
"learnMore": "Learn more",
|
||||
"message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.",
|
||||
"releaseNotes": "Release notes"
|
||||
"releaseNotes": "Release notes",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||
"updateNow": "Update now",
|
||||
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"usage": {
|
||||
"configureAuthHint": "Configure authentication in Settings to see usage data.",
|
||||
@@ -6713,13 +6816,29 @@ export default interface Resources {
|
||||
},
|
||||
"workflowColumns": {
|
||||
"add": "Add column",
|
||||
"agent": "Column agent",
|
||||
"agentBadgeDefer": "Column agent (defer)",
|
||||
"agentBadgeOverride": "Column agent (override)",
|
||||
"agentFlagHint": "Enable both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor to staff columns with agents",
|
||||
"agentLabel": "Column agent",
|
||||
"agentMode": "Agent mode",
|
||||
"agentModeDefer": "Defer",
|
||||
"agentModeDeferHint": "Column agent applies only when the work carries no agent/model settings of its own",
|
||||
"agentModeOverride": "Override",
|
||||
"agentModeOverrideHint": "Column agent supersedes node- and task-level agent/model settings",
|
||||
"agentNone": "(none)",
|
||||
"agentNotFound": "Agent not found — {{id}}",
|
||||
"agentsLoadFailed": "Failed to load agents",
|
||||
"compositionBlocked": "Resolve trait conflicts on highlighted columns before saving",
|
||||
"confirmPolicyEscalation": "Bind it anyway? The column agent will run with broader permissions than this project's default.",
|
||||
"empty": "No columns yet. Add a column to place nodes into board lanes.",
|
||||
"escalationDeclined": "Save cancelled — column agent binding not confirmed",
|
||||
"moveDown": "Move column down",
|
||||
"moveUp": "Move column up",
|
||||
"nameLabel": "Column name",
|
||||
"newColumnName": "New column",
|
||||
"nodeUnplaced": "Not placed in a column",
|
||||
"overriddenByColumnAgent": "Overridden by column agent {{name}} — this node's executor settings are superseded.",
|
||||
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
|
||||
"remove": "Remove column",
|
||||
"title": "Columns",
|
||||
@@ -6728,6 +6847,27 @@ export default interface Resources {
|
||||
"unplacedCount_one": "{{count}} nodes not placed in a column",
|
||||
"unplacedCount_other": "{{count}} nodes not placed in a column"
|
||||
},
|
||||
"workflowEditor": {
|
||||
"cliAgent": {
|
||||
"adapterLabel": "CLI adapter",
|
||||
"adapterNote": "Drives a CLI coding agent in an engine-owned terminal for this step.",
|
||||
"adapterPlaceholder": "— select adapter —",
|
||||
"autonomyLabel": "Elevated autonomy (bypass approvals)",
|
||||
"autonomyNote": "Elevated autonomy requires a per-project approval before the agent can launch. Until approved, launches with elevated posture fail.",
|
||||
"executorOption": "CLI agent",
|
||||
"notify": {
|
||||
"banner": "In-app banner",
|
||||
"bannerNotify": "Banner + push notification"
|
||||
},
|
||||
"notifyLabel": "Waiting-on-input notification",
|
||||
"notifyNote": "How you are alerted when the agent pauses waiting for input on this step.",
|
||||
"tier": {
|
||||
"generic": "generic",
|
||||
"hybrid": "hybrid",
|
||||
"native": "native"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workflowFields": {
|
||||
"add": "Add field",
|
||||
"addOption": "Add option",
|
||||
|
||||
Reference in New Issue
Block a user