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:
189
packages/cli/src/commands/claude-skills.test.ts
Normal file
189
packages/cli/src/commands/claude-skills.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user