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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
92
packages/dashboard/app/utils/pathDisplay.ts
Normal file
92
packages/dashboard/app/utils/pathDisplay.ts
Normal 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("/"),
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
30
packages/dashboard/src/__tests__/cli-package-version.test.ts
Normal file
30
packages/dashboard/src/__tests__/cli-package-version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] || "\\";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" } }),
|
||||
|
||||
69
packages/dashboard/src/cli-package-version.ts
Normal file
69
packages/dashboard/src/cli-package-version.ts
Normal 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";
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user