feat: implement plugin management UI

This commit is contained in:
LukeGus committed 2026-10-06 10:35:56 -05:00
1 parent 88d7bc5525
commit 3ed5775ed5
52 files changed
+20072 -41

No files matched your search

+54 -4
View File
@@ -29,6 +29,7 @@ import {
getUnpackedPluginsDir,
} from "./paths.js";
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
import { moveWithRetry } from "./fs-retry.js";
import {
createPluginContext,
createPluginHandle,
@@ -90,6 +91,14 @@ export class PluginLoader {
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
/**
* Bundled plugins an admin uninstalled. The shipped copy is ignored as if
* it were not there, so a reinstall from the registry loads as a normal
* download and a recreated image cannot bring the old copy back.
*/
private uninstalled = new Set<string>();
/** Versions an admin pinned, which may sit below the bundled copy. */
private pinned = new Map<string, string>();
constructor(private readonly options: PluginLoaderOptions = {}) {}
@@ -111,6 +120,33 @@ export class PluginLoader {
]);
}
setUninstalled(ids: Iterable<string>): void {
this.uninstalled = new Set(ids);
for (const id of this.uninstalled) this.bundledVersions.delete(id);
}
setPinned(pins: Map<string, string>): void {
this.pinned = new Map(pins);
}
/** Version of the copy that ships in the image, loaded or not. */
bundledVersion(pluginId: string): string | undefined {
return this.bundledVersions.get(pluginId);
}
/** Loads the shipped copy of a bundled plugin, e.g. after a reinstall. */
async loadBundled(pluginId: string): Promise<LoadedPlugin> {
if (!PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error(`invalid plugin id "${pluginId}"`);
}
const plugin = await this.load(
path.join(getBundledPluginsDir(), pluginId),
"bundled",
);
this.bundledVersions.set(plugin.id, plugin.manifest.version);
return plugin;
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
@@ -231,7 +267,10 @@ export class PluginLoader {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
if (source === "bundled") {
for (const entry of entries) {
if (entry.isDirectory()) bundledIds.add(entry.name);
if (!entry.isDirectory() || this.uninstalled.has(entry.name)) {
continue;
}
bundledIds.add(entry.name);
}
}
@@ -244,6 +283,7 @@ export class PluginLoader {
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
const dir = path.join(root, entry.name);
if (source === "bundled" && this.uninstalled.has(entry.name)) continue;
try {
if (isArtifact) {
const plugin = await this.loadArtifact(dir, bundledIds);
@@ -355,6 +395,7 @@ export class PluginLoader {
raw?.version,
signedBy,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
this.pinned.get(id),
);
} else if (this.plugins.has(id)) {
throw new Error(`another plugin already uses the id "${id}"`);
@@ -364,8 +405,12 @@ export class PluginLoader {
}
const target = path.join(unpackedRoot, id);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.rename(staging, target);
await fs.promises.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
});
await moveWithRetry(staging, target);
if (replaced) this.plugins.delete(id);
try {
@@ -547,8 +592,10 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
// A release signed by a trusted key is official whether it shipped in
// the image or was downloaded, so it may adopt its 2.8 tables too.
const applied = await migratePlugin(plugin.id, plugin.dir, {
bundled: plugin.source === "bundled",
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
@@ -776,6 +823,7 @@ export function assertBundledUpdate(
version: unknown,
signedBy: string | undefined,
bundledVersion: string | undefined,
pinnedVersion?: string,
): void {
if (!signedBy) {
throw new Error(
@@ -785,6 +833,8 @@ export function assertBundledUpdate(
if (typeof version !== "string" || !semver.valid(version)) {
throw new Error(`update for "${id}" has no valid version`);
}
// An admin pinning an exact older release is the one allowed downgrade.
if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,