fix(fusion): prevent nested .fusion roots and safe fn version lookup
This commit is contained in:
5
.changeset/fix-fn-binary-probe-and-root-guards.md
Normal file
5
.changeset/fix-fn-binary-probe-and-root-guards.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Avoid nested `.fusion/.fusion` regressions by hardening project-root path handling and stop the CLI binary status probe from executing outdated global `fn` installs just to read their version.
|
||||
@@ -85,6 +85,7 @@ function makeMockStore() {
|
||||
updatePrInfo: vi.fn().mockResolvedValue({}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
getGlobalSettingsStore: vi.fn(() => ({
|
||||
getSettings: mockGlobalSettingsGetSettings,
|
||||
|
||||
@@ -72,6 +72,7 @@ const mocks = vi.hoisted(() => {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}`),
|
||||
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
|
||||
getGlobalSettingsStore: vi.fn(() => ({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
@@ -879,12 +880,12 @@ describe("runServe — Plugin wiring", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("initializes PluginStore with the task store's fusion directory", async () => {
|
||||
it("initializes PluginStore with the task store's project root", async () => {
|
||||
const { PluginStore } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
|
||||
expect(PluginStore).toHaveBeenCalledWith("/repo");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { join, resolve as pathResolve } from "node:path";
|
||||
import { dirname, join, resolve as pathResolve } from "node:path";
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { stat, readdir, readFile as fsReadFile } from "node:fs/promises";
|
||||
@@ -1062,7 +1062,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const pluginStoreRootDir =
|
||||
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
|
||||
? store.getRootDir()
|
||||
: store.getFusionDir();
|
||||
: dirname(store.getFusionDir());
|
||||
const pluginStore = new PluginStore(pluginStoreRootDir);
|
||||
await pluginStore.init();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
CentralCore,
|
||||
PluginStore,
|
||||
@@ -413,7 +413,7 @@ export async function runServe(
|
||||
const pluginStoreRootDir =
|
||||
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
|
||||
? store.getRootDir()
|
||||
: store.getFusionDir();
|
||||
: dirname(store.getFusionDir());
|
||||
const pluginStore = new PluginStore(pluginStoreRootDir);
|
||||
await pluginStore.init();
|
||||
|
||||
|
||||
195
packages/core/src/__tests__/fn-binary-probe.test.ts
Normal file
195
packages/core/src/__tests__/fn-binary-probe.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.unmock("node:child_process");
|
||||
vi.unmock("node:fs");
|
||||
vi.unmock("node:os");
|
||||
});
|
||||
|
||||
function createSpawnMock(options: {
|
||||
lookupPath: string;
|
||||
lookupCommand: "which" | "where";
|
||||
versionStdout?: string;
|
||||
versionExitCode?: number;
|
||||
}) {
|
||||
return vi.fn((command: string, args: string[]) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (command === options.lookupCommand) {
|
||||
child.stdout.emit("data", Buffer.from(`${options.lookupPath}\n`));
|
||||
child.emit("close", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes("--version")) {
|
||||
if (options.versionStdout) {
|
||||
child.stdout.emit("data", Buffer.from(options.versionStdout));
|
||||
}
|
||||
child.emit("close", options.versionExitCode ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
child.emit("close", 1);
|
||||
});
|
||||
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
async function importWithMocks(options: {
|
||||
lookupPath: string;
|
||||
realPath: string;
|
||||
platform?: "darwin" | "linux" | "win32";
|
||||
packageJsons?: Record<string, { name: string; version: string }>;
|
||||
scriptContents?: Record<string, string>;
|
||||
versionStdout?: string;
|
||||
versionExitCode?: number;
|
||||
}) {
|
||||
const lookupCommand = options.platform === "win32" ? "where" : "which";
|
||||
const spawnMock = createSpawnMock({
|
||||
lookupPath: options.lookupPath,
|
||||
lookupCommand,
|
||||
versionStdout: options.versionStdout,
|
||||
versionExitCode: options.versionExitCode,
|
||||
});
|
||||
|
||||
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
|
||||
vi.doMock("node:os", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
return {
|
||||
...actual,
|
||||
platform: () => options.platform ?? "darwin",
|
||||
};
|
||||
});
|
||||
vi.doMock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: vi.fn(() => options.realPath),
|
||||
existsSync: vi.fn((path: string) => !!options.packageJsons?.[String(path)]),
|
||||
readFileSync: vi.fn((path: string) => {
|
||||
const manifest = options.packageJsons?.[String(path)];
|
||||
if (manifest) {
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
const script = options.scriptContents?.[String(path)];
|
||||
if (script !== undefined) {
|
||||
return script;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected readFileSync(${path})`);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mod = await import("../fn-binary.js");
|
||||
return { mod, spawnMock };
|
||||
}
|
||||
|
||||
describe("detectFnBinary", () => {
|
||||
it("reads the installed version from the resolved package manifest without executing fn --version", async () => {
|
||||
const lookupPath = "/opt/homebrew/bin/fn";
|
||||
const realPath = "/opt/homebrew/lib/node_modules/runfusion.ai/index.js";
|
||||
const packageJsonPath = "/opt/homebrew/lib/node_modules/runfusion.ai/package.json";
|
||||
const { mod, spawnMock } = await importWithMocks({
|
||||
lookupPath,
|
||||
realPath,
|
||||
packageJsons: {
|
||||
[packageJsonPath]: {
|
||||
name: "runfusion.ai",
|
||||
version: "0.13.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await mod.detectFnBinary();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
installed: true,
|
||||
binary: "fn",
|
||||
path: lookupPath,
|
||||
version: "0.13.0",
|
||||
invocation: "fn",
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("which", ["fn"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("resolves the installed version from an npm-generated Windows cmd shim without executing fn --version", async () => {
|
||||
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.cmd";
|
||||
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
|
||||
const { mod, spawnMock } = await importWithMocks({
|
||||
lookupPath,
|
||||
realPath: lookupPath,
|
||||
platform: "win32",
|
||||
packageJsons: {
|
||||
[packageJsonPath]: {
|
||||
name: "runfusion.ai",
|
||||
version: "0.14.2",
|
||||
},
|
||||
},
|
||||
scriptContents: {
|
||||
[lookupPath]: "\"%~dp0\\node_modules\\runfusion.ai\\index.js\" %*",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await mod.detectFnBinary();
|
||||
|
||||
expect(result.version).toBe("0.14.2");
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("resolves the installed version from an npm-generated Windows PowerShell shim without executing fn --version", async () => {
|
||||
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.ps1";
|
||||
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
|
||||
const { mod, spawnMock } = await importWithMocks({
|
||||
lookupPath,
|
||||
realPath: lookupPath,
|
||||
platform: "win32",
|
||||
packageJsons: {
|
||||
[packageJsonPath]: {
|
||||
name: "runfusion.ai",
|
||||
version: "0.14.3",
|
||||
},
|
||||
},
|
||||
scriptContents: {
|
||||
[lookupPath]: "& \"$basedir\\node_modules\\runfusion.ai\\index.js\" $args",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await mod.detectFnBinary();
|
||||
|
||||
expect(result.version).toBe("0.14.3");
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("falls back to fn --version when no package manifest can be derived from the resolved path", async () => {
|
||||
const lookupPath = "/usr/local/bin/fn";
|
||||
const { mod, spawnMock } = await importWithMocks({
|
||||
lookupPath,
|
||||
realPath: lookupPath,
|
||||
versionStdout: "fn v0.15.0\n",
|
||||
});
|
||||
|
||||
const result = await mod.detectFnBinary();
|
||||
|
||||
expect(result.version).toBe("0.15.0");
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(1, "which", ["fn"], expect.any(Object));
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(2, "fn", ["--version"], expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -1664,7 +1664,7 @@ describe("MissionStore", () => {
|
||||
it("throws if feature not found", async () => {
|
||||
// Need a TaskStore reference for this test
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
|
||||
@@ -1674,7 +1674,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("throws if feature is already triaged", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1694,7 +1694,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("creates a task and links it to the feature", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1726,7 +1726,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("uses provided title and description overrides", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1747,7 +1747,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("emits feature:linked event", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const linkedHandler = vi.fn();
|
||||
@@ -1782,7 +1782,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("throws if slice not found", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
|
||||
@@ -1792,7 +1792,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("triages all defined features in a slice", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1819,7 +1819,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("skips already triaged features", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1841,7 +1841,7 @@ describe("MissionStore", () => {
|
||||
|
||||
it("returns empty array if no defined features", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({ title: "Mission" });
|
||||
@@ -1862,7 +1862,7 @@ describe("MissionStore", () => {
|
||||
ms: MissionStore;
|
||||
}> {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const ms = ts.getMissionStore();
|
||||
return { ts, ms };
|
||||
}
|
||||
|
||||
20
packages/core/src/__tests__/project-root-guard.test.ts
Normal file
20
packages/core/src/__tests__/project-root-guard.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore } from "../store.js";
|
||||
import { PluginStore } from "../plugin-store.js";
|
||||
import { AutomationStore } from "../automation-store.js";
|
||||
import { RoutineStore } from "../routine-store.js";
|
||||
|
||||
describe("project root guards", () => {
|
||||
const fusionDir = join(tmpdir(), "fusion-root-guard", ".fusion");
|
||||
|
||||
it.each([
|
||||
["TaskStore", () => new TaskStore(fusionDir, undefined, { inMemoryDb: true })],
|
||||
["PluginStore", () => new PluginStore(fusionDir, { inMemoryDb: true })],
|
||||
["AutomationStore", () => new AutomationStore(fusionDir, { inMemoryDb: true })],
|
||||
["RoutineStore", () => new RoutineStore(fusionDir, { inMemoryDb: true })],
|
||||
])("rejects a .fusion directory for %s", (_label, createStore) => {
|
||||
expect(createStore).toThrow(/expected a project root, got a \.fusion directory/i);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
import type { ScheduleType } from "./automation.js";
|
||||
import { Database, fromJson } from "./db.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
|
||||
const CRON_TIMEZONE = "UTC";
|
||||
|
||||
@@ -52,6 +53,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
|
||||
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
|
||||
super();
|
||||
assertProjectRootDir(rootDir, "AutomationStore");
|
||||
this.inMemoryDb = options?.inMemoryDb === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
* 2. `fusion` — long alias name
|
||||
* 3. `npx -y runfusion.ai` — zero-install fallback that always works
|
||||
*
|
||||
* The npm bin name on disk varies by install path and platform; the version
|
||||
* is read by spawning `<bin> --version` so we report the actually-runnable
|
||||
* binary, not just the first match on PATH.
|
||||
* The npm bin name on disk varies by install path and platform. Prefer
|
||||
* reading the installed package manifest from the resolved binary path so we
|
||||
* don't execute older buggy global installs just to discover their version.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { platform, tmpdir } from "node:os";
|
||||
import { posix, win32 } from "node:path";
|
||||
|
||||
interface ProbeResult {
|
||||
exitCode: number | null;
|
||||
@@ -70,6 +72,9 @@ export const FN_NPX_INVOCATION = `npx -y ${FN_NPM_PACKAGE}`;
|
||||
|
||||
/** Candidate binary names checked, in preference order. */
|
||||
const CANDIDATES = ["fn", "fusion"] as const;
|
||||
const FUSION_PACKAGE_NAMES = new Set(["runfusion.ai", "@runfusion/fusion"]);
|
||||
|
||||
type PathApi = Pick<typeof posix, "dirname" | "resolve" | "sep">;
|
||||
|
||||
export type FnBinaryName = (typeof CANDIDATES)[number];
|
||||
|
||||
@@ -102,6 +107,97 @@ async function whichBinary(name: string): Promise<string | undefined> {
|
||||
return firstLine || undefined;
|
||||
}
|
||||
|
||||
function getPathApi(pathValue: string): PathApi {
|
||||
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.includes("\\")
|
||||
? win32
|
||||
: posix;
|
||||
}
|
||||
|
||||
function readPackageVersionFromPath(startPath: string): string | undefined {
|
||||
const pathApi = getPathApi(startPath);
|
||||
let dir = pathApi.dirname(startPath);
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const packageJsonPath = pathApi.resolve(dir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
if (
|
||||
typeof parsed.name === "string"
|
||||
&& typeof parsed.version === "string"
|
||||
&& FUSION_PACKAGE_NAMES.has(parsed.name)
|
||||
) {
|
||||
return parsed.version;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed manifests and keep walking upward.
|
||||
}
|
||||
}
|
||||
|
||||
const parent = pathApi.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveShimTargets(resolvedPath: string): string[] {
|
||||
const pathApi = getPathApi(resolvedPath);
|
||||
const basedir = pathApi.dirname(resolvedPath);
|
||||
let contents: string;
|
||||
|
||||
try {
|
||||
contents = readFileSync(resolvedPath, "utf-8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets = new Set<string>();
|
||||
const pattern = /([^\r\n"'`]*node_modules[\\/](?:runfusion\.ai|@runfusion[\\/](?:fusion))[^\r\n"'`]*(?:\.js|package\.json))/gi;
|
||||
|
||||
for (const match of contents.matchAll(pattern)) {
|
||||
const raw = match[1];
|
||||
if (!raw) continue;
|
||||
|
||||
const trimmed = raw.trim().replace(/^['"]|['"]$/g, "");
|
||||
const normalized = trimmed
|
||||
.replace(/^%~?dp0%?/i, "")
|
||||
.replace(/^\$basedir/i, "")
|
||||
.replace(/^\$PSScriptRoot/i, "")
|
||||
.replace(/^[\\/]+/, "")
|
||||
.replace(/[\\/]/g, pathApi.sep);
|
||||
|
||||
targets.add(pathApi.resolve(basedir, normalized));
|
||||
}
|
||||
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
function readVersionFromResolvedBinaryPath(resolvedPath: string): string | undefined {
|
||||
const candidatePaths = new Set<string>([resolvedPath]);
|
||||
|
||||
try {
|
||||
candidatePaths.add(realpathSync(resolvedPath));
|
||||
} catch {
|
||||
// Fall back to the original resolved path.
|
||||
}
|
||||
|
||||
for (const shimTarget of resolveShimTargets(resolvedPath)) {
|
||||
candidatePaths.add(shimTarget);
|
||||
}
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
const version = readPackageVersionFromPath(candidatePath);
|
||||
if (version) return version;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort version probe. Returns undefined if the binary refuses the
|
||||
* flag or produces no parseable output — the caller should treat undefined
|
||||
@@ -129,7 +225,7 @@ export async function detectFnBinary(): Promise<FnBinaryStatus> {
|
||||
try {
|
||||
const resolvedPath = await whichBinary(candidate);
|
||||
if (!resolvedPath) continue;
|
||||
const version = await probeVersion(candidate);
|
||||
const version = readVersionFromResolvedBinaryPath(resolvedPath) ?? await probeVersion(candidate);
|
||||
return {
|
||||
installed: true,
|
||||
binary: candidate,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PluginState,
|
||||
} from "./plugin-types.js";
|
||||
import { validatePluginManifest } from "./plugin-types.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
|
||||
export interface PluginStoreEvents {
|
||||
"plugin:registered": [plugin: PluginInstallation];
|
||||
@@ -69,6 +70,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
|
||||
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
|
||||
super();
|
||||
assertProjectRootDir(rootDir, "PluginStore");
|
||||
this.inMemoryDb = options?.inMemoryDb === true;
|
||||
}
|
||||
|
||||
|
||||
16
packages/core/src/project-root-guard.ts
Normal file
16
packages/core/src/project-root-guard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Guard helpers for store constructors that expect a project root and append
|
||||
* `.fusion` internally. Passing an existing `.fusion` directory produces the
|
||||
* nested `.fusion/.fusion` tree we want to fail loudly on.
|
||||
*/
|
||||
|
||||
const FUSION_DIR_SUFFIX = /(?:^|[\\/])\.fusion(?:[\\/])?$/;
|
||||
|
||||
export function assertProjectRootDir(rootDir: string, caller: string): void {
|
||||
if (FUSION_DIR_SUFFIX.test(rootDir)) {
|
||||
throw new Error(
|
||||
`[fusion] ${caller} expected a project root, got a .fusion directory: ${rootDir}\n` +
|
||||
"Pass the project root instead; this store appends `.fusion` internally.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type RoutineManualTrigger,
|
||||
MAX_ROUTINE_RUN_HISTORY,
|
||||
} from "./routine.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
|
||||
const CRON_TIMEZONE = "UTC";
|
||||
|
||||
@@ -71,6 +72,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
||||
|
||||
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
|
||||
super();
|
||||
assertProjectRootDir(rootDir, "RoutineStore");
|
||||
this.inMemoryDb = options?.inMemoryDb === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { runCommandAsync } from "./run-command.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
|
||||
/** Database row shape for the tasks table (all columns). */
|
||||
interface TaskRow {
|
||||
@@ -523,6 +524,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
assertProjectRootDir(rootDir, "TaskStore");
|
||||
this.fusionDir = join(rootDir, ".fusion");
|
||||
this.tasksDir = join(this.fusionDir, "tasks");
|
||||
this.configPath = join(this.fusionDir, "config.json");
|
||||
|
||||
Reference in New Issue
Block a user