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

- test(FN-2944): cover already checked out worktree conflict recovery
- fix(FN-2944): recognize git already checked out worktree conflict
- fix(engine): auto-recover from squash-merge orphan rebase failures

Fusion-Task-Id: FN-2944
This commit is contained in:
Fusion
2026-04-29 07:10:52 -07:00
committed by gsxdsm
parent a74b77cec5
commit ba3d868c2d
46 changed files with 454 additions and 163 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
import { browseDirectory, type BrowseDirectoryResult } from "../api";
import { getPathBreadcrumbs } from "../utils/pathDisplay";
import "./DirectoryPicker.css";
export interface DirectoryPickerProps {
@@ -99,9 +100,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
}
}, [browser.showHidden]);
const breadcrumbs = browser.currentPath
? browser.currentPath.split("/").filter(Boolean)
: [];
const breadcrumbs = browser.currentPath ? getPathBreadcrumbs(browser.currentPath) : [];
return (
<div className="directory-picker">
@@ -129,26 +128,17 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
{/* Breadcrumbs */}
<div className="directory-picker-breadcrumbs">
<button
type="button"
className="directory-picker-breadcrumb"
onClick={() => handleNavigate("/")}
title="Root"
>
/
</button>
{breadcrumbs.map((segment, i) => {
const segPath = "/" + breadcrumbs.slice(0, i + 1).join("/");
{breadcrumbs.map((breadcrumb, index) => {
return (
<span key={segPath} className="directory-picker-breadcrumb-item">
<ChevronRight size={12} className="directory-picker-breadcrumb-sep" />
<span key={breadcrumb.path} className="directory-picker-breadcrumb-item">
{index > 0 && <ChevronRight size={12} className="directory-picker-breadcrumb-sep" />}
<button
type="button"
className="directory-picker-breadcrumb"
onClick={() => handleNavigate(segPath)}
title={segPath}
onClick={() => handleNavigate(breadcrumb.path)}
title={breadcrumb.path}
>
{segment}
{breadcrumb.label}
</button>
</span>
);

View File

@@ -5,6 +5,7 @@ import type { FileNode } from "../api";
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
import { appendTokenQuery } from "../auth";
import { getErrorMessage } from "@fusion/core";
import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay";
interface FileBrowserProps {
entries: FileNode[];
@@ -37,7 +38,7 @@ function formatTime(mtime?: string): string {
/** Build the full relative path for a file/directory entry */
function entryPath(currentPath: string, name: string): string {
return currentPath === "." ? name : `${currentPath}/${name}`;
return joinDisplayPath(currentPath, name);
}
// ── Context Menu State ──────────────────────────────────────────────────
@@ -550,16 +551,14 @@ export function FileBrowser({
<button
className="file-browser-up"
onClick={() => {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
onNavigate(parts.length === 0 ? "." : parts.join("/"));
onNavigate(getParentDisplayPath(currentPath));
}}
>
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
Up one level
</button>
)}
<span className="file-browser-path">{currentPath === "." ? "Root" : currentPath}</span>
<span className="file-browser-path">{currentPath === "." ? "Root" : normalizeDisplayPath(currentPath)}</span>
</div>
<div className="file-browser-list">

View File

@@ -1,5 +1,6 @@
import { File } from "lucide-react";
import type { FileSearchItem } from "../hooks/useFileMention";
import { getDisplayDirname } from "../utils/pathDisplay";
import "./FileMentionPopup.css";
import type { ReactNode } from "react";
@@ -54,7 +55,7 @@ export function FileMentionPopup({
{!loading && files.length > 0 && (
<ul className="file-mention-popup-list" role="listbox">
{files.map((file, index) => {
const dirPath = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/") + 1) : "";
const dirPath = getDisplayDirname(file.path);
const highlightName = file.name;
return (
@@ -85,4 +86,4 @@ export function FileMentionPopup({
)}
</div>
);
}
}

View File

@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { truncateMiddle } from "../utils/truncatePath";
import { getPathBasename } from "../utils/pathDisplay";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type {
@@ -1624,7 +1625,7 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
{worktree.isMain && <span className="gm-badge main">main</span>}
{worktree.isBare && <span className="gm-badge bare">bare</span>}
<span className="gm-worktree-path" title={worktree.path}>
{worktree.path.split("/").pop() || worktree.path}
{getPathBasename(worktree.path) || worktree.path}
</span>
</div>
<div className="gm-worktree-detail">

View File

@@ -10,6 +10,7 @@ import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { NodeHealthDot } from "./NodeHealthDot";
import { PluginSlot } from "./PluginSlot";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
import { getTrailingPath } from "../utils/pathDisplay";
export { useViewportMode };
@@ -114,7 +115,7 @@ function ProjectSelector({
<div className="project-selector-info">
<span className="project-selector-name">{project.name}</span>
<span className="project-selector-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="project-selector-check" />}
@@ -874,7 +875,7 @@ export function Header({
<div className="mobile-project-switch-info">
<span className="mobile-project-switch-name">{project.name}</span>
<span className="mobile-project-switch-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}

View File

@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { getTrailingPath } from "../utils/pathDisplay";
export interface ProjectSelectorProps {
projects: ProjectInfo[];
@@ -354,7 +355,7 @@ export function ProjectSelector({
{project.name}
</span>
<span className="project-selector__item-path">
{project.path.split("/").slice(-2).join("/")}
{getTrailingPath(project.path, 2)}
</span>
</div>
{currentProject?.id === project.id && (

View File

@@ -13,6 +13,7 @@ import {
import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { getPathBasename } from "../utils/pathDisplay";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
@@ -750,7 +751,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
const unsubConnect = onConnect((info) => {
// Update tab title with shell name
updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell);
updateTabTitle(activeTab.id, getPathBasename(info.shell) || info.shell);
});
const unsubExit = onExit((code) => {

View File

@@ -0,0 +1,92 @@
export interface PathBreadcrumb {
label: string;
path: string;
}
export function normalizeDisplayPath(path: string): string {
return path.replace(/\\/g, "/");
}
function trimTrailingSeparators(path: string): string {
return path.replace(/[\\/]+$/g, "");
}
export function splitPathSegments(path: string): string[] {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
if (!normalized) return [];
return normalized.split("/").filter(Boolean);
}
export function getPathBasename(path: string): string {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
if (!normalized) return path;
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || normalized;
}
export function getTrailingPath(path: string, count: number): string {
const segments = splitPathSegments(path);
if (segments.length === 0) {
return normalizeDisplayPath(path);
}
return segments.slice(-count).join("/");
}
export function getDisplayDirname(path: string): string {
const normalized = normalizeDisplayPath(trimTrailingSeparators(path));
const lastSeparator = normalized.lastIndexOf("/");
if (lastSeparator < 0) return "";
return normalized.slice(0, lastSeparator + 1);
}
export function joinDisplayPath(basePath: string, name: string): string {
if (!basePath || basePath === ".") {
return name;
}
const segments = splitPathSegments(basePath);
return [...segments, name].join("/");
}
export function getParentDisplayPath(path: string): string {
const segments = splitPathSegments(path);
if (segments.length === 0) {
return ".";
}
segments.pop();
return segments.length === 0 ? "." : segments.join("/");
}
export function getPathBreadcrumbs(path: string): PathBreadcrumb[] {
const normalized = normalizeDisplayPath(path);
const winMatch = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (winMatch) {
const root = `${winMatch[1]}/`;
const segments = (winMatch[2] ?? "").split("/").filter(Boolean);
const breadcrumbs: PathBreadcrumb[] = [{ label: winMatch[1], path: root }];
for (let i = 0; i < segments.length; i += 1) {
breadcrumbs.push({
label: segments[i]!,
path: `${root}${segments.slice(0, i + 1).join("/")}`,
});
}
return breadcrumbs;
}
if (normalized.startsWith("/")) {
const segments = normalized.split("/").filter(Boolean);
const breadcrumbs: PathBreadcrumb[] = [{ label: "/", path: "/" }];
for (let i = 0; i < segments.length; i += 1) {
breadcrumbs.push({
label: segments[i]!,
path: `/${segments.slice(0, i + 1).join("/")}`,
});
}
return breadcrumbs;
}
const segments = normalized.split("/").filter(Boolean);
return segments.map((segment, index) => ({
label: segment,
path: segments.slice(0, index + 1).join("/"),
}));
}

View File

@@ -1,4 +1,5 @@
import type { Task } from "@fusion/core";
import { getPathBasename } from "./pathDisplay";
export interface WorktreeGroupData {
label: string;
@@ -11,9 +12,7 @@ export interface WorktreeGroupData {
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
// Take the last segment of the path
const segments = worktreePath.replace(/\/+$/, "").split("/");
return segments[segments.length - 1] || worktreePath;
return getPathBasename(worktreePath) || worktreePath;
}
/**

View File

@@ -0,0 +1,30 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getCliPackageVersion, resolveCliPackageVersionInfo } from "../cli-package-version.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
describe("cli-package-version", () => {
it("resolves the published CLI package from dashboard source directories", () => {
const versionInfo = resolveCliPackageVersionInfo(join(__dirname, ".."));
const expectedCliPackageJson = join(__dirname, "..", "..", "..", "cli", "package.json");
const dashboardPackageJson = join(__dirname, "..", "..", "package.json");
expect(versionInfo).toEqual({
packageJsonPath: expectedCliPackageJson,
version: JSON.parse(readFileSync(expectedCliPackageJson, "utf-8")).version,
});
expect(versionInfo?.packageJsonPath).not.toBe(dashboardPackageJson);
});
it("returns the published CLI version for dashboard consumers", () => {
const expectedCliPackageJson = join(__dirname, "..", "..", "..", "cli", "package.json");
const expectedVersion = JSON.parse(readFileSync(expectedCliPackageJson, "utf-8")).version;
expect(getCliPackageVersion()).toBe(expectedVersion);
});
});

View File

@@ -36,8 +36,8 @@ vi.mock("../terminal-service.js", () => {
const { __mockTerminalService: mockTerminalService } = await import("../terminal-service.js") as any;
const __dirname = dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PACKAGE_VERSION = (() => {
const packageJsonPath = join(__dirname, "..", "..", "package.json");
const CLI_PACKAGE_VERSION = (() => {
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
@@ -305,7 +305,7 @@ describe("createServer health and headless mode", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
version: DASHBOARD_PACKAGE_VERSION,
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
});
});
@@ -317,7 +317,7 @@ describe("createServer health and headless mode", () => {
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
if (DASHBOARD_PACKAGE_VERSION === "0.4.0") {
if (CLI_PACKAGE_VERSION === "0.4.0") {
expect(res.body.version).toBe("0.4.0");
return;
}

View File

@@ -10,3 +10,11 @@ import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
if (process.platform === "win32") {
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}

View File

@@ -43,6 +43,36 @@ describe("update-check", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("ignores a fresh cache entry after the installed version changes", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.8.1",
latestVersion: "0.8.3",
updateAvailable: true,
lastChecked: Date.now(),
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({
"dist-tags": {
latest: "0.8.3",
},
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.8.3");
expect(fetchSpy).toHaveBeenCalledOnce();
expect(result).toEqual({
currentVersion: "0.8.3",
latestVersion: "0.8.3",
updateAvailable: false,
lastChecked: expect.any(Number),
});
});
it("fetches latest version when cache is expired", async () => {
const stale: UpdateCheckResult = {
currentVersion: "0.6.0",
@@ -198,6 +228,17 @@ describe("update-check", () => {
expect(fetchSpy).not.toHaveBeenCalled();
expect(fromCache).toEqual(cached);
// After an upgrade, ignore stale cached currentVersion instead of
// surfacing an outdated update banner.
const afterUpgrade = await performUpdateCheck(fusionDir, "0.8.3", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(afterUpgrade).toEqual({
currentVersion: "0.8.3",
latestVersion: null,
updateAvailable: false,
lastChecked: expect.any(Number),
});
// force=true (used by /update-check/refresh) overrides manual.
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.0.0" } }),

View File

@@ -0,0 +1,69 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const CLI_PACKAGE_NAME = "@runfusion/fusion";
export interface CliPackageVersionInfo {
packageJsonPath: string;
version: string;
}
function readCliPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
if (!existsSync(pkgPath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
if (parsed.name === CLI_PACKAGE_NAME && typeof parsed.version === "string" && parsed.version.length > 0) {
return {
packageJsonPath: pkgPath,
version: parsed.version,
};
}
} catch {
// Ignore unreadable or malformed package manifests and keep searching.
}
return null;
}
/**
* Resolve the published CLI package version from dashboard code.
*
* Supported layouts:
* - Monorepo source: `packages/dashboard/src/...` with sibling `packages/cli/package.json`
* - Installed dependency: `node_modules/@runfusion/fusion/dist/...`
* - Bundled CLI: dashboard code inlined into `dist/bin.js` next to the CLI manifest
*/
export function resolveCliPackageVersionInfo(startDir: string): CliPackageVersionInfo | null {
let currentDir = startDir;
for (let i = 0; i < 8; i += 1) {
const candidates = [
resolve(currentDir, "package.json"),
resolve(currentDir, "..", "cli", "package.json"),
];
for (const pkgPath of candidates) {
const versionInfo = readCliPackageVersion(pkgPath);
if (versionInfo) {
return versionInfo;
}
}
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return null;
}
export function getCliPackageVersion(importMetaUrl: string = import.meta.url): string {
const startDir = dirname(fileURLToPath(importMetaUrl));
return resolveCliPackageVersionInfo(startDir)?.version ?? process.env.npm_package_version ?? "0.0.0";
}

View File

@@ -3685,7 +3685,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
entries.sort((a, b) => a.name.localeCompare(b.name));
const parentPath = resolvedPath === "/" ? null : dirname(resolvedPath);
const parentPath = dirname(resolvedPath) === resolvedPath ? null : dirname(resolvedPath);
res.json({ currentPath: resolvedPath, parentPath, entries });
} catch (err: unknown) {

View File

@@ -1,4 +1,5 @@
import type { ProjectSettings } from "@fusion/core";
import { basename } from "node:path";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
@@ -74,7 +75,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
// Build sync payload
const payload = {
global: globalSettings,
projects: { [store.getRootDir().split("/").pop()!]: projectSettings.project },
projects: { [basename(store.getRootDir())]: projectSettings.project },
exportedAt: new Date().toISOString(),
version: 1 as const,
};

View File

@@ -1,39 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGlobalDir } from "@fusion/core";
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
import { getCliPackageVersion } from "../cli-package-version.js";
import type { ApiRouteRegistrar } from "./types.js";
// Walk up from this module to find the @runfusion/fusion package.json. Works
// across layouts: monorepo source (packages/dashboard/src/...), installed
// dependency (node_modules/@runfusion/fusion/dist/...), and the bundled CLI
// binary where dashboard code is inlined into bin.js next to the cli's
// package.json. Falls back to "0.0.0" when nothing is found.
const CLI_PACKAGE_VERSION = (() => {
try {
let cur = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 8; i++) {
const pkgPath = resolve(cur, "package.json");
if (existsSync(pkgPath)) {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
}
const parent = resolve(cur, "..");
if (parent === cur) break;
cur = parent;
}
} catch {
// Fall through to env/default fallback.
}
return process.env.npm_package_version ?? "0.0.0";
})();
export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, rethrowAsApiError } = ctx;
const cliPackageVersion = getCliPackageVersion(import.meta.url);
router.get("/update-check", async (_req, res) => {
try {
@@ -42,14 +14,14 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
res.json({
updateAvailable: false,
disabled: true,
currentVersion: CLI_PACKAGE_VERSION,
currentVersion: cliPackageVersion,
latestVersion: null,
lastChecked: Date.now(),
});
return;
}
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION, {
const result = await performUpdateCheck(resolveGlobalDir(), cliPackageVersion, {
frequency: globalSettings.updateCheckFrequency,
});
res.json(result);
@@ -64,7 +36,7 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
await clearUpdateCheckCache(fusionDir);
// Explicit `force: true` so a "manual" frequency setting doesn't short
// out the network fetch on the user's deliberate "Check now" click.
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION, {
const result = await performUpdateCheck(fusionDir, cliPackageVersion, {
force: true,
});
res.json(result);

View File

@@ -48,52 +48,10 @@ import { stopAllDevServers } from "./dev-server-routes.js";
import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion } from "./cli-package-version.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_VERSION = (() => {
try {
const packageJsonPath = join(__dirname, "..", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
return packageJson.version;
}
} catch {
// Fall through to environment fallback.
}
return process.env.npm_package_version ?? "0.0.0";
})();
// Walk up from this module to find the @runfusion/fusion package.json. Works
// across layouts: monorepo source, installed dependency, and the bundled CLI
// binary where dashboard code is inlined into bin.js next to the cli's
// package.json. Falls back to "0.0.0" when nothing is found.
const CLI_PACKAGE_VERSION = (() => {
try {
let cur = __dirname;
for (let i = 0; i < 8; i++) {
const pkgPath = resolve(cur, "package.json");
if (existsSync(pkgPath)) {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
}
const parent = resolve(cur, "..");
if (parent === cur) break;
cur = parent;
}
} catch {
// Fall through to environment fallback.
}
return process.env.npm_package_version ?? "0.0.0";
})();
function parseVersion(version: string): number[] {
return version
.split(".")
@@ -463,6 +421,7 @@ export function loadTlsCredentialsFromEnv(
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
const cliPackageVersion = getCliPackageVersion(import.meta.url);
// ── Derive defaults from engine when provided (explicit options override) ──
const engine = options?.engine;
if (engine) {
@@ -978,13 +937,13 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
app.get("/api/health", (_req, res) => {
res.json({
status: "ok",
version: PACKAGE_VERSION,
version: cliPackageVersion,
uptime: Math.floor(process.uptime()),
});
});
app.get("/api/updates/check", async (_req, res) => {
const currentVersion = CLI_PACKAGE_VERSION;
const currentVersion = cliPackageVersion;
res.set("Cache-Control", "no-store");
const controller = new AbortController();

View File

@@ -407,7 +407,11 @@ export class TerminalService extends EventEmitter {
cwd = path.resolve(this.projectRoot, cwd);
// Ensure path is within project root (path traversal protection)
if (!cwd.startsWith(this.projectRoot)) {
const relativeToProjectRoot = path.relative(this.projectRoot, cwd);
if (
relativeToProjectRoot.startsWith("..") ||
path.isAbsolute(relativeToProjectRoot)
) {
console.warn(`Path traversal attempt blocked: ${requestedCwd}`);
return this.projectRoot;
}

View File

@@ -110,8 +110,9 @@ export async function performUpdateCheck(
): Promise<UpdateCheckResult> {
const now = Date.now();
const cached = readCachedUpdateCheck(fusionDir);
const cacheMatchesCurrentVersion = !cached || cached.currentVersion === currentVersion;
const ttl = ttlForFrequency(options.frequency);
const cacheStillFresh = cached && now - cached.lastChecked < ttl;
const cacheStillFresh = cached && cacheMatchesCurrentVersion && now - cached.lastChecked < ttl;
// `on-startup`: refresh exactly once per process lifetime; afterwards
// serve the freshly-written cache for the rest of the run.
@@ -119,7 +120,8 @@ export async function performUpdateCheck(
!options.force &&
options.frequency === "on-startup" &&
hasRefreshedThisProcess &&
cached
cached &&
cacheMatchesCurrentVersion
) {
return cached;
}
@@ -131,11 +133,12 @@ export async function performUpdateCheck(
// For `manual`, never go to the network on a regular check — only the
// `/update-check/refresh` endpoint (which sets `force: true`) should.
// Return whatever's in the cache so the UI can still display the last
// known result; if there's nothing cached, return a no-op disabled-style
// payload.
// known result, but only when it matches the currently installed version.
// After an upgrade, the old cache would otherwise keep showing a stale
// "current version" until the next manual refresh.
if (!options.force && options.frequency === "manual") {
return (
cached ?? {
(cacheMatchesCurrentVersion ? cached : null) ?? {
currentVersion,
latestVersion: null,
updateAvailable: false,