From 43a9cad3b99ac378e471c66e42f6a7839a66bf33 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 3 May 2026 02:02:25 -0700 Subject: [PATCH] fix(fusion): prevent nested .fusion roots and safe fn version lookup --- .../fix-fn-binary-probe-and-root-guards.md | 5 + .../src/commands/__tests__/dashboard.test.ts | 1 + .../cli/src/commands/__tests__/serve.test.ts | 5 +- packages/cli/src/commands/dashboard.ts | 4 +- packages/cli/src/commands/serve.ts | 4 +- .../src/__tests__/fn-binary-probe.test.ts | 195 ++++++++++++++++++ .../core/src/__tests__/mission-store.test.ts | 20 +- .../src/__tests__/project-root-guard.test.ts | 20 ++ packages/core/src/automation-store.ts | 2 + packages/core/src/fn-binary.ts | 104 +++++++++- packages/core/src/plugin-store.ts | 2 + packages/core/src/project-root-guard.ts | 16 ++ packages/core/src/routine-store.ts | 2 + packages/core/src/store.ts | 2 + 14 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-fn-binary-probe-and-root-guards.md create mode 100644 packages/core/src/__tests__/fn-binary-probe.test.ts create mode 100644 packages/core/src/__tests__/project-root-guard.test.ts create mode 100644 packages/core/src/project-root-guard.ts diff --git a/.changeset/fix-fn-binary-probe-and-root-guards.md b/.changeset/fix-fn-binary-probe-and-root-guards.md new file mode 100644 index 000000000..5db28db91 --- /dev/null +++ b/.changeset/fix-fn-binary-probe-and-root-guards.md @@ -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. diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index e9f43f89c..178803409 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -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, diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index cf866b793..8bc996e91 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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"); }); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index bcee47e18..55c81ceb1 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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(); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2e28d0ea6..e63e5c0dc 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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(); diff --git a/packages/core/src/__tests__/fn-binary-probe.test.ts b/packages/core/src/__tests__/fn-binary-probe.test.ts new file mode 100644 index 000000000..efea7e2f8 --- /dev/null +++ b/packages/core/src/__tests__/fn-binary-probe.test.ts @@ -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; + }; + 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; + scriptContents?: Record; + 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("node:os"); + return { + ...actual, + platform: () => options.platform ?? "darwin", + }; + }); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("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)); + }); +}); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 0ec8de9f3..8dfd0ccc7 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -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 }; } diff --git a/packages/core/src/__tests__/project-root-guard.test.ts b/packages/core/src/__tests__/project-root-guard.test.ts new file mode 100644 index 000000000..e965a710d --- /dev/null +++ b/packages/core/src/__tests__/project-root-guard.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/automation-store.ts b/packages/core/src/automation-store.ts index 164eb23a6..f599e4066 100644 --- a/packages/core/src/automation-store.ts +++ b/packages/core/src/automation-store.ts @@ -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 { constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) { super(); + assertProjectRootDir(rootDir, "AutomationStore"); this.inMemoryDb = options?.inMemoryDb === true; } diff --git a/packages/core/src/fn-binary.ts b/packages/core/src/fn-binary.ts index aecb73a3f..d14fc5419 100644 --- a/packages/core/src/fn-binary.ts +++ b/packages/core/src/fn-binary.ts @@ -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 ` --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; export type FnBinaryName = (typeof CANDIDATES)[number]; @@ -102,6 +107,97 @@ async function whichBinary(name: string): Promise { 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(); + 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([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 { 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, diff --git a/packages/core/src/plugin-store.ts b/packages/core/src/plugin-store.ts index a9dfd53fb..11ae35d81 100644 --- a/packages/core/src/plugin-store.ts +++ b/packages/core/src/plugin-store.ts @@ -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 { constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) { super(); + assertProjectRootDir(rootDir, "PluginStore"); this.inMemoryDb = options?.inMemoryDb === true; } diff --git a/packages/core/src/project-root-guard.ts b/packages/core/src/project-root-guard.ts new file mode 100644 index 000000000..0c968b190 --- /dev/null +++ b/packages/core/src/project-root-guard.ts @@ -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.", + ); + } +} diff --git a/packages/core/src/routine-store.ts b/packages/core/src/routine-store.ts index 4f91fea49..a18c6c3f8 100644 --- a/packages/core/src/routine-store.ts +++ b/packages/core/src/routine-store.ts @@ -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 { constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) { super(); + assertProjectRootDir(rootDir, "RoutineStore"); this.inMemoryDb = options?.inMemoryDb === true; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 90d62fcb4..7073b9fa4 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { ) { 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");