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

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

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

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard version label so it matches the version used by update notifications.

View 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"

View File

@@ -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");

View File

@@ -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] || "\\";
}
}

View File

@@ -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,
}; };

View File

@@ -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;
} }

View File

@@ -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();

View File

@@ -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] || "\\";
}
}

View File

@@ -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");
} }
/** /**

View File

@@ -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");
} }
} }

View File

@@ -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>
); );

View File

@@ -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">

View File

@@ -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 (

View File

@@ -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">

View File

@@ -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" />}

View File

@@ -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 && (

View File

@@ -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) => {

View File

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

View File

@@ -1,4 +1,5 @@
import type { Task } from "@fusion/core"; import 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;
} }
/** /**

View File

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

View File

@@ -36,8 +36,8 @@ vi.mock("../terminal-service.js", () => {
const { __mockTerminalService: mockTerminalService } = await import("../terminal-service.js") as any; const { __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;
} }

View File

@@ -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] || "\\";
}
}

View File

@@ -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" } }),

View File

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

View File

@@ -3685,7 +3685,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
entries.sort((a, b) => a.name.localeCompare(b.name)); 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) {

View File

@@ -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,
}; };

View File

@@ -1,39 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGlobalDir } from "@fusion/core"; import { 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);

View File

@@ -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();

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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;

View File

@@ -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] || "\\";
}
}

View File

@@ -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 (

View File

@@ -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]!;
} }

View File

@@ -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", () => {

View File

@@ -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)),
); );

View File

@@ -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] || "\\";
}
}

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {