mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: consent check, developer mode file installs, install counts and issue links for plugins
This commit is contained in:
1 parent
26a474bb3d
commit
08db1fcdde
28 files changed
+2248
-72
No files matched your search
@@ -85,9 +85,12 @@ function adminSections() {
|
|||||||
const settings = read("AdminSettingsSections.tsx");
|
const settings = read("AdminSettingsSections.tsx");
|
||||||
const management = read("AdminManagementSections.tsx");
|
const management = read("AdminManagementSections.tsx");
|
||||||
return {
|
return {
|
||||||
"admin-general": keysIn(
|
"admin-general": [
|
||||||
functionBody(settings, "AdminGeneralSettingsSection"),
|
...new Set([
|
||||||
),
|
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
|
||||||
|
...keysIn(read("AdminPluginDeveloperMode.tsx")),
|
||||||
|
]),
|
||||||
|
],
|
||||||
"admin-users": [
|
"admin-users": [
|
||||||
...new Set([
|
...new Set([
|
||||||
...keysIn(functionBody(management, "AdminUsersSection")),
|
...keysIn(functionBody(management, "AdminUsersSection")),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
|||||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||||
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||||
|
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||||
@@ -240,6 +241,13 @@ export function createCurrentPluginSettingsRepository(): PluginSettingsRepositor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
|
||||||
|
return new PluginInstallCountRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("plugin_install_count_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||||
return new PluginMigrationRepository(
|
return new PluginMigrationRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { pluginInstallCounts } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||||
|
|
||||||
|
export interface PluginInstallCountInput {
|
||||||
|
pluginId: string;
|
||||||
|
count: number;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popularity numbers for the store, one row per plugin and registry. */
|
||||||
|
export class PluginInstallCountRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listByRegistry(
|
||||||
|
registryId: string,
|
||||||
|
): Promise<PluginInstallCountRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(pluginInstallCounts)
|
||||||
|
.where(eq(pluginInstallCounts.registryId, registryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes only the rows that changed, so an unchanged sync costs no save. */
|
||||||
|
async upsertMany(
|
||||||
|
registryId: string,
|
||||||
|
entries: PluginInstallCountInput[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<number> {
|
||||||
|
const existing = new Map(
|
||||||
|
(await this.listByRegistry(registryId)).map((row) => [row.pluginId, row]),
|
||||||
|
);
|
||||||
|
let written = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const row = existing.get(entry.pluginId);
|
||||||
|
if (row && row.count === entry.count && row.source === entry.source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(pluginInstallCounts)
|
||||||
|
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||||
|
eq(pluginInstallCounts.registryId, registryId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.insert(pluginInstallCounts).values({
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
registryId,
|
||||||
|
count: entry.count,
|
||||||
|
source: entry.source,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
if (written > 0) await this.onWrite?.();
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1399,6 +1399,20 @@ router.put(
|
|||||||
|
|
||||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
/** The consented capability list, or null when the body is malformed. */
|
||||||
|
function readCapabilities(body: unknown): string[] | undefined | null {
|
||||||
|
const value = (body as { capabilities?: unknown } | undefined)?.capabilities;
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length > 200 ||
|
||||||
|
!value.every((c) => typeof c === "string" && c.length <= 128)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
function readVersion(body: unknown): string | undefined | null {
|
function readVersion(body: unknown): string | undefined | null {
|
||||||
const version = (body as { version?: unknown } | undefined)?.version;
|
const version = (body as { version?: unknown } | undefined)?.version;
|
||||||
if (version === undefined || version === null || version === "") {
|
if (version === undefined || version === null || version === "") {
|
||||||
@@ -1503,7 +1517,9 @@ router.post(
|
|||||||
* starts it along with any installed dependency that is off. Without a
|
* starts it along with any installed dependency that is off. Without a
|
||||||
* version the newest compatible release is used; naming an older one
|
* version the newest compatible release is used; naming an older one
|
||||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||||
* no download.
|
* no download. capabilities must list exactly what the consent prompt
|
||||||
|
* showed; when it differs from what the release asks for, nothing is
|
||||||
|
* downloaded and the answer is 409 CONSENT_MISMATCH with the real list.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -1513,24 +1529,31 @@ router.post(
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* requestBody:
|
* requestBody:
|
||||||
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - capabilities
|
||||||
* properties:
|
* properties:
|
||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
|
* capabilities:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The installed version and the plugin's state.
|
* description: The installed version and the plugin's state.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid id or version.
|
* description: Invalid id, version or capability list, or no capability list.
|
||||||
* 403:
|
* 403:
|
||||||
* description: The caller lacks admin.plugins.manage.
|
* description: The caller lacks admin.plugins.manage.
|
||||||
* 404:
|
* 404:
|
||||||
* description: Not in the official registry.
|
* description: Not in the official registry.
|
||||||
* 409:
|
* 409:
|
||||||
* description: Already installed, incompatible, or managed by a linked server.
|
* description: Already installed, incompatible, the consent does not match, or managed by a linked server.
|
||||||
* 502:
|
* 502:
|
||||||
* description: The registry could not be reached.
|
* description: The registry could not be reached.
|
||||||
*/
|
*/
|
||||||
@@ -1541,17 +1564,29 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const pluginId = String(req.params.id);
|
const pluginId = String(req.params.id);
|
||||||
const version = readVersion(req.body);
|
const version = readVersion(req.body);
|
||||||
if (!ID_PATTERN.test(pluginId) || version === null) {
|
const capabilities = readCapabilities(req.body);
|
||||||
res.status(400).json({ error: "Invalid plugin id or version" });
|
if (
|
||||||
|
!ID_PATTERN.test(pluginId) ||
|
||||||
|
version === null ||
|
||||||
|
capabilities === null
|
||||||
|
) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid plugin id, version or capabilities" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await refuseOnLinkedDesktop(res)) return;
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
try {
|
try {
|
||||||
const { installPlugin } = await import("../../plugins/manage.js");
|
const { installPlugin } = await import("../../plugins/manage.js");
|
||||||
const userId = (req as AuthenticatedRequest).userId as string;
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
const result = await installPlugin(pluginId, { version, userId });
|
const result = await installPlugin(pluginId, {
|
||||||
|
version,
|
||||||
|
userId,
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||||
version: result.version,
|
version: result.version,
|
||||||
|
capabilities,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1573,8 +1608,9 @@ router.post(
|
|||||||
* description: >
|
* description: >
|
||||||
* Swaps in another release from the official registry, newer or older.
|
* Swaps in another release from the official registry, newer or older.
|
||||||
* If the release declares capabilities the installed one does not, the
|
* If the release declares capabilities the installed one does not, the
|
||||||
* request must set acceptCapabilities, otherwise it answers 409 with
|
* request must set acceptCapabilities and list exactly those added
|
||||||
* code CAPABILITIES_ADDED and the list. A running plugin is started
|
* capabilities in capabilities, otherwise it answers 409 with code
|
||||||
|
* CAPABILITIES_ADDED (or CONSENT_MISMATCH) and the list. A running plugin is started
|
||||||
* again on the new version. Naming a version that is not the newest
|
* again on the new version. Naming a version that is not the newest
|
||||||
* pins the plugin to it.
|
* pins the plugin to it.
|
||||||
* tags:
|
* tags:
|
||||||
@@ -1595,6 +1631,10 @@ router.post(
|
|||||||
* type: string
|
* type: string
|
||||||
* acceptCapabilities:
|
* acceptCapabilities:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* capabilities:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The new version and the plugin's state.
|
* description: The new version and the plugin's state.
|
||||||
@@ -1614,8 +1654,15 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const pluginId = String(req.params.id);
|
const pluginId = String(req.params.id);
|
||||||
const version = readVersion(req.body);
|
const version = readVersion(req.body);
|
||||||
if (!ID_PATTERN.test(pluginId) || version === null) {
|
const capabilities = readCapabilities(req.body);
|
||||||
res.status(400).json({ error: "Invalid plugin id or version" });
|
if (
|
||||||
|
!ID_PATTERN.test(pluginId) ||
|
||||||
|
version === null ||
|
||||||
|
capabilities === null
|
||||||
|
) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid plugin id, version or capabilities" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await refuseOnLinkedDesktop(res)) return;
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
@@ -1626,6 +1673,7 @@ router.post(
|
|||||||
version,
|
version,
|
||||||
userId,
|
userId,
|
||||||
acceptCapabilities: req.body?.acceptCapabilities === true,
|
acceptCapabilities: req.body?.acceptCapabilities === true,
|
||||||
|
capabilities,
|
||||||
});
|
});
|
||||||
await auditPluginAction(req, "update_plugin", pluginId, {
|
await auditPluginAction(req, "update_plugin", pluginId, {
|
||||||
version: result.version,
|
version: result.version,
|
||||||
@@ -1807,4 +1855,231 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/developer-mode:
|
||||||
|
* get:
|
||||||
|
* summary: Read whether plugin developer mode is on
|
||||||
|
* description: Developer mode lets admins install a .tmxplug from a file, without the registry's signature check. The UI shows a banner while it is on.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Whether developer mode is on, and whether TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs anyway.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/developer-mode",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { getDeveloperMode } = await import("../../plugins/manage.js");
|
||||||
|
const { requireSignedPlugins } = await import("../../plugins/trust.js");
|
||||||
|
res.json({
|
||||||
|
enabled: await getDeveloperMode(),
|
||||||
|
signedOnly: requireSignedPlugins(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to read developer mode",
|
||||||
|
"plugin_developer_mode",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/developer-mode:
|
||||||
|
* put:
|
||||||
|
* summary: Turn plugin developer mode on or off
|
||||||
|
* description: Turning it off drops any file that was uploaded but not installed yet. Plugins already installed from a file keep running and stay marked unverified.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - enabled
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The saved value.
|
||||||
|
* 400:
|
||||||
|
* description: enabled is not a boolean.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/developer-mode",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const enabled = req.body?.enabled;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
res.status(400).json({ error: "enabled must be a boolean" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { setDeveloperMode } = await import("../../plugins/manage.js");
|
||||||
|
await setDeveloperMode(enabled);
|
||||||
|
await auditPluginAction(req, "plugin_developer_mode", "*", { enabled });
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to save developer mode",
|
||||||
|
"plugin_developer_mode",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/upload:
|
||||||
|
* post:
|
||||||
|
* summary: Upload a .tmxplug to install from a file
|
||||||
|
* description: >
|
||||||
|
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
||||||
|
* The file is checked (archive, manifest, plugin API) and held for 30
|
||||||
|
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||||
|
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||||
|
* installs it. A file can replace an earlier upload with the same id,
|
||||||
|
* never a bundled or registry plugin.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/octet-stream:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: binary
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A token plus the plugin's id, name, version and capabilities.
|
||||||
|
* 400:
|
||||||
|
* description: Not a valid .tmxplug or manifest.
|
||||||
|
* 403:
|
||||||
|
* description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage.
|
||||||
|
* 409:
|
||||||
|
* description: The id belongs to a bundled or registry plugin, the plugin API does not match, or managed by a linked server.
|
||||||
|
* 413:
|
||||||
|
* description: The file is larger than 50 MB.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/upload",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
express.raw({ type: "application/octet-stream", limit: "50mb" }),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
|
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Send the .tmxplug as application/octet-stream",
|
||||||
|
code: "INVALID_ARTIFACT",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stageUpload } = await import("../../plugins/manage.js");
|
||||||
|
res.json(await stageUpload(req.body));
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to read the uploaded plugin",
|
||||||
|
"plugin_upload",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/upload/{token}/install:
|
||||||
|
* post:
|
||||||
|
* summary: Install a plugin uploaded from a file
|
||||||
|
* description: >
|
||||||
|
* Developer mode only. capabilities must list exactly what the upload
|
||||||
|
* asks for, as the consent prompt showed it. The plugin is installed,
|
||||||
|
* granted those capabilities and started, and is marked unverified for
|
||||||
|
* as long as it stays installed.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: token
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - capabilities
|
||||||
|
* properties:
|
||||||
|
* capabilities:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The installed version and the plugin's state.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid token or capability list.
|
||||||
|
* 403:
|
||||||
|
* description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: The upload expired or never existed.
|
||||||
|
* 409:
|
||||||
|
* description: The consent does not match, the id is taken, or managed by a linked server.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/upload/:token/install",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const token = String(req.params.token);
|
||||||
|
const capabilities = readCapabilities(req.body);
|
||||||
|
if (!/^[a-f0-9]{32}$/.test(token) || capabilities === null) {
|
||||||
|
res.status(400).json({ error: "Invalid upload token or capabilities" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
|
try {
|
||||||
|
const { installUpload } = await import("../../plugins/manage.js");
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
const result = await installUpload(token, { userId, capabilities });
|
||||||
|
await auditPluginAction(req, "install_plugin_from_file", result.id, {
|
||||||
|
version: result.version,
|
||||||
|
capabilities,
|
||||||
|
verified: false,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to install the plugin",
|
||||||
|
"plugin_install",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
|
||||||
|
* disk. Returns null when the archive has no such file.
|
||||||
|
*/
|
||||||
|
export function readArtifactFile(
|
||||||
|
buffer: Buffer,
|
||||||
|
wanted: string,
|
||||||
|
): Buffer | null {
|
||||||
|
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||||
|
let offset = 0;
|
||||||
|
while (offset + 512 <= tar.length) {
|
||||||
|
const header = tar.subarray(offset, offset + 512);
|
||||||
|
if (header.every((byte) => byte === 0)) break;
|
||||||
|
const field = (start: number, length: number) =>
|
||||||
|
header
|
||||||
|
.subarray(start, start + length)
|
||||||
|
.toString("utf8")
|
||||||
|
.replace(/\0.*$/s, "");
|
||||||
|
const prefix = field(345, 155);
|
||||||
|
const name = (
|
||||||
|
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
|
||||||
|
).replace(/^\.\//, "");
|
||||||
|
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
|
||||||
|
if (!Number.isFinite(size) || size < 0) break;
|
||||||
|
const start = offset + 512;
|
||||||
|
if (name === wanted) return tar.subarray(start, start + size);
|
||||||
|
offset = start + Math.ceil(size / 512) * 512;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
|
||||||
|
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
||||||
|
let raw: Buffer | null;
|
||||||
|
try {
|
||||||
|
raw = readArtifactFile(buffer, "manifest.json");
|
||||||
|
} catch {
|
||||||
|
throw new Error("the file is not a valid .tmxplug archive");
|
||||||
|
}
|
||||||
|
if (!raw) throw new Error("the archive has no manifest.json");
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new Error("manifest.json is not valid JSON");
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("manifest.json is not an object");
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two capability lists name the same set, order ignored. */
|
||||||
|
export function sameCapabilities(
|
||||||
|
a: readonly string[],
|
||||||
|
b: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
const left = new Set(a);
|
||||||
|
const right = new Set(b);
|
||||||
|
if (left.size !== right.size) return false;
|
||||||
|
for (const capability of left) if (!right.has(capability)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Applies plugin updates on a timer for plugins with auto-update on. An
|
* The registry timer: refreshes the store's install counts and applies
|
||||||
* update that asks for a new capability is never applied here; admins get
|
* plugin updates for plugins with auto-update on. An update that asks for a
|
||||||
* an alert and review it in the Plugins tab.
|
* new capability is never applied here; admins get an alert and review it in
|
||||||
|
* the Plugins tab. Nothing is ever installed from here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
@@ -12,6 +13,9 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
|||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
export async function runPluginAutoUpdate(): Promise<void> {
|
export async function runPluginAutoUpdate(): Promise<void> {
|
||||||
|
const { syncInstallCounts } = await import("./install-counts.js");
|
||||||
|
await syncInstallCounts();
|
||||||
|
|
||||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||||
if (await isLinkedDesktop()) return;
|
if (await isLinkedDesktop()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Popularity numbers for the plugin store, copied from the registry's
|
||||||
|
* stats.json into plugin_install_counts.
|
||||||
|
*
|
||||||
|
* Two sources, and the store prefers the first:
|
||||||
|
* - "aggregate-telemetry": instances that reported the plugin enabled in the
|
||||||
|
* last 7 days through the Usage Statistics plugin. It undercounts by every
|
||||||
|
* instance that turned the report off or never installed that plugin.
|
||||||
|
* - "github-releases": downloads of the plugin's .tmxplug release assets.
|
||||||
|
* This counts downloads, not active installs: an instance that updates
|
||||||
|
* three times counts three times, and Docker images that bundle the plugin
|
||||||
|
* count once per build, not once per install.
|
||||||
|
*
|
||||||
|
* Treat both as a rough ranking, never as a user count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
|
||||||
|
|
||||||
|
export const TELEMETRY_SOURCE = "aggregate-telemetry";
|
||||||
|
export const DOWNLOADS_SOURCE = "github-releases";
|
||||||
|
|
||||||
|
export interface InstallCount {
|
||||||
|
count: number;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repository() {
|
||||||
|
const { createCurrentPluginInstallCountRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
return createCurrentPluginInstallCountRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the number to show: active installs when known, downloads otherwise. */
|
||||||
|
export function preferredCount(entry: {
|
||||||
|
downloads: number;
|
||||||
|
activeInstalls: number | null;
|
||||||
|
}): InstallCount {
|
||||||
|
return entry.activeInstalls !== null && entry.activeInstalls > 0
|
||||||
|
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
|
||||||
|
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||||
|
export async function syncInstallCounts(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stats = await fetchRegistryStats();
|
||||||
|
if (!stats) return 0;
|
||||||
|
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
|
||||||
|
pluginId,
|
||||||
|
...preferredCount(entry),
|
||||||
|
}));
|
||||||
|
return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Could not sync plugin install counts: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ operation: "plugin_registry" },
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readInstallCounts(
|
||||||
|
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||||
|
): Promise<Map<string, InstallCount>> {
|
||||||
|
try {
|
||||||
|
const rows = await (await repository()).listByRegistry(registryId);
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.pluginId,
|
||||||
|
{ count: row.count, source: row.source },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
+306
-15
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Installing, updating, uninstalling and switching plugins at runtime.
|
* Installing, updating, uninstalling and switching plugins at runtime.
|
||||||
*
|
*
|
||||||
* Only the official registry is a source for now. Bundled plugins ship in the
|
* The official registry is the one real source. With developer mode on, an
|
||||||
|
* admin can also install a .tmxplug from a file; that skips the download,
|
||||||
|
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||||
* image and can still be uninstalled: their files are deleted and the id is
|
* image and can still be uninstalled: their files are deleted and the id is
|
||||||
* recorded in the settings table, so the shipped copy is ignored from then
|
* recorded in the settings table, so the shipped copy is ignored from then
|
||||||
* on and a reinstall downloads the plugin like any other.
|
* on and a reinstall downloads the plugin like any other.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
@@ -38,8 +41,15 @@ import {
|
|||||||
} from "./registry-index.js";
|
} from "./registry-index.js";
|
||||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||||
import { moveWithRetry } from "./fs-retry.js";
|
import { moveWithRetry } from "./fs-retry.js";
|
||||||
|
import { readInstallCounts } from "./install-counts.js";
|
||||||
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
|
import { parseManifest, type PluginManifest } from "./manifest.js";
|
||||||
|
import { requireSignedPlugins } from "./trust.js";
|
||||||
|
|
||||||
const UNINSTALLED_KEY = "plugins_uninstalled";
|
const UNINSTALLED_KEY = "plugins_uninstalled";
|
||||||
|
const DEVELOPER_MODE_KEY = "plugins_developer_mode";
|
||||||
|
/** Installed from a file with no registry signature behind it. Never cleared. */
|
||||||
|
export const UNVERIFIED_TIER = "unverified";
|
||||||
|
|
||||||
export class PluginManageError extends Error {
|
export class PluginManageError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -212,6 +222,9 @@ export interface RegistryListing {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
|
/** See install-counts.ts for what this number does and does not mean. */
|
||||||
|
installCount: number | null;
|
||||||
|
installCountSource: string | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +236,7 @@ export async function listRegistry(
|
|||||||
const records = new Map(
|
const records = new Map(
|
||||||
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
||||||
);
|
);
|
||||||
|
const counts = await readInstallCounts();
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
const shipped = loader.bundledIds();
|
const shipped = loader.bundledIds();
|
||||||
|
|
||||||
@@ -271,6 +285,8 @@ export async function listRegistry(
|
|||||||
pinnedVersion: record?.pinnedVersion ?? null,
|
pinnedVersion: record?.pinnedVersion ?? null,
|
||||||
autoUpdate: Boolean(record?.autoUpdate),
|
autoUpdate: Boolean(record?.autoUpdate),
|
||||||
bundled: shipped.has(plugin.id),
|
bundled: shipped.has(plugin.id),
|
||||||
|
installCount: counts.get(plugin.id)?.count ?? null,
|
||||||
|
installCountSource: counts.get(plugin.id)?.source ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -313,24 +329,31 @@ async function grantDeclared(
|
|||||||
invalidatePluginPermissionCache(plugin.id);
|
invalidatePluginPermissionCache(plugin.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SwapSource = { release: RegistryVersion } | { upload: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts `release` in place of whatever copy of the plugin is loaded. On a
|
* Puts a registry release, or an uploaded file, in place of whatever copy of
|
||||||
* failed load the previous copy comes back.
|
* the plugin is loaded. On a failed load the previous copy comes back.
|
||||||
*/
|
*/
|
||||||
async function swapIn(
|
async function swapIn(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
release: RegistryVersion,
|
source: SwapSource,
|
||||||
): Promise<LoadedPlugin> {
|
): Promise<LoadedPlugin> {
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
const previous = loader.get(pluginId);
|
const previous = loader.get(pluginId);
|
||||||
const bundledVersion = loader.bundledVersion(pluginId);
|
const bundledVersion = loader.bundledVersion(pluginId);
|
||||||
const useBundled =
|
const useBundled =
|
||||||
|
"release" in source &&
|
||||||
bundledVersion !== undefined &&
|
bundledVersion !== undefined &&
|
||||||
bundledVersion === release.version &&
|
bundledVersion === source.release.version &&
|
||||||
isBundledOnDisk(pluginId);
|
isBundledOnDisk(pluginId);
|
||||||
|
|
||||||
// Fetched before anything stops, so a bad download changes nothing.
|
// Fetched before anything stops, so a bad download changes nothing.
|
||||||
const staged = useBundled ? null : await downloadRelease(pluginId, release);
|
const staged = useBundled
|
||||||
|
? null
|
||||||
|
: "release" in source
|
||||||
|
? await downloadRelease(pluginId, source.release)
|
||||||
|
: { file: source.upload, signatureFile: `${source.upload}.sig` };
|
||||||
|
|
||||||
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||||
const backupDir = path.join(getDownloadsDir(), `${pluginId}.previous`);
|
const backupDir = path.join(getDownloadsDir(), `${pluginId}.previous`);
|
||||||
@@ -561,6 +584,32 @@ export interface InstallOptions {
|
|||||||
version?: string;
|
version?: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
acceptCapabilities?: boolean;
|
acceptCapabilities?: boolean;
|
||||||
|
/**
|
||||||
|
* The capabilities the consent prompt showed: every one for an install,
|
||||||
|
* the added ones for an update. Must match what the release asks for.
|
||||||
|
*/
|
||||||
|
capabilities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConsent(
|
||||||
|
consented: string[] | undefined,
|
||||||
|
asked: readonly string[],
|
||||||
|
): void {
|
||||||
|
if (consented === undefined) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The capabilities agreed to were not sent",
|
||||||
|
400,
|
||||||
|
"CONSENT_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!sameCapabilities(consented, asked)) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The capabilities agreed to do not match what this release asks for",
|
||||||
|
409,
|
||||||
|
"CONSENT_MISMATCH",
|
||||||
|
{ capabilities: [...asked] },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireRelease(
|
async function requireRelease(
|
||||||
@@ -641,12 +690,13 @@ export function installPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { release, latest } = await requireRelease(pluginId, options.version);
|
const { release, latest } = await requireRelease(pluginId, options.version);
|
||||||
|
assertConsent(options.capabilities, release.capabilities);
|
||||||
const pin = pinFor(release, latest, options.version);
|
const pin = pinFor(release, latest, options.version);
|
||||||
await applyPin(pluginId, pin);
|
await applyPin(pluginId, pin);
|
||||||
|
|
||||||
let plugin: LoadedPlugin;
|
let plugin: LoadedPlugin;
|
||||||
try {
|
try {
|
||||||
plugin = await swapIn(pluginId, release);
|
plugin = await swapIn(pluginId, { release });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await applyPin(pluginId, null);
|
await applyPin(pluginId, null);
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
@@ -716,13 +766,16 @@ async function updateUnlocked(
|
|||||||
live.manifest.capabilities,
|
live.manifest.capabilities,
|
||||||
release.capabilities,
|
release.capabilities,
|
||||||
);
|
);
|
||||||
if (added.length > 0 && !options.acceptCapabilities) {
|
if (added.length > 0) {
|
||||||
throw new PluginManageError(
|
if (!options.acceptCapabilities) {
|
||||||
"This version asks for new capabilities",
|
throw new PluginManageError(
|
||||||
409,
|
"This version asks for new capabilities",
|
||||||
"CAPABILITIES_ADDED",
|
409,
|
||||||
{ capabilities: added },
|
"CAPABILITIES_ADDED",
|
||||||
);
|
{ capabilities: added },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertConsent(options.capabilities, added);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasEnabled = record.state !== "disabled";
|
const wasEnabled = record.state !== "disabled";
|
||||||
@@ -731,7 +784,7 @@ async function updateUnlocked(
|
|||||||
|
|
||||||
let plugin: LoadedPlugin;
|
let plugin: LoadedPlugin;
|
||||||
try {
|
try {
|
||||||
plugin = await swapIn(pluginId, release);
|
plugin = await swapIn(pluginId, { release });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await applyPin(pluginId, record.pinnedVersion ?? null);
|
await applyPin(pluginId, record.pinnedVersion ?? null);
|
||||||
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
||||||
@@ -1003,3 +1056,241 @@ export function deletePluginData(
|
|||||||
return removed;
|
return removed;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeveloperMode(): Promise<boolean> {
|
||||||
|
const { createCurrentSettingsRepository } = await repos();
|
||||||
|
return (
|
||||||
|
(await createCurrentSettingsRepository().get(DEVELOPER_MODE_KEY)) === "true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeveloperMode(enabled: boolean): Promise<boolean> {
|
||||||
|
const { createCurrentSettingsRepository } = await repos();
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
DEVELOPER_MODE_KEY,
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
if (!enabled) await clearUploads();
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 50 MB, the most nginx lets through to /plugins. */
|
||||||
|
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
const UPLOAD_TTL_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
interface StagedUpload {
|
||||||
|
file: string;
|
||||||
|
manifest: PluginManifest;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploads = new Map<string, StagedUpload>();
|
||||||
|
|
||||||
|
async function clearUploads(now = Infinity): Promise<void> {
|
||||||
|
for (const [token, upload] of uploads) {
|
||||||
|
if (upload.expiresAt > now) continue;
|
||||||
|
uploads.delete(token);
|
||||||
|
await fs.promises.rm(upload.file, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadPreview {
|
||||||
|
token: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
capabilities: string[];
|
||||||
|
/** The version of an earlier upload this one replaces. */
|
||||||
|
replaces: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUploadsAllowed(): Promise<void> {
|
||||||
|
if (!(await getDeveloperMode())) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"Installing from a file needs developer mode",
|
||||||
|
403,
|
||||||
|
"DEVELOPER_MODE_OFF",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requireSignedPlugins()) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"TERMIX_REQUIRE_SIGNED_PLUGINS is on, so unsigned files cannot be installed",
|
||||||
|
403,
|
||||||
|
"SIGNED_ONLY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUpload(buffer: Buffer): PluginManifest {
|
||||||
|
let raw: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
raw = readArtifactManifest(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
400,
|
||||||
|
"INVALID_ARTIFACT",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { manifest, errors } = parseManifest(raw);
|
||||||
|
if (!manifest) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`The manifest is not valid: ${errors.join("; ")}`,
|
||||||
|
400,
|
||||||
|
"INVALID_MANIFEST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isApiCompatible(String(manifest.engine.api))) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`${manifest.id} ${manifest.version} does not run on this version of Termix`,
|
||||||
|
409,
|
||||||
|
"INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only another upload of the same id can be replaced from a file. */
|
||||||
|
async function assertReplaceable(pluginId: string): Promise<string | null> {
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
if (loader.bundledIds().has(pluginId)) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`${pluginId} ships with Termix and can only be replaced by a signed release`,
|
||||||
|
409,
|
||||||
|
"BUNDLED_ID",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { createCurrentPluginRepository } = await repos();
|
||||||
|
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||||
|
if (!record) return null;
|
||||||
|
if (record.tier !== UNVERIFIED_TIER) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"A plugin with this id is already installed from another source",
|
||||||
|
409,
|
||||||
|
"ALREADY_INSTALLED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return record.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
|
||||||
|
* it asks for. Nothing is loaded yet.
|
||||||
|
*/
|
||||||
|
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
||||||
|
await assertUploadsAllowed();
|
||||||
|
await clearUploads(Date.now());
|
||||||
|
const manifest = validateUpload(buffer);
|
||||||
|
const replaces = await assertReplaceable(manifest.id);
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(16).toString("hex");
|
||||||
|
const dir = getDownloadsDir();
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, `upload-${token}.tmxplug`);
|
||||||
|
await fs.promises.writeFile(file, buffer);
|
||||||
|
uploads.set(token, {
|
||||||
|
file,
|
||||||
|
manifest,
|
||||||
|
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
version: manifest.version,
|
||||||
|
description: manifest.description ?? "",
|
||||||
|
author: manifest.author?.name ?? "",
|
||||||
|
capabilities: [...manifest.capabilities],
|
||||||
|
replaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs a staged upload. It stays marked unverified for good. */
|
||||||
|
export function installUpload(
|
||||||
|
token: string,
|
||||||
|
options: { userId: string | null; capabilities?: string[] },
|
||||||
|
): Promise<{ id: string; version: string; state: string }> {
|
||||||
|
return serialized(async () => {
|
||||||
|
await assertUploadsAllowed();
|
||||||
|
const upload = uploads.get(token);
|
||||||
|
if (!upload || upload.expiresAt < Date.now()) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The uploaded file has expired, upload it again",
|
||||||
|
404,
|
||||||
|
"UPLOAD_EXPIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pluginId = upload.manifest.id;
|
||||||
|
// Read again: the staged file is what gets loaded, not the first look.
|
||||||
|
const manifest = validateUpload(await fs.promises.readFile(upload.file));
|
||||||
|
if (manifest.id !== pluginId) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The staged file changed",
|
||||||
|
409,
|
||||||
|
"UPLOAD_CHANGED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertConsent(options.capabilities, manifest.capabilities);
|
||||||
|
await assertReplaceable(pluginId);
|
||||||
|
|
||||||
|
const { createCurrentPluginRepository } = await repos();
|
||||||
|
const repository = createCurrentPluginRepository();
|
||||||
|
const before = await repository.findById(pluginId);
|
||||||
|
const wasEnabled = !before || before.state !== "disabled";
|
||||||
|
|
||||||
|
uploads.delete(token);
|
||||||
|
let plugin: LoadedPlugin;
|
||||||
|
try {
|
||||||
|
plugin = await swapIn(pluginId, { upload: upload.file });
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(upload.file, { force: true });
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
if (before && wasEnabled && loader.get(pluginId)) {
|
||||||
|
await startAndRecord(pluginId);
|
||||||
|
}
|
||||||
|
throw new PluginManageError(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
500,
|
||||||
|
"INSTALL_FAILED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.update(pluginId, {
|
||||||
|
tier: UNVERIFIED_TIER,
|
||||||
|
registryId: null,
|
||||||
|
pinnedVersion: null,
|
||||||
|
autoUpdate: false,
|
||||||
|
});
|
||||||
|
await grantDeclared(plugin, options.userId);
|
||||||
|
|
||||||
|
if (!wasEnabled) {
|
||||||
|
return { id: pluginId, version: manifest.version, state: "stopped" };
|
||||||
|
}
|
||||||
|
const { missing } = dependencyChain(pluginId);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
await recordState(
|
||||||
|
pluginId,
|
||||||
|
"blocked",
|
||||||
|
`requires ${missing.map((id) => `"${id}"`).join(", ")}, which is not installed`,
|
||||||
|
);
|
||||||
|
return { id: pluginId, version: manifest.version, state: "blocked" };
|
||||||
|
}
|
||||||
|
for (const id of (await planStateChange(pluginId, true)).enable) {
|
||||||
|
await startAndRecord(id);
|
||||||
|
}
|
||||||
|
const state = await startAndRecord(pluginId);
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
|
||||||
|
{ operation: "plugin_install" },
|
||||||
|
);
|
||||||
|
return { id: pluginId, version: manifest.version, state };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetUploads(): void {
|
||||||
|
uploads.clear();
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest";
|
|||||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
import { verifyPluginArtifact } from "./trust.js";
|
import { verifyPluginArtifact } from "./trust.js";
|
||||||
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import { getPluginsDir } from "./paths.js";
|
import { getPluginsDir } from "./paths.js";
|
||||||
|
|
||||||
export const OFFICIAL_REGISTRY_ID = "official";
|
export const OFFICIAL_REGISTRY_ID = "official";
|
||||||
@@ -245,6 +246,55 @@ export async function fetchRegistryIndex(
|
|||||||
return inflight;
|
return inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegistryStats {
|
||||||
|
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
||||||
|
export function getStatsUrl(): string | null {
|
||||||
|
if (process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||||
|
return process.env.TERMIX_PLUGIN_STATS_URL;
|
||||||
|
}
|
||||||
|
const url = getRegistryUrl();
|
||||||
|
return /\/index\.json$/.test(url)
|
||||||
|
? url.replace(/\/index\.json$/, "/stats.json")
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCount(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||||
|
? Math.floor(value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRegistryStats(raw: unknown): RegistryStats {
|
||||||
|
const plugins = new Map<
|
||||||
|
string,
|
||||||
|
{ downloads: number; activeInstalls: number | null }
|
||||||
|
>();
|
||||||
|
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
||||||
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||||
|
return { plugins };
|
||||||
|
}
|
||||||
|
for (const [id, value] of Object.entries(entries)) {
|
||||||
|
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
||||||
|
const entry = value as Record<string, unknown>;
|
||||||
|
plugins.set(id, {
|
||||||
|
downloads: asCount(entry.downloads) ?? 0,
|
||||||
|
activeInstalls: asCount(entry.activeInstalls),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { plugins };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
||||||
|
const url = getStatsUrl();
|
||||||
|
if (!url) return null;
|
||||||
|
const response = await fetchFollowing(url);
|
||||||
|
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||||
|
return parseRegistryStats(JSON.parse(body.toString("utf8")));
|
||||||
|
}
|
||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
export function resetRegistryCache(): void {
|
export function resetRegistryCache(): void {
|
||||||
cache = null;
|
cache = null;
|
||||||
@@ -266,6 +316,35 @@ export function findRelease(
|
|||||||
return { plugin, release };
|
return { plugin, release };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed file is the truth and the index only describes it, so the
|
||||||
|
* consent the admin gave (built from the index) must match what the packed
|
||||||
|
* manifest asks for.
|
||||||
|
*/
|
||||||
|
export function assertArtifactMatches(
|
||||||
|
pluginId: string,
|
||||||
|
release: RegistryVersion,
|
||||||
|
manifest: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const where = `${pluginId} ${release.version}`;
|
||||||
|
if (manifest.id !== pluginId) {
|
||||||
|
throw new Error(`${where}: the archive is for "${String(manifest.id)}"`);
|
||||||
|
}
|
||||||
|
if (manifest.version !== release.version) {
|
||||||
|
throw new Error(
|
||||||
|
`${where}: the archive is version ${String(manifest.version)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const declared = Array.isArray(manifest.capabilities)
|
||||||
|
? manifest.capabilities.filter((c): c is string => typeof c === "string")
|
||||||
|
: [];
|
||||||
|
if (!sameCapabilities(declared, release.capabilities)) {
|
||||||
|
throw new Error(
|
||||||
|
`${where}: the archive asks for different capabilities than the registry lists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a release, verifies it, and leaves it in a staging folder the
|
* Downloads a release, verifies it, and leaves it in a staging folder the
|
||||||
* loader never scans. The caller moves it into place.
|
* loader never scans. The caller moves it into place.
|
||||||
@@ -296,6 +375,7 @@ export async function downloadRelease(
|
|||||||
if (verified.ok === false) {
|
if (verified.ok === false) {
|
||||||
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
||||||
}
|
}
|
||||||
|
assertArtifactMatches(pluginId, release, readArtifactManifest(buffer));
|
||||||
|
|
||||||
const staging = getDownloadsDir();
|
const staging = getDownloadsDir();
|
||||||
await fs.promises.mkdir(staging, { recursive: true });
|
await fs.promises.mkdir(staging, { recursive: true });
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ const manageMock = vi.hoisted(() => ({
|
|||||||
deletePluginData: vi.fn(),
|
deletePluginData: vi.fn(),
|
||||||
planStateChange: vi.fn(),
|
planStateChange: vi.fn(),
|
||||||
setPluginState: vi.fn(),
|
setPluginState: vi.fn(),
|
||||||
|
getDeveloperMode: vi.fn(),
|
||||||
|
setDeveloperMode: vi.fn(),
|
||||||
|
stageUpload: vi.fn(),
|
||||||
|
installUpload: vi.fn(),
|
||||||
isLinkedDesktop: vi.fn(async () => false),
|
isLinkedDesktop: vi.fn(async () => false),
|
||||||
}));
|
}));
|
||||||
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
|
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
|
||||||
@@ -630,11 +634,15 @@ describe("plugins route", () => {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
state: "active",
|
state: "active",
|
||||||
});
|
});
|
||||||
const res = await post("/plugins/docker/install", { version: "1.0.0" });
|
const res = await post("/plugins/docker/install", {
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
|
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
userId: "admin-1",
|
userId: "admin-1",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
});
|
});
|
||||||
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -649,6 +657,13 @@ describe("plugins route", () => {
|
|||||||
expect(
|
expect(
|
||||||
(await post("/plugins/docker/install", { version: 7 })).status,
|
(await post("/plugins/docker/install", { version: 7 })).status,
|
||||||
).toBe(400);
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await post("/plugins/docker/install", { capabilities: "kv:own" }))
|
||||||
|
.status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await post("/plugins/docker/install", { capabilities: [1] })).status,
|
||||||
|
).toBe(400);
|
||||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -668,6 +683,25 @@ describe("plugins route", () => {
|
|||||||
version: undefined,
|
version: undefined,
|
||||||
userId: "admin-1",
|
userId: "admin-1",
|
||||||
acceptCapabilities: false,
|
acceptCapabilities: false,
|
||||||
|
capabilities: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the consented list on an accepted update", async () => {
|
||||||
|
manageMock.updatePlugin.mockResolvedValue({
|
||||||
|
id: "docker",
|
||||||
|
version: "1.1.0",
|
||||||
|
state: "active",
|
||||||
|
});
|
||||||
|
await post("/plugins/docker/update", {
|
||||||
|
acceptCapabilities: true,
|
||||||
|
capabilities: ["notify:send"],
|
||||||
|
});
|
||||||
|
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
|
||||||
|
version: undefined,
|
||||||
|
userId: "admin-1",
|
||||||
|
acceptCapabilities: true,
|
||||||
|
capabilities: ["notify:send"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -734,6 +768,21 @@ describe("plugins route", () => {
|
|||||||
body: JSON.stringify({ autoUpdate: true }),
|
body: JSON.stringify({ autoUpdate: true }),
|
||||||
}),
|
}),
|
||||||
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
||||||
|
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
|
||||||
|
fetch(`${baseUrl}/plugins/developer-mode`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
}),
|
||||||
|
fetch(`${baseUrl}/plugins/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
"x-test-user-id": user,
|
||||||
|
},
|
||||||
|
body: Buffer.from("x"),
|
||||||
|
}),
|
||||||
|
post(`/plugins/upload/${"a".repeat(32)}/install`, {}, user),
|
||||||
];
|
];
|
||||||
for (const res of await Promise.all(calls)) {
|
for (const res of await Promise.all(calls)) {
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
@@ -745,4 +794,106 @@ describe("plugins route", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("developer mode and file installs", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(manageMock)) fn.mockReset();
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||||
|
auditMock.logAudit.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and saves developer mode, auditing the change", async () => {
|
||||||
|
manageMock.getDeveloperMode.mockResolvedValue(true);
|
||||||
|
const read = await fetch(`${baseUrl}/plugins/developer-mode`);
|
||||||
|
expect(await read.json()).toEqual({ enabled: true, signedOnly: false });
|
||||||
|
|
||||||
|
const bad = await fetch(`${baseUrl}/plugins/developer-mode`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: "yes" }),
|
||||||
|
});
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
|
||||||
|
const saved = await fetch(`${baseUrl}/plugins/developer-mode`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
});
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
expect(manageMock.setDeveloperMode).toHaveBeenCalledWith(false);
|
||||||
|
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "plugin_developer_mode" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the raw upload to the stager", async () => {
|
||||||
|
manageMock.stageUpload.mockResolvedValue({ token: "t", id: "notes" });
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
body: Buffer.from("archive"),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const [buffer] = manageMock.stageUpload.mock.calls[0];
|
||||||
|
expect(Buffer.from(buffer).toString()).toBe("archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an upload that is not octet-stream", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(manageMock.stageUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the developer mode refusal through", async () => {
|
||||||
|
manageMock.stageUpload.mockRejectedValue(
|
||||||
|
new PluginManageError("off", 403, "DEVELOPER_MODE_OFF"),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
body: Buffer.from("archive"),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).code).toBe("DEVELOPER_MODE_OFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs a staged upload and audits it as unverified", async () => {
|
||||||
|
manageMock.installUpload.mockResolvedValue({
|
||||||
|
id: "notes",
|
||||||
|
version: "0.1.0",
|
||||||
|
state: "active",
|
||||||
|
});
|
||||||
|
const token = "b".repeat(32);
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/upload/${token}/install`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capabilities: ["kv:own"] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(manageMock.installUpload).toHaveBeenCalledWith(token, {
|
||||||
|
userId: "admin-1",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
});
|
||||||
|
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "install_plugin_from_file",
|
||||||
|
resourceId: "notes",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed upload token", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/upload/nope/install`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capabilities: [] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(manageMock.installUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as tar from "tar";
|
||||||
|
import {
|
||||||
|
readArtifactFile,
|
||||||
|
readArtifactManifest,
|
||||||
|
sameCapabilities,
|
||||||
|
} from "../../plugins/artifact-manifest.js";
|
||||||
|
|
||||||
|
function pack(files: Record<string, string>, prefix = ""): Buffer {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-artifact-"));
|
||||||
|
try {
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, name), content);
|
||||||
|
}
|
||||||
|
const file = path.join(dir, "out.tmxplug");
|
||||||
|
tar.c(
|
||||||
|
{ gzip: true, sync: true, file, cwd: dir },
|
||||||
|
Object.keys(files).map((name) => `${prefix}${name}`),
|
||||||
|
);
|
||||||
|
return fs.readFileSync(file);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readArtifactFile", () => {
|
||||||
|
it("finds a file by name, with or without a ./ prefix", () => {
|
||||||
|
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
||||||
|
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
|
||||||
|
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
||||||
|
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
|
||||||
|
'{"id":"a"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a missing file", () => {
|
||||||
|
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readArtifactManifest", () => {
|
||||||
|
it("parses the manifest", () => {
|
||||||
|
expect(
|
||||||
|
readArtifactManifest(pack({ "manifest.json": '{"id":"notes"}' })),
|
||||||
|
).toEqual({ id: "notes" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a file that is not an archive", () => {
|
||||||
|
expect(() => readArtifactManifest(Buffer.from("nope"))).toThrow(
|
||||||
|
/not a valid .tmxplug/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an archive with no manifest or a broken one", () => {
|
||||||
|
expect(() => readArtifactManifest(pack({ "a.txt": "a" }))).toThrow(
|
||||||
|
/no manifest.json/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
readArtifactManifest(pack({ "manifest.json": "{oops" })),
|
||||||
|
).toThrow(/not valid JSON/);
|
||||||
|
expect(() =>
|
||||||
|
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
||||||
|
).toThrow(/not an object/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sameCapabilities", () => {
|
||||||
|
it("compares as sets", () => {
|
||||||
|
expect(sameCapabilities(["a", "b"], ["b", "a"])).toBe(true);
|
||||||
|
expect(sameCapabilities(["a"], ["a", "b"])).toBe(false);
|
||||||
|
expect(sameCapabilities(["a", "c"], ["a", "b"])).toBe(false);
|
||||||
|
expect(sameCapabilities([], [])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
stats: null as null | {
|
||||||
|
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||||
|
},
|
||||||
|
fail: false,
|
||||||
|
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||||
|
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/registry-index.js", () => ({
|
||||||
|
OFFICIAL_REGISTRY_ID: "official",
|
||||||
|
fetchRegistryStats: async () => {
|
||||||
|
if (state.fail) throw new Error("offline");
|
||||||
|
return state.stats;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginInstallCountRepository: () => ({
|
||||||
|
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||||
|
state.upserted.push({ registryId, entries });
|
||||||
|
return entries.length;
|
||||||
|
},
|
||||||
|
listByRegistry: async () => state.rows,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
DOWNLOADS_SOURCE,
|
||||||
|
TELEMETRY_SOURCE,
|
||||||
|
preferredCount,
|
||||||
|
readInstallCounts,
|
||||||
|
syncInstallCounts,
|
||||||
|
} = await import("../../plugins/install-counts.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.stats = null;
|
||||||
|
state.fail = false;
|
||||||
|
state.upserted = [];
|
||||||
|
state.rows = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("preferredCount", () => {
|
||||||
|
it("prefers active installs and falls back to downloads", () => {
|
||||||
|
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||||
|
count: 12,
|
||||||
|
source: TELEMETRY_SOURCE,
|
||||||
|
});
|
||||||
|
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||||
|
count: 90,
|
||||||
|
source: DOWNLOADS_SOURCE,
|
||||||
|
});
|
||||||
|
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||||
|
count: 90,
|
||||||
|
source: DOWNLOADS_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncInstallCounts", () => {
|
||||||
|
it("writes one row per plugin with its preferred source", async () => {
|
||||||
|
state.stats = {
|
||||||
|
plugins: new Map([
|
||||||
|
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||||
|
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
expect(await syncInstallCounts()).toBe(2);
|
||||||
|
expect(state.upserted).toEqual([
|
||||||
|
{
|
||||||
|
registryId: "official",
|
||||||
|
entries: [
|
||||||
|
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||||
|
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
expect(await syncInstallCounts()).toBe(0);
|
||||||
|
expect(state.upserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for a registry with no stats file", async () => {
|
||||||
|
expect(await syncInstallCounts()).toBe(0);
|
||||||
|
expect(state.upserted).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readInstallCounts", () => {
|
||||||
|
it("maps the stored rows by plugin id", async () => {
|
||||||
|
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||||
|
const counts = await readInstallCounts();
|
||||||
|
expect(counts.get("docker")).toEqual({
|
||||||
|
count: 20,
|
||||||
|
source: TELEMETRY_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,6 +155,8 @@ function tempRoot(prefix: string): string {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CAPS = ["hosts:read", "kv:own"];
|
||||||
|
|
||||||
function registryEntry(
|
function registryEntry(
|
||||||
id: string,
|
id: string,
|
||||||
versions: Array<{ version: string; capabilities?: string[] }>,
|
versions: Array<{ version: string; capabilities?: string[] }>,
|
||||||
@@ -248,7 +250,10 @@ describe("uninstall and reinstall of a bundled plugin", () => {
|
|||||||
expect(loader.get("docker")).toBeUndefined();
|
expect(loader.get("docker")).toBeUndefined();
|
||||||
|
|
||||||
registry.download.mockImplementation(() => stageArtifact("docker"));
|
registry.download.mockImplementation(() => stageArtifact("docker"));
|
||||||
const result = await manage.installPlugin("docker", { userId: "admin" });
|
const result = await manage.installPlugin("docker", {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
expect(result.state).toBe("active");
|
expect(result.state).toBe("active");
|
||||||
expect(registry.download).toHaveBeenCalledOnce();
|
expect(registry.download).toHaveBeenCalledOnce();
|
||||||
expect(loader.get("docker")?.source).toBe("user");
|
expect(loader.get("docker")?.source).toBe("user");
|
||||||
@@ -299,7 +304,10 @@ describe("installing from the registry", () => {
|
|||||||
);
|
);
|
||||||
const loader = await boot();
|
const loader = await boot();
|
||||||
|
|
||||||
const result = await manage.installPlugin("notes", { userId: "admin" });
|
const result = await manage.installPlugin("notes", {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({ id: "notes", state: "active" });
|
expect(result).toMatchObject({ id: "notes", state: "active" });
|
||||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||||
@@ -326,7 +334,11 @@ describe("installing from the registry", () => {
|
|||||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||||
await boot();
|
await boot();
|
||||||
|
|
||||||
await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" });
|
await manage.installPlugin("notes", {
|
||||||
|
userId: "admin",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,7 +346,10 @@ describe("installing from the registry", () => {
|
|||||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||||
await boot();
|
await boot();
|
||||||
await manage.installPlugin("notes", { userId: "admin" });
|
await manage.installPlugin("notes", {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
|
|
||||||
await manage.uninstallPlugin("notes");
|
await manage.uninstallPlugin("notes");
|
||||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||||
@@ -429,3 +444,199 @@ describe("dependencies", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("consent", () => {
|
||||||
|
it("refuses an install without the consented list, before any download", async () => {
|
||||||
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||||
|
await boot();
|
||||||
|
await expect(
|
||||||
|
manage.installPlugin("notes", { userId: "admin" }),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||||
|
expect(registry.download).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the consent does not match what the release asks for", async () => {
|
||||||
|
registry.index.plugins = [
|
||||||
|
registryEntry("notes", [
|
||||||
|
{ version: "1.0.0", capabilities: ["kv:own", "credentials:read"] },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
await boot();
|
||||||
|
await expect(
|
||||||
|
manage.installPlugin("notes", {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "CONSENT_MISMATCH",
|
||||||
|
details: { capabilities: ["kv:own", "credentials:read"] },
|
||||||
|
});
|
||||||
|
expect(registry.download).not.toHaveBeenCalled();
|
||||||
|
expect(db.rows.has("notes")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the same list in another order", async () => {
|
||||||
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||||
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||||
|
await boot();
|
||||||
|
const result = await manage.installPlugin("notes", {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: ["kv:own", "hosts:read"],
|
||||||
|
});
|
||||||
|
expect(result.state).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs the added capabilities listed to accept an update", 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",
|
||||||
|
acceptCapabilities: true,
|
||||||
|
capabilities: ["ssh:connect"],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||||
|
await expect(
|
||||||
|
manage.updatePlugin("docker", {
|
||||||
|
userId: "admin",
|
||||||
|
acceptCapabilities: true,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||||
|
expect(registry.download).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installing from a file", () => {
|
||||||
|
async function artifactBuffer(
|
||||||
|
id: string,
|
||||||
|
manifestOverrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const staged = await stageArtifact(id, manifestOverrides);
|
||||||
|
const buffer = fs.readFileSync(staged.file);
|
||||||
|
fs.rmSync(staged.file);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
manage.resetUploads();
|
||||||
|
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused while developer mode is off", async () => {
|
||||||
|
await boot();
|
||||||
|
await expect(
|
||||||
|
manage.stageUpload(await artifactBuffer("notes")),
|
||||||
|
).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused when signed plugins are required", async () => {
|
||||||
|
await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||||
|
await expect(
|
||||||
|
manage.stageUpload(await artifactBuffer("notes")),
|
||||||
|
).rejects.toMatchObject({ code: "SIGNED_ONLY" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages, asks for consent, then installs it marked unverified", async () => {
|
||||||
|
const loader = await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
|
||||||
|
const preview = await manage.stageUpload(
|
||||||
|
await artifactBuffer("notes", { capabilities: ["kv:own"] }),
|
||||||
|
);
|
||||||
|
expect(preview).toMatchObject({
|
||||||
|
id: "notes",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
replaces: null,
|
||||||
|
});
|
||||||
|
expect(loader.get("notes")).toBeUndefined();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manage.installUpload(preview.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: ["kv:own", "hosts:read"],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||||
|
|
||||||
|
const result = await manage.installUpload(preview.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
});
|
||||||
|
expect(result.state).toBe("active");
|
||||||
|
expect(db.rows.get("notes")).toMatchObject({
|
||||||
|
tier: manage.UNVERIFIED_TIER,
|
||||||
|
registryId: null,
|
||||||
|
});
|
||||||
|
expect(loader.get("notes")?.signedBy).toBeUndefined();
|
||||||
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A token is used once.
|
||||||
|
await expect(
|
||||||
|
manage.installUpload(preview.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an earlier upload but never a bundled plugin", async () => {
|
||||||
|
createFixturePlugin({ id: "docker", root: bundled });
|
||||||
|
const loader = await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manage.stageUpload(await artifactBuffer("docker")),
|
||||||
|
).rejects.toMatchObject({ code: "BUNDLED_ID" });
|
||||||
|
|
||||||
|
const first = await manage.stageUpload(await artifactBuffer("notes"));
|
||||||
|
await manage.installUpload(first.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: first.capabilities,
|
||||||
|
});
|
||||||
|
const second = await manage.stageUpload(
|
||||||
|
await artifactBuffer("notes", { version: "1.1.0" }),
|
||||||
|
);
|
||||||
|
expect(second.replaces).toBe("1.0.0");
|
||||||
|
await manage.installUpload(second.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: second.capabilities,
|
||||||
|
});
|
||||||
|
expect(loader.get("notes")?.manifest.version).toBe("1.1.0");
|
||||||
|
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a file that is not a plugin archive", async () => {
|
||||||
|
await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
await expect(
|
||||||
|
manage.stageUpload(Buffer.from("not a tarball")),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID_ARTIFACT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops staged files when developer mode is turned off", async () => {
|
||||||
|
await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
const preview = await manage.stageUpload(await artifactBuffer("notes"));
|
||||||
|
await manage.setDeveloperMode(false);
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
await expect(
|
||||||
|
manage.installUpload(preview.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: preview.capabilities,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import * as tar from "tar";
|
||||||
|
|
||||||
const fetchMock = vi.hoisted(() => vi.fn());
|
const fetchMock = vi.hoisted(() => vi.fn());
|
||||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||||
@@ -26,9 +27,12 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
|||||||
import {
|
import {
|
||||||
downloadRelease,
|
downloadRelease,
|
||||||
fetchRegistryIndex,
|
fetchRegistryIndex,
|
||||||
|
fetchRegistryStats,
|
||||||
findRelease,
|
findRelease,
|
||||||
|
getStatsUrl,
|
||||||
isApiCompatible,
|
isApiCompatible,
|
||||||
parseRegistryIndex,
|
parseRegistryIndex,
|
||||||
|
parseRegistryStats,
|
||||||
resetRegistryCache,
|
resetRegistryCache,
|
||||||
type RegistryVersion,
|
type RegistryVersion,
|
||||||
} from "../../plugins/registry-index.js";
|
} from "../../plugins/registry-index.js";
|
||||||
@@ -186,8 +190,25 @@ describe("fetchRegistryIndex", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A real .tmxplug holding only a manifest, enough for the checks. */
|
||||||
|
function pack(manifest: Record<string, unknown>): Buffer {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest));
|
||||||
|
const file = path.join(dir, "out.tmxplug");
|
||||||
|
tar.c({ gzip: true, sync: true, file, cwd: dir }, ["manifest.json"]);
|
||||||
|
return fs.readFileSync(file);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("downloadRelease", () => {
|
describe("downloadRelease", () => {
|
||||||
const artifact = Buffer.from("plugin archive bytes");
|
const artifact = pack({
|
||||||
|
id: "docker",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
});
|
||||||
|
|
||||||
it("writes a verified artifact and its signature to staging", async () => {
|
it("writes a verified artifact and its signature to staging", async () => {
|
||||||
const signed = sign(artifact);
|
const signed = sign(artifact);
|
||||||
@@ -206,6 +227,47 @@ describe("downloadRelease", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a file signed by a key this build does not trust", async () => {
|
||||||
|
const other = crypto.generateKeyPairSync("ed25519").privateKey;
|
||||||
|
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||||
|
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||||
|
await expect(
|
||||||
|
downloadRelease(
|
||||||
|
"docker",
|
||||||
|
version({
|
||||||
|
sha256: digest.toString("hex"),
|
||||||
|
signature: crypto.sign(null, digest, other).toString("base64"),
|
||||||
|
size: artifact.length,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/trusted key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["another plugin id", { id: "evil" }, /archive is for/],
|
||||||
|
["another version", { version: "9.9.9" }, /archive is version/],
|
||||||
|
[
|
||||||
|
"more capabilities than the index lists",
|
||||||
|
{ capabilities: ["kv:own", "credentials:read"] },
|
||||||
|
/different capabilities/,
|
||||||
|
],
|
||||||
|
])("refuses a signed file with %s", async (_label, override, error) => {
|
||||||
|
const tampered = pack({
|
||||||
|
id: "docker",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
...override,
|
||||||
|
});
|
||||||
|
const signed = sign(tampered);
|
||||||
|
fetchMock.mockResolvedValueOnce(response(tampered));
|
||||||
|
await expect(
|
||||||
|
downloadRelease("docker", version({ ...signed, size: tampered.length })),
|
||||||
|
).rejects.toThrow(error);
|
||||||
|
expect(fs.existsSync(path.join(dataDir, "plugins", ".downloads"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a file that does not match its signature", async () => {
|
it("refuses a file that does not match its signature", async () => {
|
||||||
const signed = sign(artifact);
|
const signed = sign(artifact);
|
||||||
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
||||||
@@ -229,3 +291,38 @@ describe("downloadRelease", () => {
|
|||||||
).rejects.toThrow(/larger than allowed/);
|
).rejects.toThrow(/larger than allowed/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("registry stats", () => {
|
||||||
|
it("sits next to the index", () => {
|
||||||
|
expect(getStatsUrl()).toBe(
|
||||||
|
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/stats.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps valid counts and drops the rest", () => {
|
||||||
|
const stats = parseRegistryStats({
|
||||||
|
plugins: {
|
||||||
|
docker: { downloads: 120, activeInstalls: 40 },
|
||||||
|
tunnels: { downloads: 7 },
|
||||||
|
"../bad": { downloads: 1 },
|
||||||
|
broken: { downloads: -3, activeInstalls: "many" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||||
|
docker: { downloads: 120, activeInstalls: 40 },
|
||||||
|
tunnels: { downloads: 7, activeInstalls: null },
|
||||||
|
broken: { downloads: 0, activeInstalls: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches and parses stats.json", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||||
|
);
|
||||||
|
const stats = await fetchRegistryStats();
|
||||||
|
expect(stats?.plugins.get("docker")).toEqual({
|
||||||
|
downloads: 3,
|
||||||
|
activeInstalls: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -284,6 +284,9 @@ export interface RegistryPluginEntry {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
|
/** Active installs when known, release downloads otherwise. */
|
||||||
|
installCount: number | null;
|
||||||
|
installCountSource: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryListing {
|
export interface RegistryListing {
|
||||||
@@ -338,14 +341,16 @@ export async function getPluginRegistry(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `capabilities` is exactly what the consent prompt showed. */
|
||||||
export async function installPlugin(
|
export async function installPlugin(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
version?: string,
|
options: { version?: string; capabilities: string[] },
|
||||||
): Promise<{ id: string; version: string; state: string }> {
|
): Promise<{ id: string; version: string; state: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.post(`${pluginPath(pluginId)}/install`, {
|
const response = await rbacApi.post(
|
||||||
version,
|
`${pluginPath(pluginId)}/install`,
|
||||||
});
|
options,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} finally {
|
} finally {
|
||||||
announcePluginsChanged();
|
announcePluginsChanged();
|
||||||
@@ -354,7 +359,11 @@ export async function installPlugin(
|
|||||||
|
|
||||||
export async function updatePlugin(
|
export async function updatePlugin(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
options: { version?: string; acceptCapabilities?: boolean } = {},
|
options: {
|
||||||
|
version?: string;
|
||||||
|
acceptCapabilities?: boolean;
|
||||||
|
capabilities?: string[];
|
||||||
|
} = {},
|
||||||
): Promise<{ id: string; version: string; state: string }> {
|
): Promise<{ id: string; version: string; state: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.post(
|
const response = await rbacApi.post(
|
||||||
@@ -443,3 +452,57 @@ export async function deletePluginData(pluginId: string): Promise<void> {
|
|||||||
announcePluginsChanged();
|
announcePluginsChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeveloperModeState {
|
||||||
|
enabled: boolean;
|
||||||
|
/** TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs even in developer mode. */
|
||||||
|
signedOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeveloperMode(): Promise<DeveloperModeState> {
|
||||||
|
const response = await rbacApi.get("/plugins/developer-mode");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after developer mode changes; detail is the new value. */
|
||||||
|
export const DEVELOPER_MODE_CHANGED_EVENT = "termix:plugin-developer-mode";
|
||||||
|
|
||||||
|
export async function setDeveloperMode(enabled: boolean): Promise<void> {
|
||||||
|
await rbacApi.put("/plugins/developer-mode", { enabled });
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(DEVELOPER_MODE_CHANGED_EVENT, { detail: enabled }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUploadPreview {
|
||||||
|
token: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
capabilities: string[];
|
||||||
|
replaces: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
|
||||||
|
const response = await rbacApi.post("/plugins/upload", file, {
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installUploadedPlugin(
|
||||||
|
token: string,
|
||||||
|
capabilities: string[],
|
||||||
|
): Promise<{ id: string; version: string; state: string }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.post(
|
||||||
|
`/plugins/upload/${encodeURIComponent(token)}/install`,
|
||||||
|
{ capabilities },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
announcePluginsChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-3
@@ -1976,6 +1976,7 @@
|
|||||||
"notRunning": "Not running"
|
"notRunning": "Not running"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
|
"popular": "Most popular",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"updated": "Recently updated",
|
"updated": "Recently updated",
|
||||||
"category": "Category"
|
"category": "Category"
|
||||||
@@ -2017,7 +2018,9 @@
|
|||||||
"updateExplain": "This version asks for something the one you have does not, so it will not install on its own.",
|
"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",
|
"newInVersion": "New in this version",
|
||||||
"minor": "It also adds its own screens and stores its own data, kept separate from other plugins.",
|
"minor": "It also adds its own screens and stores its own data, kept separate from other plugins.",
|
||||||
"none": "It asks for no special access."
|
"none": "It asks for no special access.",
|
||||||
|
"uploadTitle": "Install {{name}} from a file",
|
||||||
|
"uploadWarning": "This file was not checked or signed by the Termix registry. Only install it if you built it yourself or trust whoever did."
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"uninstallTitle": "Uninstall {{name}}?",
|
"uninstallTitle": "Uninstall {{name}}?",
|
||||||
@@ -2054,7 +2057,10 @@
|
|||||||
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
||||||
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
|
"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.",
|
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
|
||||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed."
|
"missingDependencies": "{{name}} needs {{list}}, which is not installed.",
|
||||||
|
"consentChanged": "What this plugin asks for changed since you looked. Check the list again.",
|
||||||
|
"upload": "Could not read {{name}}: {{error}}",
|
||||||
|
"developerMode": "Could not change developer mode: {{error}}"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Stored data",
|
"title": "Stored data",
|
||||||
@@ -2122,7 +2128,29 @@
|
|||||||
"registryUnreachable": "Could not reach the plugin registry ({{error}}). Installed plugins still work.",
|
"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.",
|
"managedByServer": "This desktop is linked to a server, which decides its plugins. Changes are made on the server.",
|
||||||
"browsePlugins": "Browse plugins",
|
"browsePlugins": "Browse plugins",
|
||||||
"loading": "Loading"
|
"loading": "Loading",
|
||||||
|
"developer": {
|
||||||
|
"title": "Plugin developer mode",
|
||||||
|
"hint": "Lets admins install plugins from a .tmxplug file in the Plugins tab, without the registry checks. Use it to build and test your own plugins.",
|
||||||
|
"enableTitle": "Turn on developer mode?",
|
||||||
|
"enableBody": "Admins will be able to install plugins from files that Termix has not checked. A plugin can do anything its capabilities allow, so only turn this on while you are building or testing plugins.",
|
||||||
|
"enable": "Turn on",
|
||||||
|
"installFromFile": "Install from file",
|
||||||
|
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"active_one": "{{formatted}} active install",
|
||||||
|
"active_other": "{{formatted}} active installs",
|
||||||
|
"downloads_one": "{{formatted}} download",
|
||||||
|
"downloads_other": "{{formatted}} downloads",
|
||||||
|
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
|
||||||
|
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
||||||
|
},
|
||||||
|
"unverified": "Unverified",
|
||||||
|
"unverifiedHint": "Installed from a file. Termix did not check or sign it.",
|
||||||
|
"reportIssue": "Report an issue",
|
||||||
|
"reportTo": "Report to {{author}}",
|
||||||
|
"openSettings": "Open its settings"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Facts } from "@/components/panel-layout";
|
import { Facts } from "@/components/panel-layout";
|
||||||
import { PanePrompt } from "@/components/surface/surface-scope";
|
import { PanePrompt } from "@/components/surface/surface-scope";
|
||||||
@@ -7,10 +8,12 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
|
|||||||
|
|
||||||
export interface ConsentRequest {
|
export interface ConsentRequest {
|
||||||
plugin: PluginEntry;
|
plugin: PluginEntry;
|
||||||
mode: "install" | "update";
|
mode: "install" | "update" | "upload";
|
||||||
version: string;
|
version: string;
|
||||||
/** What the prompt lists: all of them to install, only the new ones to update. */
|
/** What the prompt lists: all of them to install, only the new ones to update. */
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
|
/** Set for a file uploaded in developer mode. */
|
||||||
|
uploadToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,13 +47,15 @@ export function PluginConsentPrompt({
|
|||||||
title={
|
title={
|
||||||
mode === "update"
|
mode === "update"
|
||||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
: mode === "upload"
|
||||||
|
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
||||||
|
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Facts>
|
<Facts>
|
||||||
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{mode === "update" && plugin.version
|
{mode !== "install" && plugin.version
|
||||||
? t("plugins.manager.versionChange", {
|
? t("plugins.manager.versionChange", {
|
||||||
from: plugin.version,
|
from: plugin.version,
|
||||||
to: version,
|
to: version,
|
||||||
@@ -78,6 +83,14 @@ export function PluginConsentPrompt({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{mode === "upload" && (
|
||||||
|
<div className="flex items-start gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
|
||||||
|
<TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
|
||||||
|
<span className="text-[11px] leading-snug text-foreground">
|
||||||
|
{t("plugins.manager.consent.uploadWarning")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<p className="text-xs leading-relaxed text-foreground">
|
<p className="text-xs leading-relaxed text-foreground">
|
||||||
{t("plugins.manager.consent.updateExplain")}
|
{t("plugins.manager.consent.updateExplain")}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
Bug,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Pin,
|
Pin,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
Trash2,
|
Trash2,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -21,15 +23,25 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/select";
|
} from "@/components/select";
|
||||||
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
||||||
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
|
import {
|
||||||
|
CapabilityRow,
|
||||||
|
InstallCountFact,
|
||||||
|
PluginIconBox,
|
||||||
|
UnverifiedBadge,
|
||||||
|
} from "./plugin-bits";
|
||||||
import {
|
import {
|
||||||
describeContributions,
|
describeContributions,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
orderByRisk,
|
orderByRisk,
|
||||||
|
reportIssueUrl,
|
||||||
type PluginEntry,
|
type PluginEntry,
|
||||||
} from "./plugin-model";
|
} from "./plugin-model";
|
||||||
|
import { openPluginSettings } from "./open-plugins";
|
||||||
import type { PluginsManager } from "./use-plugins-manager";
|
import type { PluginsManager } from "./use-plugins-manager";
|
||||||
|
|
||||||
|
const LINK_ROW =
|
||||||
|
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
function formatDate(value: string | null | undefined): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -60,6 +72,15 @@ export function PluginDetail({
|
|||||||
}, [plugin.version, plugin.latestVersion]);
|
}, [plugin.version, plugin.latestVersion]);
|
||||||
|
|
||||||
const contributions = describeContributions(plugin.contributes);
|
const contributions = describeContributions(plugin.contributes);
|
||||||
|
const settings = plugin.contributes?.settings as
|
||||||
|
{ admin?: unknown[]; user?: unknown[] } | undefined;
|
||||||
|
const hasSettings =
|
||||||
|
plugin.installed &&
|
||||||
|
((settings?.admin?.length ?? 0) > 0 || (settings?.user?.length ?? 0) > 0);
|
||||||
|
const issueUrl = reportIssueUrl(
|
||||||
|
plugin,
|
||||||
|
import.meta.env.VITE_APP_VERSION || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,6 +98,7 @@ export function PluginDetail({
|
|||||||
{plugin.description}
|
{plugin.description}
|
||||||
</span>
|
</span>
|
||||||
<Facts className="text-[11px] text-muted-foreground/70">
|
<Facts className="text-[11px] text-muted-foreground/70">
|
||||||
|
{plugin.unverified && <UnverifiedBadge />}
|
||||||
{plugin.author && <span>{plugin.author}</span>}
|
{plugin.author && <span>{plugin.author}</span>}
|
||||||
{plugin.version ? (
|
{plugin.version ? (
|
||||||
<span>v{plugin.version}</span>
|
<span>v{plugin.version}</span>
|
||||||
@@ -91,6 +113,10 @@ export function PluginDetail({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<InstallCountFact
|
||||||
|
count={plugin.installCount}
|
||||||
|
source={plugin.installCountSource}
|
||||||
|
/>
|
||||||
</Facts>
|
</Facts>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col gap-1.5">
|
<div className="flex shrink-0 flex-col gap-1.5">
|
||||||
@@ -305,6 +331,7 @@ export function PluginDetail({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
disabled={
|
disabled={
|
||||||
busy || locked || !picked || picked === plugin.version
|
busy || locked || !picked || picked === plugin.version
|
||||||
}
|
}
|
||||||
@@ -367,12 +394,43 @@ export function PluginDetail({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPluginSettings(plugin.id)}
|
||||||
|
className={LINK_ROW}
|
||||||
|
>
|
||||||
|
<Settings className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t("plugins.manager.openSettings")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{issueUrl && (
|
||||||
|
<a
|
||||||
|
href={issueUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={LINK_ROW}
|
||||||
|
>
|
||||||
|
<Bug className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{plugin.source === "official" ||
|
||||||
|
plugin.source === "bundled" ||
|
||||||
|
!plugin.author
|
||||||
|
? t("plugins.manager.reportIssue")
|
||||||
|
: t("plugins.manager.reportTo", { author: plugin.author })}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{plugin.repository && (
|
{plugin.repository && (
|
||||||
<a
|
<a
|
||||||
href={plugin.repository}
|
href={plugin.repository}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
className={LINK_ROW}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3.5 shrink-0" />
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowDownWideNarrow,
|
ArrowDownWideNarrow,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Card } from "@/components/card";
|
import { Card } from "@/components/card";
|
||||||
@@ -33,7 +34,11 @@ import {
|
|||||||
} from "@/components/panel-layout";
|
} from "@/components/panel-layout";
|
||||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PluginIconBox } from "./plugin-bits";
|
import {
|
||||||
|
InstallCountFact,
|
||||||
|
PluginIconBox,
|
||||||
|
UnverifiedBadge,
|
||||||
|
} from "./plugin-bits";
|
||||||
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
||||||
import { PluginDetail } from "./PluginDetail";
|
import { PluginDetail } from "./PluginDetail";
|
||||||
import {
|
import {
|
||||||
@@ -210,7 +215,7 @@ function SortSelect({
|
|||||||
onChange: (next: SortKey) => void;
|
onChange: (next: SortKey) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const keys: SortKey[] = ["name", "updated", "category"];
|
const keys: SortKey[] = ["popular", "name", "updated", "category"];
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={(v) => onChange(v as SortKey)}>
|
<Select value={value} onValueChange={(v) => onChange(v as SortKey)}>
|
||||||
<SelectTrigger size="sm" className="w-auto gap-1.5 text-xs">
|
<SelectTrigger size="sm" className="w-auto gap-1.5 text-xs">
|
||||||
@@ -306,6 +311,9 @@ function InstalledSection({
|
|||||||
>
|
>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<SortSelect value={sort} onChange={setSort} />
|
<SortSelect value={sort} onChange={setSort} />
|
||||||
|
{manager.developerMode && !manager.signedOnly && (
|
||||||
|
<UploadButton manager={manager} />
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
@@ -369,6 +377,35 @@ function InstalledSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UploadButton({ manager }: { manager: PluginsManager }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type="file"
|
||||||
|
accept=".tmxplug"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) void manager.requestUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={manager.busy.size > 0 || manager.managedByServer}
|
||||||
|
onClick={() => input.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="size-3.5" />
|
||||||
|
{t("plugins.manager.developer.installFromFile")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CardHead({
|
function CardHead({
|
||||||
plugin,
|
plugin,
|
||||||
muted,
|
muted,
|
||||||
@@ -431,6 +468,7 @@ function InstalledCard({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
facts={
|
facts={
|
||||||
<>
|
<>
|
||||||
|
{plugin.unverified && <UnverifiedBadge />}
|
||||||
{plugin.author && <span>{plugin.author}</span>}
|
{plugin.author && <span>{plugin.author}</span>}
|
||||||
<span>v{plugin.version}</span>
|
<span>v{plugin.version}</span>
|
||||||
{adds.length > 0 && <span>{adds.join(", ")}</span>}
|
{adds.length > 0 && <span>{adds.join(", ")}</span>}
|
||||||
@@ -540,7 +578,7 @@ function BrowseSection({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [category, setCategory] = useState<string | null>(null);
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
const [sort, setSort] = useState<SortKey>("name");
|
const [sort, setSort] = useState<SortKey>("popular");
|
||||||
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
||||||
|
|
||||||
const rows = sortPlugins(
|
const rows = sortPlugins(
|
||||||
@@ -633,6 +671,10 @@ function BrowseCard({
|
|||||||
{plugin.author && <span>{plugin.author}</span>}
|
{plugin.author && <span>{plugin.author}</span>}
|
||||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||||
{plugin.category && <span>{plugin.category}</span>}
|
{plugin.category && <span>{plugin.category}</span>}
|
||||||
|
<InstallCountFact
|
||||||
|
count={plugin.installCount}
|
||||||
|
source={plugin.installCountSource}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
/** Asks the shell to open the Plugins tab. */
|
/** Asks the shell to open the Plugins tab. */
|
||||||
export const OPEN_PLUGINS_EVENT = "termix:open-plugins";
|
export const OPEN_PLUGINS_EVENT = "termix:open-plugins";
|
||||||
|
|
||||||
|
/** Asks the shell to open Settings at a plugin's page; detail is the plugin id. */
|
||||||
|
export const OPEN_PLUGIN_SETTINGS_EVENT = "termix:open-plugin-settings";
|
||||||
|
|
||||||
|
export function openPluginSettings(pluginId: string): void {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(OPEN_PLUGIN_SETTINGS_EVENT, { detail: pluginId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { capabilityRisk, isSevere } from "./plugin-model";
|
import { capabilityRisk, formatCount, isSevere } from "./plugin-model";
|
||||||
|
|
||||||
export function PluginIconBox({
|
export function PluginIconBox({
|
||||||
name,
|
name,
|
||||||
@@ -74,3 +74,45 @@ export function CapabilityRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanent mark on anything installed without a registry signature. */
|
||||||
|
export function UnverifiedBadge() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning"
|
||||||
|
title={t("plugins.manager.unverifiedHint")}
|
||||||
|
>
|
||||||
|
{t("plugins.manager.unverified")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active installs when the registry has them, release downloads otherwise. */
|
||||||
|
export function InstallCountFact({
|
||||||
|
count,
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
count: number | null;
|
||||||
|
source: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (count === null || count <= 0) return null;
|
||||||
|
const active = source === "aggregate-telemetry";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
active
|
||||||
|
? t("plugins.manager.count.activeHint")
|
||||||
|
: t("plugins.manager.count.downloadsHint")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
active
|
||||||
|
? "plugins.manager.count.active"
|
||||||
|
: "plugins.manager.count.downloads",
|
||||||
|
{ count, formatted: formatCount(count) },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||||
import type {
|
import type {
|
||||||
PluginSummary,
|
PluginSummary,
|
||||||
|
PluginUploadPreview,
|
||||||
RegistryPluginEntry,
|
RegistryPluginEntry,
|
||||||
RegistryPluginVersion,
|
RegistryPluginVersion,
|
||||||
} from "@/api/plugins-api";
|
} from "@/api/plugins-api";
|
||||||
@@ -36,6 +37,22 @@ export interface PluginEntry {
|
|||||||
inRegistry: boolean;
|
inRegistry: boolean;
|
||||||
contributes: Record<string, unknown> | null;
|
contributes: Record<string, unknown> | null;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
|
installCount: number | null;
|
||||||
|
installCountSource: string | null;
|
||||||
|
source: PluginSource;
|
||||||
|
/** Installed from a file or folder with no registry signature behind it. */
|
||||||
|
unverified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSource = "bundled" | "official" | "unverified";
|
||||||
|
|
||||||
|
/** Where an installed copy came from, as the issue report names it. */
|
||||||
|
export function pluginSource(summary: PluginSummary): PluginSource {
|
||||||
|
if (summary.tier === "unverified") return "unverified";
|
||||||
|
if (summary.source === "bundled" || summary.tier === "bundled") {
|
||||||
|
return "bundled";
|
||||||
|
}
|
||||||
|
return summary.signedBy ? "official" : "unverified";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||||
@@ -79,6 +96,10 @@ export function mergePlugins(
|
|||||||
inRegistry: Boolean(entry),
|
inRegistry: Boolean(entry),
|
||||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||||
publishedAt: latestPublished(entry?.versions ?? []),
|
publishedAt: latestPublished(entry?.versions ?? []),
|
||||||
|
installCount: entry?.installCount ?? null,
|
||||||
|
installCountSource: entry?.installCountSource ?? null,
|
||||||
|
source: pluginSource(summary),
|
||||||
|
unverified: pluginSource(summary) === "unverified",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +131,47 @@ export function mergePlugins(
|
|||||||
inRegistry: true,
|
inRegistry: true,
|
||||||
contributes: null,
|
contributes: null,
|
||||||
publishedAt: latestPublished(entry.versions),
|
publishedAt: latestPublished(entry.versions),
|
||||||
|
installCount: entry.installCount ?? null,
|
||||||
|
installCountSource: entry.installCountSource ?? null,
|
||||||
|
source: "official",
|
||||||
|
unverified: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A row for a file that was uploaded but not installed yet. */
|
||||||
|
export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||||
|
return {
|
||||||
|
id: preview.id,
|
||||||
|
name: preview.name,
|
||||||
|
description: preview.description,
|
||||||
|
author: preview.author,
|
||||||
|
category: "",
|
||||||
|
installed: preview.replaces !== null,
|
||||||
|
version: preview.replaces,
|
||||||
|
latestVersion: preview.version,
|
||||||
|
updateAvailable: false,
|
||||||
|
addedCapabilities: [],
|
||||||
|
versions: [],
|
||||||
|
capabilities: preview.capabilities,
|
||||||
|
status: null,
|
||||||
|
enabled: false,
|
||||||
|
lastError: null,
|
||||||
|
autoUpdate: false,
|
||||||
|
pinnedVersion: null,
|
||||||
|
bundled: false,
|
||||||
|
inRegistry: false,
|
||||||
|
contributes: null,
|
||||||
|
publishedAt: null,
|
||||||
|
installCount: null,
|
||||||
|
installCountSource: null,
|
||||||
|
source: "unverified",
|
||||||
|
unverified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
||||||
const dates = versions
|
const dates = versions
|
||||||
.map((v) => v.publishedAt)
|
.map((v) => v.publishedAt)
|
||||||
@@ -124,11 +180,17 @@ function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
|||||||
return dates.at(-1) ?? null;
|
return dates.at(-1) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortKey = "name" | "updated" | "category";
|
export type SortKey = "popular" | "name" | "updated" | "category";
|
||||||
|
|
||||||
export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
||||||
const sorted = [...rows];
|
const sorted = [...rows];
|
||||||
if (key === "updated") {
|
if (key === "popular") {
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.installCount ?? -1) - (a.installCount ?? -1) ||
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
} else if (key === "updated") {
|
||||||
sorted.sort((a, b) =>
|
sorted.sort((a, b) =>
|
||||||
(b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
|
(b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
|
||||||
);
|
);
|
||||||
@@ -230,3 +292,49 @@ export function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 1234 reads as 1.2k, so the number stays a rough ranking. */
|
||||||
|
export function formatCount(count: number): string {
|
||||||
|
if (count < 1000) return String(count);
|
||||||
|
if (count < 1_000_000) {
|
||||||
|
return `${(count / 1000).toFixed(count < 10_000 ? 1 : 0)}k`;
|
||||||
|
}
|
||||||
|
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new GitHub issue on the plugin's own repo with what a maintainer asks
|
||||||
|
* first already filled in. Null when the repository is not on GitHub.
|
||||||
|
*/
|
||||||
|
export function reportIssueUrl(
|
||||||
|
plugin: Pick<
|
||||||
|
PluginEntry,
|
||||||
|
"id" | "repository" | "version" | "latestVersion" | "status" | "source"
|
||||||
|
>,
|
||||||
|
termixVersion?: string,
|
||||||
|
): string | null {
|
||||||
|
const repo = plugin.repository
|
||||||
|
?.trim()
|
||||||
|
.replace(/\.git$/, "")
|
||||||
|
.replace(/\/+$/, "");
|
||||||
|
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
`**Plugin:** ${plugin.id}`,
|
||||||
|
`**Version:** ${plugin.version ?? plugin.latestVersion ?? "not installed"}`,
|
||||||
|
`**Source:** ${plugin.source}`,
|
||||||
|
`**State:** ${plugin.status ?? "not installed"}`,
|
||||||
|
...(termixVersion ? [`**Termix:** ${termixVersion}`] : []),
|
||||||
|
"",
|
||||||
|
"**What happened:**",
|
||||||
|
"",
|
||||||
|
"**What you expected:**",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
title: `[${plugin.id}] `,
|
||||||
|
body: lines.join("\n"),
|
||||||
|
});
|
||||||
|
return `${repo}/issues/new?${params.toString()}`;
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
|
DEVELOPER_MODE_CHANGED_EVENT,
|
||||||
deletePluginData,
|
deletePluginData,
|
||||||
|
getDeveloperMode,
|
||||||
getPluginRegistry,
|
getPluginRegistry,
|
||||||
getPlugins,
|
getPlugins,
|
||||||
installPlugin,
|
installPlugin,
|
||||||
|
installUploadedPlugin,
|
||||||
previewPluginState,
|
previewPluginState,
|
||||||
retryPlugin,
|
retryPlugin,
|
||||||
setPluginOptions,
|
setPluginOptions,
|
||||||
@@ -14,11 +17,13 @@ import {
|
|||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
updateAllPlugins,
|
updateAllPlugins,
|
||||||
updatePlugin,
|
updatePlugin,
|
||||||
|
uploadPlugin,
|
||||||
|
type DeveloperModeState,
|
||||||
type PluginSummary,
|
type PluginSummary,
|
||||||
type RegistryListing,
|
type RegistryListing,
|
||||||
} from "@/api/plugins-api";
|
} from "@/api/plugins-api";
|
||||||
import { useConfirm } from "@/components/surface/surface-scope";
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import { mergePlugins, type PluginEntry } from "./plugin-model";
|
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
||||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||||
|
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
@@ -53,6 +58,10 @@ export function usePluginsManager() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||||
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||||
|
const [developer, setDeveloper] = useState<DeveloperModeState>({
|
||||||
|
enabled: false,
|
||||||
|
signedOnly: false,
|
||||||
|
});
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,12 +73,14 @@ export function usePluginsManager() {
|
|||||||
|
|
||||||
const load = useCallback(async (refresh = false) => {
|
const load = useCallback(async (refresh = false) => {
|
||||||
if (refresh) setRefreshing(true);
|
if (refresh) setRefreshing(true);
|
||||||
const [plugins, listing] = await Promise.allSettled([
|
const [plugins, listing, devMode] = await Promise.allSettled([
|
||||||
getPlugins(),
|
getPlugins(),
|
||||||
getPluginRegistry(refresh),
|
getPluginRegistry(refresh),
|
||||||
|
getDeveloperMode(),
|
||||||
]);
|
]);
|
||||||
if (!mounted.current) return;
|
if (!mounted.current) return;
|
||||||
if (plugins.status === "fulfilled") setInstalled(plugins.value);
|
if (plugins.status === "fulfilled") setInstalled(plugins.value);
|
||||||
|
if (devMode.status === "fulfilled") setDeveloper(devMode.value);
|
||||||
if (listing.status === "fulfilled") {
|
if (listing.status === "fulfilled") {
|
||||||
setRegistry(listing.value);
|
setRegistry(listing.value);
|
||||||
setRegistryError(listing.value.registry.error);
|
setRegistryError(listing.value.registry.error);
|
||||||
@@ -84,6 +95,18 @@ export function usePluginsManager() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// The switch lives in admin settings, which may change while this is open.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = (event: Event) => {
|
||||||
|
const enabled = (event as CustomEvent<boolean>).detail;
|
||||||
|
if (typeof enabled !== "boolean") return;
|
||||||
|
setDeveloper((current) => ({ ...current, enabled }));
|
||||||
|
};
|
||||||
|
window.addEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const plugins = useMemo(
|
const plugins = useMemo(
|
||||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
() => mergePlugins(installed, registry?.plugins ?? null),
|
||||||
[installed, registry],
|
[installed, registry],
|
||||||
@@ -152,13 +175,13 @@ export function usePluginsManager() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const doInstall = useCallback(
|
const doInstall = useCallback(
|
||||||
(plugin: PluginEntry, version: string) =>
|
(plugin: PluginEntry, version: string, capabilities: string[]) =>
|
||||||
run(plugin.id, async () => {
|
run(plugin.id, async () => {
|
||||||
try {
|
try {
|
||||||
const result = await installPlugin(
|
const result = await installPlugin(plugin.id, {
|
||||||
plugin.id,
|
version: version === plugin.latestVersion ? undefined : version,
|
||||||
version === plugin.latestVersion ? undefined : version,
|
capabilities,
|
||||||
);
|
});
|
||||||
if (result.state === "failed" || result.state === "blocked") {
|
if (result.state === "failed" || result.state === "blocked") {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("plugins.manager.toast.installedNotRunning", {
|
t("plugins.manager.toast.installedNotRunning", {
|
||||||
@@ -171,6 +194,17 @@ export function usePluginsManager() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const info = readError(error, "");
|
||||||
|
if (info.code === "CONSENT_MISMATCH") {
|
||||||
|
toast.error(t("plugins.manager.errors.consentChanged"));
|
||||||
|
setConsent({
|
||||||
|
plugin,
|
||||||
|
mode: "install",
|
||||||
|
version,
|
||||||
|
capabilities: (info.data.capabilities as string[]) ?? [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
failToast(error, "plugins.manager.errors.install", plugin.name);
|
failToast(error, "plugins.manager.errors.install", plugin.name);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -178,12 +212,13 @@ export function usePluginsManager() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const doUpdate = useCallback(
|
const doUpdate = useCallback(
|
||||||
(plugin: PluginEntry, version: string, acceptCapabilities: boolean) =>
|
(plugin: PluginEntry, version: string, accepted: string[] | null) =>
|
||||||
run(plugin.id, async () => {
|
run(plugin.id, async () => {
|
||||||
try {
|
try {
|
||||||
await updatePlugin(plugin.id, {
|
await updatePlugin(plugin.id, {
|
||||||
version: version === plugin.latestVersion ? undefined : version,
|
version: version === plugin.latestVersion ? undefined : version,
|
||||||
acceptCapabilities,
|
acceptCapabilities: accepted !== null,
|
||||||
|
capabilities: accepted ?? undefined,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("plugins.manager.toast.updated", {
|
t("plugins.manager.toast.updated", {
|
||||||
@@ -193,7 +228,13 @@ export function usePluginsManager() {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const info = readError(error, "");
|
const info = readError(error, "");
|
||||||
if (info.code === "CAPABILITIES_ADDED") {
|
if (
|
||||||
|
info.code === "CAPABILITIES_ADDED" ||
|
||||||
|
info.code === "CONSENT_MISMATCH"
|
||||||
|
) {
|
||||||
|
if (info.code === "CONSENT_MISMATCH") {
|
||||||
|
toast.error(t("plugins.manager.errors.consentChanged"));
|
||||||
|
}
|
||||||
setConsent({
|
setConsent({
|
||||||
plugin,
|
plugin,
|
||||||
mode: "update",
|
mode: "update",
|
||||||
@@ -242,21 +283,74 @@ export function usePluginsManager() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await doUpdate(plugin, target, false);
|
await doUpdate(plugin, target, null);
|
||||||
},
|
},
|
||||||
[confirm, t, doUpdate],
|
[confirm, t, doUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const doInstallUpload = useCallback(
|
||||||
|
(request: ConsentRequest) =>
|
||||||
|
run(request.plugin.id, async () => {
|
||||||
|
try {
|
||||||
|
const result = await installUploadedPlugin(
|
||||||
|
request.uploadToken!,
|
||||||
|
request.capabilities,
|
||||||
|
);
|
||||||
|
if (result.state === "failed" || result.state === "blocked") {
|
||||||
|
toast.error(
|
||||||
|
t("plugins.manager.toast.installedNotRunning", {
|
||||||
|
name: request.plugin.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("plugins.manager.toast.installed", {
|
||||||
|
name: request.plugin.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failToast(
|
||||||
|
error,
|
||||||
|
"plugins.manager.errors.install",
|
||||||
|
request.plugin.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[run, t, failToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Reads a picked .tmxplug and asks for consent before anything loads. */
|
||||||
|
const requestUpload = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
try {
|
||||||
|
const preview = await uploadPlugin(file);
|
||||||
|
setConsent({
|
||||||
|
plugin: uploadEntry(preview),
|
||||||
|
mode: "upload",
|
||||||
|
version: preview.version,
|
||||||
|
capabilities: preview.capabilities,
|
||||||
|
uploadToken: preview.token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
failToast(error, "plugins.manager.errors.upload", file.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[failToast],
|
||||||
|
);
|
||||||
|
|
||||||
const confirmConsent = useCallback(async () => {
|
const confirmConsent = useCallback(async () => {
|
||||||
if (!consent) return;
|
if (!consent) return;
|
||||||
const request = consent;
|
const request = consent;
|
||||||
setConsent(null);
|
setConsent(null);
|
||||||
if (request.mode === "install") {
|
if (request.mode === "upload" && request.uploadToken) {
|
||||||
await doInstall(request.plugin, request.version);
|
await doInstallUpload(request);
|
||||||
|
} else if (request.mode === "install") {
|
||||||
|
await doInstall(request.plugin, request.version, request.capabilities);
|
||||||
} else {
|
} else {
|
||||||
await doUpdate(request.plugin, request.version, true);
|
await doUpdate(request.plugin, request.version, request.capabilities);
|
||||||
}
|
}
|
||||||
}, [consent, doInstall, doUpdate]);
|
}, [consent, doInstall, doUpdate, doInstallUpload]);
|
||||||
|
|
||||||
const nameOf = useCallback(
|
const nameOf = useCallback(
|
||||||
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
||||||
@@ -439,6 +533,9 @@ export function usePluginsManager() {
|
|||||||
busy,
|
busy,
|
||||||
consent,
|
consent,
|
||||||
setConsent,
|
setConsent,
|
||||||
|
developerMode: developer.enabled,
|
||||||
|
signedOnly: developer.signedOnly,
|
||||||
|
requestUpload,
|
||||||
refresh: () => load(true),
|
refresh: () => load(true),
|
||||||
requestInstall,
|
requestInstall,
|
||||||
requestUpdate,
|
requestUpdate,
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
|||||||
"admin.sessionTimeoutRange",
|
"admin.sessionTimeoutRange",
|
||||||
"admin.statusCheck",
|
"admin.statusCheck",
|
||||||
"common.save",
|
"common.save",
|
||||||
|
"plugins.manager.developer.enable",
|
||||||
|
"plugins.manager.developer.enableBody",
|
||||||
|
"plugins.manager.developer.enableTitle",
|
||||||
|
"plugins.manager.developer.hint",
|
||||||
|
"plugins.manager.developer.signedOnly",
|
||||||
|
"plugins.manager.developer.title",
|
||||||
|
"plugins.manager.errors.developerMode",
|
||||||
],
|
],
|
||||||
"admin-host-defaults": [
|
"admin-host-defaults": [
|
||||||
"admin.hostTags",
|
"admin.hostTags",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SettingRow } from "@/components/section-card";
|
||||||
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
|
import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api";
|
||||||
|
import { AdminToggle } from "./AdminSettingsShared";
|
||||||
|
|
||||||
|
/** Lets admins install plugins from a file. Asks before turning it on. */
|
||||||
|
export function AdminPluginDeveloperMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [signedOnly, setSignedOnly] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getDeveloperMode()
|
||||||
|
.then((state) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEnabled(state.enabled);
|
||||||
|
setSignedOnly(state.signedOnly);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
const next = !enabled;
|
||||||
|
if (next) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("plugins.manager.developer.enableTitle"),
|
||||||
|
description: t("plugins.manager.developer.enableBody"),
|
||||||
|
confirmLabel: t("plugins.manager.developer.enable"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await setDeveloperMode(next);
|
||||||
|
setEnabled(next);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("plugins.manager.errors.developerMode", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingRow
|
||||||
|
label={t("plugins.manager.developer.title")}
|
||||||
|
description={t("plugins.manager.developer.hint")}
|
||||||
|
>
|
||||||
|
<AdminToggle
|
||||||
|
on={enabled}
|
||||||
|
onToggle={() => void toggle()}
|
||||||
|
disabled={!loaded || saving}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{enabled && signedOnly && (
|
||||||
|
<p className="text-[10px] text-destructive">
|
||||||
|
{t("plugins.manager.developer.signedOnly")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { SettingRow } from "@/components/section-card";
|
|||||||
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
|
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
|
||||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||||
import { AdminHostTags } from "./AdminHostTags";
|
import { AdminHostTags } from "./AdminHostTags";
|
||||||
|
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
|
||||||
import type { TlsStatus } from "@/api/tls-api";
|
import type { TlsStatus } from "@/api/tls-api";
|
||||||
|
|
||||||
type GeneralSettingsSectionProps = {
|
type GeneralSettingsSectionProps = {
|
||||||
@@ -142,6 +143,7 @@ export function AdminGeneralSettingsSection({
|
|||||||
onToggle={handleTogglePasswordReset}
|
onToggle={handleTogglePasswordReset}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<AdminPluginDeveloperMode />
|
||||||
<div className="flex flex-col gap-2 pt-3 mt-2">
|
<div className="flex flex-col gap-2 pt-3 mt-2">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("admin.sessionTimeout")}
|
{t("admin.sessionTimeout")}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const [plugin] = mergePlugins(
|
|||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
bundled: false,
|
bundled: false,
|
||||||
|
installCount: null,
|
||||||
|
installCountSource: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import {
|
|||||||
categoriesOf,
|
categoriesOf,
|
||||||
describeContributions,
|
describeContributions,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
formatCount,
|
||||||
matchesQuery,
|
matchesQuery,
|
||||||
mergePlugins,
|
mergePlugins,
|
||||||
orderByRisk,
|
orderByRisk,
|
||||||
|
pluginSource,
|
||||||
pluginStatus,
|
pluginStatus,
|
||||||
|
reportIssueUrl,
|
||||||
sortPlugins,
|
sortPlugins,
|
||||||
|
uploadEntry,
|
||||||
} from "@/plugins/plugin-model";
|
} from "@/plugins/plugin-model";
|
||||||
|
|
||||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||||
@@ -50,6 +54,8 @@ function entry(
|
|||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
bundled: true,
|
bundled: true,
|
||||||
|
installCount: null,
|
||||||
|
installCountSource: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,3 +178,108 @@ describe("helpers", () => {
|
|||||||
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("popularity", () => {
|
||||||
|
it("sorts by install count, unknown counts last", () => {
|
||||||
|
const rows = mergePlugins(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
|
||||||
|
entry({ id: "b", name: "B", installed: false, installCount: null }),
|
||||||
|
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
|
||||||
|
"c",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats counts roughly", () => {
|
||||||
|
expect(formatCount(950)).toBe("950");
|
||||||
|
expect(formatCount(1234)).toBe("1.2k");
|
||||||
|
expect(formatCount(45_000)).toBe("45k");
|
||||||
|
expect(formatCount(2_500_000)).toBe("2.5M");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("source and trust", () => {
|
||||||
|
it("tells bundled, signed and unverified copies apart", () => {
|
||||||
|
expect(pluginSource(summary({ source: "bundled", tier: "bundled" }))).toBe(
|
||||||
|
"bundled",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pluginSource(
|
||||||
|
summary({ source: "user", tier: "official", signedBy: "k" }),
|
||||||
|
),
|
||||||
|
).toBe("official");
|
||||||
|
expect(pluginSource(summary({ source: "user", tier: "community" }))).toBe(
|
||||||
|
"unverified",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pluginSource(
|
||||||
|
summary({ source: "user", tier: "unverified", signedBy: "k" }),
|
||||||
|
),
|
||||||
|
).toBe("unverified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an upload as unverified", () => {
|
||||||
|
const row = uploadEntry({
|
||||||
|
token: "t",
|
||||||
|
id: "notes",
|
||||||
|
name: "Notes",
|
||||||
|
version: "0.2.0",
|
||||||
|
description: "",
|
||||||
|
author: "me",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
replaces: "0.1.0",
|
||||||
|
});
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
unverified: true,
|
||||||
|
installed: true,
|
||||||
|
version: "0.1.0",
|
||||||
|
latestVersion: "0.2.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportIssueUrl", () => {
|
||||||
|
it("prefills id, version, source and state on the plugin repo", () => {
|
||||||
|
const url = reportIssueUrl(
|
||||||
|
{
|
||||||
|
id: "docker",
|
||||||
|
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
|
||||||
|
version: "1.0.0",
|
||||||
|
latestVersion: "1.1.0",
|
||||||
|
status: "failed",
|
||||||
|
source: "official",
|
||||||
|
},
|
||||||
|
"26.10.0",
|
||||||
|
)!;
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.origin + parsed.pathname).toBe(
|
||||||
|
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get("title")).toBe("[docker] ");
|
||||||
|
const body = parsed.searchParams.get("body")!;
|
||||||
|
expect(body).toContain("**Plugin:** docker");
|
||||||
|
expect(body).toContain("**Version:** 1.0.0");
|
||||||
|
expect(body).toContain("**Source:** official");
|
||||||
|
expect(body).toContain("**State:** failed");
|
||||||
|
expect(body).toContain("**Termix:** 26.10.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives nothing for a repo that is not on GitHub", () => {
|
||||||
|
expect(
|
||||||
|
reportIssueUrl({
|
||||||
|
id: "x",
|
||||||
|
repository: "https://gitlab.com/a/b",
|
||||||
|
version: null,
|
||||||
|
latestVersion: null,
|
||||||
|
status: null,
|
||||||
|
source: "official",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user