mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: implement plugin management UI
This commit is contained in:
1 parent
88d7bc5525
commit
3ed5775ed5
52 files changed
+20072
-41
No files matched your search
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user