mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugins` ADD `pinned_version` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -477,6 +477,13 @@
|
||||
"when": 1791209311841,
|
||||
"tag": "0067_external_session_ref",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "5",
|
||||
"when": 1791256341049,
|
||||
"tag": "0068_plugin_pinned_version",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "plugins" ADD COLUMN "pinned_version" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -477,6 +477,13 @@
|
||||
"when": 1791209310758,
|
||||
"tag": "0067_external_session_ref",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1791256340388,
|
||||
"tag": "0068_plugin_pinned_version",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugins` ADD `pinned_version` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -442,6 +442,13 @@
|
||||
"when": 1791209309626,
|
||||
"tag": "0062_external_session_ref",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 63,
|
||||
"version": "6",
|
||||
"when": 1791256339684,
|
||||
"tag": "0063_plugin_pinned_version",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -92,11 +92,16 @@ export function pluginVitestConfig(
|
||||
...options.alias,
|
||||
};
|
||||
|
||||
// Forks workers on Windows abort now and then with "Worker exited
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
@@ -104,6 +109,7 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
environment: "node",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
include: ["tests/backend/**/*.test.ts"],
|
||||
@@ -115,6 +121,7 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
environment: "jsdom",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
// Run the SDK through Vite rather than plain Node, so its import
|
||||
|
||||
@@ -508,6 +508,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
pinned_version TEXT,
|
||||
manifest_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
|
||||
@@ -1605,6 +1606,7 @@ const migrateSchema = () => {
|
||||
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
||||
// grants record whether a capability came from an admin or from bundling.
|
||||
addColumnIfNotExists("plugins", "last_error", "TEXT");
|
||||
addColumnIfNotExists("plugins", "pinned_version", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"plugin_permission_grants",
|
||||
"source",
|
||||
|
||||
@@ -1005,6 +1005,8 @@ export const plugins = mysqlTable(
|
||||
autoUpdate: boolean("auto_update")
|
||||
.notNull()
|
||||
.default(false),
|
||||
/** Version an admin pinned. Never auto-updated while set. */
|
||||
pinnedVersion: text("pinned_version"),
|
||||
manifestJson: text("manifest_json").notNull(),
|
||||
},
|
||||
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
|
||||
|
||||
@@ -1005,6 +1005,8 @@ export const plugins = pgTable(
|
||||
autoUpdate: boolean("auto_update")
|
||||
.notNull()
|
||||
.default(false),
|
||||
/** Version an admin pinned. Never auto-updated while set. */
|
||||
pinnedVersion: text("pinned_version"),
|
||||
manifestJson: text("manifest_json").notNull(),
|
||||
},
|
||||
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
|
||||
|
||||
@@ -1003,6 +1003,8 @@ export const plugins = sqliteTable(
|
||||
autoUpdate: integer("auto_update", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
/** Version an admin pinned. Never auto-updated while set. */
|
||||
pinnedVersion: text("pinned_version"),
|
||||
manifestJson: text("manifest_json").notNull(),
|
||||
},
|
||||
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PluginCreateInput {
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
pinnedVersion?: string | null;
|
||||
manifestJson: string;
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export interface PluginUpdateInput {
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
pinnedVersion?: string | null;
|
||||
manifestJson?: string;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ export class PluginRepository {
|
||||
state: input.state ?? "disabled",
|
||||
lastError: input.lastError ?? null,
|
||||
autoUpdate: input.autoUpdate ?? false,
|
||||
pinnedVersion: input.pinnedVersion ?? null,
|
||||
manifestJson: input.manifestJson,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
@@ -101,6 +104,10 @@ export class PluginRepository {
|
||||
lastError:
|
||||
input.lastError === undefined ? existing.lastError : input.lastError,
|
||||
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
|
||||
pinnedVersion:
|
||||
input.pinnedVersion === undefined
|
||||
? existing.pinnedVersion
|
||||
: input.pinnedVersion,
|
||||
manifestJson: input.manifestJson ?? existing.manifestJson,
|
||||
updatedAt: now,
|
||||
},
|
||||
|
||||
@@ -178,6 +178,28 @@ export class PluginSettingsRepository {
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** How many stored values the plugin has in each scope. */
|
||||
async countByScope(
|
||||
pluginId: string,
|
||||
): Promise<Record<PluginSettingsScope, number>> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ scope: pluginSettings.scope })
|
||||
.from(pluginSettings)
|
||||
.where(eq(pluginSettings.pluginId, pluginId));
|
||||
|
||||
const counts: Record<PluginSettingsScope, number> = {
|
||||
admin: 0,
|
||||
user: 0,
|
||||
host: 0,
|
||||
secret: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const scope = row.scope as PluginSettingsScope;
|
||||
if (scope in counts) counts[scope] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
@@ -247,11 +248,17 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let icon: string | undefined;
|
||||
let dependencies: Record<string, string> = {};
|
||||
let optionalDependencies: Record<string, string> = {};
|
||||
let description: string | undefined;
|
||||
let author: string | undefined;
|
||||
let repository: string | undefined;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
description?: unknown;
|
||||
author?: unknown;
|
||||
repository?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
@@ -262,6 +269,17 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
dependencies = manifest?.dependencies ?? {};
|
||||
optionalDependencies = manifest?.optionalDependencies ?? {};
|
||||
description =
|
||||
typeof manifest?.description === "string"
|
||||
? manifest.description
|
||||
: undefined;
|
||||
const rawAuthor = manifest?.author as { name?: unknown } | undefined;
|
||||
author =
|
||||
typeof rawAuthor?.name === "string" ? rawAuthor.name : undefined;
|
||||
repository =
|
||||
typeof manifest?.repository === "string"
|
||||
? manifest.repository
|
||||
: undefined;
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -285,6 +303,13 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
...summary,
|
||||
tier: record.tier,
|
||||
source: record.source,
|
||||
registryId: record.registryId ?? null,
|
||||
autoUpdate: Boolean(record.autoUpdate),
|
||||
pinnedVersion: record.pinnedVersion ?? null,
|
||||
description,
|
||||
author,
|
||||
repository,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
@@ -315,12 +340,70 @@ async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
return isServerManaged(pluginId);
|
||||
}
|
||||
|
||||
async function auditPluginAction(
|
||||
req: Request,
|
||||
action: string,
|
||||
pluginId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
try {
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action,
|
||||
resourceType: "plugin",
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: JSON.stringify(details),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
} catch {
|
||||
// An audit failure must not undo a change that already happened.
|
||||
}
|
||||
}
|
||||
|
||||
function sendManageError(
|
||||
res: Response,
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
operation: string,
|
||||
): void {
|
||||
if (error instanceof PluginManageError) {
|
||||
res.status(error.status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
...(error.details ?? {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
databaseLogger.error(
|
||||
fallback,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation },
|
||||
);
|
||||
res.status(500).json({ error: fallback });
|
||||
}
|
||||
|
||||
async function refuseOnLinkedDesktop(res: Response): Promise<boolean> {
|
||||
const { isLinkedDesktop } = await import("../../plugins/manage.js");
|
||||
if (!(await isLinkedDesktop())) return false;
|
||||
res.status(409).json({
|
||||
error: "Plugins on a linked desktop are managed by the server",
|
||||
code: "MANAGED_BY_SERVER",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
* patch:
|
||||
* summary: Enable or disable a plugin
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on.
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -329,6 +412,10 @@ async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: dryRun
|
||||
* schema:
|
||||
* type: boolean
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -364,8 +451,7 @@ router.patch(
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentPluginRepository();
|
||||
const record = await repository.findById(pluginId);
|
||||
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
@@ -380,33 +466,42 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist first: if the start or stop below throws, the recorded
|
||||
// intent still matches what the user asked for, and the next boot
|
||||
// acts on it rather than silently reverting.
|
||||
await repository.update(pluginId, {
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
const { planStateChange, setPluginState } =
|
||||
await import("../../plugins/manage.js");
|
||||
|
||||
const { activatePlugin, deactivatePlugin } =
|
||||
await import("../../plugins/index.js");
|
||||
if (req.query.dryRun === "1" || req.query.dryRun === "true") {
|
||||
res.json({
|
||||
id: pluginId,
|
||||
enabled,
|
||||
...(await planStateChange(pluginId, enabled)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) await activatePlugin(pluginId);
|
||||
else await deactivatePlugin(pluginId);
|
||||
const result = await setPluginState(pluginId, enabled);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin" : "disable_plugin",
|
||||
pluginId,
|
||||
{
|
||||
enable: result.enable,
|
||||
disable: result.disable,
|
||||
},
|
||||
);
|
||||
|
||||
databaseLogger.info(
|
||||
`Plugin ${pluginId} ${enabled ? "enabled" : "disabled"}`,
|
||||
{ operation: "plugin_state_change", pluginId },
|
||||
);
|
||||
|
||||
res.json({ id: pluginId, enabled });
|
||||
res.json({ id: pluginId, enabled, ...result });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to change state for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_state_change" },
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to change plugin state",
|
||||
"plugin_state_change",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to change plugin state" });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -668,7 +763,7 @@ router.delete(
|
||||
* /plugins/{id}/data:
|
||||
* delete:
|
||||
* summary: Delete everything a plugin stores
|
||||
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, capability grants and migration ledger. Role permissions stay. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
|
||||
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, migration ledger and files. Role permissions stay, and the capabilities the admin agreed to are granted again. The plugin stays installed but is switched off, so nothing is writing while its tables go. It cannot be undone.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -700,19 +795,18 @@ router.delete(
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopped first: dropping a table out from under a running plugin turns
|
||||
// its next query into an error rather than a clean shutdown.
|
||||
const { loader } = getPluginRuntime();
|
||||
if (loader.get(pluginId)?.state === "active") {
|
||||
await loader.deactivate(pluginId);
|
||||
if (await isManagedByLinkedServer(pluginId)) {
|
||||
res.status(409).json({
|
||||
error: "Managed by the linked server",
|
||||
code: "MANAGED_BY_SERVER",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { removePluginData } = await import("../../plugins/data.js");
|
||||
const removed = await removePluginData(pluginId, {
|
||||
knownPluginIds: getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => plugin.id),
|
||||
});
|
||||
// The plugin is stopped first: dropping a table out from under a
|
||||
// running plugin turns its next query into an error.
|
||||
const { deletePluginData } = await import("../../plugins/manage.js");
|
||||
const removed = await deletePluginData(pluginId, userId ?? null);
|
||||
|
||||
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
|
||||
operation: "plugin_remove_data",
|
||||
@@ -721,6 +815,10 @@ router.delete(
|
||||
tables: removed.tables.length,
|
||||
kvKeys: removed.kvKeys,
|
||||
});
|
||||
await auditPluginAction(req, "delete_plugin_data", pluginId, {
|
||||
tables: removed.tables.length,
|
||||
kvKeys: removed.kvKeys,
|
||||
});
|
||||
|
||||
res.json({ id: pluginId, removed });
|
||||
} catch (error) {
|
||||
@@ -1299,4 +1397,414 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
function readVersion(body: unknown): string | undefined | null {
|
||||
const version = (body as { version?: unknown } | undefined)?.version;
|
||||
if (version === undefined || version === null || version === "") {
|
||||
return undefined;
|
||||
}
|
||||
return typeof version === "string" && version.length <= 64 ? version : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registry:
|
||||
* get:
|
||||
* summary: List the plugins in the official registry
|
||||
* description: >
|
||||
* Fetches the official registry index (cached for 15 minutes unless
|
||||
* refresh is set) and merges it with what is installed here: the
|
||||
* installed version, the newest release this build can run, whether an
|
||||
* update asks for new capabilities, and the pin and auto-update choices.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: refresh
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry listing.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 502:
|
||||
* description: The registry could not be reached.
|
||||
*/
|
||||
router.get(
|
||||
"/registry",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { listRegistry } = await import("../../plugins/manage.js");
|
||||
const force = req.query.refresh === "1" || req.query.refresh === "true";
|
||||
res.json(await listRegistry({ force }));
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Plugin registry fetch failed", {
|
||||
operation: "plugin_registry",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(502).json({
|
||||
error: "Could not reach the plugin registry",
|
||||
code: "REGISTRY_UNREACHABLE",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/update-all:
|
||||
* post:
|
||||
* summary: Update every plugin that has a newer release
|
||||
* description: Skips pinned plugins and any update that asks for new capabilities, which are returned under needsReview for the admin to look at one by one.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What was updated, what needs review and what failed.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: This desktop is linked to a server, which manages its plugins.
|
||||
*/
|
||||
router.post(
|
||||
"/update-all",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { updateAllPlugins } = await import("../../plugins/manage.js");
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const result = await updateAllPlugins({ userId });
|
||||
for (const done of result.updated) {
|
||||
await auditPluginAction(req, "update_plugin", done.id, {
|
||||
version: done.version,
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(res, error, "Failed to update plugins", "plugin_update");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/install:
|
||||
* post:
|
||||
* summary: Install a plugin from the official registry
|
||||
* description: >
|
||||
* Downloads the release, checks its sha256 and signature against a key
|
||||
* this build trusts, loads it, grants the capabilities it declares and
|
||||
* starts it along with any installed dependency that is off. Without a
|
||||
* version the newest compatible release is used; naming an older one
|
||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||
* no download.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The installed version and the plugin's state.
|
||||
* 400:
|
||||
* description: Invalid id or version.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: Not in the official registry.
|
||||
* 409:
|
||||
* description: Already installed, incompatible, or managed by a linked server.
|
||||
* 502:
|
||||
* description: The registry could not be reached.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/install",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const version = readVersion(req.body);
|
||||
if (!ID_PATTERN.test(pluginId) || version === null) {
|
||||
res.status(400).json({ error: "Invalid plugin id or version" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { installPlugin } = await import("../../plugins/manage.js");
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const result = await installPlugin(pluginId, { version, userId });
|
||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||
version: result.version,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to install the plugin",
|
||||
"plugin_install",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/update:
|
||||
* post:
|
||||
* summary: Update or change the installed version of a plugin
|
||||
* description: >
|
||||
* Swaps in another release from the official registry, newer or older.
|
||||
* If the release declares capabilities the installed one does not, the
|
||||
* request must set acceptCapabilities, otherwise it answers 409 with
|
||||
* code CAPABILITIES_ADDED and the list. A running plugin is started
|
||||
* again on the new version. Naming a version that is not the newest
|
||||
* pins the plugin to it.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* acceptCapabilities:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new version and the plugin's state.
|
||||
* 400:
|
||||
* description: Invalid id or version.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: Not installed, or the version is not in the registry.
|
||||
* 409:
|
||||
* description: Same version, new capabilities need consent, or managed by a linked server.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/update",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const version = readVersion(req.body);
|
||||
if (!ID_PATTERN.test(pluginId) || version === null) {
|
||||
res.status(400).json({ error: "Invalid plugin id or version" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { updatePlugin } = await import("../../plugins/manage.js");
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const result = await updatePlugin(pluginId, {
|
||||
version,
|
||||
userId,
|
||||
acceptCapabilities: req.body?.acceptCapabilities === true,
|
||||
});
|
||||
await auditPluginAction(req, "update_plugin", pluginId, {
|
||||
version: result.version,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to update the plugin",
|
||||
"plugin_update",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}:
|
||||
* delete:
|
||||
* summary: Uninstall a plugin
|
||||
* description: >
|
||||
* Stops the plugin, drops its tables, settings, secrets, grants and
|
||||
* files, and removes it. A plugin that ships with Termix keeps its
|
||||
* files in the image but is marked uninstalled and never loads again
|
||||
* until it is installed from the Plugins tab. Refused while a running
|
||||
* plugin depends on it.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What was removed.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: Other running plugins depend on it, or managed by a linked server.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
res.status(400).json({ error: "Invalid plugin id" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { uninstallPlugin } = await import("../../plugins/manage.js");
|
||||
const result = await uninstallPlugin(pluginId);
|
||||
await auditPluginAction(req, "uninstall_plugin", pluginId, {
|
||||
tables: result.removed.tables.length,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to uninstall the plugin",
|
||||
"plugin_uninstall",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/options:
|
||||
* patch:
|
||||
* summary: Change a plugin's update choices
|
||||
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* autoUpdate:
|
||||
* type: boolean
|
||||
* pinned:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The saved choices.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id/options",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const { autoUpdate, pinned } = req.body ?? {};
|
||||
if (
|
||||
(autoUpdate !== undefined && typeof autoUpdate !== "boolean") ||
|
||||
(pinned !== undefined && typeof pinned !== "boolean")
|
||||
) {
|
||||
res.status(400).json({ error: "autoUpdate and pinned must be booleans" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { setPluginOptions } = await import("../../plugins/manage.js");
|
||||
const result = await setPluginOptions(pluginId, { autoUpdate, pinned });
|
||||
await auditPluginAction(req, "update_plugin_options", pluginId, result);
|
||||
res.json({ id: pluginId, ...result });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to save plugin options",
|
||||
"plugin_options",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/data:
|
||||
* get:
|
||||
* summary: Summarize what a plugin stores
|
||||
* description: The plugin's tables with row counts, key/value count, stored settings per scope, applied migrations, capability grants and the size of its files folder. No values are returned.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The summary.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/data",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const { getPluginDataSummary } = await import("../../plugins/manage.js");
|
||||
res.json({ id: pluginId, ...(await getPluginDataSummary(pluginId)) });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read the plugin's data",
|
||||
"plugin_data",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Applies plugin updates on a timer for plugins with auto-update on. An
|
||||
* update that asks for a new capability is never applied here; admins get
|
||||
* an alert and review it in the Plugins tab.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
const FIRST_CHECK_MS = 2 * 60_000;
|
||||
const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
export async function runPluginAutoUpdate(): Promise<void> {
|
||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||
if (await isLinkedDesktop()) return;
|
||||
|
||||
const result = await updateAllPlugins({ userId: null, onlyAutoUpdate: true });
|
||||
|
||||
for (const done of result.updated) {
|
||||
pluginLogger.info(`Auto-updated plugin ${done.id} to ${done.version}`, {
|
||||
operation: "plugin_auto_update",
|
||||
});
|
||||
}
|
||||
for (const failed of result.failed) {
|
||||
pluginLogger.warn(`Auto-update of ${failed.id} failed: ${failed.error}`, {
|
||||
operation: "plugin_auto_update",
|
||||
});
|
||||
}
|
||||
if (result.needsReview.length === 0) return;
|
||||
|
||||
const { sendCoreAlert } = await import("../notify/core-notify.js");
|
||||
for (const entry of result.needsReview) {
|
||||
await sendCoreAlert({
|
||||
title: `A plugin update needs review`,
|
||||
body: `The new version of ${entry.id} asks for more access, so it was not installed on its own. Review it in Plugins.`,
|
||||
severity: "info",
|
||||
category: "termix.plugin-update",
|
||||
dedupeKey: `plugin-review:${entry.id}:${entry.capabilities.join(",")}`,
|
||||
audience: "admins",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startPluginAutoUpdate(): void {
|
||||
if (timer) return;
|
||||
const run = () =>
|
||||
void runPluginAutoUpdate().catch((error) => {
|
||||
pluginLogger.warn(
|
||||
`Plugin auto-update check failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_auto_update" },
|
||||
);
|
||||
});
|
||||
setTimeout(run, FIRST_CHECK_MS).unref?.();
|
||||
timer = setInterval(run, CHECK_EVERY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function stopPluginAutoUpdate(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
@@ -263,6 +263,34 @@ export async function removePluginData(
|
||||
return { tables: dropped, kvKeys, migrations };
|
||||
}
|
||||
|
||||
/** The plugin's tables in the live database, with how many rows each holds. */
|
||||
export async function describePluginTables(
|
||||
pluginId: string,
|
||||
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
|
||||
): Promise<Array<{ name: string; rows: number | null }>> {
|
||||
const dialect = options.dialect ?? resolveDatabaseDialect();
|
||||
const tables = ownedTableNames(
|
||||
pluginId,
|
||||
await listDatabaseTables(dialect),
|
||||
options.knownPluginIds ?? [],
|
||||
).sort();
|
||||
|
||||
const { selectRows } = await import("../utils/crypto-migration/raw-rows.js");
|
||||
const result: Array<{ name: string; rows: number | null }> = [];
|
||||
for (const table of tables) {
|
||||
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
|
||||
try {
|
||||
const [row] = await selectRows<Record<string, unknown>>(
|
||||
sql.raw(`SELECT COUNT(*) AS count FROM ${quoted}`),
|
||||
);
|
||||
result.push({ name: table, rows: Number(row?.count ?? row?.COUNT ?? 0) });
|
||||
} catch {
|
||||
result.push({ name: table, rows: null });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables in `all` that belong to this plugin: its prefix, minus any that
|
||||
* sit under a longer prefix another plugin owns.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
// Windows refuses a rename while anything holds a file open, and antivirus
|
||||
// or the search indexer often does for a moment right after files are
|
||||
// written. These are worth waiting out.
|
||||
const TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY"]);
|
||||
const DELAYS_MS = [50, 100, 200, 400, 800, 1600];
|
||||
|
||||
function isTransient(error: unknown): boolean {
|
||||
return TRANSIENT.has((error as NodeJS.ErrnoException)?.code ?? "");
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Renames, retrying while the error looks like a passing lock. A directory
|
||||
* that still cannot be renamed is copied and the source removed instead.
|
||||
*/
|
||||
export async function moveWithRetry(
|
||||
from: string,
|
||||
to: string,
|
||||
delays: readonly number[] = DELAYS_MS,
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(from, to);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransient(error)) throw error;
|
||||
lastError = error;
|
||||
if (attempt < delays.length) await sleep(delays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(from).catch(() => null);
|
||||
if (!stat) throw lastError;
|
||||
await fs.promises.cp(from, to, { recursive: true, force: true });
|
||||
await fs.promises
|
||||
.rm(from, { recursive: true, force: true, maxRetries: 5 })
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -215,6 +215,9 @@ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
|
||||
export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
|
||||
const { applyStoredPluginChoices } = await import("./manage.js");
|
||||
await applyStoredPluginChoices(pluginLoader);
|
||||
|
||||
const loaded = await pluginLoader.loadAll();
|
||||
if (loaded.length === 0) return [];
|
||||
|
||||
@@ -567,6 +570,14 @@ export async function installPluginArtifact(
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/** Gives freshly loaded plugins a row and their bundled grants. */
|
||||
export async function registerLoadedPlugins(
|
||||
loaded: LoadedPlugin[],
|
||||
): Promise<void> {
|
||||
await seedPlugins(loaded);
|
||||
await syncCapabilityGrants(loaded);
|
||||
}
|
||||
|
||||
/** Stops a plugin and forgets it, before a new version is installed. */
|
||||
export async function unloadPlugin(pluginId: string): Promise<void> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -112,8 +112,9 @@ export function readMigrations(
|
||||
*
|
||||
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
|
||||
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
|
||||
* A legacy core table is allowed only for the bundled plugin that adopts it:
|
||||
* a user plugin with the same id gets no exemption.
|
||||
* A legacy core table is allowed only for the official plugin that adopts
|
||||
* it, bundled or signed by a trusted key: an unsigned plugin with the same id
|
||||
* gets no exemption.
|
||||
*/
|
||||
export function assertOwnedTables(
|
||||
pluginId: string,
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* The official plugin registry: fetching its index and downloading a release.
|
||||
*
|
||||
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
|
||||
* is trusted on its own: every download is checked against the sha256 and
|
||||
* Ed25519 signature listed for it, and the signature must come from a key
|
||||
* pinned in trust.ts. A tampered index can at most point at a file that then
|
||||
* fails the check.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { verifyPluginArtifact } from "./trust.js";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
export const OFFICIAL_REGISTRY_ID = "official";
|
||||
export const OFFICIAL_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
|
||||
|
||||
const CACHE_MS = 15 * 60_000;
|
||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export interface RegistryVersion {
|
||||
version: string;
|
||||
api: string;
|
||||
url: string;
|
||||
sha256: string;
|
||||
signature: string;
|
||||
size: number;
|
||||
capabilities: string[];
|
||||
releaseNotesUrl?: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
export interface RegistryPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
/** Newest first. */
|
||||
versions: RegistryVersion[];
|
||||
}
|
||||
|
||||
export interface RegistryIndex {
|
||||
registry: string;
|
||||
name: string;
|
||||
updatedAt?: string;
|
||||
plugins: RegistryPlugin[];
|
||||
}
|
||||
|
||||
export function getRegistryUrl(): string {
|
||||
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function parseVersion(raw: unknown): RegistryVersion | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const version = asString(entry.version);
|
||||
const url = asString(entry.url);
|
||||
const sha256 = asString(entry.sha256);
|
||||
const signature = asString(entry.signature);
|
||||
if (!version || !semver.valid(version) || !url || !sha256 || !signature) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
|
||||
try {
|
||||
if (new URL(url).protocol !== "https:") return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version,
|
||||
api: asString(entry.api) ?? "",
|
||||
url,
|
||||
sha256: sha256.toLowerCase(),
|
||||
signature,
|
||||
size: typeof entry.size === "number" ? entry.size : 0,
|
||||
capabilities: Array.isArray(entry.capabilities)
|
||||
? entry.capabilities.filter((c): c is string => typeof c === "string")
|
||||
: [],
|
||||
releaseNotesUrl: asString(entry.releaseNotesUrl),
|
||||
publishedAt: asString(entry.publishedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates a fetched index. Malformed entries are dropped, not fatal. */
|
||||
export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("registry index is not an object");
|
||||
}
|
||||
const index = raw as Record<string, unknown>;
|
||||
if (!Array.isArray(index.plugins)) {
|
||||
throw new Error("registry index has no plugins list");
|
||||
}
|
||||
|
||||
const plugins: RegistryPlugin[] = [];
|
||||
for (const item of index.plugins) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const id = asString(entry.id);
|
||||
if (!id || !ID_PATTERN.test(id)) continue;
|
||||
const versions = (Array.isArray(entry.versions) ? entry.versions : [])
|
||||
.map(parseVersion)
|
||||
.filter((v): v is RegistryVersion => v !== null)
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version));
|
||||
if (versions.length === 0) continue;
|
||||
plugins.push({
|
||||
id,
|
||||
name: asString(entry.name) ?? id,
|
||||
description: asString(entry.description) ?? "",
|
||||
author: asString(entry.author) ?? "",
|
||||
category: asString(entry.category) ?? "",
|
||||
repository: asString(entry.repository),
|
||||
icon: asString(entry.icon),
|
||||
versions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
|
||||
name: asString(index.name) ?? "",
|
||||
updatedAt: asString(index.updatedAt),
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether this build can run a release built against `api`. */
|
||||
export function isApiCompatible(api: string): boolean {
|
||||
const major = semver.coerce(api)?.major;
|
||||
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
/** Follows redirects by hand so each hop still gets the outbound checks. */
|
||||
async function fetchFollowing(url: string): Promise<Response> {
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response = await safeOutboundFetch(current, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": "Termix" },
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error(`redirect without a location: ${current}`);
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${current} answered ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error(`too many redirects fetching ${url}`);
|
||||
}
|
||||
|
||||
async function readLimited(response: Response, limit: number): Promise<Buffer> {
|
||||
const declared = Number(response.headers.get("content-length") ?? 0);
|
||||
if (declared > limit) throw new Error("download is larger than allowed");
|
||||
if (!response.body) return Buffer.alloc(0);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
const reader = response.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > limit) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error("download is larger than allowed");
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
|
||||
let inflight: Promise<RegistryIndex> | null = null;
|
||||
|
||||
export interface RegistryStatus {
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let lastError: string | null = null;
|
||||
|
||||
export function getRegistryStatus(): RegistryStatus {
|
||||
return {
|
||||
url: getRegistryUrl(),
|
||||
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
|
||||
error: lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRegistryIndex(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<RegistryIndex> {
|
||||
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
|
||||
return cache.index;
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const response = await fetchFollowing(getRegistryUrl());
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
|
||||
cache = {
|
||||
index,
|
||||
at: Date.now(),
|
||||
hash: crypto.createHash("sha256").update(body).digest("hex"),
|
||||
};
|
||||
lastError = null;
|
||||
return index;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
// A stale index beats none when the registry is briefly unreachable.
|
||||
if (cache) {
|
||||
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
|
||||
operation: "plugin_registry",
|
||||
});
|
||||
return cache.index;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
inflight = null;
|
||||
lastError = null;
|
||||
}
|
||||
|
||||
export function findRelease(
|
||||
index: RegistryIndex,
|
||||
pluginId: string,
|
||||
version?: string,
|
||||
): { plugin: RegistryPlugin; release: RegistryVersion } | null {
|
||||
const plugin = index.plugins.find((entry) => entry.id === pluginId);
|
||||
if (!plugin) return null;
|
||||
const release = version
|
||||
? plugin.versions.find((entry) => entry.version === version)
|
||||
: plugin.versions.find((entry) => isApiCompatible(entry.api));
|
||||
if (!release) return null;
|
||||
return { plugin, release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a release, verifies it, and leaves it in a staging folder the
|
||||
* loader never scans. The caller moves it into place.
|
||||
*/
|
||||
export async function downloadRelease(
|
||||
pluginId: string,
|
||||
release: RegistryVersion,
|
||||
): Promise<{ file: string; signatureFile: string }> {
|
||||
if (!isApiCompatible(release.api)) {
|
||||
throw new Error(
|
||||
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
|
||||
);
|
||||
}
|
||||
|
||||
const limit =
|
||||
release.size > 0 ? release.size + 1024 * 1024 : MAX_ARTIFACT_BYTES;
|
||||
const response = await fetchFollowing(release.url);
|
||||
const buffer = await readLimited(
|
||||
response,
|
||||
Math.min(limit, MAX_ARTIFACT_BYTES),
|
||||
);
|
||||
|
||||
const verified = verifyPluginArtifact(
|
||||
buffer,
|
||||
release.sha256,
|
||||
release.signature,
|
||||
);
|
||||
if (verified.ok === false) {
|
||||
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
||||
}
|
||||
|
||||
const staging = getDownloadsDir();
|
||||
await fs.promises.mkdir(staging, { recursive: true });
|
||||
const base = path.join(
|
||||
staging,
|
||||
`${pluginId}-${release.version}-${crypto.randomBytes(4).toString("hex")}.tmxplug`,
|
||||
);
|
||||
await fs.promises.writeFile(base, buffer);
|
||||
await fs.promises.writeFile(`${base}.sig`, release.signature.trim());
|
||||
return { file: base, signatureFile: `${base}.sig` };
|
||||
}
|
||||
|
||||
/** A dot folder inside the plugins dir, which the loader's scan skips. */
|
||||
export function getDownloadsDir(): string {
|
||||
return path.join(getPluginsDir(), ".downloads");
|
||||
}
|
||||
@@ -248,6 +248,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runPluginDataMigrations } =
|
||||
await import("./upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
const { startPluginAutoUpdate } =
|
||||
await import("./plugins/auto-update.js");
|
||||
startPluginAutoUpdate();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginManageError } from "../../../plugins/manage.js";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
@@ -161,6 +162,32 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const manageMock = vi.hoisted(() => ({
|
||||
listRegistry: vi.fn(),
|
||||
installPlugin: vi.fn(),
|
||||
updatePlugin: vi.fn(),
|
||||
updateAllPlugins: vi.fn(),
|
||||
uninstallPlugin: vi.fn(),
|
||||
setPluginOptions: vi.fn(),
|
||||
getPluginDataSummary: vi.fn(),
|
||||
deletePluginData: vi.fn(),
|
||||
planStateChange: vi.fn(),
|
||||
setPluginState: vi.fn(),
|
||||
isLinkedDesktop: vi.fn(async () => false),
|
||||
}));
|
||||
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
|
||||
|
||||
vi.mock("../../../plugins/manage.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/manage.js")>()),
|
||||
...manageMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: auditMock.logAudit,
|
||||
getAuditUsername: async () => "admin",
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||
.default;
|
||||
|
||||
@@ -583,4 +610,139 @@ describe("plugins route", () => {
|
||||
expect(state.grants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(manageMock)) fn.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("installs a plugin as the caller and audits it", async () => {
|
||||
manageMock.installPlugin.mockResolvedValue({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
const res = await post("/plugins/docker/install", { version: "1.0.0" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
|
||||
version: "1.0.0",
|
||||
userId: "admin-1",
|
||||
});
|
||||
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "install_plugin",
|
||||
resourceId: "docker",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a bad id or version before touching anything", async () => {
|
||||
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
|
||||
expect(
|
||||
(await post("/plugins/docker/install", { version: 7 })).status,
|
||||
).toBe(400);
|
||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a refusal through with its code and details", async () => {
|
||||
manageMock.updatePlugin.mockRejectedValue(
|
||||
new PluginManageError("new caps", 409, "CAPABILITIES_ADDED", {
|
||||
capabilities: ["notify:send"],
|
||||
}),
|
||||
);
|
||||
const res = await post("/plugins/docker/update", {});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: "CAPABILITIES_ADDED",
|
||||
capabilities: ["notify:send"],
|
||||
});
|
||||
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
|
||||
version: undefined,
|
||||
userId: "admin-1",
|
||||
acceptCapabilities: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uninstalls", async () => {
|
||||
manageMock.uninstallPlugin.mockResolvedValue({
|
||||
id: "docker",
|
||||
removed: { tables: ["p_docker_x"], kvKeys: 0 },
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/plugins/docker`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
|
||||
});
|
||||
|
||||
it("refuses changes on a linked desktop", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
const res = await post("/plugins/docker/install");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
|
||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews a state change without making it", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
manageMock.planStateChange.mockResolvedValue({
|
||||
enable: [],
|
||||
disable: ["addon"],
|
||||
missing: [],
|
||||
});
|
||||
const res = await fetch(
|
||||
`${baseUrl}/plugins/sample-plugin/state?dryRun=1`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
);
|
||||
expect(await res.json()).toMatchObject({ disable: ["addon"] });
|
||||
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 502 when the registry is unreachable", async () => {
|
||||
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
|
||||
const res = await fetch(`${baseUrl}/plugins/registry`);
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("403s a non-admin on every management route", async () => {
|
||||
const user = "regular-user";
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"x-test-user-id": user,
|
||||
};
|
||||
const calls = [
|
||||
fetch(`${baseUrl}/plugins/registry`, { headers }),
|
||||
post("/plugins/docker/install", {}, user),
|
||||
post("/plugins/docker/update", {}, user),
|
||||
post("/plugins/update-all", {}, user),
|
||||
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
|
||||
fetch(`${baseUrl}/plugins/docker/options`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ autoUpdate: true }),
|
||||
}),
|
||||
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
||||
];
|
||||
for (const res of await Promise.all(calls)) {
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
for (const fn of Object.values(manageMock)) {
|
||||
if (fn !== manageMock.isLinkedDesktop) {
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const manage = vi.hoisted(() => ({
|
||||
isLinkedDesktop: vi.fn(async () => false),
|
||||
updateAllPlugins: vi.fn(),
|
||||
}));
|
||||
const notify = vi.hoisted(() => ({ sendCoreAlert: vi.fn(async () => {}) }));
|
||||
|
||||
vi.mock("../../plugins/manage.js", () => manage);
|
||||
vi.mock("../../notify/core-notify.js", () => notify);
|
||||
|
||||
import { runPluginAutoUpdate } from "../../plugins/auto-update.js";
|
||||
|
||||
beforeEach(() => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(false);
|
||||
manage.updateAllPlugins.mockReset();
|
||||
notify.sendCoreAlert.mockClear();
|
||||
});
|
||||
|
||||
describe("runPluginAutoUpdate", () => {
|
||||
it("only touches plugins with auto-update on", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [{ id: "docker", version: "1.1.0" }],
|
||||
needsReview: [],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).toHaveBeenCalledWith({
|
||||
userId: null,
|
||||
onlyAutoUpdate: true,
|
||||
});
|
||||
expect(notify.sendCoreAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("alerts admins about an update that asks for more", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [],
|
||||
needsReview: [{ id: "docker", capabilities: ["notify:send"] }],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(notify.sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: "admins",
|
||||
dedupeKey: "plugin-review:docker:notify:send",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing on a linked desktop", async () => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(true);
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { moveWithRetry } from "../../plugins/fs-retry.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-move-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function locked(): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error("locked"), { code: "EPERM" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("moveWithRetry", () => {
|
||||
it("waits out a passing lock", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from"));
|
||||
const real = fs.promises.rename;
|
||||
const rename = vi
|
||||
.spyOn(fs.promises, "rename")
|
||||
.mockRejectedValueOnce(locked())
|
||||
.mockImplementation(real);
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1, 1]);
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(fs.existsSync(path.join(root, "to"))).toBe(true);
|
||||
});
|
||||
|
||||
it("copies a folder that never unlocks", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from", "nested"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "from", "nested", "a.js"), "x");
|
||||
vi.spyOn(fs.promises, "rename").mockRejectedValue(locked());
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1]);
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(root, "to", "nested", "a.js"), "utf8"),
|
||||
).toBe("x");
|
||||
expect(fs.existsSync(path.join(root, "from"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry a real error", async () => {
|
||||
const root = tempDir();
|
||||
await expect(
|
||||
moveWithRetry(path.join(root, "missing"), path.join(root, "to"), [1]),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import { PluginLoader, assertBundledUpdate } from "../../plugins/loader.js";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -484,3 +484,68 @@ describe("PluginLoader error budget", () => {
|
||||
expect(loader.get("sample-plugin")?.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader uninstall and pins", () => {
|
||||
it("ignores the shipped copy of a bundled plugin an admin uninstalled", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["ssh-terminal"]);
|
||||
expect(loader.bundledVersion("docker")).toBeUndefined();
|
||||
expect(loader.bundledIds().has("docker")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a downloaded copy of an uninstalled bundled id as a normal plugin", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
const userPlugins = path.join(user, "plugins");
|
||||
fs.mkdirSync(userPlugins, { recursive: true });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: userPlugins });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].source).toBe("user");
|
||||
});
|
||||
|
||||
it("loads the shipped copy again on reinstall", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
await loader.loadAll();
|
||||
const plugin = await loader.loadBundled("docker");
|
||||
|
||||
expect(plugin.source).toBe("bundled");
|
||||
expect(loader.get("docker")).toBe(plugin);
|
||||
});
|
||||
|
||||
it("refuses an older signed update unless that exact version is pinned", () => {
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "0.9.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "1.0.0"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", undefined, "1.1.0", "1.0.0"),
|
||||
).toThrow(/signed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Install, update, uninstall and switching, against a real PluginLoader and
|
||||
* fixture plugins on disk. The database and the registry are faked.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
tier: string;
|
||||
source: string;
|
||||
state: string;
|
||||
lastError: string | null;
|
||||
registryId: string | null;
|
||||
autoUpdate: boolean;
|
||||
pinnedVersion: string | null;
|
||||
manifestJson: string;
|
||||
}
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
rows: new Map<string, Record<string, unknown>>(),
|
||||
settings: new Map<string, string>(),
|
||||
grants: [] as Array<{ pluginId: string; capability: string }>,
|
||||
}));
|
||||
|
||||
const registry = vi.hoisted(() => ({
|
||||
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
||||
download: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => [...db.rows.values()],
|
||||
findById: async (id: string) => db.rows.get(id) ?? null,
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
registryId: null,
|
||||
lastError: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
...input,
|
||||
};
|
||||
db.rows.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
update: async (id: string, input: Record<string, unknown>) => {
|
||||
const row = db.rows.get(id);
|
||||
if (!row) return null;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) row[key] = value;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
delete: async (id: string) => db.rows.delete(id),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => void db.settings.set(key, value),
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (id: string) =>
|
||||
db.grants.filter((g) => g.pluginId === id),
|
||||
grant: async (input: { pluginId: string; capability: string }) =>
|
||||
void db.grants.push(input),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
countKeys: async () => 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/data.js", () => ({
|
||||
migratePlugin: async () => [],
|
||||
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
||||
describePluginTables: async () => [],
|
||||
registerTable: () => ({}),
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
||||
downloadRelease: registry.download,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/index.js", async () => {
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const loader = new PluginLoader();
|
||||
const seed = async (plugins: LoadedPlugin[]) => {
|
||||
for (const plugin of plugins) {
|
||||
if (db.rows.has(plugin.id)) {
|
||||
Object.assign(db.rows.get(plugin.id)!, {
|
||||
version: plugin.manifest.version,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
db.rows.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "community",
|
||||
source: plugin.source,
|
||||
state: plugin.source === "bundled" ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
registryId: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
} satisfies Row);
|
||||
}
|
||||
};
|
||||
return {
|
||||
getPluginRuntime: () => ({ loader }),
|
||||
activatePlugin: (id: string) => loader.activate(id),
|
||||
deactivatePlugin: (id: string) => loader.deactivate(id),
|
||||
registerLoadedPlugins: seed,
|
||||
unloadPlugin: async (id: string) => {
|
||||
if (!loader.get(id)) return;
|
||||
await loader.deactivate(id);
|
||||
loader.forget(id);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const manage = await import("../../plugins/manage.js");
|
||||
|
||||
const roots: string[] = [];
|
||||
let bundled: string;
|
||||
let data: string;
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function registryEntry(
|
||||
id: string,
|
||||
versions: Array<{ version: string; capabilities?: string[] }>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
versions: versions.map((v) => ({
|
||||
version: v.version,
|
||||
api: "1",
|
||||
url: `https://example.com/${id}-${v.version}.tmxplug`,
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
size: 1,
|
||||
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A .tmxplug of a fixture plugin, standing in for a verified download. */
|
||||
async function stageArtifact(
|
||||
id: string,
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build, manifestOverrides });
|
||||
const staging = path.join(data, "plugins", ".downloads");
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
const file = path.join(staging, `${id}-staged.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, ["."]);
|
||||
return { file, signatureFile: `${file}.sig` };
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const { loader } = getPluginRuntime();
|
||||
await manage.applyStoredPluginChoices(loader);
|
||||
const loaded = await loader.loadAll();
|
||||
const { registerLoadedPlugins } = await import("../../plugins/index.js");
|
||||
await registerLoadedPlugins(loaded);
|
||||
for (const plugin of loaded) {
|
||||
if (db.rows.get(plugin.id)?.state === "enabled") {
|
||||
await loader.activate(plugin.id);
|
||||
}
|
||||
}
|
||||
return loader;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bundled = tempRoot("termix-bundled-");
|
||||
data = tempRoot("termix-data-");
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
db.rows.clear();
|
||||
db.settings.clear();
|
||||
db.grants.length = 0;
|
||||
registry.index.plugins = [];
|
||||
registry.download.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("uninstall and reinstall of a bundled plugin", () => {
|
||||
it("deletes it, keeps it gone after a restart, and downloads it again", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
let loader = await boot();
|
||||
expect(loader.get("docker")?.state).toBe("active");
|
||||
|
||||
await manage.uninstallPlugin("docker");
|
||||
expect(loader.get("docker")).toBeUndefined();
|
||||
expect(db.rows.has("docker")).toBe(false);
|
||||
expect(fs.existsSync(path.join(bundled, "docker"))).toBe(false);
|
||||
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
||||
"docker",
|
||||
]);
|
||||
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
// A recreated container brings the image's copy back; it stays ignored.
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
loader = await boot();
|
||||
expect(loader.get("docker")).toBeUndefined();
|
||||
|
||||
registry.download.mockImplementation(() => stageArtifact("docker"));
|
||||
const result = await manage.installPlugin("docker", { userId: "admin" });
|
||||
expect(result.state).toBe("active");
|
||||
expect(registry.download).toHaveBeenCalledOnce();
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "docker.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(db.rows.get("docker")).toMatchObject({
|
||||
registryId: "official",
|
||||
tier: "official",
|
||||
});
|
||||
expect(db.grants.map((g) => g.capability)).toEqual([
|
||||
"hosts:read",
|
||||
"kv:own",
|
||||
]);
|
||||
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
loader = await boot();
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
});
|
||||
|
||||
it("deletes a plugin dropped in as a plain folder", async () => {
|
||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||
const loader = await boot();
|
||||
expect(loader.get("notes")?.source).toBe("user");
|
||||
|
||||
await manage.uninstallPlugin("notes");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(data, "plugins"))).toBe(true);
|
||||
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to install something already installed", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("docker", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "ALREADY_INSTALLED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("installing from the registry", () => {
|
||||
it("puts the artifact in place, grants what it declares and starts it", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("notes", { capabilities: ["kv:own"] }),
|
||||
);
|
||||
const loader = await boot();
|
||||
|
||||
const result = await manage.installPlugin("notes", { userId: "admin" });
|
||||
|
||||
expect(result).toMatchObject({ id: "notes", state: "active" });
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loader.get("notes")?.source).toBe("user");
|
||||
expect(db.rows.get("notes")?.tier).toBe("official");
|
||||
expect(db.grants).toEqual([
|
||||
expect.objectContaining({ pluginId: "notes", capability: "kv:own" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a plugin that is not in the registry", async () => {
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("nope", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "NOT_IN_REGISTRY" });
|
||||
});
|
||||
|
||||
it("pins an explicitly picked older version", async () => {
|
||||
registry.index.plugins = [
|
||||
registryEntry("notes", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
|
||||
await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" });
|
||||
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("uninstalls a registry plugin and its files", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
await manage.installPlugin("notes", { userId: "admin" });
|
||||
|
||||
await manage.uninstallPlugin("notes");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(db.rows.has("notes")).toBe(false);
|
||||
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updates", () => {
|
||||
it("waits for consent when a release asks for new capabilities", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [
|
||||
{
|
||||
version: "1.1.0",
|
||||
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
||||
},
|
||||
{ version: "1.0.0" },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
await expect(
|
||||
manage.updatePlugin("docker", { userId: "admin" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "CAPABILITIES_ADDED",
|
||||
details: { capabilities: ["notify:send"] },
|
||||
});
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists an update and skips it in update-all when pinned", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
const listing = await manage.listRegistry();
|
||||
expect(listing.plugins[0]).toMatchObject({
|
||||
installed: true,
|
||||
installedVersion: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: [],
|
||||
bundled: true,
|
||||
});
|
||||
|
||||
await manage.setPluginOptions("docker", { pinned: true });
|
||||
const result = await manage.updateAllPlugins({ userId: "admin" });
|
||||
expect(result.updated).toEqual([]);
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependencies", () => {
|
||||
it("starts a dependency first and stops dependents first", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
const loader = await boot();
|
||||
|
||||
const off = await manage.setPluginState("base", false);
|
||||
expect(off.disable).toEqual(["addon"]);
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
|
||||
const plan = await manage.planStateChange("addon", true);
|
||||
expect(plan.enable).toEqual(["base"]);
|
||||
const on = await manage.setPluginState("addon", true);
|
||||
expect(on.state).toBe("active");
|
||||
expect(loader.get("base")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("refuses to uninstall a plugin a running one depends on", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
await boot();
|
||||
|
||||
await expect(manage.uninstallPlugin("base")).rejects.toMatchObject({
|
||||
code: "HAS_DEPENDENTS",
|
||||
details: { dependents: ["addon"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.value as never,
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
findRelease,
|
||||
isApiCompatible,
|
||||
parseRegistryIndex,
|
||||
resetRegistryCache,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.value = {
|
||||
id: "test",
|
||||
publicKey: publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "26.10.0",
|
||||
};
|
||||
|
||||
function sign(buffer: Buffer) {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
return {
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, privateKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function response(body: string | Buffer, status = 200, headers = {}) {
|
||||
return new Response(status >= 300 && status < 400 ? null : body, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function version(overrides: Partial<RegistryVersion> = {}) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
api: "1",
|
||||
url: "https://example.com/p-1.0.0.tmxplug",
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
size: 10,
|
||||
capabilities: ["kv:own"],
|
||||
publishedAt: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-registry-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fetchMock.mockReset();
|
||||
resetRegistryCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
registry: "official",
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
versions: [version(), version({ version: "1.2.0" })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins).toHaveLength(1);
|
||||
expect(index.plugins[0].versions.map((v) => v.version)).toEqual([
|
||||
"1.2.0",
|
||||
"1.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops entries with a bad id, no https url or a malformed sha256", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{ id: "../evil", versions: [version()] },
|
||||
{ id: "plain", versions: [version({ url: "http://x/y.tmxplug" })] },
|
||||
{ id: "short", versions: [version({ sha256: "abc" })] },
|
||||
{ id: "ok", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("refuses something that is not an index", () => {
|
||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelease", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [
|
||||
version({ version: "2.0.0", api: "2" }),
|
||||
version({ version: "1.1.0" }),
|
||||
version(),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("picks the newest release this build can run", () => {
|
||||
expect(isApiCompatible("1")).toBe(true);
|
||||
expect(isApiCompatible("2")).toBe(false);
|
||||
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("finds an exact version", () => {
|
||||
expect(findRelease(index, "docker", "1.0.0")?.release.version).toBe(
|
||||
"1.0.0",
|
||||
);
|
||||
expect(findRelease(index, "docker", "9.9.9")).toBeNull();
|
||||
expect(findRelease(index, "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRegistryIndex", () => {
|
||||
const body = JSON.stringify({
|
||||
plugins: [{ id: "docker", versions: [version()] }],
|
||||
});
|
||||
|
||||
it("follows redirects and caches the result", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://cdn.example.com/index.json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(body));
|
||||
|
||||
const first = await fetchRegistryIndex();
|
||||
const second = await fetchRegistryIndex();
|
||||
expect(first.plugins[0].id).toBe("docker");
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
"https://cdn.example.com/index.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the stale copy when a refresh fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
await expect(fetchRegistryIndex()).rejects.toThrow(/503/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadRelease", () => {
|
||||
const artifact = Buffer.from("plugin archive bytes");
|
||||
|
||||
it("writes a verified artifact and its signature to staging", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({ ...signed, size: artifact.length }),
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
expect(fs.readFileSync(result.signatureFile, "utf8")).toBe(
|
||||
signed.signature,
|
||||
);
|
||||
expect(
|
||||
result.file.startsWith(path.join(dataDir, "plugins", ".downloads")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a file that does not match its signature", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 0 })),
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
).rejects.toThrow(/plugin API/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a download larger than the index says", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.alloc(3 * 1024 * 1024)));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 10 })),
|
||||
).rejects.toThrow(/larger than allowed/);
|
||||
});
|
||||
});
|
||||
@@ -149,7 +149,8 @@ type KnownTabType =
|
||||
| "settings"
|
||||
| "user-profile"
|
||||
| "admin-settings"
|
||||
| "split-screen";
|
||||
| "split-screen"
|
||||
| "plugins";
|
||||
|
||||
/**
|
||||
* TabType covers every built-in tab plus any plugin-contributed tab id.
|
||||
|
||||
@@ -238,6 +238,7 @@ import {
|
||||
setShellLayoutProvider,
|
||||
} from "@/plugin-host/shell-bridge";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
|
||||
export { buildHostTree } from "@/sidebar/build-host-tree";
|
||||
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
@@ -1723,6 +1724,7 @@ export function AppShell({
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.manage"),
|
||||
settings: t("nav.settings"),
|
||||
plugins: t("nav.plugins"),
|
||||
};
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
@@ -1776,6 +1778,12 @@ export function AppShell({
|
||||
const openSingletonTabRef = useRef(openSingletonTab);
|
||||
openSingletonTabRef.current = openSingletonTab;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => openSingletonTabRef.current("plugins");
|
||||
window.addEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||
return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const getTabCloseLabel = useCallback((tab: Tab) => {
|
||||
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
||||
}, []);
|
||||
@@ -3161,6 +3169,7 @@ export function AppShell({
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"plugins",
|
||||
].includes(type)
|
||||
) {
|
||||
openSingletonTab(type, pendingEvent);
|
||||
|
||||
@@ -237,9 +237,209 @@ export interface PluginSummary {
|
||||
lastError?: string | null;
|
||||
/** Paths a running plugin serves without login. Admins only. */
|
||||
publicRoutes?: { http: string[]; ws: string[] };
|
||||
registryId?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
pinnedVersion?: string | null;
|
||||
description?: string;
|
||||
author?: string;
|
||||
repository?: string;
|
||||
signedBy?: string | null;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||
const response = await rbacApi.get("/plugins");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
/** Fired after any change to installed plugins; the loader resyncs on it. */
|
||||
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
function announcePluginsChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export interface RegistryPluginVersion {
|
||||
version: string;
|
||||
compatible: boolean;
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface RegistryPluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
versions: RegistryPluginVersion[];
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
addedCapabilities: string[];
|
||||
pinnedVersion: string | null;
|
||||
autoUpdate: boolean;
|
||||
bundled: boolean;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
registry: {
|
||||
id: string;
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
managedByServer: boolean;
|
||||
plugins: RegistryPluginEntry[];
|
||||
}
|
||||
|
||||
export interface PluginStateChange {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
enable: string[];
|
||||
disable: string[];
|
||||
missing: string[];
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface PluginDataSummary {
|
||||
id: string;
|
||||
tables: Array<{ name: string; rows: number | null }>;
|
||||
kvKeys: number;
|
||||
settings: { admin: number; user: number; host: number; secret: number };
|
||||
migrations: Array<{ id: string; appliedAt: string | null }>;
|
||||
grants: Array<{
|
||||
capability: string;
|
||||
source: string;
|
||||
grantedAt: string | null;
|
||||
}>;
|
||||
filesBytes: number;
|
||||
}
|
||||
|
||||
export interface UpdateAllResult {
|
||||
updated: Array<{ id: string; version: string }>;
|
||||
needsReview: Array<{ id: string; capabilities: string[] }>;
|
||||
failed: Array<{ id: string; error: string }>;
|
||||
}
|
||||
|
||||
const pluginPath = (pluginId: string) =>
|
||||
`/plugins/${encodeURIComponent(pluginId)}`;
|
||||
|
||||
export async function getPluginRegistry(
|
||||
refresh = false,
|
||||
): Promise<RegistryListing> {
|
||||
const response = await rbacApi.get("/plugins/registry", {
|
||||
params: refresh ? { refresh: 1 } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function installPlugin(
|
||||
pluginId: string,
|
||||
version?: string,
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(`${pluginPath(pluginId)}/install`, {
|
||||
version,
|
||||
});
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlugin(
|
||||
pluginId: string,
|
||||
options: { version?: string; acceptCapabilities?: boolean } = {},
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`${pluginPath(pluginId)}/update`,
|
||||
options,
|
||||
);
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
||||
try {
|
||||
const response = await rbacApi.post("/plugins/update-all");
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.delete(pluginPath(pluginId));
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<PluginStateChange> {
|
||||
const response = await rbacApi.patch(
|
||||
`${pluginPath(pluginId)}/state`,
|
||||
{ enabled },
|
||||
{ params: { dryRun: 1 } },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<PluginStateChange> {
|
||||
try {
|
||||
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryPlugin(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.post(`${pluginPath(pluginId)}/retry`);
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPluginOptions(
|
||||
pluginId: string,
|
||||
options: { autoUpdate?: boolean; pinned?: boolean },
|
||||
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
|
||||
const response = await rbacApi.patch(
|
||||
`${pluginPath(pluginId)}/options`,
|
||||
options,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getPluginData(
|
||||
pluginId: string,
|
||||
): Promise<PluginDataSummary> {
|
||||
const response = await rbacApi.get(`${pluginPath(pluginId)}/data`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePluginData(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
+168
-1
@@ -331,7 +331,8 @@
|
||||
"openSettings": "Open settings",
|
||||
"settings": "Settings",
|
||||
"manage": "Manage",
|
||||
"mobileNavigation": "Navigation"
|
||||
"mobileNavigation": "Navigation",
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"hosts": {
|
||||
"hosts": "Hosts",
|
||||
@@ -1952,6 +1953,172 @@
|
||||
"loading": "Loading {{name}}",
|
||||
"crashed": "Something went wrong in {{name}}",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"manager": {
|
||||
"tabs": {
|
||||
"installed": "Installed",
|
||||
"browse": "Browse",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"status": {
|
||||
"summary": "{{installed}} installed, {{running}} running",
|
||||
"running": "Running",
|
||||
"disabled": "Disabled",
|
||||
"failed": "Stopped unexpectedly",
|
||||
"blocked": "Waiting on another plugin"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Running",
|
||||
"notRunning": "Not running"
|
||||
},
|
||||
"sort": {
|
||||
"name": "Name",
|
||||
"updated": "Recently updated",
|
||||
"category": "Category"
|
||||
},
|
||||
"risk": {
|
||||
"critical": "Critical",
|
||||
"high": "High risk",
|
||||
"medium": "Medium risk",
|
||||
"low": "Low risk"
|
||||
},
|
||||
"contributes": {
|
||||
"tabs_one": "{{count}} tab",
|
||||
"tabs_other": "{{count}} tabs",
|
||||
"panels_one": "{{count}} panel",
|
||||
"panels_other": "{{count}} panels",
|
||||
"dashboardCards_one": "{{count}} dashboard card",
|
||||
"dashboardCards_other": "{{count}} dashboard cards",
|
||||
"actions_one": "{{count}} action",
|
||||
"actions_other": "{{count}} actions",
|
||||
"protocols_one": "{{count}} connection type",
|
||||
"protocols_other": "{{count}} connection types",
|
||||
"auth_one": "{{count}} sign in method",
|
||||
"auth_other": "{{count}} sign in methods",
|
||||
"settings_one": "Settings",
|
||||
"settings_other": "Settings",
|
||||
"hostCapability_one": "Host settings",
|
||||
"hostCapability_other": "Host settings",
|
||||
"permissions_one": "{{count}} role permission",
|
||||
"permissions_other": "{{count}} role permissions",
|
||||
"keybindingActions_one": "{{count}} shortcut",
|
||||
"keybindingActions_other": "{{count}} shortcuts",
|
||||
"syncEntities_one": "Desktop sync",
|
||||
"syncEntities_other": "Desktop sync"
|
||||
},
|
||||
"consent": {
|
||||
"installTitle": "Install {{name}}",
|
||||
"updateTitle": "Update {{name}}",
|
||||
"agreeUpdate": "Agree and update",
|
||||
"updateExplain": "This version asks for something the one you have does not, so it will not install on its own.",
|
||||
"newInVersion": "New in this version",
|
||||
"minor": "It also adds its own screens and stores its own data, kept separate from other plugins.",
|
||||
"none": "It asks for no special access."
|
||||
},
|
||||
"confirm": {
|
||||
"uninstallTitle": "Uninstall {{name}}?",
|
||||
"uninstallBody": "This stops it and deletes everything it stores, including its settings and secrets. You can install it again later, but the data will not come back.",
|
||||
"deleteDataTitle": "Delete all data for {{name}}?",
|
||||
"deleteDataBody": "Its tables, settings, secrets and files are deleted and it is turned off. This cannot be undone.",
|
||||
"enableAlsoTitle": "Turn on {{name}}?",
|
||||
"enableAlsoBody": "It needs these to run, so they will be turned on too: {{list}}.",
|
||||
"disableAlsoTitle": "Turn off {{name}}?",
|
||||
"disableAlsoBody": "These need it to run, so they will be turned off too: {{list}}.",
|
||||
"downgradeTitle": "Go back to {{name}} {{version}}?",
|
||||
"downgradeBody": "Data written by the newer version stays as it is, and an older version may not understand it. The plugin will stay on this version until you unpin it.",
|
||||
"downgradeConfirm": "Switch version"
|
||||
},
|
||||
"toast": {
|
||||
"installed": "{{name}} installed",
|
||||
"installedNotRunning": "{{name}} installed but could not start. Open it to see why.",
|
||||
"updated": "{{name}} updated to {{version}}",
|
||||
"updatedMany_one": "{{count}} plugin updated",
|
||||
"updatedMany_other": "{{count}} plugins updated",
|
||||
"uninstalled": "{{name}} uninstalled",
|
||||
"dataDeleted": "Data for {{name}} deleted",
|
||||
"failedToStart": "{{name}} could not start. Open it to see why."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong",
|
||||
"install": "Could not install {{name}}: {{error}}",
|
||||
"update": "Could not update {{name}}: {{error}}",
|
||||
"updateAll": "Could not update plugins: {{error}}",
|
||||
"uninstall": "Could not uninstall {{name}}: {{error}}",
|
||||
"state": "Could not change {{name}}: {{error}}",
|
||||
"retry": "Could not restart {{name}}: {{error}}",
|
||||
"options": "Could not save options for {{name}}: {{error}}",
|
||||
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
||||
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
|
||||
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
|
||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed."
|
||||
},
|
||||
"data": {
|
||||
"title": "Stored data",
|
||||
"delete": "Delete data",
|
||||
"loadFailed": "Could not read what this plugin stores.",
|
||||
"tables": "Tables",
|
||||
"kv": "Keys",
|
||||
"settings": "Settings",
|
||||
"files": "Files",
|
||||
"rows_one": "{{count}} row",
|
||||
"rows_other": "{{count}} rows",
|
||||
"secrets_one": "{{count}} secret",
|
||||
"secrets_other": "{{count}} secrets",
|
||||
"migrations_one": "{{count}} migration applied",
|
||||
"migrations_other": "{{count}} migrations applied",
|
||||
"grants_one": "{{count}} capability granted",
|
||||
"grants_other": "{{count}} capabilities granted"
|
||||
},
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"restart": "Restart",
|
||||
"update": "Update",
|
||||
"updateAll": "Update all",
|
||||
"updateTo": "Update to {{version}}",
|
||||
"review": "Review",
|
||||
"reviewUpdate": "Review update",
|
||||
"updates": "Updates",
|
||||
"auto": "Auto",
|
||||
"autoUpdate": "Update on its own",
|
||||
"autoUpdateHint": "Never when the update asks for something new.",
|
||||
"pin": "Stay on this version",
|
||||
"pinHint": "Keeps {{version}} and skips every update until you turn this off.",
|
||||
"pinnedTo": "Pinned to {{version}}",
|
||||
"version": "Version",
|
||||
"versionPickHint": "Install an older release instead of the newest. It stays on that version until you unpin it.",
|
||||
"versionPickHintInstalled": "Switch to another release. Picking one that is not the newest pins it.",
|
||||
"switchVersion": "Switch",
|
||||
"latestVersion": "{{version}} (latest)",
|
||||
"versionChange": "{{from}} to {{to}}",
|
||||
"versionHistory": "Version history",
|
||||
"releaseNotes": "Release notes",
|
||||
"installedBadge": "Installed",
|
||||
"incompatible": "Needs a newer Termix",
|
||||
"whatItCanDo": "What it can do",
|
||||
"addsToTermix": "Adds to Termix",
|
||||
"addsToTermixHint": "Uninstall and every one of these goes away.",
|
||||
"noServerAccess": "No access to your servers",
|
||||
"searchInstalled": "Search installed",
|
||||
"searchPlugins": "Search plugins",
|
||||
"allCategories": "All",
|
||||
"nothingMatches": "Nothing matches that.",
|
||||
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
||||
"nothingHere": "Nothing here.",
|
||||
"nothingHereHint": "Try another category or search.",
|
||||
"upToDate": "Everything is up to date.",
|
||||
"lastChecked": "Last checked {{time}}",
|
||||
"updatesReady_one": "{{count}} update ready.",
|
||||
"updatesReady_other": "{{count}} updates ready.",
|
||||
"updatesNeedReview_one": "{{count}} more needs your review first.",
|
||||
"updatesNeedReview_other": "{{count}} more need your review first.",
|
||||
"updateAsksMore": "This update asks for more access, so it waits for you.",
|
||||
"checkRegistry": "Check for plugins and updates",
|
||||
"registryUnreachable": "Could not reach the plugin registry ({{error}}). Installed plugins still work.",
|
||||
"managedByServer": "This desktop is linked to a server, which decides its plugins. Changes are made on the server.",
|
||||
"browsePlugins": "Browse plugins",
|
||||
"loading": "Loading"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { usePluginStore } from "./plugin-store";
|
||||
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
||||
|
||||
@@ -22,6 +25,7 @@ export function PluginViewPlaceholder({
|
||||
const { t } = useTranslation();
|
||||
// Subscribing re-renders this when plugin state changes.
|
||||
usePluginStore();
|
||||
const { has } = usePermissions();
|
||||
const { status, owner } = unregisteredViewStatus(kind, viewId);
|
||||
const name = owner?.summary.name ?? viewId;
|
||||
|
||||
@@ -60,6 +64,18 @@ export function PluginViewPlaceholder({
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">{hint}</span>
|
||||
{!compact && status !== "failed" && has("admin.plugins.manage") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
|
||||
}
|
||||
>
|
||||
{t("plugins.manager.browsePlugins")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { PanePrompt } from "@/components/surface/surface-scope";
|
||||
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
|
||||
import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
|
||||
|
||||
export interface ConsentRequest {
|
||||
plugin: PluginEntry;
|
||||
mode: "install" | "update";
|
||||
version: string;
|
||||
/** What the prompt lists: all of them to install, only the new ones to update. */
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Install and update consent. Capabilities read as consequences, worst first;
|
||||
* the low risk ones fold into one line so the list stays short enough to read.
|
||||
*/
|
||||
export function PluginConsentPrompt({
|
||||
request,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
request: ConsentRequest | null;
|
||||
busy?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!request) return null;
|
||||
const { plugin, mode, version } = request;
|
||||
const ordered = orderByRisk(request.capabilities);
|
||||
const notable = ordered.filter((c) => capabilityRisk(c) !== "low");
|
||||
const minor = ordered.filter((c) => capabilityRisk(c) === "low");
|
||||
|
||||
return (
|
||||
<PanePrompt
|
||||
open
|
||||
className="max-w-md"
|
||||
onCancel={busy ? undefined : onCancel}
|
||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||
title={
|
||||
mode === "update"
|
||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||
}
|
||||
description={
|
||||
<Facts>
|
||||
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
||||
<span className="shrink-0">
|
||||
{mode === "update" && plugin.version
|
||||
? t("plugins.manager.versionChange", {
|
||||
from: plugin.version,
|
||||
to: version,
|
||||
})
|
||||
: version}
|
||||
</span>
|
||||
</Facts>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={busy}>
|
||||
{mode === "update"
|
||||
? t("plugins.manager.consent.agreeUpdate")
|
||||
: t("plugins.manager.install")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{mode === "update" && (
|
||||
<p className="text-xs leading-relaxed text-foreground">
|
||||
{t("plugins.manager.consent.updateExplain")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{mode === "update"
|
||||
? t("plugins.manager.consent.newInVersion")
|
||||
: t("plugins.manager.whatItCanDo")}
|
||||
</span>
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{notable.map((capability) => (
|
||||
<CapabilityRow
|
||||
key={capability}
|
||||
capability={capability}
|
||||
className="py-2.5"
|
||||
/>
|
||||
))}
|
||||
{minor.length > 0 && (
|
||||
<div className="py-2.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.consent.minor")}
|
||||
</div>
|
||||
)}
|
||||
{ordered.length === 0 && (
|
||||
<div className="py-2.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.consent.none")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanePrompt>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Facts, PANEL } from "@/components/panel-layout";
|
||||
import { FakeSwitch, SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
||||
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
|
||||
import {
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
orderByRisk,
|
||||
type PluginEntry,
|
||||
} from "./plugin-model";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PluginDetail({
|
||||
plugin,
|
||||
manager,
|
||||
onUninstalled,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
manager: PluginsManager;
|
||||
onUninstalled: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
|
||||
const locked = manager.managedByServer;
|
||||
const running = plugin.status === "running";
|
||||
const failed = plugin.status === "failed" || plugin.status === "blocked";
|
||||
const compatible = plugin.versions.filter((v) => v.compatible);
|
||||
const [picked, setPicked] = useState<string>(
|
||||
plugin.version ?? plugin.latestVersion ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPicked(plugin.version ?? plugin.latestVersion ?? "");
|
||||
}, [plugin.version, plugin.latestVersion]);
|
||||
|
||||
const contributions = describeContributions(plugin.contributes);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`}
|
||||
>
|
||||
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
|
||||
<PluginIconBox
|
||||
name={plugin.icon}
|
||||
size="lg"
|
||||
muted={plugin.installed && !running}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<h1 className="text-lg font-bold tracking-tight">{plugin.name}</h1>
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{plugin.description}
|
||||
</span>
|
||||
<Facts className="text-[11px] text-muted-foreground/70">
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.version ? (
|
||||
<span>v{plugin.version}</span>
|
||||
) : (
|
||||
plugin.latestVersion && <span>v{plugin.latestVersion}</span>
|
||||
)}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
{plugin.pinnedVersion && (
|
||||
<span>
|
||||
{t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Facts>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
{plugin.installed &&
|
||||
plugin.updateAvailable &&
|
||||
!plugin.pinnedVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateTo", {
|
||||
version: plugin.latestVersion,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{plugin.installed ? (
|
||||
<>
|
||||
{failed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.retry(plugin)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{t("plugins.manager.restart")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.toggle(plugin)}
|
||||
>
|
||||
{plugin.enabled
|
||||
? t("plugins.manager.disable")
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={async () => {
|
||||
if (await manager.uninstall(plugin)) onUninstalled();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("plugins.manager.uninstall")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked || !plugin.latestVersion}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{plugin.installed && failed && plugin.lastError && (
|
||||
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
</span>
|
||||
<span className="break-words font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{plugin.lastError}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
{orderByRisk(plugin.capabilities).map((capability) => (
|
||||
<CapabilityRow
|
||||
key={capability}
|
||||
capability={capability}
|
||||
showId
|
||||
className="px-4 py-2.5"
|
||||
/>
|
||||
))}
|
||||
{plugin.capabilities.length === 0 && (
|
||||
<div className="px-4 py-2.5 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.consent.none")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{plugin.versions.length > 0 && (
|
||||
<SectionCard
|
||||
title={t("plugins.manager.versionHistory")}
|
||||
icon={null}
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
{plugin.versions.map((v) => (
|
||||
<div
|
||||
key={v.version}
|
||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold">{v.version}</span>
|
||||
{v.version === plugin.version && (
|
||||
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
|
||||
{t("plugins.manager.installedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{!v.compatible && (
|
||||
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
|
||||
{t("plugins.manager.incompatible")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{formatDate(v.publishedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{v.releaseNotesUrl && (
|
||||
<a
|
||||
href={v.releaseNotesUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
||||
>
|
||||
{t("plugins.manager.releaseNotes")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{plugin.installed && (
|
||||
<DataCard
|
||||
plugin={plugin}
|
||||
disabled={busy || locked}
|
||||
onDelete={() => manager.removeData(plugin)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{contributions.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contributions.map((c) => (
|
||||
<span
|
||||
key={c.kind}
|
||||
className="border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t(`plugins.manager.contributes.${c.kind}`, {
|
||||
count: c.count,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground/70">
|
||||
{t("plugins.manager.addsToTermixHint")}
|
||||
</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{plugin.inRegistry && compatible.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.version")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{plugin.installed
|
||||
? t("plugins.manager.versionPickHintInstalled")
|
||||
: t("plugins.manager.versionPickHint")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={picked} onValueChange={setPicked}>
|
||||
<SelectTrigger size="sm" className="flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{compatible.map((v) => (
|
||||
<SelectItem
|
||||
key={v.version}
|
||||
value={v.version}
|
||||
className="text-xs"
|
||||
>
|
||||
{v.version === plugin.latestVersion
|
||||
? t("plugins.manager.latestVersion", {
|
||||
version: v.version,
|
||||
})
|
||||
: v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy || locked || !picked || picked === plugin.version
|
||||
}
|
||||
onClick={() =>
|
||||
plugin.installed
|
||||
? void manager.requestUpdate(plugin, picked)
|
||||
: manager.requestInstall(plugin, picked)
|
||||
}
|
||||
>
|
||||
{plugin.installed
|
||||
? t("plugins.manager.switchVersion")
|
||||
: t("plugins.manager.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{plugin.installed && (
|
||||
<SectionCard title={t("plugins.manager.updates")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("plugins.manager.autoUpdate")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.autoUpdateHint")}
|
||||
</span>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={plugin.autoUpdate}
|
||||
disabled={busy || locked || !!plugin.pinnedVersion}
|
||||
onChange={(v) =>
|
||||
void manager.setOptions(plugin, { autoUpdate: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<Pin className="size-3 text-muted-foreground" />
|
||||
{t("plugins.manager.pin")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.pinHint", {
|
||||
version: plugin.version,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={!!plugin.pinnedVersion}
|
||||
disabled={busy || locked}
|
||||
onChange={(v) =>
|
||||
void manager.setOptions(plugin, { pinned: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{plugin.repository && (
|
||||
<a
|
||||
href={plugin.repository}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{plugin.repository.replace(/^https?:\/\//, "")}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataCard({
|
||||
plugin,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
disabled: boolean;
|
||||
onDelete: () => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<PluginDataSummary | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(false);
|
||||
getPluginData(plugin.id)
|
||||
.then((summary) => {
|
||||
if (!cancelled) setData(summary);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [plugin.id, plugin.version, version]);
|
||||
|
||||
const settings = data
|
||||
? data.settings.admin + data.settings.user + data.settings.host
|
||||
: 0;
|
||||
const rows = data?.tables.reduce((sum, table) => sum + (table.rows ?? 0), 0);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("plugins.manager.data.title")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1 text-muted-foreground hover:text-destructive"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
if (await onDelete()) setVersion((v) => v + 1);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{t("plugins.manager.data.delete")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.data.loadFailed")}
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("plugins.manager.data.tables")}
|
||||
value={`${data.tables.length}`}
|
||||
hint={t("plugins.manager.data.rows", { count: rows ?? 0 })}
|
||||
/>
|
||||
<Stat
|
||||
label={t("plugins.manager.data.kv")}
|
||||
value={`${data.kvKeys}`}
|
||||
/>
|
||||
<Stat
|
||||
label={t("plugins.manager.data.settings")}
|
||||
value={`${settings}`}
|
||||
hint={t("plugins.manager.data.secrets", {
|
||||
count: data.settings.secret,
|
||||
})}
|
||||
/>
|
||||
<Stat
|
||||
label={t("plugins.manager.data.files")}
|
||||
value={formatBytes(data.filesBytes)}
|
||||
/>
|
||||
</div>
|
||||
{data.tables.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{data.tables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="flex items-center gap-2 px-4 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">
|
||||
{table.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{table.rows === null
|
||||
? "-"
|
||||
: t("plugins.manager.data.rows", { count: table.rows })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("plugins.manager.data.migrations", {
|
||||
count: data.migrations.length,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("plugins.manager.data.grants", { count: data.grants.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Check,
|
||||
Download,
|
||||
Globe,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import {
|
||||
Facts,
|
||||
GroupHeading,
|
||||
PANEL,
|
||||
PanelShell,
|
||||
} from "@/components/panel-layout";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginIconBox } from "./plugin-bits";
|
||||
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
||||
import { PluginDetail } from "./PluginDetail";
|
||||
import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
isSevere,
|
||||
matchesQuery,
|
||||
orderByRisk,
|
||||
sortPlugins,
|
||||
type PluginEntry,
|
||||
type SortKey,
|
||||
} from "./plugin-model";
|
||||
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
type Section = "installed" | "browse" | "updates";
|
||||
|
||||
export function PluginsTab() {
|
||||
return (
|
||||
<SurfaceScope kind="tab" className="h-full min-h-0">
|
||||
<PluginsTabBody />
|
||||
</SurfaceScope>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginsTabBody() {
|
||||
const { t } = useTranslation();
|
||||
const manager = usePluginsManager();
|
||||
const [section, setSection] = useState<Section>("installed");
|
||||
const [query, setQuery] = useState("");
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
|
||||
const installed = manager.plugins.filter((p) => p.installed);
|
||||
const available = manager.plugins.filter((p) => p.inRegistry);
|
||||
const updates = installed.filter((p) => p.updateAvailable);
|
||||
const running = installed.filter((p) => p.status === "running").length;
|
||||
const detail = manager.plugins.find((p) => p.id === detailId) ?? null;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "installed",
|
||||
label: t("plugins.manager.tabs.installed"),
|
||||
icon: <Puzzle className="size-3" />,
|
||||
count: installed.length,
|
||||
},
|
||||
{
|
||||
id: "browse",
|
||||
label: t("plugins.manager.tabs.browse"),
|
||||
icon: <Globe className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "updates",
|
||||
label: t("plugins.manager.tabs.updates"),
|
||||
icon: <Download className="size-3" />,
|
||||
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelShell
|
||||
icon={<Puzzle className="size-4" />}
|
||||
title={t("nav.plugins")}
|
||||
status={t("plugins.manager.status.summary", {
|
||||
installed: installed.length,
|
||||
running,
|
||||
})}
|
||||
scroll={false}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={t("plugins.manager.checkRegistry")}
|
||||
aria-label={t("plugins.manager.checkRegistry")}
|
||||
disabled={manager.refreshing}
|
||||
onClick={() => void manager.refresh()}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", manager.refreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
tabs={
|
||||
<TabStrip
|
||||
tabs={tabs}
|
||||
activeTab={section}
|
||||
onTabChange={(id) => setSection(id as Section)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{manager.managedByServer && (
|
||||
<Notice>{t("plugins.manager.managedByServer")}</Notice>
|
||||
)}
|
||||
{manager.registryError && (
|
||||
<Notice>
|
||||
{t("plugins.manager.registryUnreachable", {
|
||||
error: manager.registryError,
|
||||
})}
|
||||
</Notice>
|
||||
)}
|
||||
{manager.loading ? (
|
||||
<div className={PANEL.body}>
|
||||
<EmptyState icon={Puzzle} title={t("plugins.manager.loading")} />
|
||||
</div>
|
||||
) : section === "installed" ? (
|
||||
<InstalledSection
|
||||
plugins={installed}
|
||||
manager={manager}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : section === "browse" ? (
|
||||
<BrowseSection
|
||||
plugins={available}
|
||||
manager={manager}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : (
|
||||
<UpdatesSection
|
||||
plugins={updates}
|
||||
manager={manager}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
|
||||
<InlineView
|
||||
open={!!detail}
|
||||
onOpenChange={(open) => !open && setDetailId(null)}
|
||||
title={detail?.name ?? ""}
|
||||
icon={<Puzzle className="size-4" />}
|
||||
width="wide"
|
||||
bare
|
||||
>
|
||||
{detail && (
|
||||
<PluginDetail
|
||||
plugin={detail}
|
||||
manager={manager}
|
||||
onUninstalled={() => setDetailId(null)}
|
||||
/>
|
||||
)}
|
||||
</InlineView>
|
||||
|
||||
<PluginConsentPrompt
|
||||
request={manager.consent}
|
||||
onCancel={() => manager.setConsent(null)}
|
||||
onConfirm={() => void manager.confirmConsent()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2">
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: SortKey;
|
||||
onChange: (next: SortKey) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const keys: SortKey[] = ["name", "updated", "category"];
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as SortKey)}>
|
||||
<SelectTrigger size="sm" className="w-auto gap-1.5 text-xs">
|
||||
<ArrowDownWideNarrow className="size-3.5 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{keys.map((key) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{t(`plugins.manager.sort.${key}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
query,
|
||||
onQuery,
|
||||
placeholder,
|
||||
children,
|
||||
}: {
|
||||
query: string;
|
||||
onQuery: (v: string) => void;
|
||||
placeholder: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-40 max-w-xs flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2.5">
|
||||
<GroupHeading title={title} count={count} />
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection({
|
||||
plugins,
|
||||
manager,
|
||||
query,
|
||||
onQuery,
|
||||
onOpen,
|
||||
}: {
|
||||
plugins: PluginEntry[];
|
||||
manager: PluginsManager;
|
||||
query: string;
|
||||
onQuery: (v: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
const rows = sortPlugins(
|
||||
plugins.filter((p) => matchesQuery(p, query)),
|
||||
sort,
|
||||
);
|
||||
const running = rows.filter((p) => p.status === "running");
|
||||
const stopped = rows.filter((p) => p.status !== "running");
|
||||
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchInstalled")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={
|
||||
pending.length === 0 ||
|
||||
manager.busy.size > 0 ||
|
||||
manager.managedByServer
|
||||
}
|
||||
onClick={() => void manager.updateAll()}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={
|
||||
plugins.length === 0
|
||||
? t("plugins.manager.noneInstalled")
|
||||
: t("plugins.manager.nothingMatches")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{running.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.running")}
|
||||
count={running.length}
|
||||
>
|
||||
{running.map((p) => (
|
||||
<InstalledCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.notRunning")}
|
||||
count={stopped.length}
|
||||
>
|
||||
{stopped.map((p) => (
|
||||
<InstalledCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
plugin,
|
||||
muted,
|
||||
facts,
|
||||
onOpen,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
muted?: boolean;
|
||||
facts: React.ReactNode;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="group/open flex flex-1 items-start gap-3 p-3 text-left"
|
||||
>
|
||||
<PluginIconBox name={plugin.icon} muted={muted} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm font-semibold transition-colors group-hover/open:text-accent-brand",
|
||||
muted && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
</span>
|
||||
<Facts className="text-[10px] text-muted-foreground/70">{facts}</Facts>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledCard({
|
||||
plugin,
|
||||
manager,
|
||||
onOpen,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
manager: PluginsManager;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const running = plugin.status === "running";
|
||||
const failed = plugin.status === "failed" || plugin.status === "blocked";
|
||||
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
|
||||
const locked = manager.managedByServer;
|
||||
const adds = describeContributions(plugin.contributes)
|
||||
.slice(0, 3)
|
||||
.map((c) => t(`plugins.manager.contributes.${c.kind}`, { count: c.count }));
|
||||
|
||||
return (
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHead
|
||||
plugin={plugin}
|
||||
muted={!running}
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
<span>v{plugin.version}</span>
|
||||
{adds.length > 0 && <span>{adds.join(", ")}</span>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
|
||||
<span
|
||||
className="flex-1 truncate text-[11px] text-muted-foreground"
|
||||
title={plugin.lastError ?? undefined}
|
||||
>
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
</span>
|
||||
</div>
|
||||
) : !running ? (
|
||||
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.status.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-green-500" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})
|
||||
: t("plugins.manager.status.running")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
||||
<Button
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{plugin.addedCapabilities.length > 0
|
||||
? t("plugins.manager.reviewUpdate")
|
||||
: t("plugins.manager.updateTo", {
|
||||
version: plugin.latestVersion,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{failed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.retry(plugin)}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("plugins.manager.restart")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.toggle(plugin)}
|
||||
>
|
||||
{plugin.enabled
|
||||
? t("plugins.manager.disable")
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowseSection({
|
||||
plugins,
|
||||
manager,
|
||||
query,
|
||||
onQuery,
|
||||
onOpen,
|
||||
}: {
|
||||
plugins: PluginEntry[];
|
||||
manager: PluginsManager;
|
||||
query: string;
|
||||
onQuery: (v: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
||||
|
||||
const rows = sortPlugins(
|
||||
plugins.filter(
|
||||
(p) =>
|
||||
matchesQuery(p, query) &&
|
||||
(category === null || p.category === category),
|
||||
),
|
||||
sort,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{[null, ...categories].map((c) => (
|
||||
<button
|
||||
key={c ?? "__all"}
|
||||
type="button"
|
||||
onClick={() => setCategory(c)}
|
||||
className={cn(
|
||||
"border px-2 py-1 text-[11px] font-medium transition-colors",
|
||||
category === c
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c ?? t("plugins.manager.allCategories")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.nothingHere")}
|
||||
hint={
|
||||
plugins.length === 0
|
||||
? undefined
|
||||
: t("plugins.manager.nothingHereHint")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{rows.map((p) => (
|
||||
<BrowseCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowseCard({
|
||||
plugin,
|
||||
manager,
|
||||
onOpen,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
manager: PluginsManager;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const severe = orderByRisk(plugin.capabilities).filter(isSevere);
|
||||
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
|
||||
|
||||
return (
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHead
|
||||
plugin={plugin}
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
|
||||
{severe.length > 0
|
||||
? t(`plugins.capabilities.${severe[0]}.title`, {
|
||||
defaultValue: severe[0],
|
||||
nsSeparator: false,
|
||||
})
|
||||
: t("plugins.manager.noServerAccess")}
|
||||
</span>
|
||||
{plugin.installed ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
|
||||
<Check className="size-3" />
|
||||
{t("plugins.manager.installedBadge")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={busy || manager.managedByServer || !plugin.latestVersion}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
{t("plugins.manager.install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatesSection({
|
||||
plugins,
|
||||
manager,
|
||||
onOpen,
|
||||
}: {
|
||||
plugins: PluginEntry[];
|
||||
manager: PluginsManager;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pinned = plugins.filter((p) => p.pinnedVersion);
|
||||
const open = plugins.filter((p) => !p.pinnedVersion);
|
||||
const gated = open.filter((p) => p.addedCapabilities.length > 0);
|
||||
const plain = open.filter((p) => p.addedCapabilities.length === 0);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<div className={PANEL.body}>
|
||||
<EmptyState
|
||||
icon={Check}
|
||||
title={t("plugins.manager.upToDate")}
|
||||
hint={
|
||||
manager.lastCheckedAt
|
||||
? t("plugins.manager.lastChecked", {
|
||||
time: new Date(manager.lastCheckedAt).toLocaleString(),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
{plain.length > 0 && (
|
||||
<Card className="flex-row items-center gap-3 px-3 py-2.5">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{t("plugins.manager.updatesReady", { count: plain.length })}
|
||||
{gated.length > 0 &&
|
||||
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={manager.busy.size > 0 || manager.managedByServer}
|
||||
onClick={() => void manager.updateAll()}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...gated, ...plain, ...pinned].map((p) => (
|
||||
<UpdateRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRow({
|
||||
plugin,
|
||||
manager,
|
||||
onOpen,
|
||||
}: {
|
||||
plugin: PluginEntry;
|
||||
manager: PluginsManager;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const gated = plugin.addedCapabilities.length > 0;
|
||||
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
|
||||
const notes = plugin.versions.find((v) => v.version === plugin.latestVersion);
|
||||
|
||||
return (
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<div className="flex flex-wrap items-start gap-3 p-3">
|
||||
<PluginIconBox name={plugin.icon} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="truncate text-sm font-semibold hover:text-accent-brand"
|
||||
>
|
||||
{plugin.name}
|
||||
</button>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.versionChange", {
|
||||
from: plugin.version,
|
||||
to: plugin.latestVersion,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{plugin.pinnedVersion ? (
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })}
|
||||
</span>
|
||||
) : gated ? (
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.updateAsksMore")}
|
||||
</span>
|
||||
) : null}
|
||||
{notes?.releaseNotesUrl && (
|
||||
<a
|
||||
href={notes.releaseNotesUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
||||
>
|
||||
{t("plugins.manager.releaseNotes")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("plugins.manager.auto")}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={plugin.autoUpdate}
|
||||
disabled={
|
||||
busy || manager.managedByServer || !!plugin.pinnedVersion
|
||||
}
|
||||
onChange={(v) =>
|
||||
void manager.setOptions(plugin, { autoUpdate: v })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{!plugin.pinnedVersion && (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={busy || manager.managedByServer}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
>
|
||||
{gated
|
||||
? t("plugins.manager.review")
|
||||
: t("plugins.manager.update")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Asks the shell to open the Plugins tab. */
|
||||
export const OPEN_PLUGINS_EVENT = "termix:open-plugins";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { capabilityRisk, isSevere } from "./plugin-model";
|
||||
|
||||
export function PluginIconBox({
|
||||
name,
|
||||
size = "md",
|
||||
muted,
|
||||
}: {
|
||||
name?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const box = size === "lg" ? "size-10" : size === "sm" ? "size-7" : "size-9";
|
||||
const glyph =
|
||||
size === "lg" ? "size-5" : size === "sm" ? "size-3.5" : "size-4";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
box,
|
||||
"flex shrink-0 items-center justify-center border border-border bg-muted",
|
||||
muted && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<PluginIcon name={name} className={cn(glyph, "text-accent-brand")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One capability as its consequence, with a mark only on the severe ones. */
|
||||
export function CapabilityRow({
|
||||
capability,
|
||||
showId,
|
||||
className,
|
||||
}: {
|
||||
capability: string;
|
||||
showId?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const severe = isSevere(capability);
|
||||
return (
|
||||
<div className={cn("flex items-start gap-2.5", className)}>
|
||||
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
|
||||
{severe && (
|
||||
<span
|
||||
className="flex size-4 items-center justify-center border border-destructive/50 bg-destructive/10 text-[10px] font-bold text-destructive"
|
||||
title={t(`plugins.manager.risk.${capabilityRisk(capability)}`)}
|
||||
>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium leading-snug">
|
||||
{t(`plugins.capabilities.${capability}.title`, {
|
||||
defaultValue: capability,
|
||||
nsSeparator: false,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t(`plugins.capabilities.${capability}.consequence`, {
|
||||
defaultValue: "",
|
||||
nsSeparator: false,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{showId && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/50">
|
||||
{capability}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
CAPABILITY_RISK_ORDER,
|
||||
getCapabilityInfo,
|
||||
type CapabilityRisk,
|
||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||
import type {
|
||||
PluginSummary,
|
||||
RegistryPluginEntry,
|
||||
RegistryPluginVersion,
|
||||
} from "@/api/plugins-api";
|
||||
|
||||
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
|
||||
|
||||
/** One row of the Plugins tab: what is installed merged with the registry. */
|
||||
export interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
repository?: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
addedCapabilities: string[];
|
||||
versions: RegistryPluginVersion[];
|
||||
capabilities: string[];
|
||||
status: PluginStatus | null;
|
||||
enabled: boolean;
|
||||
lastError: string | null;
|
||||
autoUpdate: boolean;
|
||||
pinnedVersion: string | null;
|
||||
bundled: boolean;
|
||||
inRegistry: boolean;
|
||||
contributes: Record<string, unknown> | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||
if (summary.state === "failed") return "failed";
|
||||
if (summary.state === "blocked") return "blocked";
|
||||
if (!summary.enabled) return "disabled";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export function mergePlugins(
|
||||
installed: PluginSummary[],
|
||||
registry: RegistryPluginEntry[] | null,
|
||||
): PluginEntry[] {
|
||||
const byId = new Map((registry ?? []).map((entry) => [entry.id, entry]));
|
||||
const rows: PluginEntry[] = [];
|
||||
|
||||
for (const summary of installed) {
|
||||
const entry = byId.get(summary.id);
|
||||
byId.delete(summary.id);
|
||||
rows.push({
|
||||
id: summary.id,
|
||||
name: summary.name,
|
||||
description: summary.description ?? entry?.description ?? "",
|
||||
author: summary.author ?? entry?.author ?? "",
|
||||
category: entry?.category ?? "",
|
||||
icon: summary.icon ?? entry?.icon,
|
||||
repository: summary.repository ?? entry?.repository,
|
||||
installed: true,
|
||||
version: summary.version,
|
||||
latestVersion: entry?.latestVersion ?? null,
|
||||
updateAvailable: entry?.updateAvailable ?? false,
|
||||
addedCapabilities: entry?.addedCapabilities ?? [],
|
||||
versions: entry?.versions ?? [],
|
||||
capabilities: summary.capabilities ?? [],
|
||||
status: pluginStatus(summary),
|
||||
enabled: summary.enabled,
|
||||
lastError: summary.lastError ?? null,
|
||||
autoUpdate: summary.autoUpdate ?? entry?.autoUpdate ?? false,
|
||||
pinnedVersion: summary.pinnedVersion ?? entry?.pinnedVersion ?? null,
|
||||
bundled: entry?.bundled ?? summary.tier === "bundled",
|
||||
inRegistry: Boolean(entry),
|
||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||
publishedAt: latestPublished(entry?.versions ?? []),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of byId.values()) {
|
||||
const latest =
|
||||
entry.versions.find((v) => v.version === entry.latestVersion) ??
|
||||
entry.versions[0];
|
||||
rows.push({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
author: entry.author,
|
||||
category: entry.category,
|
||||
icon: entry.icon,
|
||||
repository: entry.repository,
|
||||
installed: false,
|
||||
version: null,
|
||||
latestVersion: entry.latestVersion,
|
||||
updateAvailable: false,
|
||||
addedCapabilities: [],
|
||||
versions: entry.versions,
|
||||
capabilities: latest?.capabilities ?? [],
|
||||
status: null,
|
||||
enabled: false,
|
||||
lastError: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
bundled: entry.bundled,
|
||||
inRegistry: true,
|
||||
contributes: null,
|
||||
publishedAt: latestPublished(entry.versions),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
||||
const dates = versions
|
||||
.map((v) => v.publishedAt)
|
||||
.filter((d): d is string => Boolean(d))
|
||||
.sort();
|
||||
return dates.at(-1) ?? null;
|
||||
}
|
||||
|
||||
export type SortKey = "name" | "updated" | "category";
|
||||
|
||||
export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
||||
const sorted = [...rows];
|
||||
if (key === "updated") {
|
||||
sorted.sort((a, b) =>
|
||||
(b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
|
||||
);
|
||||
} else if (key === "category") {
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
);
|
||||
} else {
|
||||
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function matchesQuery(row: PluginEntry, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
row.name.toLowerCase().includes(q) ||
|
||||
row.description.toLowerCase().includes(q) ||
|
||||
row.id.includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
export function categoriesOf(rows: PluginEntry[]): string[] {
|
||||
return [...new Set(rows.map((row) => row.category).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function capabilityRisk(capability: string): CapabilityRisk {
|
||||
return getCapabilityInfo(capability)?.risk ?? "medium";
|
||||
}
|
||||
|
||||
/** Worst first, the order the consent copy is read in. */
|
||||
export function orderByRisk(capabilities: string[]): string[] {
|
||||
return [...capabilities].sort(
|
||||
(a, b) =>
|
||||
CAPABILITY_RISK_ORDER.indexOf(capabilityRisk(a)) -
|
||||
CAPABILITY_RISK_ORDER.indexOf(capabilityRisk(b)),
|
||||
);
|
||||
}
|
||||
|
||||
export function isSevere(capability: string): boolean {
|
||||
const risk = capabilityRisk(capability);
|
||||
return risk === "critical" || risk === "high";
|
||||
}
|
||||
|
||||
/** Contribution kinds worth naming in "Adds to Termix", with a count each. */
|
||||
const CONTRIBUTION_KINDS = [
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"actions",
|
||||
"protocols",
|
||||
"auth",
|
||||
"settings",
|
||||
"hostCapability",
|
||||
"permissions",
|
||||
"keybindingActions",
|
||||
"syncEntities",
|
||||
] as const;
|
||||
|
||||
export type ContributionKind = (typeof CONTRIBUTION_KINDS)[number];
|
||||
|
||||
export function describeContributions(
|
||||
contributes: Record<string, unknown> | null,
|
||||
): Array<{ kind: ContributionKind; count: number }> {
|
||||
if (!contributes) return [];
|
||||
const result: Array<{ kind: ContributionKind; count: number }> = [];
|
||||
for (const kind of CONTRIBUTION_KINDS) {
|
||||
const value = contributes[kind];
|
||||
if (value === undefined || value === null || value === false) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) result.push({ kind, count: value.length });
|
||||
continue;
|
||||
}
|
||||
if (kind === "auth" && typeof value === "object") {
|
||||
const auth = value as {
|
||||
loginMethods?: unknown[];
|
||||
secondFactors?: unknown[];
|
||||
};
|
||||
const count =
|
||||
(auth.loginMethods?.length ?? 0) + (auth.secondFactors?.length ?? 0);
|
||||
if (count > 0) result.push({ kind, count });
|
||||
continue;
|
||||
}
|
||||
result.push({ kind, count: 1 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import semver from "semver";
|
||||
import {
|
||||
deletePluginData,
|
||||
getPluginRegistry,
|
||||
getPlugins,
|
||||
installPlugin,
|
||||
previewPluginState,
|
||||
retryPlugin,
|
||||
setPluginOptions,
|
||||
setPluginState,
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
updatePlugin,
|
||||
type PluginSummary,
|
||||
type RegistryListing,
|
||||
} from "@/api/plugins-api";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { mergePlugins, type PluginEntry } from "./plugin-model";
|
||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string): ApiError {
|
||||
const data = (error as { response?: { data?: Record<string, unknown> } })
|
||||
?.response?.data;
|
||||
if (data && typeof data === "object") {
|
||||
return {
|
||||
message: typeof data.error === "string" ? data.error : fallback,
|
||||
code: typeof data.code === "string" ? data.code : undefined,
|
||||
data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginsManager() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [installed, setInstalled] = useState<PluginSummary[]>([]);
|
||||
const [registry, setRegistry] = useState<RegistryListing | null>(null);
|
||||
const [registryError, setRegistryError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (refresh = false) => {
|
||||
if (refresh) setRefreshing(true);
|
||||
const [plugins, listing] = await Promise.allSettled([
|
||||
getPlugins(),
|
||||
getPluginRegistry(refresh),
|
||||
]);
|
||||
if (!mounted.current) return;
|
||||
if (plugins.status === "fulfilled") setInstalled(plugins.value);
|
||||
if (listing.status === "fulfilled") {
|
||||
setRegistry(listing.value);
|
||||
setRegistryError(listing.value.registry.error);
|
||||
} else {
|
||||
setRegistryError(readError(listing.reason, "").message || "unreachable");
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
||||
[installed, registry],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, action: () => Promise<void>) => {
|
||||
setBusy((current) => new Set(current).add(id));
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
await load();
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const failToast = useCallback(
|
||||
(error: unknown, key: string, name: string) => {
|
||||
const info = readError(error, t("plugins.manager.errors.generic"));
|
||||
if (info.code === "MANAGED_BY_SERVER") {
|
||||
toast.error(t("plugins.manager.errors.managedByServer"));
|
||||
} else if (info.code === "HAS_DEPENDENTS") {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.hasDependents", {
|
||||
name,
|
||||
list: ((info.data.dependents as string[]) ?? []).join(", "),
|
||||
}),
|
||||
);
|
||||
} else if (info.code === "MISSING_DEPENDENCIES") {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.missingDependencies", {
|
||||
name,
|
||||
list: ((info.data.missing as string[]) ?? []).join(", "),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(t(key, { name, error: info.message }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const versionCapabilities = (plugin: PluginEntry, version: string) =>
|
||||
plugin.versions.find((v) => v.version === version)?.capabilities ?? [];
|
||||
|
||||
const requestInstall = useCallback(
|
||||
(plugin: PluginEntry, version?: string) => {
|
||||
const target = version ?? plugin.latestVersion;
|
||||
if (!target) return;
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "install",
|
||||
version: target,
|
||||
capabilities: versionCapabilities(plugin, target),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const doInstall = useCallback(
|
||||
(plugin: PluginEntry, version: string) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
const result = await installPlugin(
|
||||
plugin.id,
|
||||
version === plugin.latestVersion ? undefined : version,
|
||||
);
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
t("plugins.manager.toast.installedNotRunning", {
|
||||
name: plugin.name,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("plugins.manager.toast.installed", { name: plugin.name }),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.install", plugin.name);
|
||||
}
|
||||
}),
|
||||
[run, t, failToast],
|
||||
);
|
||||
|
||||
const doUpdate = useCallback(
|
||||
(plugin: PluginEntry, version: string, acceptCapabilities: boolean) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
await updatePlugin(plugin.id, {
|
||||
version: version === plugin.latestVersion ? undefined : version,
|
||||
acceptCapabilities,
|
||||
});
|
||||
toast.success(
|
||||
t("plugins.manager.toast.updated", {
|
||||
name: plugin.name,
|
||||
version,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const info = readError(error, "");
|
||||
if (info.code === "CAPABILITIES_ADDED") {
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "update",
|
||||
version,
|
||||
capabilities: (info.data.capabilities as string[]) ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
failToast(error, "plugins.manager.errors.update", plugin.name);
|
||||
}
|
||||
}),
|
||||
[run, t, failToast],
|
||||
);
|
||||
|
||||
/** Updates, or changes to another version. New capabilities ask first. */
|
||||
const requestUpdate = useCallback(
|
||||
async (plugin: PluginEntry, version?: string) => {
|
||||
const target = version ?? plugin.latestVersion;
|
||||
if (!target || !plugin.version) return;
|
||||
if (
|
||||
semver.valid(target) &&
|
||||
semver.valid(plugin.version) &&
|
||||
semver.lt(target, plugin.version)
|
||||
) {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.confirm.downgradeTitle", {
|
||||
name: plugin.name,
|
||||
version: target,
|
||||
}),
|
||||
description: t("plugins.manager.confirm.downgradeBody"),
|
||||
confirmLabel: t("plugins.manager.confirm.downgradeConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const have = new Set(plugin.capabilities);
|
||||
const added = versionCapabilities(plugin, target).filter(
|
||||
(c) => !have.has(c),
|
||||
);
|
||||
if (added.length > 0) {
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "update",
|
||||
version: target,
|
||||
capabilities: added,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await doUpdate(plugin, target, false);
|
||||
},
|
||||
[confirm, t, doUpdate],
|
||||
);
|
||||
|
||||
const confirmConsent = useCallback(async () => {
|
||||
if (!consent) return;
|
||||
const request = consent;
|
||||
setConsent(null);
|
||||
if (request.mode === "install") {
|
||||
await doInstall(request.plugin, request.version);
|
||||
} else {
|
||||
await doUpdate(request.plugin, request.version, true);
|
||||
}
|
||||
}, [consent, doInstall, doUpdate]);
|
||||
|
||||
const nameOf = useCallback(
|
||||
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
||||
[plugins],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (plugin: PluginEntry) => {
|
||||
const enable = !plugin.enabled;
|
||||
try {
|
||||
const preview = await previewPluginState(plugin.id, enable);
|
||||
if (enable && preview.missing.length > 0) {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.missingDependencies", {
|
||||
name: plugin.name,
|
||||
list: preview.missing.join(", "),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const others = enable ? preview.enable : preview.disable;
|
||||
if (others.length > 0) {
|
||||
const ok = await confirm({
|
||||
title: enable
|
||||
? t("plugins.manager.confirm.enableAlsoTitle", {
|
||||
name: plugin.name,
|
||||
})
|
||||
: t("plugins.manager.confirm.disableAlsoTitle", {
|
||||
name: plugin.name,
|
||||
}),
|
||||
description: enable
|
||||
? t("plugins.manager.confirm.enableAlsoBody", {
|
||||
list: others.map(nameOf).join(", "),
|
||||
})
|
||||
: t("plugins.manager.confirm.disableAlsoBody", {
|
||||
list: others.map(nameOf).join(", "),
|
||||
}),
|
||||
confirmLabel: enable
|
||||
? t("plugins.manager.enable")
|
||||
: t("plugins.manager.disable"),
|
||||
destructive: !enable,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.state", plugin.name);
|
||||
return;
|
||||
}
|
||||
await run(plugin.id, async () => {
|
||||
try {
|
||||
const result = await setPluginState(plugin.id, enable);
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
t("plugins.manager.toast.failedToStart", { name: plugin.name }),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.state", plugin.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
[confirm, t, run, failToast, nameOf],
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(plugin: PluginEntry) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
await retryPlugin(plugin.id);
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.retry", plugin.name);
|
||||
}
|
||||
}),
|
||||
[run, failToast],
|
||||
);
|
||||
|
||||
const uninstall = useCallback(
|
||||
async (plugin: PluginEntry): Promise<boolean> => {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.confirm.uninstallTitle", {
|
||||
name: plugin.name,
|
||||
}),
|
||||
description: t("plugins.manager.confirm.uninstallBody"),
|
||||
confirmLabel: t("plugins.manager.uninstall"),
|
||||
});
|
||||
if (!ok) return false;
|
||||
let done = false;
|
||||
await run(plugin.id, async () => {
|
||||
try {
|
||||
await uninstallPlugin(plugin.id);
|
||||
done = true;
|
||||
toast.success(
|
||||
t("plugins.manager.toast.uninstalled", { name: plugin.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.uninstall", plugin.name);
|
||||
}
|
||||
});
|
||||
return done;
|
||||
},
|
||||
[confirm, t, run, failToast],
|
||||
);
|
||||
|
||||
const removeData = useCallback(
|
||||
async (plugin: PluginEntry): Promise<boolean> => {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.confirm.deleteDataTitle", {
|
||||
name: plugin.name,
|
||||
}),
|
||||
description: t("plugins.manager.confirm.deleteDataBody"),
|
||||
confirmLabel: t("plugins.manager.data.delete"),
|
||||
});
|
||||
if (!ok) return false;
|
||||
let done = false;
|
||||
await run(plugin.id, async () => {
|
||||
try {
|
||||
await deletePluginData(plugin.id);
|
||||
done = true;
|
||||
toast.success(
|
||||
t("plugins.manager.toast.dataDeleted", { name: plugin.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.deleteData", plugin.name);
|
||||
}
|
||||
});
|
||||
return done;
|
||||
},
|
||||
[confirm, t, run, failToast],
|
||||
);
|
||||
|
||||
const setOptions = useCallback(
|
||||
(
|
||||
plugin: PluginEntry,
|
||||
options: { autoUpdate?: boolean; pinned?: boolean },
|
||||
) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
await setPluginOptions(plugin.id, options);
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.options", plugin.name);
|
||||
}
|
||||
}),
|
||||
[run, failToast],
|
||||
);
|
||||
|
||||
const updateAll = useCallback(
|
||||
() =>
|
||||
run("*", async () => {
|
||||
try {
|
||||
const result = await updateAllPlugins();
|
||||
if (result.updated.length > 0) {
|
||||
toast.success(
|
||||
t("plugins.manager.toast.updatedMany", {
|
||||
count: result.updated.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const failed of result.failed) {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.update", {
|
||||
name: nameOf(failed.id),
|
||||
error: failed.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.updateAll", "");
|
||||
}
|
||||
}),
|
||||
[run, t, failToast, nameOf],
|
||||
);
|
||||
|
||||
return {
|
||||
plugins,
|
||||
loading,
|
||||
refreshing,
|
||||
registryError,
|
||||
managedByServer: registry?.managedByServer ?? false,
|
||||
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
|
||||
busy,
|
||||
consent,
|
||||
setConsent,
|
||||
refresh: () => load(true),
|
||||
requestInstall,
|
||||
requestUpdate,
|
||||
confirmConsent,
|
||||
toggle,
|
||||
retry,
|
||||
uninstall,
|
||||
removeData,
|
||||
setOptions,
|
||||
updateAll,
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginsManager = ReturnType<typeof usePluginsManager>;
|
||||
@@ -26,6 +26,7 @@ const CORE_UNSAVED = new Set([
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"split-screen",
|
||||
"plugins",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LayoutPanelLeft,
|
||||
Puzzle,
|
||||
LibraryBig,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -30,6 +31,9 @@ const ManageTab = lazy(() =>
|
||||
const SettingsTab = lazy(() =>
|
||||
import("@/settings/SettingsTab").then((m) => ({ default: m.SettingsTab })),
|
||||
);
|
||||
const PluginsTab = lazy(() =>
|
||||
import("@/plugins/PluginsTab").then((m) => ({ default: m.PluginsTab })),
|
||||
);
|
||||
const DashboardTab = lazy(() =>
|
||||
import("@/dashboard/DashboardTab").then((m) => ({
|
||||
default: m.DashboardTab,
|
||||
@@ -105,6 +109,8 @@ export function tabIcon(type: TabType) {
|
||||
return <Settings className="size-3.5" />;
|
||||
case "split-screen":
|
||||
return <LayoutPanelLeft className="size-3.5" />;
|
||||
case "plugins":
|
||||
return <Puzzle className="size-3.5" />;
|
||||
default: {
|
||||
const Icon = getTabType(type)?.icon;
|
||||
return Icon ? <Icon className="size-3.5" /> : null;
|
||||
@@ -236,6 +242,9 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
||||
case "host-manager":
|
||||
return withTabSuspense(<ManageTab />);
|
||||
|
||||
case "plugins":
|
||||
return withTabSuspense(<PluginsTab />);
|
||||
|
||||
case "settings":
|
||||
return context.settings
|
||||
? withTabSuspense(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Plug,
|
||||
Server,
|
||||
LibraryBig,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
@@ -118,6 +119,15 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
group: "system",
|
||||
useBadge: useSyncAttentionCount,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
icon: Puzzle,
|
||||
labelKey: "nav.plugins",
|
||||
kind: "tab",
|
||||
placement: "footer",
|
||||
group: "system",
|
||||
permission: "admin.plugins.manage",
|
||||
},
|
||||
];
|
||||
|
||||
/** Main rail items split into their bands, in band order, empty bands left out. */
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { mergePlugins } from "@/plugins/plugin-model";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const [plugin] = mergePlugins(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "",
|
||||
author: "Termix",
|
||||
category: "",
|
||||
versions: [],
|
||||
latestVersion: "1.0.0",
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
updateAvailable: false,
|
||||
addedCapabilities: [],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
describe("PluginConsentPrompt", () => {
|
||||
it("lists notable capabilities and folds the low risk ones", () => {
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "install",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own", "ssh:connect", "ui:surface"],
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.capabilities.ssh:connect.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("plugins.capabilities.kv:own.title")).toBeNull();
|
||||
expect(screen.getByText("plugins.manager.consent.minor")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms and cancels", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "update",
|
||||
version: "1.1.0",
|
||||
capabilities: ["notify:send"],
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.manager.consent.updateExplain"),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("plugins.manager.consent.agreeUpdate"));
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing without a request", () => {
|
||||
const { container } = render(
|
||||
<PluginConsentPrompt
|
||||
request={null}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import { CapabilityRow } from "@/plugins/plugin-bits";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("CapabilityRow", () => {
|
||||
it("resolves capability ids that contain a colon", async () => {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
plugins: {
|
||||
capabilities: {
|
||||
"users:impersonate": {
|
||||
title: "Act as other users",
|
||||
consequence: "It can do anything a user can.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CapabilityRow capability="users:impersonate" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Act as other users")).toBeTruthy();
|
||||
expect(screen.getByText("It can do anything a user can.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
|
||||
import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
matchesQuery,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pluginStatus,
|
||||
sortPlugins,
|
||||
} from "@/plugins/plugin-model";
|
||||
|
||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: null,
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(
|
||||
overrides: Partial<RegistryPluginEntry> = {},
|
||||
): RegistryPluginEntry {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "Containers",
|
||||
author: "Termix",
|
||||
category: "Infrastructure",
|
||||
versions: [
|
||||
{
|
||||
version: "1.1.0",
|
||||
compatible: true,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
publishedAt: "2026-10-02",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
latestVersion: "1.1.0",
|
||||
installed: true,
|
||||
installedVersion: "1.0.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergePlugins", () => {
|
||||
it("joins an installed plugin with its registry entry", () => {
|
||||
const [row] = mergePlugins([summary()], [entry()]);
|
||||
expect(row).toMatchObject({
|
||||
installed: true,
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
capabilities: ["kv:own"],
|
||||
category: "Infrastructure",
|
||||
inRegistry: true,
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists registry plugins that are not installed with the latest capabilities", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[entry({ installed: false, installedVersion: null })],
|
||||
);
|
||||
expect(rows[0]).toMatchObject({
|
||||
installed: false,
|
||||
version: null,
|
||||
status: null,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps installed plugins when the registry is unreachable", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].inRegistry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginStatus", () => {
|
||||
it("maps loader and stored states", () => {
|
||||
expect(pluginStatus(summary({ state: "failed" }))).toBe("failed");
|
||||
expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked");
|
||||
expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe(
|
||||
"disabled",
|
||||
);
|
||||
expect(pluginStatus(summary())).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting and filtering", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "b", name: "Beta", category: "Monitoring" }),
|
||||
entry({
|
||||
id: "a",
|
||||
name: "Alpha",
|
||||
category: "Access",
|
||||
description: "Sign in",
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
compatible: true,
|
||||
capabilities: [],
|
||||
publishedAt: "2026-10-05",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
it("sorts by name, date and category", () => {
|
||||
expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("searches name, description and id", () => {
|
||||
expect(
|
||||
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
|
||||
).toEqual(["a"]);
|
||||
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
|
||||
});
|
||||
|
||||
it("collects categories", () => {
|
||||
expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("orders capabilities worst first", () => {
|
||||
expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([
|
||||
"credentials:read",
|
||||
"hosts:write",
|
||||
"kv:own",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts contributions", () => {
|
||||
expect(
|
||||
describeContributions({
|
||||
tabs: [{}, {}],
|
||||
settings: { admin: [] },
|
||||
auth: { loginMethods: [{}], secondFactors: [] },
|
||||
panels: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: "tabs", count: 2 },
|
||||
{ kind: "auth", count: 1 },
|
||||
{ kind: "settings", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats sizes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(2048)).toBe("2.0 KB");
|
||||
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
||||
});
|
||||
});
|
||||
@@ -63,9 +63,19 @@ describe("RAIL_ITEMS", () => {
|
||||
"connections",
|
||||
"quick-connect",
|
||||
"sync",
|
||||
"plugins",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the Plugins tab only for plugin managers", () => {
|
||||
const plugins = RAIL_ITEMS.find((item) => item.id === "plugins");
|
||||
expect(plugins).toMatchObject({
|
||||
kind: "tab",
|
||||
placement: "footer",
|
||||
permission: "admin.plugins.manage",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes every visible rail item as hideable", () => {
|
||||
expect(hideableRailIds()).toEqual(
|
||||
visibleRailItems().map((item) => item.id),
|
||||
|
||||
@@ -29,6 +29,9 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
// Forks workers on Windows abort now and then (0xC0000409) in a random
|
||||
// file and Vitest reports "Worker exited unexpectedly". Threads don't.
|
||||
pool: "threads",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
|
||||
Reference in new issue
Block a user