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

This commit is contained in:
gsxdsm
2026-04-14 12:45:09 -07:00
parent 66dafb0e96
commit a10151d234
10 changed files with 102 additions and 110 deletions

View File

@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInit } from "./init.js";
@@ -123,4 +123,38 @@ describe("init command", () => {
expect(existsSync(fusionDir)).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
});
it("should add .fusion to .gitignore when it doesn't exist", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
expect(existsSync(gitignorePath)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(gitignorePath)).toBe(true);
const content = readFileSync(gitignorePath, "utf-8");
expect(content).toContain(".fusion");
});
it("should append .fusion to existing .gitignore", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\ndist\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
expect(content).toContain("node_modules");
expect(content).toContain("dist");
expect(content).toContain(".fusion");
});
it("should not duplicate .fusion in .gitignore (idempotent)", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
const fusionMatches = content.match(/\.fusion/g);
expect(fusionMatches).toHaveLength(1);
});
});

View File

@@ -8,7 +8,7 @@
* Idempotent: if already initialized, reports success without recreating.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join, resolve, basename } from "node:path";
import { homedir } from "node:os";
import { exec } from "node:child_process";
@@ -75,6 +75,9 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`);
}
// Add .fusion to .gitignore
await addFusionToGitignore(cwd);
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
// SQLite database header for an empty database
@@ -159,3 +162,36 @@ async function detectProjectName(dir: string): Promise<string> {
// Fallback to directory name
return basename(dir) || "my-project";
}
/**
* Add .fusion to .gitignore if not already present.
* Idempotent: only adds if not already in the file.
*/
async function addFusionToGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, ".gitignore");
let content = "";
if (existsSync(gitignorePath)) {
try {
content = readFileSync(gitignorePath, "utf-8");
} catch {
// Best-effort: if we can't read, treat as empty
}
}
// Check if .fusion is already in the file
const lines = content.split(/\r?\n/);
if (lines.some((line) => line.trim() === ".fusion")) {
return; // Already present, skip
}
// Append .fusion to .gitignore
const newContent = content.endsWith("\n") ? content + ".fusion\n" : content + "\n.fusion\n";
try {
writeFileSync(gitignorePath, newContent);
console.log(` ✓ Added .fusion to .gitignore`);
} catch {
// Best-effort: don't fail init if we can't write to .gitignore
console.log(` ⚠ Could not update .gitignore (best-effort)`);
}
}

View File

@@ -9,12 +9,11 @@ function writeJson(path: string, value: Record<string, unknown>): void {
}
describe("createReadOnlyProviderSettingsView", () => {
it("reads provider package settings from .pi and .fusion with .fusion taking precedence", () => {
it("reads provider package settings from .fusion/settings.json", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(join(cwd, ".pi"), { recursive: true });
mkdirSync(join(cwd, ".fusion"), { recursive: true });
mkdirSync(agentDir, { recursive: true });
@@ -22,11 +21,6 @@ describe("createReadOnlyProviderSettingsView", () => {
npmCommand: ["pnpm"],
globalOnly: true,
});
writeJson(join(cwd, ".pi", "settings.json"), {
npmCommand: ["npm"],
extensions: [{ name: "pi-provider", enabled: true }],
shared: "pi",
});
writeJson(join(cwd, ".fusion", "settings.json"), {
extensions: [{ name: "fusion-provider", enabled: true }],
shared: "fusion",
@@ -42,26 +36,24 @@ describe("createReadOnlyProviderSettingsView", () => {
extensions: [{ name: "fusion-provider", enabled: true }],
shared: "fusion",
});
expect(view.getNpmCommand()).toEqual(["npm"]);
expect(view.getNpmCommand()).toEqual(["pnpm"]);
});
it("falls back to .pi settings when .fusion settings do not exist", () => {
it("returns empty project settings when .fusion/settings.json does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(join(cwd, ".pi"), { recursive: true });
mkdirSync(agentDir, { recursive: true });
writeJson(join(cwd, ".pi", "settings.json"), {
extensions: [{ name: "pi-provider", enabled: true }],
writeJson(join(agentDir, "settings.json"), {
npmCommand: ["pnpm"],
});
const view = createReadOnlyProviderSettingsView(cwd, agentDir);
expect(view.getProjectSettings()).toMatchObject({
extensions: [{ name: "pi-provider", enabled: true }],
});
expect(view.getProjectSettings()).toEqual({});
expect(view.getNpmCommand()).toEqual(["pnpm"]);
});
});
@@ -85,26 +77,7 @@ describe("createProjectSettingsPersistence", () => {
});
});
it("falls back to .pi/settings.json when .fusion/settings.json does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const cwd = join(root, "project");
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeJson(join(cwd, ".pi", "settings.json"), {
skills: ["-other-skill"],
npmCommand: ["npm"],
});
const persistence = createProjectSettingsPersistence(cwd);
const settings = persistence.read();
expect(settings).toEqual({
skills: ["-other-skill"],
npmCommand: ["npm"],
});
});
it("returns empty object when neither settings file exists", () => {
it("returns empty object when .fusion/settings.json does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const cwd = join(root, "project");

View File

@@ -22,14 +22,12 @@ function readJsonObject(path: string): Record<string, any> {
export function createReadOnlyProviderSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView {
const globalSettings = readJsonObject(join(agentDir, "settings.json"));
const legacyProjectSettings = readJsonObject(join(cwd, ".pi", "settings.json"));
const fusionProjectSettings = readJsonObject(join(cwd, ".fusion", "settings.json"));
const projectSettings = { ...legacyProjectSettings, ...fusionProjectSettings };
const mergedSettings = { ...globalSettings, ...projectSettings };
const mergedSettings = { ...globalSettings, ...fusionProjectSettings };
return {
getGlobalSettings: () => structuredClone(globalSettings),
getProjectSettings: () => structuredClone(projectSettings),
getProjectSettings: () => structuredClone(fusionProjectSettings),
getNpmCommand: () => Array.isArray(mergedSettings.npmCommand)
? [...mergedSettings.npmCommand]
: undefined,
@@ -39,8 +37,7 @@ export function createReadOnlyProviderSettingsView(cwd: string, agentDir: string
/**
* Project settings persistence helper.
*
* Reads from and writes to `.fusion/settings.json` with fallback to `.pi/settings.json`
* for backward compatibility. Changes are always written to `.fusion/settings.json`.
* Reads from and writes to `.fusion/settings.json`.
*
* @param projectPath - Absolute path to the project root
* @returns Object with read/write methods for project settings
@@ -54,23 +51,13 @@ export function createProjectSettingsPersistence(projectPath: string): {
getSettingsPath(): string;
} {
const fusionSettingsPath = join(projectPath, ".fusion", "settings.json");
const legacySettingsPath = join(projectPath, ".pi", "settings.json");
function readSettings(): Record<string, any> {
// Try .fusion first
if (existsSync(fusionSettingsPath)) {
try {
return JSON.parse(readFileSync(fusionSettingsPath, "utf-8")) as Record<string, any>;
} catch {
// Fall through to legacy
}
}
// Fall back to .pi
if (existsSync(legacySettingsPath)) {
try {
return JSON.parse(readFileSync(legacySettingsPath, "utf-8")) as Record<string, any>;
} catch {
// Return empty
// Return empty on parse error
}
}
return {};