/**
* Shared bridge extension registration for pi's settings.json.
* Used by both the server and Electron app to register the dashboard
* bridge extension so pi sessions can discover and load it.
*
* Single source of truth — replaces the near-identical implementations
* in packages/server/src/extension-register.ts and
* packages/electron/src/lib/bridge-register.ts.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createRequire } from "node:module";
/**
* Check that a candidate path is a valid, stable extension directory.
* Returns true when the directory exists, contains a package.json, and
* is NOT under /tmp/.mount_* (unstable AppImage mount).
*/
function isValidExtensionPath(candidate: string): boolean {
if (!fs.existsSync(candidate)) return false;
if (!fs.existsSync(path.join(candidate, "package.json"))) return false;
if (candidate.includes("/tmp/.mount_")) {
console.warn(
"[dashboard] AppImage detected — extension path is temporary, skipping registration:",
candidate,
);
return false;
}
return true;
}
/**
* Optional dependency injection for `findBundledExtension`. Tests pass
* `{ resolvePackage: () => null }` to disable the node-resolver fallback.
*/
export interface FindExtensionDeps {
/**
* Resolve `@blackbelt-technology/pi-dashboard-extension/package.json`
* via Node's module resolver. Return the absolute package.json path
* or null. Defaults to `createRequire(import.meta.url).resolve(...)`.
*/
resolvePackage?: () => string | null;
}
/**
* Read `name` from `
/package.json`. Returns null on any error
* (missing file, unreadable, invalid JSON, missing name field).
* Used for identity-based dedup in `registerBridgeExtension`.
*/
function readPackageName(dir: string): string | null {
try {
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) return null;
const raw = fs.readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(raw);
return typeof parsed?.name === "string" ? parsed.name : null;
} catch {
return null;
}
}
function defaultResolvePackage(): string | null {
try {
const req = createRequire(import.meta.url);
return req.resolve("@blackbelt-technology/pi-dashboard-extension/package.json");
} catch {
return null;
}
}
/**
* Find the bundled extension directory.
*
* Resolution order:
* 1. Monorepo layout: `/packages/extension/`.
* 2. Node module resolution: `@blackbelt-technology/pi-dashboard-extension/package.json`
* via `require.resolve` from this module. Works in ANY install layout
* (flat `node_modules/`, scoped, nested, pnpm, npm-g). This is the
* canonical identity-based lookup and the only reliable strategy
* when pi-dashboard is installed via `npm i -g`.
*
* Returns null if both strategies fail, the resolved directory doesn't
* have a package.json, or the path is under /tmp/.mount_* (AppImage).
*
* See change: unified-bootstrap-install.
*/
export function findBundledExtension(
baseDir: string,
deps: FindExtensionDeps = {},
): string | null {
// Strategy 1: monorepo sibling layout.
const monorepoCandidate = path.resolve(baseDir, "packages", "extension");
if (isValidExtensionPath(monorepoCandidate)) return monorepoCandidate;
// Strategy 2: Node module resolver. This works for the `npm i -g
// pi-dashboard` layout where the extension is shipped as a runtime dep
// of pi-dashboard-server.
const resolver = deps.resolvePackage ?? defaultResolvePackage;
const extPkgJson = resolver();
if (extPkgJson) {
const extDir = path.dirname(extPkgJson);
if (isValidExtensionPath(extDir)) return extDir;
}
return null;
}
/** Optional overrides for testing / multi-HOME scenarios. */
export interface BridgeRegisterOptions {
/**
* Override the HOME used to locate settings.json. When omitted,
* falls back to `$HOME || $USERPROFILE || os.homedir()` (existing behavior).
*/
homedir?: string;
}
/**
* Register an extension path in pi's settings.json packages array.
*
* Non-destructive cleanup: only removes dashboard-related paths
* that point to non-existent directories or directories without package.json.
* Existing valid registrations (dev, global, other bundled) are preserved.
*
* No-op if the path is already registered.
*/
export function registerBridgeExtension(
extensionPath: string,
opts: BridgeRegisterOptions = {},
): void {
// Compute at call time so tests can override HOME
const home = opts.homedir
?? process.env.HOME
?? process.env.USERPROFILE
?? os.homedir();
const settingsPath = path.join(home, ".pi", "agent", "settings.json");
const settingsDir = path.dirname(settingsPath);
fs.mkdirSync(settingsDir, { recursive: true });
let settings: Record = {};
try {
if (fs.existsSync(settingsPath)) {
const raw = fs.readFileSync(settingsPath, "utf-8").trim();
if (raw) settings = JSON.parse(raw);
}
} catch { /* start fresh */ }
const packages = Array.isArray(settings.packages) ? settings.packages as string[] : [];
// Already registered?
if (packages.includes(extensionPath)) return;
// Compute the identity (package.json#name) of the new entry. We use it
// to dedupe across install layouts (dev / .app / npm-global / legacy
// managed dir) that all register the same extension under different
// absolute paths.
const newIdentity = readPackageName(extensionPath);
// Non-destructive cleanup: drop stale dashboard paths AND drop any
// local entry with the same package.json#name as the new one
// (most-recently-asserted path wins). npm:-scheme entries pass through
// untouched.
const cleaned = packages.filter((p) => {
if (typeof p !== "string") return true;
const isLocalPath = p.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(p);
if (!isLocalPath) return true;
// Identity dedup: same package name as the incoming entry?
if (newIdentity) {
const existingIdentity = readPackageName(p);
if (existingIdentity && existingIdentity === newIdentity) return false;
}
// Only consider dashboard-related paths for path-based cleanup
// Normalize: lowercase + collapse spaces/hyphens so "PI Dashboard" matches "pi-dashboard"
const normalized = p.toLowerCase().replace(/[\s_-]/g, "");
if (!normalized.includes("pidashboard") && !normalized.includes("piagentdashboard")) return true;
// Keep paths that point to existing directories with a package.json
try {
return fs.existsSync(p) && fs.existsSync(path.join(p, "package.json"));
} catch {
return false; // Can't check — treat as stale
}
});
cleaned.push(extensionPath);
settings.packages = cleaned;
try {
const tmp = settingsPath + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
fs.renameSync(tmp, settingsPath);
console.log(`[dashboard] Registered bridge extension in pi settings: ${extensionPath}`);
} catch (err) {
console.error("[dashboard] Failed to register bridge extension:", err);
}
}