Defaults to true so existing behaviour (dashboard probes PATH for `fn` / `fusion` and surfaces install / version-mismatch states) is unchanged. When the user toggles it off in Settings → General → CLI Binary: - `GET /system/fn-binary/status` short-circuits before `detectFnBinary()` and returns `state: "skipped"`. No `<bin> --version` subprocess is spawned at all, so an outdated globally-installed CLI cannot run as a side effect of opening the dashboard. - The install banner is hidden when `state` is `"skipped"`. - `POST /system/fn-binary/install` rejects with HTTP 409 since install is the user action that the status check informs. - The Settings UI gains a checkbox under the existing CLI binary panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { X } from "lucide-react";
|
|
import {
|
|
fetchFnBinaryStatus,
|
|
installFnBinary,
|
|
type FnBinaryStatus,
|
|
} from "../api/legacy";
|
|
import "./CliBinaryInstallBanner.css";
|
|
|
|
interface Props {
|
|
/** Open Settings → General so the user can manage manually. */
|
|
onOpenSettings: () => void;
|
|
}
|
|
|
|
/** localStorage key for permanent dismissal. */
|
|
const DISMISS_KEY = "fusion:cli-binary-banner-dismissed";
|
|
|
|
function isDismissed(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
try {
|
|
return window.localStorage.getItem(DISMISS_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistDismissal(): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(DISMISS_KEY, "1");
|
|
} catch {
|
|
// Ignore quota / private-mode errors — dismissal lasts the session only.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-time banner that nudges users to install the global `fn`/`fusion`
|
|
* CLI binary. Renders only when:
|
|
*
|
|
* - Status probe completes successfully
|
|
* - The binary is not on PATH
|
|
* - User has not previously dismissed the banner
|
|
*
|
|
* Dismissal is permanent (localStorage). The Settings → General → CLI
|
|
* Binary panel always lets the user reinstall later.
|
|
*/
|
|
export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
|
|
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
|
|
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
|
|
const [installing, setInstalling] = useState(false);
|
|
const [installError, setInstallError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (dismissed) return;
|
|
let cancelled = false;
|
|
void fetchFnBinaryStatus()
|
|
.then((next) => {
|
|
if (!cancelled) setStatus(next);
|
|
})
|
|
.catch(() => {
|
|
// Treat probe failure as "don't show banner" — better silent than
|
|
// bothering the user with infrastructure errors on first load.
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [dismissed]);
|
|
|
|
const handleInstall = useCallback(async () => {
|
|
setInstalling(true);
|
|
setInstallError(null);
|
|
try {
|
|
const response = await installFnBinary();
|
|
setStatus({
|
|
binary: response.binary,
|
|
expectedVersion: response.expectedVersion,
|
|
state: response.state,
|
|
install: response.install,
|
|
});
|
|
if (!response.installResult.success) {
|
|
setInstallError(
|
|
response.installResult.permissionsHint ||
|
|
response.installResult.stderr ||
|
|
`Install failed (exit ${response.installResult.exitCode ?? "n/a"})`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
setInstallError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleDismiss = useCallback(() => {
|
|
persistDismissal();
|
|
setDismissed(true);
|
|
}, []);
|
|
|
|
if (dismissed) return null;
|
|
if (!status) return null;
|
|
if (status.state === "installed") return null;
|
|
// Honour the global `fnBinaryCheckEnabled` opt-out — when checks are
|
|
// disabled the install banner would be misleading.
|
|
if (status.state === "skipped") return null;
|
|
|
|
return (
|
|
<div className="cli-binary-banner" role="status">
|
|
<div className="cli-binary-banner__body">
|
|
<div className="cli-binary-banner__title">Install the Fusion CLI</div>
|
|
<div className="cli-binary-banner__text">
|
|
Get the <code>fn</code> and <code>fusion</code> commands on your terminal so you
|
|
can drive Fusion from anywhere. One click below or copy the command into your shell.
|
|
</div>
|
|
<div className="cli-binary-banner__actions">
|
|
<button
|
|
type="button"
|
|
className="cli-binary-banner__primary"
|
|
onClick={() => void handleInstall()}
|
|
disabled={installing}
|
|
>
|
|
{installing ? "Installing…" : "Install with npm"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-binary-banner__secondary"
|
|
onClick={onOpenSettings}
|
|
>
|
|
Open Settings
|
|
</button>
|
|
</div>
|
|
{installError && (
|
|
<div className="cli-binary-banner__error">{installError}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="cli-binary-banner__dismiss"
|
|
aria-label="Dismiss"
|
|
onClick={handleDismiss}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|