fix(FN-1952): use fusion storage for pi config
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
7
packages/cli/src/commands/auth-paths.ts
Normal file
7
packages/cli/src/commands/auth-paths.ts
Normal 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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user