mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
* feat(plugins): enable registry installs by default * fix(plugins): explain explicit Git source for missing registry IDs * fix(plugins): tailor missing registry guidance to installation * test(app): sample footer geometry in one browser frame * fix(plugins): make registry source resolution deterministic Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures. * fix(plugins): recognize dot and Windows directory sources * test(cli): wait for readable daemon ownership before cancellation The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup. * test(app): expect missing directory install guidance
810 lines
30 KiB
TypeScript
810 lines
30 KiB
TypeScript
import type { PluginLifecycle } from "./lifecycle/index.js";
|
|
import path from "node:path";
|
|
import { stat, rm } from "node:fs/promises";
|
|
import type pino from "pino";
|
|
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
|
import {
|
|
PluginIdSchema,
|
|
type PluginLogEntry,
|
|
type PluginListItem,
|
|
type PluginSource,
|
|
type PluginSourceStatusItem,
|
|
type PluginSourceUpdateItem,
|
|
type PluginUpdateSelection,
|
|
type PluginUpdateProposal,
|
|
type PluginUpdatePreview,
|
|
type PluginUpdateResult,
|
|
} from "@getpaseo/protocol/messages";
|
|
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
|
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
|
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
|
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
|
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
|
import { readPluginManifest } from "./manifest.js";
|
|
import { expandTilde } from "../../utils/path.js";
|
|
import { runPluginBuild } from "./preparation.js";
|
|
import { PluginRuntime } from "./runtime.js";
|
|
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
|
|
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
|
import { readPluginProviderIcon } from "./provider-icon.js";
|
|
import {
|
|
type AgentUsageLookup,
|
|
type ListUsageReportsOptions,
|
|
UsageSourceRegistry,
|
|
} from "./usage-sources/index.js";
|
|
import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js";
|
|
|
|
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
|
|
|
|
interface PluginRuntimePort {
|
|
emit?: PluginLifecycle["emit"];
|
|
drainEvents?: PluginRuntime["drainEvents"];
|
|
before?: PluginLifecycle["before"];
|
|
catalog: PluginRuntime["catalog"];
|
|
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
|
getLogs(pluginId: string): PluginLogEntry[];
|
|
clearLogs(pluginId: string): void;
|
|
getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[];
|
|
getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[];
|
|
fetchUsage: PluginRuntime["fetchUsage"];
|
|
discoverUsage: PluginRuntime["discoverUsage"];
|
|
connectProvider: PluginRuntime["connectProvider"];
|
|
getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"];
|
|
getProviderStatus?: PluginRuntime["getProviderStatus"];
|
|
validatePlugin?(path: string): Promise<void>;
|
|
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
|
|
startBuiltinPlugin?(plugin: BuiltinPlugin): Promise<void>;
|
|
stopPluginById(pluginId: string): Promise<boolean>;
|
|
stopAll(): Promise<void>;
|
|
subscribe(listener: (pluginId: string, error?: string) => void): () => void;
|
|
bindPaseoSessionHost(sessionHost: Parameters<PluginRuntime["bindPaseoSessionHost"]>[0]): void;
|
|
}
|
|
|
|
interface PluginServiceDependencies {
|
|
usageAgents?: AgentUsageLookup;
|
|
settingsDirectory?: string;
|
|
runtime?: PluginRuntimePort;
|
|
managedSources?: ManagedPluginSources;
|
|
builtinPlugins?: BuiltinPluginLoader;
|
|
}
|
|
|
|
function resolvePluginStatus(input: {
|
|
enabled: boolean;
|
|
globallyEnabled: boolean;
|
|
running: boolean;
|
|
}): PluginListItem["status"] {
|
|
if (!input.enabled || !input.globallyEnabled) return "disabled";
|
|
return input.running ? "running" : "failed";
|
|
}
|
|
|
|
export class PluginService {
|
|
private readonly runtime: PluginRuntimePort;
|
|
private readonly managedSources: ManagedPluginSources | null;
|
|
private readonly builtinPluginIds: ReadonlySet<string>;
|
|
private readonly builtinPlugins: BuiltinPluginLoader;
|
|
private readonly logger: pino.Logger;
|
|
private readonly errors = new Map<string, string>();
|
|
private readonly listeners = new Set<(pluginId: string) => void>();
|
|
private readonly providers = new Map<string, ProviderRegistration>();
|
|
private readonly usageSources: UsageSourceRegistry;
|
|
private readonly usageSourceIdsByPlugin = new Map<string, string[]>();
|
|
private readonly providerIdsByPlugin = new Map<string, readonly string[]>();
|
|
private readonly providerListeners = new Set<() => void>();
|
|
private lifecycle = Promise.resolve();
|
|
private globalStartsBlocked = true;
|
|
private started = false;
|
|
private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>();
|
|
|
|
constructor(
|
|
logger: pino.Logger,
|
|
private readonly configStore: DaemonConfigStore,
|
|
private readonly daemonVersion: string,
|
|
private readonly dependencies: PluginServiceDependencies = {},
|
|
) {
|
|
this.logger = logger.child({ module: "plugin-service" });
|
|
this.usageSources = new UsageSourceRegistry(
|
|
Date.now,
|
|
300_000,
|
|
this.logger,
|
|
dependencies.usageAgents,
|
|
);
|
|
this.runtime =
|
|
dependencies.runtime ??
|
|
new PluginRuntime(logger, daemonVersion, {
|
|
settingsDirectory: dependencies.settingsDirectory,
|
|
onSettingsChanged: (pluginId, settingsId) => {
|
|
for (const listener of this.settingsListeners) listener(pluginId, settingsId);
|
|
},
|
|
});
|
|
this.managedSources = dependencies.managedSources ?? null;
|
|
this.builtinPlugins = dependencies.builtinPlugins ?? new BuiltinPluginLoader(undefined, []);
|
|
this.builtinPluginIds = this.builtinPlugins.ids;
|
|
this.runtime.subscribe((pluginId, error) => {
|
|
this.removeProviderRegistrations(pluginId);
|
|
this.removeUsageSources(pluginId);
|
|
if (error) this.errors.set(pluginId, error);
|
|
this.notify(pluginId);
|
|
});
|
|
}
|
|
|
|
readonly emit: PluginLifecycle["emit"] = (name, event) => {
|
|
this.runtime.emit?.(name, event);
|
|
};
|
|
|
|
async drainEvents(): Promise<void> {
|
|
await this.runtime.drainEvents?.();
|
|
}
|
|
|
|
readonly before: PluginLifecycle["before"] = async (name, request) => {
|
|
if (this.runtime.before) {
|
|
return this.runtime.before(name, request);
|
|
}
|
|
return request;
|
|
};
|
|
|
|
subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void {
|
|
this.settingsListeners.add(listener);
|
|
return () => this.settingsListeners.delete(listener);
|
|
}
|
|
|
|
subscribe(listener: (pluginId: string) => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
bindPaseoSessionHost(sessionHost: Parameters<PluginRuntime["bindPaseoSessionHost"]>[0]): void {
|
|
this.runtime.bindPaseoSessionHost(sessionHost);
|
|
}
|
|
|
|
getProviderRegistrations(): readonly ProviderRegistration[] {
|
|
return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|
|
|
|
listUsageReports(options?: ListUsageReportsOptions) {
|
|
return this.usageSources.listReports(options);
|
|
}
|
|
|
|
listLegacyUsage() {
|
|
return this.usageSources.listLegacyUsage();
|
|
}
|
|
|
|
subscribeProviderRegistrations(listener: () => void): () => void {
|
|
this.providerListeners.add(listener);
|
|
return () => this.providerListeners.delete(listener);
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.started) return;
|
|
this.started = true;
|
|
const config = this.configStore.get();
|
|
this.globalStartsBlocked = config.pluginsEnabled !== true;
|
|
await this.builtinPlugins.load(async (plugin) => {
|
|
try {
|
|
await this.runtime.startBuiltinPlugin?.(plugin);
|
|
await this.publishProviderRegistrations(plugin.id, plugin.directory);
|
|
this.publishUsageSources(plugin.id);
|
|
} catch (error) {
|
|
this.logger.error({ err: error, pluginId: plugin.id }, "Failed to start built-in plugin");
|
|
}
|
|
});
|
|
if (config.pluginsEnabled === true) {
|
|
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
|
if (source.enabled === false) continue;
|
|
await this.startConfigured(pluginId);
|
|
this.notify(pluginId);
|
|
}
|
|
}
|
|
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
|
this.handleGlobalSwitch(value === true);
|
|
});
|
|
}
|
|
|
|
async listPlugins(): Promise<PluginListItem[]> {
|
|
const config = this.configStore.get();
|
|
const running = new Set(this.runtime.catalog().map((plugin) => plugin.id));
|
|
const plugins = await Promise.all(
|
|
Object.entries(config.plugins ?? {}).map(async ([id, source]) => {
|
|
const enabled = source.enabled !== false;
|
|
const item: PluginListItem = {
|
|
id,
|
|
path: source.path,
|
|
enabled,
|
|
status: resolvePluginStatus({
|
|
enabled,
|
|
globallyEnabled: config.pluginsEnabled === true,
|
|
running: running.has(id),
|
|
}),
|
|
};
|
|
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
|
|
if (manifest?.description) item.description = manifest.description;
|
|
item.installation = await this.managedSources
|
|
?.describe(id, source.path)
|
|
.catch(() => undefined);
|
|
if (!this.managedSources)
|
|
item.installation = { identity: { kind: "directory", path: path.resolve(source.path) } };
|
|
if (item.installation?.identity.kind === "git") {
|
|
item.source = "git";
|
|
item.remote = item.installation.identity.remote;
|
|
item.commit = item.installation.currentRevision;
|
|
}
|
|
const error = this.errors.get(id);
|
|
if (error && item.status === "failed") item.error = error;
|
|
return item;
|
|
}),
|
|
);
|
|
return plugins.sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|
|
|
|
getLogs(pluginId: string): PluginLogEntry[] {
|
|
if (!this.builtinPluginIds.has(pluginId)) {
|
|
this.requireSource(pluginId);
|
|
}
|
|
return this.runtime.getLogs(pluginId);
|
|
}
|
|
|
|
catalog(): ReturnType<PluginRuntime["catalog"]> {
|
|
return this.runtime.catalog();
|
|
}
|
|
|
|
async installDirectory(input: { path: string; id?: string }): Promise<PluginListItem> {
|
|
return this.enqueue(async () => {
|
|
const directory = path.resolve(input.path);
|
|
const manifest = await readPluginManifest(directory);
|
|
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
|
const pluginId = PluginIdSchema.parse(input.id ?? manifest.id);
|
|
if (this.builtinPluginIds.has(pluginId)) {
|
|
throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`);
|
|
}
|
|
if (this.configStore.get().plugins?.[pluginId]) {
|
|
throw new Error(
|
|
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
|
);
|
|
}
|
|
const sources = {
|
|
...this.configStore.get().plugins,
|
|
[pluginId]: { source: "directory" as const, path: directory, enabled: true },
|
|
};
|
|
this.configStore.patch({ plugins: sources });
|
|
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
|
|
this.notify(pluginId);
|
|
const installed = await this.requireItem(pluginId);
|
|
if (installed.status === "failed") {
|
|
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
|
|
}
|
|
return installed;
|
|
});
|
|
}
|
|
|
|
async inspectDirectory(configuredPath: string): Promise<{ id: string }> {
|
|
return readPluginManifest(path.resolve(configuredPath));
|
|
}
|
|
|
|
async installSource(input: {
|
|
source: string;
|
|
id?: string;
|
|
ref?: string;
|
|
}): Promise<PluginListItem> {
|
|
if (input.id && this.builtinPluginIds.has(input.id)) {
|
|
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
|
|
}
|
|
const reference = parsePluginSourceReference(input.source);
|
|
if (reference.kind === "directory") {
|
|
const directory = path.resolve(expandTilde(reference.source));
|
|
const info = await stat(directory).catch(() => null);
|
|
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
|
|
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
|
|
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
|
|
return this.installDirectory({ path: pluginDirectory, id: input.id });
|
|
}
|
|
const managedSources = this.requireManagedSources();
|
|
return this.enqueue(async () => {
|
|
let candidate = await managedSources.prepareInstall({
|
|
...input,
|
|
source: reference.source,
|
|
pluginPath: reference.pluginPath,
|
|
});
|
|
let pluginId: string;
|
|
try {
|
|
await this.checkRequirements(candidate.directory);
|
|
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
|
|
if (this.builtinPluginIds.has(pluginId)) {
|
|
throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`);
|
|
}
|
|
if (this.configStore.get().plugins?.[pluginId]) {
|
|
throw new Error(
|
|
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
|
);
|
|
}
|
|
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
|
candidate = await managedSources.place(pluginId, candidate);
|
|
await this.validateCandidate(candidate);
|
|
await managedSources.verifyCandidate(pluginId, candidate);
|
|
} catch (error) {
|
|
await managedSources.discard(candidate);
|
|
throw error;
|
|
}
|
|
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
|
|
try {
|
|
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
|
|
managedSources.commit(pluginId, candidate.record);
|
|
} catch (error) {
|
|
await this.stopPlugin(pluginId);
|
|
const sources = { ...this.configStore.get().plugins };
|
|
delete sources[pluginId];
|
|
this.configStore.patch({ plugins: sources });
|
|
await managedSources.discard(candidate);
|
|
this.errors.delete(pluginId);
|
|
this.notify(pluginId);
|
|
throw error;
|
|
}
|
|
this.notify(pluginId);
|
|
return this.requireItem(pluginId);
|
|
});
|
|
}
|
|
|
|
async statusSources(pluginId?: string): Promise<PluginSourceStatusItem[]> {
|
|
return this.enqueue(async () => {
|
|
const sources = this.configStore.get().plugins ?? {};
|
|
const selected = pluginId
|
|
? [[pluginId, this.requireSource(pluginId)] as const]
|
|
: Object.entries(sources);
|
|
const managedSources = this.requireManagedSources();
|
|
const statuses: PluginSourceStatusItem[] = [];
|
|
for (const [id, source] of selected) {
|
|
statuses.push(await managedSources.status(id, source.path));
|
|
}
|
|
return statuses.sort((left, right) => left.id.localeCompare(right.id));
|
|
});
|
|
}
|
|
|
|
// COMPAT(plugin-immediate-update): added in v0.8.0; remove after 2027-03-16 when clients use reviewed targets.
|
|
async updateSources(_pluginId?: string): Promise<PluginSourceUpdateItem[]> {
|
|
throw new Error("Update the client to review plugin updates before applying them.");
|
|
}
|
|
|
|
async previewUpdates(input: {
|
|
pluginId?: string;
|
|
target?: PluginUpdateSelection;
|
|
}): Promise<PluginUpdatePreview[]> {
|
|
if (input.target && !input.pluginId)
|
|
throw new Error("An explicit update target requires one plugin ID");
|
|
const sources = this.configStore.get().plugins ?? {};
|
|
const ids = input.pluginId ? [input.pluginId] : Object.keys(sources).sort();
|
|
return Promise.all(
|
|
ids.map(async (id) => {
|
|
try {
|
|
return await this.requireManagedSources().preview(
|
|
id,
|
|
this.requireSource(id).path,
|
|
input.target,
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
id,
|
|
outcome: "error" as const,
|
|
links: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async applyUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]> {
|
|
return this.enqueue(async () => {
|
|
const results: PluginUpdateResult[] = [];
|
|
for (const proposal of proposals) {
|
|
try {
|
|
results.push(await this.updateSource(proposal));
|
|
} catch (error) {
|
|
results.push({
|
|
id: proposal.id,
|
|
outcome: "error",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
});
|
|
}
|
|
|
|
async reloadPlugin(pluginId: string): Promise<PluginListItem> {
|
|
return this.enqueue(async () => {
|
|
const source = this.requireEnabledSource(pluginId);
|
|
if (this.configStore.get().pluginsEnabled !== true) {
|
|
throw new Error("Plugins are globally disabled");
|
|
}
|
|
this.errors.delete(pluginId);
|
|
await this.stopPlugin(pluginId);
|
|
await this.startExplicit(pluginId, source.path);
|
|
this.notify(pluginId);
|
|
return this.requireItem(pluginId);
|
|
});
|
|
}
|
|
|
|
async enablePlugin(pluginId: string): Promise<PluginListItem> {
|
|
const source = this.requireSource(pluginId);
|
|
this.patchSource(pluginId, { ...source, enabled: true });
|
|
this.errors.delete(pluginId);
|
|
return this.enqueue(async () => {
|
|
const current = this.requireSource(pluginId);
|
|
if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) {
|
|
await this.startExplicit(pluginId, current.path);
|
|
}
|
|
this.notify(pluginId);
|
|
return this.requireItem(pluginId);
|
|
});
|
|
}
|
|
|
|
async disablePlugin(pluginId: string): Promise<PluginListItem> {
|
|
const source = this.requireSource(pluginId);
|
|
this.patchSource(pluginId, { ...source, enabled: false });
|
|
const stopping = this.stopPlugin(pluginId);
|
|
return this.enqueue(async () => {
|
|
await stopping;
|
|
this.errors.delete(pluginId);
|
|
this.notify(pluginId);
|
|
return this.requireItem(pluginId);
|
|
});
|
|
}
|
|
|
|
async removePlugin(pluginId: string): Promise<void> {
|
|
this.requireSource(pluginId);
|
|
const stopping = this.stopPlugin(pluginId);
|
|
const sources = { ...this.configStore.get().plugins };
|
|
delete sources[pluginId];
|
|
this.configStore.patch({ plugins: sources });
|
|
await this.enqueue(async () => {
|
|
await stopping;
|
|
this.runtime.clearLogs(pluginId);
|
|
this.errors.delete(pluginId);
|
|
this.notify(pluginId);
|
|
await this.managedSources?.remove(pluginId);
|
|
if (this.dependencies.settingsDirectory)
|
|
await rm(path.join(this.dependencies.settingsDirectory, pluginId), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
|
|
return this.runtime.invoke(pluginId, method, input);
|
|
}
|
|
|
|
async stopAllPlugins(): Promise<void> {
|
|
this.globalStartsBlocked = true;
|
|
const stopping = this.stopAll();
|
|
await this.enqueue(async () => {
|
|
await stopping;
|
|
await this.stopAll();
|
|
});
|
|
}
|
|
|
|
private handleGlobalSwitch(enabled: boolean): void {
|
|
if (!enabled) {
|
|
this.globalStartsBlocked = true;
|
|
const stopping = this.stopConfiguredPlugins();
|
|
for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id);
|
|
void this.enqueue(async () => {
|
|
await stopping;
|
|
}).catch((error) => this.logger.error({ err: error }, "Failed to disable plugins"));
|
|
return;
|
|
}
|
|
void this.enqueue(async () => {
|
|
this.globalStartsBlocked = false;
|
|
for (const [pluginId, source] of Object.entries(this.configStore.get().plugins ?? {})) {
|
|
if (source.enabled !== false) await this.startConfigured(pluginId);
|
|
this.notify(pluginId);
|
|
}
|
|
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
|
}
|
|
|
|
private async startConfigured(pluginId: string): Promise<void> {
|
|
const source = this.configStore.get().plugins?.[pluginId];
|
|
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
|
this.errors.delete(pluginId);
|
|
try {
|
|
await this.startPlugin(pluginId, source.path);
|
|
} catch (error) {
|
|
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
|
}
|
|
}
|
|
|
|
private async startExplicit(pluginId: string, sourcePath: string): Promise<void> {
|
|
try {
|
|
await this.startPlugin(pluginId, sourcePath);
|
|
} catch (error) {
|
|
if (this.canPublish(pluginId)) {
|
|
this.recordFailure(pluginId, error);
|
|
this.notify(pluginId);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private canPublish(pluginId: string): boolean {
|
|
const config = this.configStore.get();
|
|
return (
|
|
!this.globalStartsBlocked &&
|
|
config.pluginsEnabled === true &&
|
|
config.plugins?.[pluginId]?.enabled !== false &&
|
|
config.plugins?.[pluginId] !== undefined
|
|
);
|
|
}
|
|
|
|
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
|
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
|
try {
|
|
await this.publishProviderRegistrations(pluginId, sourcePath);
|
|
this.publishUsageSources(pluginId);
|
|
} catch (error) {
|
|
try {
|
|
this.removeProviderRegistrations(pluginId);
|
|
this.removeUsageSources(pluginId);
|
|
} finally {
|
|
await this.runtime.stopPluginById(pluginId);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private stopPlugin(pluginId: string): Promise<boolean> {
|
|
this.removeProviderRegistrations(pluginId);
|
|
this.removeUsageSources(pluginId);
|
|
return this.runtime.stopPluginById(pluginId);
|
|
}
|
|
|
|
private async stopConfiguredPlugins(): Promise<void> {
|
|
const stopping = Object.keys(this.configStore.get().plugins ?? {}).map((id) =>
|
|
this.stopPlugin(id),
|
|
);
|
|
await Promise.all(stopping);
|
|
}
|
|
|
|
private async stopAll(): Promise<void> {
|
|
for (const pluginId of this.providerIdsByPlugin.keys()) {
|
|
this.removeProviderRegistrations(pluginId);
|
|
}
|
|
for (const pluginId of this.usageSourceIdsByPlugin.keys()) this.removeUsageSources(pluginId);
|
|
await this.runtime.stopAll();
|
|
}
|
|
|
|
private publishUsageSources(pluginId: string): void {
|
|
const metadata = this.runtime.getUsageSourceRegistrations(pluginId);
|
|
const registered: string[] = [];
|
|
try {
|
|
for (const source of metadata) {
|
|
this.usageSources.register({
|
|
id: source.id,
|
|
label: source.label,
|
|
icon: source.icon,
|
|
discover: async (scope) => {
|
|
const result = await this.runtime.discoverUsage(pluginId, source.id, scope);
|
|
if (!Array.isArray(result))
|
|
throw new Error(`Invalid usage discovery from ${source.id}`);
|
|
return result;
|
|
},
|
|
fetch: (input) => {
|
|
return this.runtime.fetchUsage(pluginId, source.id, input);
|
|
},
|
|
});
|
|
registered.push(source.id);
|
|
}
|
|
} catch (error) {
|
|
for (const id of registered) this.usageSources.unregister(id);
|
|
throw error;
|
|
}
|
|
this.usageSourceIdsByPlugin.set(pluginId, registered);
|
|
}
|
|
|
|
private removeUsageSources(pluginId: string): void {
|
|
for (const id of this.usageSourceIdsByPlugin.get(pluginId) ?? [])
|
|
this.usageSources.unregister(id);
|
|
this.usageSourceIdsByPlugin.delete(pluginId);
|
|
}
|
|
|
|
private async publishProviderRegistrations(
|
|
pluginId: string,
|
|
pluginDirectory: string,
|
|
): Promise<void> {
|
|
const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? [];
|
|
for (const provider of metadata) {
|
|
if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) {
|
|
throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`);
|
|
}
|
|
if (this.providers.has(provider.id)) {
|
|
throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`);
|
|
}
|
|
}
|
|
const registrations = await Promise.all(
|
|
metadata.map(
|
|
async (provider): Promise<ProviderRegistration> => ({
|
|
id: provider.id,
|
|
label: provider.label,
|
|
description: provider.description,
|
|
command: provider.command,
|
|
status: provider.hasStatus
|
|
? (request) => {
|
|
if (!this.runtime.getProviderStatus)
|
|
throw new Error("Plugin runtime cannot resolve provider status");
|
|
return this.runtime.getProviderStatus(pluginId, provider.id, request);
|
|
}
|
|
: undefined,
|
|
getCatalogCacheKey: provider.hasCatalogCacheKey
|
|
? (options) => {
|
|
if (!this.runtime.getProviderCatalogCacheKey)
|
|
throw new Error("Plugin runtime cannot resolve catalogue keys");
|
|
return this.runtime.getProviderCatalogCacheKey(pluginId, provider.id, options);
|
|
}
|
|
: undefined,
|
|
icon: provider.iconPath
|
|
? await readPluginProviderIcon(pluginDirectory, provider.iconPath)
|
|
: undefined,
|
|
connect: (request) => this.runtime.connectProvider(pluginId, provider.id, request),
|
|
}),
|
|
),
|
|
);
|
|
const ids = registrations.map((provider) => provider.id);
|
|
for (const provider of registrations) {
|
|
this.providers.set(provider.id, {
|
|
...provider,
|
|
});
|
|
}
|
|
if (ids.length > 0) {
|
|
this.providerIdsByPlugin.set(pluginId, ids);
|
|
this.notifyProviderRegistrations();
|
|
}
|
|
}
|
|
|
|
private removeProviderRegistrations(pluginId: string): void {
|
|
const ids = this.providerIdsByPlugin.get(pluginId);
|
|
if (!ids) return;
|
|
this.providerIdsByPlugin.delete(pluginId);
|
|
for (const id of ids) this.providers.delete(id);
|
|
this.notifyProviderRegistrations();
|
|
}
|
|
|
|
private notifyProviderRegistrations(): void {
|
|
for (const listener of this.providerListeners) listener();
|
|
}
|
|
|
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
const result = this.lifecycle.then(operation, operation);
|
|
this.lifecycle = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
private recordFailure(pluginId: string, error: unknown): void {
|
|
this.errors.set(pluginId, error instanceof Error ? error.message : String(error));
|
|
}
|
|
|
|
private async checkRequirements(directory: string): Promise<void> {
|
|
const manifest = await readPluginManifest(directory);
|
|
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
|
}
|
|
|
|
private async updateSource(proposal: PluginUpdateProposal): Promise<PluginUpdateResult> {
|
|
const pluginId = proposal.id;
|
|
const managedSources = this.requireManagedSources();
|
|
const source = this.requireSource(pluginId);
|
|
let candidate = await managedSources.prepareUpdate(proposal, source.path);
|
|
try {
|
|
await this.checkRequirements(candidate.directory);
|
|
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
|
candidate = await managedSources.place(pluginId, candidate);
|
|
await this.validateCandidate(candidate);
|
|
await managedSources.verifyCandidate(pluginId, candidate, proposal.target);
|
|
await managedSources.assertCurrent(proposal, this.requireSource(pluginId).path);
|
|
} catch (error) {
|
|
await managedSources.discard(candidate);
|
|
throw error;
|
|
}
|
|
|
|
const current = this.requireSource(pluginId);
|
|
const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId);
|
|
const shouldActivate =
|
|
current.enabled !== false && this.configStore.get().pluginsEnabled === true;
|
|
if (shouldActivate) {
|
|
if (isRunning) await this.stopPlugin(pluginId);
|
|
try {
|
|
await this.startExplicit(pluginId, candidate.directory);
|
|
} catch (error) {
|
|
let recoveryError: unknown;
|
|
try {
|
|
this.errors.delete(pluginId);
|
|
if (isRunning && this.canPublish(pluginId))
|
|
await this.startExplicit(pluginId, source.path);
|
|
} catch (restoreError) {
|
|
recoveryError = restoreError;
|
|
} finally {
|
|
await managedSources.discard(candidate);
|
|
this.notify(pluginId);
|
|
}
|
|
if (recoveryError)
|
|
throw new Error(
|
|
`Update failed: ${error instanceof Error ? error.message : String(error)}; restoring previous plugin also failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
|
|
{ cause: error },
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
const activatedSource = this.configStore.get().plugins?.[pluginId];
|
|
if (!activatedSource) {
|
|
await this.stopPlugin(pluginId);
|
|
await managedSources.discard(candidate);
|
|
throw new Error(`Plugin is no longer configured: ${pluginId}`);
|
|
}
|
|
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
|
|
this.errors.delete(pluginId);
|
|
this.notify(pluginId);
|
|
let warning: string | undefined;
|
|
try {
|
|
await managedSources.removeVersion(pluginId, source.path);
|
|
} catch (error) {
|
|
warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
}
|
|
return {
|
|
id: pluginId,
|
|
outcome: "updated",
|
|
plugin: await this.requireItem(pluginId),
|
|
...(warning ? { warning } : {}),
|
|
};
|
|
}
|
|
|
|
private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> {
|
|
if (!this.runtime.validatePlugin) {
|
|
throw new Error("Plugin runtime cannot validate managed sources");
|
|
}
|
|
return this.runtime.validatePlugin(candidate.directory);
|
|
}
|
|
|
|
private requireManagedSources(): ManagedPluginSources {
|
|
if (!this.managedSources) throw new Error("Plugin source management is unavailable");
|
|
return this.managedSources;
|
|
}
|
|
|
|
private requireSource(pluginId: string): PluginSource {
|
|
PluginIdSchema.parse(pluginId);
|
|
const source = this.configStore.get().plugins?.[pluginId];
|
|
if (!source) throw new Error(`Plugin is not configured: ${pluginId}`);
|
|
return source;
|
|
}
|
|
|
|
private requireEnabledSource(pluginId: string): PluginSource {
|
|
const source = this.requireSource(pluginId);
|
|
if (source.enabled === false) throw new Error(`Plugin is disabled: ${pluginId}`);
|
|
return source;
|
|
}
|
|
|
|
private patchSource(pluginId: string, source: PluginSource): void {
|
|
this.configStore.patch({
|
|
plugins: { ...this.configStore.get().plugins, [pluginId]: source },
|
|
});
|
|
}
|
|
|
|
private async requireItem(pluginId: string): Promise<PluginListItem> {
|
|
const item = (await this.listPlugins()).find((plugin) => plugin.id === pluginId);
|
|
if (!item) throw new Error(`Plugin is not configured: ${pluginId}`);
|
|
return item;
|
|
}
|
|
|
|
private notify(pluginId: string): void {
|
|
for (const listener of this.listeners) listener(pluginId);
|
|
}
|
|
}
|
|
|
|
function resolveLocalPluginPath(directory: string, pluginPath: string | undefined): string {
|
|
if (!pluginPath) return directory;
|
|
if (path.isAbsolute(pluginPath)) throw new Error("Plugin path must be relative to the source");
|
|
const pluginDirectory = path.resolve(directory, pluginPath);
|
|
const relative = path.relative(directory, pluginDirectory);
|
|
const escapesSource =
|
|
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
if (escapesSource) throw new Error("Plugin path must stay inside the source directory");
|
|
return pluginDirectory;
|
|
}
|