mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
import { basename, dirname, extname, join, relative, resolve } from "path";
|
|
import {
|
|
DefaultPackageManager,
|
|
getAgentDir,
|
|
SettingsManager,
|
|
type PackageSource,
|
|
type ResolvedPaths,
|
|
type ResolvedResource,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
|
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
|
import { getProjectTrustStatus } from "@/lib/project-trust";
|
|
import { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime";
|
|
import type {
|
|
PluginDiagnostic,
|
|
PluginPackageInfo,
|
|
PluginResourceCounts,
|
|
PluginResourceInfo,
|
|
PluginResourceKind,
|
|
PluginScope,
|
|
PluginsResponse,
|
|
} from "@/lib/api-types";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
|
|
|
|
function emptyCounts(): PluginResourceCounts {
|
|
return { extensions: 0, skills: 0, prompts: 0, themes: 0 };
|
|
}
|
|
|
|
function toPluginScope(scope: string): PluginScope {
|
|
return scope === "project" ? "project" : "global";
|
|
}
|
|
|
|
function keyFor(source: string, scope: PluginScope): string {
|
|
return `${scope}\0${source}`;
|
|
}
|
|
|
|
function getPackageSource(entry: PackageSource): string {
|
|
return typeof entry === "string" ? entry : entry.source;
|
|
}
|
|
|
|
function isDisabledPackage(entry: PackageSource): boolean {
|
|
if (typeof entry === "string") return false;
|
|
return (
|
|
Array.isArray(entry.extensions) && entry.extensions.length === 0 &&
|
|
Array.isArray(entry.skills) && entry.skills.length === 0 &&
|
|
Array.isArray(entry.prompts) && entry.prompts.length === 0 &&
|
|
Array.isArray(entry.themes) && entry.themes.length === 0
|
|
);
|
|
}
|
|
|
|
function getDisabledPackages(settingsManager: SettingsManager): Map<string, boolean> {
|
|
const disabled = new Map<string, boolean>();
|
|
for (const entry of settingsManager.getGlobalSettings().packages ?? []) {
|
|
disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry));
|
|
}
|
|
for (const entry of settingsManager.getProjectSettings().packages ?? []) {
|
|
disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry));
|
|
}
|
|
return disabled;
|
|
}
|
|
|
|
function setPackageDisabled(
|
|
settingsManager: SettingsManager,
|
|
source: string,
|
|
scope: PluginScope,
|
|
disabled: boolean,
|
|
): boolean {
|
|
const current = scope === "project"
|
|
? settingsManager.getProjectSettings().packages ?? []
|
|
: settingsManager.getGlobalSettings().packages ?? [];
|
|
let changed = false;
|
|
const next = current.map((entry): PackageSource => {
|
|
if (getPackageSource(entry) !== source) return entry;
|
|
changed = true;
|
|
if (disabled) {
|
|
return {
|
|
...(typeof entry === "string" ? { source: entry } : entry),
|
|
extensions: [],
|
|
skills: [],
|
|
prompts: [],
|
|
themes: [],
|
|
};
|
|
}
|
|
return getPackageSource(entry);
|
|
});
|
|
if (!changed) return false;
|
|
if (scope === "project") settingsManager.setProjectPackages(next);
|
|
else settingsManager.setPackages(next);
|
|
return true;
|
|
}
|
|
|
|
function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void {
|
|
counts[kind] += 1;
|
|
}
|
|
|
|
function getResourceName(path: string, kind: PluginResourceKind): string {
|
|
const file = basename(path);
|
|
const ext = extname(file);
|
|
if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path));
|
|
if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) {
|
|
if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path));
|
|
return file.slice(0, -ext.length);
|
|
}
|
|
return file;
|
|
}
|
|
|
|
function getRelativePath(resource: ResolvedResource): string {
|
|
const baseDir = resource.metadata.baseDir;
|
|
if (!baseDir) return resource.path;
|
|
const rel = relative(baseDir, resource.path);
|
|
return rel && !rel.startsWith("..") ? rel : resource.path;
|
|
}
|
|
|
|
function getConfiguredVersion(source: string): string | undefined {
|
|
const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined;
|
|
if (npmSpec) {
|
|
const lastAt = npmSpec.lastIndexOf("@");
|
|
const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0;
|
|
if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined;
|
|
return undefined;
|
|
}
|
|
|
|
if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) {
|
|
const lastAt = source.lastIndexOf("@");
|
|
const lastSlash = source.lastIndexOf("/");
|
|
const lastColon = source.lastIndexOf(":");
|
|
if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } {
|
|
if (!installedPath) return {};
|
|
try {
|
|
const stats = statSync(installedPath);
|
|
const packageJsonPath = stats.isDirectory()
|
|
? join(installedPath, "package.json")
|
|
: join(dirname(installedPath), "package.json");
|
|
if (!existsSync(packageJsonPath)) return {};
|
|
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
name?: unknown;
|
|
version?: unknown;
|
|
};
|
|
return {
|
|
packageName: typeof parsed.name === "string" ? parsed.name : undefined,
|
|
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function collectResource(
|
|
resource: ResolvedResource,
|
|
kind: keyof PluginResourceCounts,
|
|
countsByPackage: Map<string, PluginResourceCounts>,
|
|
resourcesByPackage: Map<string, PluginResourceInfo[]>,
|
|
totals: PluginResourceCounts,
|
|
): void {
|
|
if (!isManagedPath(resource.path)) return;
|
|
if (!resource.enabled || resource.metadata.origin !== "package") return;
|
|
const source = resource.metadata.source;
|
|
const scope = toPluginScope(resource.metadata.scope);
|
|
const key = keyFor(source, scope);
|
|
const counts = countsByPackage.get(key) ?? emptyCounts();
|
|
addCount(counts, kind);
|
|
addCount(totals, kind);
|
|
countsByPackage.set(key, counts);
|
|
const resources = resourcesByPackage.get(key) ?? [];
|
|
const resourceKind = kind === "extensions"
|
|
? "extension"
|
|
: kind === "skills"
|
|
? "skill"
|
|
: kind === "prompts"
|
|
? "prompt"
|
|
: "theme";
|
|
resources.push({
|
|
kind: resourceKind,
|
|
name: getResourceName(resource.path, resourceKind),
|
|
path: resource.path,
|
|
relativePath: getRelativePath(resource),
|
|
});
|
|
resourcesByPackage.set(key, resources);
|
|
}
|
|
|
|
function collectResources(paths: ResolvedPaths): {
|
|
countsByPackage: Map<string, PluginResourceCounts>;
|
|
resourcesByPackage: Map<string, PluginResourceInfo[]>;
|
|
totals: PluginResourceCounts;
|
|
} {
|
|
const countsByPackage = new Map<string, PluginResourceCounts>();
|
|
const resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
|
const totals = emptyCounts();
|
|
for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals);
|
|
for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals);
|
|
for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals);
|
|
for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals);
|
|
return { countsByPackage, resourcesByPackage, totals };
|
|
}
|
|
|
|
async function readPlugins(cwd: string): Promise<PluginsResponse> {
|
|
const agentDir = getAgentDir();
|
|
const projectTrust = getProjectTrustStatus(cwd, agentDir);
|
|
const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted);
|
|
const packageManager = new DefaultPackageManager({
|
|
cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
});
|
|
|
|
const diagnostics: PluginDiagnostic[] = [];
|
|
let countsByPackage = new Map<string, PluginResourceCounts>();
|
|
let resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
|
let totals = emptyCounts();
|
|
const disabledByPackage = getDisabledPackages(settingsManager);
|
|
|
|
try {
|
|
const resolved = await packageManager.resolve(async (source) => {
|
|
diagnostics.push({
|
|
type: "warning",
|
|
source,
|
|
message: "Package is configured but not installed yet.",
|
|
});
|
|
return "skip";
|
|
});
|
|
({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved));
|
|
} catch (error) {
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
const packages = packageManager.listConfiguredPackages().map((pkg) => {
|
|
const scope = toPluginScope(pkg.scope);
|
|
const key = keyFor(pkg.source, scope);
|
|
const disabled = disabledByPackage.get(key) ?? false;
|
|
const counts = countsByPackage.get(key) ?? emptyCounts();
|
|
const resources = resourcesByPackage.get(key) ?? [];
|
|
const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes;
|
|
const packageMetadata = readPackageMetadata(pkg.installedPath);
|
|
if (!pkg.installedPath) {
|
|
diagnostics.push({
|
|
type: "warning",
|
|
source: pkg.source,
|
|
message: "Configured package path was not found.",
|
|
});
|
|
}
|
|
return {
|
|
source: pkg.source,
|
|
scope,
|
|
filtered: pkg.filtered,
|
|
disabled,
|
|
installedPath: pkg.installedPath,
|
|
packageName: packageMetadata.packageName,
|
|
version: packageMetadata.version,
|
|
configuredVersion: getConfiguredVersion(pkg.source),
|
|
counts,
|
|
resources,
|
|
status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing",
|
|
} satisfies PluginPackageInfo;
|
|
});
|
|
|
|
return {
|
|
packages,
|
|
totals,
|
|
diagnostics,
|
|
projectResourcesLoaded: projectTrust.trusted,
|
|
};
|
|
}
|
|
|
|
function readScope(scope: unknown): PluginScope {
|
|
if (isManagedRuntime()) return "global";
|
|
return scope === "project" ? "project" : "global";
|
|
}
|
|
|
|
function isRemotePackageSource(source: string): boolean {
|
|
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
const { searchParams } = new URL(req.url);
|
|
const cwd = searchParams.get("cwd");
|
|
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
|
|
|
try {
|
|
const allowedRoots = await getAllowedFileRoots();
|
|
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
return NextResponse.json(await readPlugins(cwd));
|
|
} catch (error) {
|
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// POST /api/plugins body: { action, source?, scope?, cwd }
|
|
export async function POST(req: Request) {
|
|
if (!isApiRequestAllowed(req)) {
|
|
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
|
}
|
|
if (!hasJsonContentType(req)) {
|
|
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
|
|
}
|
|
|
|
try {
|
|
const body = await req.json() as {
|
|
action?: PluginAction;
|
|
source?: string;
|
|
scope?: PluginScope;
|
|
cwd?: string;
|
|
};
|
|
if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
|
if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 });
|
|
const allowedRoots = await getAllowedFileRoots();
|
|
if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) {
|
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
|
|
const agentDir = getAgentDir();
|
|
const projectTrust = getProjectTrustStatus(body.cwd, agentDir);
|
|
const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted);
|
|
const scope = readScope(body.scope);
|
|
if (scope === "project" && !projectTrust.trusted) {
|
|
return NextResponse.json(
|
|
{ error: "Project resources must be trusted before modifying project plugins" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
const packageManager = new DefaultPackageManager({
|
|
cwd: body.cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
});
|
|
const source = body.source?.trim();
|
|
const local = scope === "project";
|
|
|
|
if (
|
|
source &&
|
|
isManagedRuntime() &&
|
|
!isRemotePackageSource(source) &&
|
|
!isManagedPath(resolve(body.cwd, source))
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: "Local plugins must be stored inside the integrated application directory" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (body.action === "install") {
|
|
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
|
await packageManager.installAndPersist(source, { local });
|
|
} else if (body.action === "remove") {
|
|
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
|
await packageManager.removeAndPersist(source, { local });
|
|
} else if (body.action === "update") {
|
|
await packageManager.update(source);
|
|
} else if (body.action === "disable") {
|
|
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
|
setPackageDisabled(settingsManager, source, scope, true);
|
|
await settingsManager.flush();
|
|
} else if (body.action === "enable") {
|
|
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
|
setPackageDisabled(settingsManager, source, scope, false);
|
|
await settingsManager.flush();
|
|
} else {
|
|
return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 });
|
|
}
|
|
|
|
return NextResponse.json(await readPlugins(body.cwd));
|
|
} catch (error) {
|
|
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
}
|
|
}
|