fix(FN-1952): use fusion storage for pi config

This commit is contained in:
gsxdsm
2026-04-16 20:12:09 -07:00
parent 00918dcff2
commit 35c89af4b6
15 changed files with 210 additions and 180 deletions

View File

@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const commandMocks = vi.hoisted(() => ({
runDashboard: vi.fn(),
@@ -188,94 +190,14 @@ vi.mock("./commands/message.js", () => ({
const originalArgv = process.argv;
const originalExit = process.exit;
const originalSkipMigration = process.env.KB_SKIP_MIGRATION;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
let importCounter = 0;
async function runBin(args: string[]) {
process.argv = ["node", "bin.ts", ...args];
importCounter += 1;
if (importCounter === 1) {
await import("./bin.ts?test=1");
} else if (importCounter === 2) {
await import("./bin.ts?test=2");
} else if (importCounter === 3) {
await import("./bin.ts?test=3");
} else if (importCounter === 4) {
await import("./bin.ts?test=4");
} else if (importCounter === 5) {
await import("./bin.ts?test=5");
} else if (importCounter === 6) {
await import("./bin.ts?test=6");
} else if (importCounter === 7) {
await import("./bin.ts?test=7");
} else if (importCounter === 8) {
await import("./bin.ts?test=8");
} else if (importCounter === 9) {
await import("./bin.ts?test=9");
} else if (importCounter === 10) {
await import("./bin.ts?test=10");
} else if (importCounter === 11) {
await import("./bin.ts?test=11");
} else if (importCounter === 12) {
await import("./bin.ts?test=12");
} else if (importCounter === 13) {
await import("./bin.ts?test=13");
} else if (importCounter === 14) {
await import("./bin.ts?test=14");
} else if (importCounter === 15) {
await import("./bin.ts?test=15");
} else if (importCounter === 16) {
await import("./bin.ts?test=16");
} else if (importCounter === 17) {
await import("./bin.ts?test=17");
} else if (importCounter === 18) {
await import("./bin.ts?test=18");
} else if (importCounter === 19) {
await import("./bin.ts?test=19");
} else if (importCounter === 20) {
await import("./bin.ts?test=20");
} else if (importCounter === 21) {
await import("./bin.ts?test=21");
} else if (importCounter === 22) {
await import("./bin.ts?test=22");
} else if (importCounter === 23) {
await import("./bin.ts?test=23");
} else if (importCounter === 24) {
await import("./bin.ts?test=24");
} else if (importCounter === 25) {
await import("./bin.ts?test=25");
} else if (importCounter === 26) {
await import("./bin.ts?test=26");
} else if (importCounter === 27) {
await import("./bin.ts?test=27");
} else if (importCounter === 28) {
await import("./bin.ts?test=28");
} else if (importCounter === 29) {
await import("./bin.ts?test=29");
} else if (importCounter === 30) {
await import("./bin.ts?test=30");
} else if (importCounter === 31) {
await import("./bin.ts?test=31");
} else if (importCounter === 32) {
await import("./bin.ts?test=32");
} else if (importCounter === 33) {
await import("./bin.ts?test=33");
} else if (importCounter === 34) {
await import("./bin.ts?test=34");
} else if (importCounter === 35) {
await import("./bin.ts?test=35");
} else if (importCounter === 36) {
await import("./bin.ts?test=36");
} else if (importCounter === 37) {
await import("./bin.ts?test=37");
} else if (importCounter === 38) {
await import("./bin.ts?test=38");
} else if (importCounter === 39) {
await import("./bin.ts?test=39");
} else {
await import("./bin.ts?test=40");
}
await import(/* @vite-ignore */ `./bin.ts?test=${importCounter}`);
}
describe("bin command routing and fallbacks", () => {
@@ -285,6 +207,7 @@ describe("bin command routing and fallbacks", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.KB_SKIP_MIGRATION = "1";
delete process.env.PI_PACKAGE_DIR;
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
@@ -298,6 +221,23 @@ describe("bin command routing and fallbacks", () => {
} else {
process.env.KB_SKIP_MIGRATION = originalSkipMigration;
}
if (originalPiPackageDir === undefined) {
delete process.env.PI_PACKAGE_DIR;
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
});
it("configures pi to use .fusion as its project config directory", async () => {
await expect(runBin(["--help"])).rejects.toThrow("process.exit:0");
const piPackageDir = process.env.PI_PACKAGE_DIR;
expect(piPackageDir).toBeTruthy();
const pkg = JSON.parse(readFileSync(join(piPackageDir!, "package.json"), "utf-8")) as {
piConfig?: { configDir?: string };
};
expect(pkg.piConfig?.configDir).toBe(".fusion");
});
it("shows help with --help and exits 0", async () => {

View File

@@ -1,42 +1,64 @@
#!/usr/bin/env node
/**
* Bootstrap: when running as a bun-compiled binary, the bundled pi-coding-agent
* reads package.json from the executable's directory at module-init time
* (top-level `readFileSync` in its config module). We redirect that read to a
* temp directory containing a minimal package.json so the binary works
* standalone without any co-located package.json.
* Bootstrap: pi-coding-agent reads package.json at module-init time (top-level
* `readFileSync` in its config module) and uses `piConfig.configDir` to decide
* where project-local resources live. Fusion wants those resources in `.fusion`
* rather than `.pi`, so we provide pi with a package.json config before any
* application imports can load pi.
*
* Node built-ins are safe to import statically — they have no side-effects
* that depend on package.json. All application imports MUST be dynamic
* (after the env is configured) so they resolve after PI_PACKAGE_DIR is set.
*/
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
// @ts-expect-error -- Bun-only global; undefined in Node
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
if (isBunBinary) {
const execDir = dirname(process.execPath);
const localPkg = join(execDir, "package.json");
if (!existsSync(localPkg)) {
// Write a minimal package.json to a temp dir and redirect PI_PACKAGE_DIR
const tmp = mkdtempSync(join(tmpdir(), "fn-pkg-"));
writeFileSync(
join(tmp, "package.json"),
JSON.stringify(
{ name: "fn", version: "0.1.0", type: "module", piConfig: { name: "fn", configDir: ".fusion" } },
null,
2,
) + "\n",
);
process.env.PI_PACKAGE_DIR = tmp;
function configurePiPackage(): void {
if (process.env.PI_PACKAGE_DIR) {
return;
}
const tmp = mkdtempSync(join(tmpdir(), "fn-pkg-"));
let packageJson: Record<string, unknown> = {
name: "pi",
version: "0.1.0",
type: "module",
};
try {
const require = createRequire(import.meta.url);
const piPackagePath = require.resolve("@mariozechner/pi-coding-agent/package.json");
const piPackageDir = dirname(piPackagePath);
packageJson = JSON.parse(readFileSync(piPackagePath, "utf-8")) as Record<string, unknown>;
for (const entry of ["dist", "docs", "examples", "README.md", "CHANGELOG.md"]) {
const source = join(piPackageDir, entry);
if (existsSync(source)) {
symlinkSync(source, join(tmp, entry));
}
}
} catch {
// A bundled binary may not expose pi's package.json. The config value is
// the only part required by Fusion's non-interactive agent sessions.
}
packageJson.piConfig = {
...((packageJson.piConfig as Record<string, unknown> | undefined) ?? {}),
configDir: ".fusion",
};
writeFileSync(join(tmp, "package.json"), JSON.stringify(packageJson, null, 2) + "\n");
process.env.PI_PACKAGE_DIR = tmp;
}
configurePiPackage();
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runServe } = await import("./commands/serve.js");

View File

@@ -0,0 +1,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
export function getFusionAuthPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".fusion", "agent", "auth.json");
}

View File

@@ -38,6 +38,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -324,7 +325,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create();
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
// PackageManager may be used for skills adapter even if extension loading fails

View File

@@ -10,6 +10,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -357,11 +358,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
// ── Auth & model wiring ────────────────────────────────────────────
// AuthStorage manages OAuth/API-key credentials (stored in ~/.pi/agent/auth.json).
// AuthStorage manages OAuth/API-key credentials (stored in ~/.fusion/agent/auth.json).
// ModelRegistry discovers available models from configured providers.
// Passing these to createServer enables the dashboard's Authentication
// tab (login/logout) and Model selector.
const authStorage = AuthStorage.create();
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
// PackageManager may be used for skills adapter even if extension loading fails

View File

@@ -124,7 +124,7 @@ describe("init command", () => {
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
});
it("should add .fusion to .gitignore when it doesn't exist", async () => {
it("should add local storage directories to .gitignore when it doesn't exist", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
expect(existsSync(gitignorePath)).toBe(false);
@@ -133,9 +133,10 @@ describe("init command", () => {
expect(existsSync(gitignorePath)).toBe(true);
const content = readFileSync(gitignorePath, "utf-8");
expect(content).toContain(".fusion");
expect(content).toContain(".pi");
});
it("should append .fusion to existing .gitignore", async () => {
it("should append local storage directories to existing .gitignore", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\ndist\n");
@@ -145,9 +146,23 @@ describe("init command", () => {
expect(content).toContain("node_modules");
expect(content).toContain("dist");
expect(content).toContain(".fusion");
expect(content).toContain(".pi");
});
it("should not duplicate .fusion in .gitignore (idempotent)", async () => {
it("should not duplicate local storage directories in .gitignore (idempotent)", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n.pi\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
const fusionMatches = content.match(/\.fusion/g);
const piMatches = content.match(/\.pi/g);
expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1);
});
it("should add .pi when .fusion is already ignored", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n");
@@ -155,6 +170,8 @@ describe("init command", () => {
const content = readFileSync(gitignorePath, "utf-8");
const fusionMatches = content.match(/\.fusion/g);
const piMatches = content.match(/\.pi/g);
expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1);
});
});

View File

@@ -75,8 +75,8 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`);
}
// Add .fusion to .gitignore
await addFusionToGitignore(cwd);
// Add local Fusion/Pi storage directories to .gitignore
await addLocalStorageToGitignore(cwd);
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
@@ -164,10 +164,10 @@ async function detectProjectName(dir: string): Promise<string> {
}
/**
* Add .fusion to .gitignore if not already present.
* Idempotent: only adds if not already in the file.
* Add local Fusion/Pi storage directories to .gitignore if not already present.
* Idempotent: only adds missing entries.
*/
async function addFusionToGitignore(cwd: string): Promise<void> {
async function addLocalStorageToGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, ".gitignore");
let content = "";
@@ -179,17 +179,19 @@ async function addFusionToGitignore(cwd: string): Promise<void> {
}
}
// 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
const existingEntries = new Set(lines.map((line) => line.trim()));
const missingEntries = [".fusion", ".pi"].filter((entry) => !existingEntries.has(entry));
if (missingEntries.length === 0) {
return;
}
// Append .fusion to .gitignore
const newContent = content.endsWith("\n") ? content + ".fusion\n" : content + "\n.fusion\n";
const prefix = content.length === 0 || content.endsWith("\n") ? "" : "\n";
const newContent = `${content}${prefix}${missingEntries.join("\n")}\n`;
try {
writeFileSync(gitignorePath, newContent);
console.log(` ✓ Added .fusion to .gitignore`);
console.log(` ✓ Added ${missingEntries.join(" and ")} 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

@@ -39,6 +39,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -383,7 +384,7 @@ export async function runServe(
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create();
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
// PackageManager may be used for skills adapter even if extension loading fails

View File

@@ -204,6 +204,6 @@ export async function runSkillsInstall(
}
console.log(
`Installed skill from ${source}. Skills are discovered from .pi/skills/ and .agents/skills/.`,
`Installed skill from ${source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/.`,
);
}

View File

@@ -1713,7 +1713,7 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "FN: Install Skill",
description:
"Install an agent skill from skills.sh into the current project. " +
"Downloads skill files into the project's skill directories (.pi/skills/, .agents/skills/). " +
"Downloads skill files into the project's skill directories (.fusion/skills/, legacy .pi/skills/, .agents/skills/). " +
"The skill becomes available to AI agents in subsequent sessions.",
promptSnippet: "Install a skill from skills.sh into the current project",
promptGuidelines: [
@@ -1807,7 +1807,7 @@ export default function kbExtension(pi: ExtensionAPI) {
content: [
{
type: "text",
text: `Installed skill from ${params.source}. Skills are discovered from .pi/skills/ and .agents/skills/. The skill will be available in future agent sessions.`,
text: `Installed skill from ${params.source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/. The skill will be available in future agent sessions.`,
},
],
details: { source: params.source, skill: params.skill ?? "all" },