feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
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 });
}
}