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:
5
.changeset/fix-version-mismatch.md
Normal file
5
.changeset/fix-version-mismatch.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the dashboard version label so it matches the version used by update notifications.
|
||||||
5
.changeset/fix-worktree-already-checked-out.md
Normal file
5
.changeset/fix-worktree-already-checked-out.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix worktree creation failure when git reports "already checked out at" instead of "already used by worktree at"
|
||||||
@@ -21,8 +21,9 @@
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const cliRoot = dirname(new URL(import.meta.url).pathname);
|
const cliRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
const workspaceRoot = join(cliRoot, "..", "..");
|
const workspaceRoot = join(cliRoot, "..", "..");
|
||||||
const outDir = join(cliRoot, "dist");
|
const outDir = join(cliRoot, "dist");
|
||||||
const dashboardClientSrc = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
const dashboardClientSrc = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||||
process.env.HOME = tempHome;
|
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] || "\\";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline/promises";
|
|||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { GitHubClient } from "@fusion/dashboard";
|
import { GitHubClient } from "@fusion/dashboard";
|
||||||
import {
|
import {
|
||||||
getGhErrorMessage,
|
getGhErrorMessage,
|
||||||
@@ -30,7 +30,7 @@ function asLocalProjectContext(store: TaskStore): ProjectContext {
|
|||||||
return {
|
return {
|
||||||
projectId: cwd,
|
projectId: cwd,
|
||||||
projectPath: cwd,
|
projectPath: cwd,
|
||||||
projectName: cwd.split("/").filter(Boolean).at(-1) ?? "current-project",
|
projectName: basename(cwd) || "current-project",
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
store,
|
store,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core";
|
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core";
|
||||||
import { resolve, dirname } from "node:path";
|
import { resolve, dirname, basename } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
/** Project context for CLI operations */
|
/** Project context for CLI operations */
|
||||||
@@ -198,7 +198,7 @@ export async function detectProjectFromCwd(
|
|||||||
// Use empty string for id to indicate unregistered status.
|
// Use empty string for id to indicate unregistered status.
|
||||||
return {
|
return {
|
||||||
id: "",
|
id: "",
|
||||||
name: currentDir.split("/").filter(Boolean).at(-1) ?? "current-project",
|
name: basename(currentDir) || "current-project",
|
||||||
path: currentDir,
|
path: currentDir,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -309,4 +309,3 @@ export async function getStore(
|
|||||||
const context = await resolveProject(projectName, cwd, globalDir);
|
const context = await resolveProject(projectName, cwd, globalDir);
|
||||||
return context.store;
|
return context.store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { dirname, resolve, normalize } from "node:path";
|
import { basename, dirname, resolve, normalize } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core";
|
import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core";
|
||||||
import { ProjectManager } from "@fusion/engine";
|
import { ProjectManager } from "@fusion/engine";
|
||||||
@@ -250,7 +250,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
|||||||
|
|
||||||
if (shouldRegister) {
|
if (shouldRegister) {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const defaultName = fusionDir.split("/").pop() || "unnamed";
|
const defaultName = basename(fusionDir) || "unnamed";
|
||||||
const name = await rl.question(` Project name [${defaultName}]: `);
|
const name = await rl.question(` Project name [${defaultName}]: `);
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||||
process.env.HOME = tempHome;
|
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] || "\\";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,19 +20,23 @@ import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|||||||
import type { GlobalSettings } from "./types.js";
|
import type { GlobalSettings } from "./types.js";
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||||
|
|
||||||
|
function getHomeDir(): string {
|
||||||
|
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
|
}
|
||||||
|
|
||||||
/** Legacy directory for global settings (original name before rename to `.fusion`). */
|
/** Legacy directory for global settings (original name before rename to `.fusion`). */
|
||||||
export function legacyGlobalDir(): string {
|
export function legacyGlobalDir(): string {
|
||||||
return join(homedir(), ".pi", "fusion");
|
return join(getHomeDir(), ".pi", "fusion");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy directory for global settings from the earliest fn version (`.pi/kb`). */
|
/** Legacy directory for global settings from the earliest fn version (`.pi/kb`). */
|
||||||
export function legacyGlobalDirOriginal(): string {
|
export function legacyGlobalDirOriginal(): string {
|
||||||
return join(homedir(), ".pi", "kb");
|
return join(getHomeDir(), ".pi", "kb");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default directory for global fusion settings: `~/.fusion/` */
|
/** Default directory for global fusion settings: `~/.fusion/` */
|
||||||
export function defaultGlobalDir(): string {
|
export function defaultGlobalDir(): string {
|
||||||
return join(homedir(), ".fusion");
|
return join(getHomeDir(), ".fusion");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
|||||||
import type { CentralCore } from "./central-core.js";
|
import type { CentralCore } from "./central-core.js";
|
||||||
import { CentralCore as CentralCoreClass } from "./central-core.js";
|
import { CentralCore as CentralCoreClass } from "./central-core.js";
|
||||||
|
|
||||||
|
function getHomeDir(): string {
|
||||||
|
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
|
* Check whether `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
|
||||||
* Used to decide if a directory contains a current (.fusion/fusion.db) project database.
|
* Used to decide if a directory contains a current (.fusion/fusion.db) project database.
|
||||||
@@ -192,7 +196,7 @@ export class FirstRunDetector {
|
|||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
|
||||||
let current = resolve(startDir);
|
let current = resolve(startDir);
|
||||||
const home = homedir();
|
const home = getHomeDir();
|
||||||
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
|
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
|
||||||
// Also stop at the OS temp directory — it is a shared system boundary and
|
// Also stop at the OS temp directory — it is a shared system boundary and
|
||||||
// should never itself host a project; stopping here prevents the walk from
|
// should never itself host a project; stopping here prevents the walk from
|
||||||
@@ -299,7 +303,7 @@ export class FirstRunDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultGlobalDir(): string {
|
private getDefaultGlobalDir(): string {
|
||||||
return join(homedir(), ".pi", "kb");
|
return join(getHomeDir(), ".pi", "kb");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||||
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
||||||
|
import { getPathBreadcrumbs } from "../utils/pathDisplay";
|
||||||
import "./DirectoryPicker.css";
|
import "./DirectoryPicker.css";
|
||||||
|
|
||||||
export interface DirectoryPickerProps {
|
export interface DirectoryPickerProps {
|
||||||
@@ -99,9 +100,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
|||||||
}
|
}
|
||||||
}, [browser.showHidden]);
|
}, [browser.showHidden]);
|
||||||
|
|
||||||
const breadcrumbs = browser.currentPath
|
const breadcrumbs = browser.currentPath ? getPathBreadcrumbs(browser.currentPath) : [];
|
||||||
? browser.currentPath.split("/").filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="directory-picker">
|
<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">
|
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
|
||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
<div className="directory-picker-breadcrumbs">
|
<div className="directory-picker-breadcrumbs">
|
||||||
<button
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
type="button"
|
|
||||||
className="directory-picker-breadcrumb"
|
|
||||||
onClick={() => handleNavigate("/")}
|
|
||||||
title="Root"
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
{breadcrumbs.map((segment, i) => {
|
|
||||||
const segPath = "/" + breadcrumbs.slice(0, i + 1).join("/");
|
|
||||||
return (
|
return (
|
||||||
<span key={segPath} className="directory-picker-breadcrumb-item">
|
<span key={breadcrumb.path} className="directory-picker-breadcrumb-item">
|
||||||
<ChevronRight size={12} className="directory-picker-breadcrumb-sep" />
|
{index > 0 && <ChevronRight size={12} className="directory-picker-breadcrumb-sep" />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="directory-picker-breadcrumb"
|
className="directory-picker-breadcrumb"
|
||||||
onClick={() => handleNavigate(segPath)}
|
onClick={() => handleNavigate(breadcrumb.path)}
|
||||||
title={segPath}
|
title={breadcrumb.path}
|
||||||
>
|
>
|
||||||
{segment}
|
{breadcrumb.label}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FileNode } from "../api";
|
|||||||
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
|
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
|
||||||
import { appendTokenQuery } from "../auth";
|
import { appendTokenQuery } from "../auth";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
|
import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay";
|
||||||
|
|
||||||
interface FileBrowserProps {
|
interface FileBrowserProps {
|
||||||
entries: FileNode[];
|
entries: FileNode[];
|
||||||
@@ -37,7 +38,7 @@ function formatTime(mtime?: string): string {
|
|||||||
|
|
||||||
/** Build the full relative path for a file/directory entry */
|
/** Build the full relative path for a file/directory entry */
|
||||||
function entryPath(currentPath: string, name: string): string {
|
function entryPath(currentPath: string, name: string): string {
|
||||||
return currentPath === "." ? name : `${currentPath}/${name}`;
|
return joinDisplayPath(currentPath, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context Menu State ──────────────────────────────────────────────────
|
// ── Context Menu State ──────────────────────────────────────────────────
|
||||||
@@ -550,16 +551,14 @@ export function FileBrowser({
|
|||||||
<button
|
<button
|
||||||
className="file-browser-up"
|
className="file-browser-up"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const parts = currentPath.split("/").filter(Boolean);
|
onNavigate(getParentDisplayPath(currentPath));
|
||||||
parts.pop();
|
|
||||||
onNavigate(parts.length === 0 ? "." : parts.join("/"));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
|
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
|
||||||
Up one level
|
Up one level
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="file-browser-path">{currentPath === "." ? "Root" : currentPath}</span>
|
<span className="file-browser-path">{currentPath === "." ? "Root" : normalizeDisplayPath(currentPath)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="file-browser-list">
|
<div className="file-browser-list">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { File } from "lucide-react";
|
import { File } from "lucide-react";
|
||||||
import type { FileSearchItem } from "../hooks/useFileMention";
|
import type { FileSearchItem } from "../hooks/useFileMention";
|
||||||
|
import { getDisplayDirname } from "../utils/pathDisplay";
|
||||||
import "./FileMentionPopup.css";
|
import "./FileMentionPopup.css";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -54,7 +55,7 @@ export function FileMentionPopup({
|
|||||||
{!loading && files.length > 0 && (
|
{!loading && files.length > 0 && (
|
||||||
<ul className="file-mention-popup-list" role="listbox">
|
<ul className="file-mention-popup-list" role="listbox">
|
||||||
{files.map((file, index) => {
|
{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;
|
const highlightName = file.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { truncateMiddle } from "../utils/truncatePath";
|
import { truncateMiddle } from "../utils/truncatePath";
|
||||||
|
import { getPathBasename } from "../utils/pathDisplay";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import type {
|
import type {
|
||||||
@@ -1624,7 +1625,7 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
|
|||||||
{worktree.isMain && <span className="gm-badge main">main</span>}
|
{worktree.isMain && <span className="gm-badge main">main</span>}
|
||||||
{worktree.isBare && <span className="gm-badge bare">bare</span>}
|
{worktree.isBare && <span className="gm-badge bare">bare</span>}
|
||||||
<span className="gm-worktree-path" title={worktree.path}>
|
<span className="gm-worktree-path" title={worktree.path}>
|
||||||
{worktree.path.split("/").pop() || worktree.path}
|
{getPathBasename(worktree.path) || worktree.path}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="gm-worktree-detail">
|
<div className="gm-worktree-detail">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
|||||||
import { NodeHealthDot } from "./NodeHealthDot";
|
import { NodeHealthDot } from "./NodeHealthDot";
|
||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||||
|
import { getTrailingPath } from "../utils/pathDisplay";
|
||||||
|
|
||||||
export { useViewportMode };
|
export { useViewportMode };
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ function ProjectSelector({
|
|||||||
<div className="project-selector-info">
|
<div className="project-selector-info">
|
||||||
<span className="project-selector-name">{project.name}</span>
|
<span className="project-selector-name">{project.name}</span>
|
||||||
<span className="project-selector-path">
|
<span className="project-selector-path">
|
||||||
{project.path.split("/").slice(-2).join("/")}
|
{getTrailingPath(project.path, 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isCurrent && <Check size={14} className="project-selector-check" />}
|
{isCurrent && <Check size={14} className="project-selector-check" />}
|
||||||
@@ -874,7 +875,7 @@ export function Header({
|
|||||||
<div className="mobile-project-switch-info">
|
<div className="mobile-project-switch-info">
|
||||||
<span className="mobile-project-switch-name">{project.name}</span>
|
<span className="mobile-project-switch-name">{project.name}</span>
|
||||||
<span className="mobile-project-switch-path">
|
<span className="mobile-project-switch-path">
|
||||||
{project.path.split("/").slice(-2).join("/")}
|
{getTrailingPath(project.path, 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
|
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ProjectInfo } from "../api";
|
import type { ProjectInfo } from "../api";
|
||||||
import type { ProjectStatus } from "@fusion/core";
|
import type { ProjectStatus } from "@fusion/core";
|
||||||
|
import { getTrailingPath } from "../utils/pathDisplay";
|
||||||
|
|
||||||
export interface ProjectSelectorProps {
|
export interface ProjectSelectorProps {
|
||||||
projects: ProjectInfo[];
|
projects: ProjectInfo[];
|
||||||
@@ -354,7 +355,7 @@ export function ProjectSelector({
|
|||||||
{project.name}
|
{project.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="project-selector__item-path">
|
<span className="project-selector__item-path">
|
||||||
{project.path.split("/").slice(-2).join("/")}
|
{getTrailingPath(project.path, 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentProject?.id === project.id && (
|
{currentProject?.id === project.id && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useTerminal } from "../hooks/useTerminal";
|
import { useTerminal } from "../hooks/useTerminal";
|
||||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
|
import { getPathBasename } from "../utils/pathDisplay";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
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) => {
|
const unsubConnect = onConnect((info) => {
|
||||||
// Update tab title with shell name
|
// 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) => {
|
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 type { Task } from "@fusion/core";
|
||||||
|
import { getPathBasename } from "./pathDisplay";
|
||||||
|
|
||||||
export interface WorktreeGroupData {
|
export interface WorktreeGroupData {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -11,9 +12,7 @@ export interface WorktreeGroupData {
|
|||||||
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
|
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
|
||||||
*/
|
*/
|
||||||
export function getWorktreeLabel(worktreePath: string): string {
|
export function getWorktreeLabel(worktreePath: string): string {
|
||||||
// Take the last segment of the path
|
return getPathBasename(worktreePath) || worktreePath;
|
||||||
const segments = worktreePath.replace(/\/+$/, "").split("/");
|
|
||||||
return segments[segments.length - 1] || 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 { __mockTerminalService: mockTerminalService } = await import("../terminal-service.js") as any;
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const DASHBOARD_PACKAGE_VERSION = (() => {
|
const CLI_PACKAGE_VERSION = (() => {
|
||||||
const packageJsonPath = join(__dirname, "..", "..", "package.json");
|
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||||
version?: unknown;
|
version?: unknown;
|
||||||
};
|
};
|
||||||
@@ -305,7 +305,7 @@ describe("createServer health and headless mode", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({
|
expect(res.body).toEqual({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
version: DASHBOARD_PACKAGE_VERSION,
|
version: CLI_PACKAGE_VERSION,
|
||||||
uptime: expect.any(Number),
|
uptime: expect.any(Number),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -317,7 +317,7 @@ describe("createServer health and headless mode", () => {
|
|||||||
const res = await GET(app, "/api/health");
|
const res = await GET(app, "/api/health");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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");
|
expect(res.body.version).toBe("0.4.0");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||||
process.env.HOME = tempHome;
|
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();
|
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 () => {
|
it("fetches latest version when cache is expired", async () => {
|
||||||
const stale: UpdateCheckResult = {
|
const stale: UpdateCheckResult = {
|
||||||
currentVersion: "0.6.0",
|
currentVersion: "0.6.0",
|
||||||
@@ -198,6 +228,17 @@ describe("update-check", () => {
|
|||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
expect(fromCache).toEqual(cached);
|
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.
|
// force=true (used by /update-check/refresh) overrides manual.
|
||||||
fetchSpy.mockResolvedValueOnce({
|
fetchSpy.mockResolvedValueOnce({
|
||||||
json: async () => ({ "dist-tags": { latest: "1.0.0" } }),
|
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));
|
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 });
|
res.json({ currentPath: resolvedPath, parentPath, entries });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProjectSettings } from "@fusion/core";
|
import type { ProjectSettings } from "@fusion/core";
|
||||||
|
import { basename } from "node:path";
|
||||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import { getFusionAuthPath } from "../auth-paths.js";
|
import { getFusionAuthPath } from "../auth-paths.js";
|
||||||
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
|
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
|
||||||
@@ -74,7 +75,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
// Build sync payload
|
// Build sync payload
|
||||||
const payload = {
|
const payload = {
|
||||||
global: globalSettings,
|
global: globalSettings,
|
||||||
projects: { [store.getRootDir().split("/").pop()!]: projectSettings.project },
|
projects: { [basename(store.getRootDir())]: projectSettings.project },
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
version: 1 as const,
|
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 { resolveGlobalDir } from "@fusion/core";
|
||||||
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
|
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
|
||||||
|
import { getCliPackageVersion } from "../cli-package-version.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.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) => {
|
export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
||||||
const { router, store, rethrowAsApiError } = ctx;
|
const { router, store, rethrowAsApiError } = ctx;
|
||||||
|
const cliPackageVersion = getCliPackageVersion(import.meta.url);
|
||||||
|
|
||||||
router.get("/update-check", async (_req, res) => {
|
router.get("/update-check", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -42,14 +14,14 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
res.json({
|
res.json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
currentVersion: CLI_PACKAGE_VERSION,
|
currentVersion: cliPackageVersion,
|
||||||
latestVersion: null,
|
latestVersion: null,
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION, {
|
const result = await performUpdateCheck(resolveGlobalDir(), cliPackageVersion, {
|
||||||
frequency: globalSettings.updateCheckFrequency,
|
frequency: globalSettings.updateCheckFrequency,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -64,7 +36,7 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
await clearUpdateCheckCache(fusionDir);
|
await clearUpdateCheckCache(fusionDir);
|
||||||
// Explicit `force: true` so a "manual" frequency setting doesn't short
|
// Explicit `force: true` so a "manual" frequency setting doesn't short
|
||||||
// out the network fetch on the user's deliberate "Check now" click.
|
// 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,
|
force: true,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -48,52 +48,10 @@ import { stopAllDevServers } from "./dev-server-routes.js";
|
|||||||
import type { SkillsAdapter } from "./skills-adapter.js";
|
import type { SkillsAdapter } from "./skills-adapter.js";
|
||||||
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
||||||
import { validateRemoteAuthToken } from "./remote-auth.js";
|
import { validateRemoteAuthToken } from "./remote-auth.js";
|
||||||
|
import { getCliPackageVersion } from "./cli-package-version.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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[] {
|
function parseVersion(version: string): number[] {
|
||||||
return version
|
return version
|
||||||
.split(".")
|
.split(".")
|
||||||
@@ -463,6 +421,7 @@ export function loadTlsCredentialsFromEnv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
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) ──
|
// ── Derive defaults from engine when provided (explicit options override) ──
|
||||||
const engine = options?.engine;
|
const engine = options?.engine;
|
||||||
if (engine) {
|
if (engine) {
|
||||||
@@ -978,13 +937,13 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
app.get("/api/health", (_req, res) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
version: PACKAGE_VERSION,
|
version: cliPackageVersion,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/updates/check", async (_req, res) => {
|
app.get("/api/updates/check", async (_req, res) => {
|
||||||
const currentVersion = CLI_PACKAGE_VERSION;
|
const currentVersion = cliPackageVersion;
|
||||||
res.set("Cache-Control", "no-store");
|
res.set("Cache-Control", "no-store");
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -407,7 +407,11 @@ export class TerminalService extends EventEmitter {
|
|||||||
cwd = path.resolve(this.projectRoot, cwd);
|
cwd = path.resolve(this.projectRoot, cwd);
|
||||||
|
|
||||||
// Ensure path is within project root (path traversal protection)
|
// 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}`);
|
console.warn(`Path traversal attempt blocked: ${requestedCwd}`);
|
||||||
return this.projectRoot;
|
return this.projectRoot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ export async function performUpdateCheck(
|
|||||||
): Promise<UpdateCheckResult> {
|
): Promise<UpdateCheckResult> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = readCachedUpdateCheck(fusionDir);
|
const cached = readCachedUpdateCheck(fusionDir);
|
||||||
|
const cacheMatchesCurrentVersion = !cached || cached.currentVersion === currentVersion;
|
||||||
const ttl = ttlForFrequency(options.frequency);
|
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
|
// `on-startup`: refresh exactly once per process lifetime; afterwards
|
||||||
// serve the freshly-written cache for the rest of the run.
|
// serve the freshly-written cache for the rest of the run.
|
||||||
@@ -119,7 +120,8 @@ export async function performUpdateCheck(
|
|||||||
!options.force &&
|
!options.force &&
|
||||||
options.frequency === "on-startup" &&
|
options.frequency === "on-startup" &&
|
||||||
hasRefreshedThisProcess &&
|
hasRefreshedThisProcess &&
|
||||||
cached
|
cached &&
|
||||||
|
cacheMatchesCurrentVersion
|
||||||
) {
|
) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -131,11 +133,12 @@ export async function performUpdateCheck(
|
|||||||
// For `manual`, never go to the network on a regular check — only the
|
// For `manual`, never go to the network on a regular check — only the
|
||||||
// `/update-check/refresh` endpoint (which sets `force: true`) should.
|
// `/update-check/refresh` endpoint (which sets `force: true`) should.
|
||||||
// Return whatever's in the cache so the UI can still display the last
|
// 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
|
// known result, but only when it matches the currently installed version.
|
||||||
// payload.
|
// 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") {
|
if (!options.force && options.frequency === "manual") {
|
||||||
return (
|
return (
|
||||||
cached ?? {
|
(cacheMatchesCurrentVersion ? cached : null) ?? {
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion: null,
|
latestVersion: null,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
|
|||||||
@@ -966,6 +966,56 @@ describe("TaskExecutor worktree recovery", () => {
|
|||||||
expect(worktreeAddCalls).toHaveLength(1);
|
expect(worktreeAddCalls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extractWorktreeConflictInfo classifies already checked out errors as already-used", () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
const error: any = new Error(
|
||||||
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
||||||
|
);
|
||||||
|
error.stderr = Buffer.from(
|
||||||
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflictInfo = (executor as any).extractWorktreeConflictInfo(error);
|
||||||
|
expect(conflictInfo).toMatchObject({
|
||||||
|
type: "already-used",
|
||||||
|
path: "/tmp/test/.worktrees/green-sage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from already checked out worktree conflict and retries", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||||
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||||
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
||||||
|
const error: any = new Error(
|
||||||
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
||||||
|
);
|
||||||
|
error.stderr = Buffer.from(
|
||||||
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
await executor.execute(makeTask());
|
||||||
|
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-050",
|
||||||
|
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
||||||
|
"/tmp/test/.worktrees/swift-falcon",
|
||||||
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-050",
|
||||||
|
expect.objectContaining({ worktree: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers from worktree conflict and retries", async () => {
|
it("recovers from worktree conflict and retries", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
|||||||
@@ -10,3 +10,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||||
process.env.HOME = tempHome;
|
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] || "\\";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5107,6 +5107,12 @@ and show an appropriate message to the user.\`
|
|||||||
return { type: "already-used", path: alreadyUsedMatch[1], message: output };
|
return { type: "already-used", path: alreadyUsedMatch[1], message: output };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern: already checked out at '/path/to/worktree'
|
||||||
|
const alreadyCheckedOutMatch = output.match(/is already checked out at '([^']+)'/);
|
||||||
|
if (alreadyCheckedOutMatch) {
|
||||||
|
return { type: "already-used", path: alreadyCheckedOutMatch[1], message: output };
|
||||||
|
}
|
||||||
|
|
||||||
// Pattern: invalid reference: 'branch-name'
|
// Pattern: invalid reference: 'branch-name'
|
||||||
// Also covers: unable to resolve reference, stale file handle, not a valid ref
|
// Also covers: unable to resolve reference, stale file handle, not a valid ref
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -792,8 +792,11 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
|
|||||||
* `/project` → null (not a worktree)
|
* `/project` → null (not a worktree)
|
||||||
*/
|
*/
|
||||||
function getProjectRootFromWorktree(cwd: string): string | null {
|
function getProjectRootFromWorktree(cwd: string): string | null {
|
||||||
// Match paths like /project/.worktrees/task-id or /project/.worktrees/task-id/...
|
// Match paths like:
|
||||||
const match = cwd.match(/^(.+?)\/\.worktrees\/[^/]+/);
|
// /project/.worktrees/task-id
|
||||||
|
// /project/.worktrees/task-id/src/file.ts
|
||||||
|
// C:\project\.worktrees\task-id
|
||||||
|
const match = cwd.match(/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
return match[1]!;
|
return match[1]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core";
|
import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core";
|
||||||
@@ -184,7 +185,7 @@ describe("InProcessRuntime", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create a unique temp directory for this test run
|
// Create a unique temp directory for this test run
|
||||||
testDir = mkdtempSync(join("/tmp", `fn-test-${randomUUID().slice(0, 8)}-`));
|
testDir = mkdtempSync(join(tmpdir(), `fn-test-${randomUUID().slice(0, 8)}-`));
|
||||||
|
|
||||||
// Create mock CentralCore
|
// Create mock CentralCore
|
||||||
mockCentralCore = {
|
mockCentralCore = {
|
||||||
@@ -746,12 +747,12 @@ describe("InProcessRuntime", () => {
|
|||||||
it("should store projectId in config", () => {
|
it("should store projectId in config", () => {
|
||||||
// Access via the constructor params - runtime is created with testDir
|
// Access via the constructor params - runtime is created with testDir
|
||||||
expect(testDir).toBeDefined();
|
expect(testDir).toBeDefined();
|
||||||
expect(testDir).toContain("/tmp/fn-test-");
|
expect(testDir).toContain("fn-test-");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should store workingDirectory in config", () => {
|
it("should store workingDirectory in config", () => {
|
||||||
expect(testDir).toBeDefined();
|
expect(testDir).toBeDefined();
|
||||||
expect(testDir.startsWith("/tmp/")).toBe(true);
|
expect(testDir.startsWith(tmpdir())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should store maxConcurrent in config", () => {
|
it("should store maxConcurrent in config", () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import type { AgentSemaphore } from "./concurrency.js";
|
import type { AgentSemaphore } from "./concurrency.js";
|
||||||
import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
|
import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
|
||||||
import { schedulerLog } from "./logger.js";
|
import { schedulerLog } from "./logger.js";
|
||||||
@@ -466,7 +466,7 @@ export class Scheduler {
|
|||||||
reservedNames: Set<string>,
|
reservedNames: Set<string>,
|
||||||
): string {
|
): string {
|
||||||
if (task.worktree) {
|
if (task.worktree) {
|
||||||
const existingName = task.worktree.split("/").pop();
|
const existingName = basename(task.worktree);
|
||||||
if (existingName) reservedNames.add(existingName);
|
if (existingName) reservedNames.add(existingName);
|
||||||
return task.worktree;
|
return task.worktree;
|
||||||
}
|
}
|
||||||
@@ -659,7 +659,7 @@ export class Scheduler {
|
|||||||
let started = 0;
|
let started = 0;
|
||||||
const reservedWorktreeNames = new Set(
|
const reservedWorktreeNames = new Set(
|
||||||
tasks
|
tasks
|
||||||
.map((task) => task.worktree?.split("/").pop())
|
.map((task) => (task.worktree ? basename(task.worktree) : undefined))
|
||||||
.filter((name): name is string => Boolean(name)),
|
.filter((name): name is string => Boolean(name)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||||
process.env.HOME = tempHome;
|
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] || "\\";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -615,7 +615,8 @@ function resolveAgentsMdPath(cwd: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to global path
|
// Fall back to global path
|
||||||
const globalPath = join(homedir(), ".pi", "agent", "AGENTS.md");
|
const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
|
const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
|
||||||
if (existsSync(globalPath)) return globalPath;
|
if (existsSync(globalPath)) return globalPath;
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
@@ -9,7 +10,7 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../core/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,8 +8,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,8 +8,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,8 +8,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,8 +8,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,9 +8,9 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
"@fusion/engine": new URL("../../packages/engine/src/index.ts", import.meta.url).pathname,
|
"@fusion/engine": fileURLToPath(new URL("../../packages/engine/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,9 +8,9 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
"@fusion/engine": new URL("../../packages/engine/src/index.ts", import.meta.url).pathname,
|
"@fusion/engine": fileURLToPath(new URL("../../packages/engine/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
|
||||||
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
|
||||||
@@ -7,9 +8,9 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname,
|
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||||
"@fusion/plugin-sdk": new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url).pathname,
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
"@fusion/engine": new URL("../../packages/engine/src/index.ts", import.meta.url).pathname,
|
"@fusion/engine": fileURLToPath(new URL("../../packages/engine/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user