fix: prevent nested .fusion/.fusion dir from PluginStore path bug

PluginStore's constructor treats its rootDir arg as a project root and
internally appends `.fusion` before opening the SQLite DB. Several CLI
call sites were passing the already-resolved `.fusion` directory,
producing a doubled `.fusion/.fusion/fusion.db` that the dashboard
process kept recreating on every project load.

Pass the project root instead so the DB lands in the canonical
`.fusion/fusion.db` alongside the rest of the project's state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-23 15:55:04 -07:00
committed by gsxdsm
parent 9b193d353a
commit 534be73bde
39 changed files with 1612 additions and 242 deletions

View File

@@ -0,0 +1,189 @@
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import {
ensureFusionSkillForProjects,
installFusionSkillIntoProject,
isPiClaudeCliConfigured,
} from "./claude-skills.js";
function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string {
const dir = join(root, "src-skill", "fusion");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), body);
return dir;
}
describe("isPiClaudeCliConfigured", () => {
it("returns false for null or empty settings", () => {
expect(isPiClaudeCliConfigured(null)).toBe(false);
expect(isPiClaudeCliConfigured(undefined)).toBe(false);
expect(isPiClaudeCliConfigured({})).toBe(false);
});
it("respects explicit useClaudeCli=true", () => {
expect(isPiClaudeCliConfigured({ useClaudeCli: true })).toBe(true);
});
it("respects explicit useClaudeCli=false even when package is present", () => {
expect(
isPiClaudeCliConfigured({
useClaudeCli: false,
packages: ["npm:pi-claude-cli"],
}),
).toBe(false);
});
it("detects pi-claude-cli in packages array", () => {
expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli"] })).toBe(true);
expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli@0.3.1"] })).toBe(true);
expect(isPiClaudeCliConfigured({ packages: ["github:owner/pi-claude-cli"] })).toBe(true);
});
it("ignores unrelated packages", () => {
expect(
isPiClaudeCliConfigured({ packages: ["npm:some-other", "npm:pi-ai"] }),
).toBe(false);
});
});
describe("installFusionSkillIntoProject", () => {
it("is a no-op when disabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
const result = installFusionSkillIntoProject(projectPath, { source, enabled: false });
expect(result.outcome).toBe("skipped");
expect(existsSync(join(projectPath, ".claude"))).toBe(false);
});
it("creates a symlink on first install", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("installed");
const target = join(projectPath, ".claude", "skills", "fusion");
expect(lstatSync(target).isSymbolicLink()).toBe(true);
expect(readlinkSync(target)).toBe(source);
expect(readFileSync(join(target, "SKILL.md"), "utf-8")).toContain("name: fusion");
});
it("is idempotent when the correct symlink already exists", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
installFusionSkillIntoProject(projectPath, { source, enabled: true });
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("already-installed");
});
it("replaces a stale symlink that points elsewhere", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
// Seed a stale symlink pointing at a different dir.
const stale = join(root, "stale");
mkdirSync(stale, { recursive: true });
writeFileSync(join(stale, "SKILL.md"), "# stale");
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(join(projectPath, ".claude", "skills"), { recursive: true });
symlinkSync(stale, target, "dir");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("replaced");
expect(readlinkSync(target)).toBe(source);
});
it("replaces a prior copy-install (plain dir with SKILL.md)", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
const source = makeSourceSkill(root);
// Seed a prior copy — looks like a fusion skill install.
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "SKILL.md"), "# old copy\n");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("replaced");
expect(lstatSync(target).isSymbolicLink()).toBe(true);
});
it("refuses to clobber a foreign directory without SKILL.md", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
const source = makeSourceSkill(root);
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "random.txt"), "user data");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("failed");
expect(readFileSync(join(target, "random.txt"), "utf-8")).toBe("user data");
});
it("reports failure when source is missing", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const result = installFusionSkillIntoProject(projectPath, {
source: join(root, "nonexistent"),
enabled: true,
});
// Source missing -> symlink may succeed on POSIX (to a nonexistent path)
// then later fail to resolve. The function still creates the symlink;
// that's acceptable since fs reads will surface the broken link clearly.
expect(["installed", "failed"]).toContain(result.outcome);
});
});
describe("ensureFusionSkillForProjects", () => {
it("skips all when disabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projects = [
{ id: "a", name: "a", path: join(root, "a") },
{ id: "b", name: "b", path: join(root, "b") },
];
for (const p of projects) mkdirSync(p.path, { recursive: true });
const results = ensureFusionSkillForProjects(projects, { enabled: false });
expect(results.map((r) => r.outcome)).toEqual(["skipped", "skipped"]);
});
it("installs for all when enabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const source = makeSourceSkill(root);
const projects = [
{ id: "a", name: "a", path: join(root, "a") },
{ id: "b", name: "b", path: join(root, "b") },
];
for (const p of projects) mkdirSync(p.path, { recursive: true });
const results = ensureFusionSkillForProjects(projects, { enabled: true, source });
expect(results.map((r) => r.outcome)).toEqual(["installed", "installed"]);
for (const p of projects) {
expect(
lstatSync(join(p.path, ".claude", "skills", "fusion")).isSymbolicLink(),
).toBe(true);
}
});
});

View File

@@ -38,6 +38,10 @@ import {
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
@@ -295,6 +299,22 @@ export async function runDaemon(opts: DaemonOptions = {}) {
await engineManager.startAll();
engineManager.startReconciliation();
// Backfill Claude Code skills for all registered projects. No-op when
// pi-claude-cli isn't configured; non-blocking to protect startup latency.
void (async () => {
try {
if (!sharedCentralCore) return;
const projects = await sharedCentralCore.listProjects();
ensureClaudeSkillsForAllProjectsOnStartup(
projects.map((p) => ({ id: p.id, name: p.name, path: p.path })),
);
} catch (err) {
console.warn(
`[fusion] Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
// ── PeerExchangeService: gossip protocol for mesh peer discovery ──────
let peerExchangeService: PeerExchangeService | null = null;
if (sharedCentralCore) {
@@ -327,7 +347,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
// ── PluginStore: plugin installation management ─────────────────────
const pluginStore = new PluginStore(store.getRootDir());
// Some mocked stores used in tests may not implement getRootDir(); fall
// back to the resolved runtime cwd in that case.
const storeRootDir = typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? (store as { getRootDir: () => string }).getRootDir()
: cwd;
const pluginStore = new PluginStore(storeRootDir);
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -432,6 +457,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
pluginLoader,
pluginRunner: pluginLoader,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
headless: true,
daemon: { token: daemonToken },
skillsAdapter,

View File

@@ -21,6 +21,10 @@ import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js";
// Re-export for backward compatibility with tests
@@ -635,7 +639,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints.
//
const pluginStore = new PluginStore(store.getRootDir());
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
const pluginStore = new PluginStore(pluginStoreRootDir);
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -918,6 +926,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// required for correctness — reconciliation handles all cases.
engineManager.startReconciliation();
// Backfill Claude Code skills for all registered projects. No-op when
// pi-claude-cli isn't configured; non-blocking to protect startup latency.
void (async () => {
try {
if (!centralCoreForEngine) return;
const projects = await centralCoreForEngine.listProjects();
ensureClaudeSkillsForAllProjectsOnStartup(
projects.map((p) => ({ id: p.id, name: p.name, path: p.path })),
);
} catch (err) {
logSink.log(
`Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
"engine",
);
}
})();
// ── PeerExchangeService: gossip protocol for mesh peer discovery ──────
//
// Reuse centralCoreForEngine for peer exchange since it handles all mesh ops.
@@ -972,6 +997,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pluginLoader,
pluginRunner: pluginLoader,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
@@ -1160,6 +1188,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,

View File

@@ -42,6 +42,10 @@ import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence }
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -316,6 +320,25 @@ export async function runServe(
// Start engines for all registered projects eagerly
await engineManager.startAll();
// Backfill Claude Code skills for any registered project that's missing
// `.claude/skills/fusion`. Runs only when pi-claude-cli is configured; for
// users on the direct Anthropic provider this is a no-op and leaves no
// trace in the project tree. Non-blocking — we don't want a slow FS to
// delay server listen.
void (async () => {
try {
if (!sharedCentralCore) return;
const projects = await sharedCentralCore.listProjects();
ensureClaudeSkillsForAllProjectsOnStartup(
projects.map((p) => ({ id: p.id, name: p.name, path: p.path })),
);
} catch (err) {
console.warn(
`[fusion] Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
// Start background reconciliation to detect and start engines for projects
// registered after startup (without requiring headless node API access).
// This ensures project task execution starts from backend runtime alone.
@@ -373,7 +396,11 @@ export async function runServe(
// internally for task-execution plugin hooks. These instances here serve the
// HTTP plugin-management API routes and are intentionally separate.
//
const pluginStore = new PluginStore(store.getRootDir());
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
const pluginStore = new PluginStore(pluginStoreRootDir);
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -589,6 +616,11 @@ export async function runServe(
pluginLoader,
pluginRunner: pluginLoader,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
// Fire-and-forget: install the fusion Claude-skill when pi-claude-cli
// is configured. The runner logs its own outcome and swallows errors.
maybeInstallClaudeSkillForNewProject(path);
},
headless: true,
skillsAdapter,
daemon: daemonToken ? { token: daemonToken } : undefined,