feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import lookRoutes from "./routes/look.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
@@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences/look", lookRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
+2
View File
@@ -985,6 +985,8 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
+2
View File
@@ -709,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
+2
View File
@@ -709,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+2
View File
@@ -703,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", {
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+205
View File
@@ -0,0 +1,205 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import {
LAYOUT_SETTING,
LOOK_ID_PATTERN,
THEME_SETTING,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
async function adminDefaults() {
const settings = createCurrentSettingsRepository();
return {
layout: await settings.get(LAYOUT_SETTING),
theme: await settings.get(THEME_SETTING),
};
}
/**
* @openapi
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults, the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
* 200:
* description: What the look is chosen from.
* content:
* application/json:
* schema:
* type: object
* properties:
* layoutId:
* type: string
* nullable: true
* themeId:
* type: string
* nullable: true
* defaults:
* type: object
* description: Admin defaults, null where unset.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* defaultLook:
* type: object
* description: The default-look plugin's layout and theme, null where none is installed.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* safeLayout:
* type: boolean
* unavailable:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* version:
* type: string
* nullable: true
* layouts:
* type: array
* items:
* type: string
* themes:
* type: array
* items:
* type: string
* error:
* type: string
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
const { loader } = getPluginRuntime();
res.json({
layoutId: preferences?.layoutId ?? null,
themeId: preferences?.themeId ?? null,
defaults: await adminDefaults(),
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
databaseLogger.error("Failed to get the look", error, {
operation: "get_look",
userId,
});
res.status(500).json({ error: "Failed to get the look" });
}
});
/**
* @openapi
* /user-preferences/look/defaults:
* put:
* summary: Set the default layout and theme (admin only)
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
* tags:
* - User Preferences
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* responses:
* 200:
* description: The defaults now in effect.
* 400:
* description: Not a layout or theme id.
* 403:
* description: Not an admin.
*/
router.put(
"/defaults",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await createCurrentUserRepository().findById(userId);
if (!actor?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
const changes: Array<[string, unknown]> = [
[LAYOUT_SETTING, body.layout],
[THEME_SETTING, body.theme],
];
for (const [key, value] of changes) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const settings = createCurrentSettingsRepository();
for (const [key, value] of changes) {
if (value === undefined) continue;
if (value === null) await settings.delete(key);
else await settings.set(key, value as string);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_default_look",
resourceType: "setting",
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
ipAddress,
userAgent,
success: true,
});
res.json(await adminDefaults());
} catch (error) {
databaseLogger.error("Failed to set the default look", error, {
operation: "set_default_look",
userId,
});
res.status(500).json({ error: "Failed to set the default look" });
}
},
);
export default router;
+3 -1
View File
@@ -14,6 +14,7 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -352,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies,
optionalDependencies,
docs,
defaultLook: isDefaultLookPlugin(record.id),
...describePluginFrontend(loaded),
};
@@ -2306,7 +2308,7 @@ router.post(
* 404:
* description: No such plugin.
* 409:
* description: Other running plugins depend on it, or managed by a linked server.
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
*/
router.delete(
"/:id",
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customKeybindings: row?.customKeybindings ?? null,
layoutId: row?.layoutId ?? null,
themeId: row?.themeId ?? null,
});
/** Layout and theme ids, the same shape the plugin manifest allows. */
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
/**
* @openapi
* /user-preferences:
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
* layoutId:
* type: string
* nullable: true
* description: The layout this user chose, overriding the admin default. Null follows the default.
* themeId:
* type: string
* nullable: true
* description: The theme this user chose, overriding the admin default. Null follows the default.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* layoutId:
* type: string
* nullable: true
* description: A layout id, or null to follow the admin default.
* themeId:
* type: string
* nullable: true
* description: A theme id, or null to follow the admin default.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose,
hiddenRailTabs,
customKeybindings,
layoutId,
themeId,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customKeybindings?: string | null;
layoutId?: string | null;
themeId?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
for (const [key, value] of Object.entries({ layoutId, themeId })) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandPaletteEnabled,
pinAppRail,
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (layoutId !== undefined) updates.layoutId = layoutId;
if (themeId !== undefined) updates.themeId = themeId;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
+36 -3
View File
@@ -1,4 +1,5 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
function frontendEntry(
@@ -72,9 +89,16 @@ export function describePluginFrontend(
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
@@ -96,7 +120,8 @@ export function describePluginFrontend(
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
@@ -111,6 +136,7 @@ export function describePluginFrontend(
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
if (clean.startsWith(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
+21 -1
View File
@@ -1,5 +1,5 @@
/**
* The onboarding defaults of the bundled plugins, from
* The onboarding defaults and the default look of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
@@ -27,6 +27,7 @@ interface BundledIndex {
consent?: boolean;
enabledByEnv?: string[];
};
defaultLook?: boolean;
}
>;
}
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
return cached;
}
/**
* Bundled plugins whose layout and theme a fresh install shows. Core reads
* the default look from here so it never has to name a plugin.
*/
export function defaultLookPluginIds(): string[] {
const index = readBundledIndex();
if (!index) return [];
return Object.entries(index.plugins)
.filter(([, entry]) => entry?.defaultLook === true)
.map(([id]) => id)
.sort();
}
export function isDefaultLookPlugin(pluginId: string): boolean {
return defaultLookPluginIds().includes(pluginId);
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
@@ -96,6 +114,8 @@ export function initialPluginState(
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+91 -1
View File
@@ -18,7 +18,11 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import {
isTermixCompatible,
parseManifest,
validateThemeCss,
} from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
signedTier?: KeyTier;
}
/**
* A plugin folder that did not load. Kept so the layout repair view can say
* why a chosen layout or theme is missing instead of only "not installed".
*/
export interface SkippedPlugin {
/** Manifest id when it could be read, else the folder or file name. */
id: string;
version: string | null;
layouts: string[];
themes: string[];
error: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
onFailed?: (plugin: LoadedPlugin) => void;
}
/**
* Every theme file a manifest declares must sit inside the plugin and hold
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
* being served.
*/
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.join(dir, theme.css);
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
);
}
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
if (errors.length > 0) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
);
}
}
}
/** What can still be said about a plugin folder that failed to load. */
function describeSkipped(
dir: string,
name: string,
isArtifact: boolean,
error: unknown,
): SkippedPlugin {
const skipped: SkippedPlugin = {
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
version: null,
layouts: [],
themes: [],
error: error instanceof Error ? error.message : String(error),
};
if (isArtifact) return skipped;
try {
const raw = JSON.parse(
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
) as {
id?: unknown;
version?: unknown;
contributes?: { layouts?: unknown; themes?: unknown };
};
const ids = (list: unknown) =>
Array.isArray(list)
? list
.map((entry) => (entry as { id?: unknown })?.id)
.filter((id): id is string => typeof id === "string")
: [];
if (typeof raw.id === "string") skipped.id = raw.id;
if (typeof raw.version === "string") skipped.version = raw.version;
skipped.layouts = ids(raw.contributes?.layouts);
skipped.themes = ids(raw.contributes?.themes);
} catch {
// An unreadable manifest leaves only the folder name.
}
return skipped;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private skippedPlugins: SkippedPlugin[] = [];
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
@@ -113,6 +192,11 @@ export class PluginLoader {
return [...this.plugins.values()];
}
/** Plugin folders the last full load skipped, with the reason. */
skipped(): SkippedPlugin[] {
return [...this.skippedPlugins];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
@@ -210,6 +294,8 @@ export class PluginLoader {
);
}
validatePluginThemes(dir, manifest);
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
@@ -258,6 +344,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
this.skippedPlugins = [];
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
@@ -322,6 +409,9 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
+57
View File
@@ -0,0 +1,57 @@
/**
* Layouts and themes on the server side: which look a fresh install shows,
* and which installed looks could not load. Core names no plugin here; the
* default look comes from the bundled index's defaultLook flag.
*/
import { defaultLookPluginIds } from "./bundled-index.js";
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
export interface DefaultLook {
layout: string | null;
theme: string | null;
}
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
skipped(): SkippedPlugin[];
}
/**
* The first default-look plugin's default layout (else its first one) and
* that layout's theme. A default-look plugin with themes only fills in the
* theme. Null where none is installed, and the browser uses its built-in.
*/
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
const look: DefaultLook = { layout: null, theme: null };
for (const id of defaultLookPluginIds()) {
const contributes = loader.get(id)?.manifest.contributes;
const layouts = contributes?.layouts ?? [];
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
if (layout && !look.layout) {
look.layout = layout.id;
look.theme ??= layout.defaultTheme ?? null;
}
if (!look.theme && contributes?.themes?.length) {
look.theme = contributes.themes[0].id;
}
}
return look;
}
/** Plugins with a layout or theme that were found but did not load. */
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
return loader
.skipped()
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
return value === "1" || value === "true";
}
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
export const LAYOUT_SETTING = "ui.layout";
export const THEME_SETTING = "ui.theme";
+11 -1
View File
@@ -7,7 +7,9 @@
* 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
* 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. The one exception
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
* it can be disabled or swapped but never uninstalled.
*/
import crypto from "node:crypto";
@@ -15,6 +17,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
activatePlugin,
deactivatePlugin,
@@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (shipped && isDefaultLookPlugin(pluginId)) {
throw new PluginManageError(
"The default look cannot be uninstalled",
409,
"DEFAULT_LOOK",
);
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
+1
View File
@@ -21,6 +21,7 @@ export {
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
validateThemeCss,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
userId: "user-1",
admins: new Set(["admin-1"]),
prefs: null as { layoutId: string | null; themeId: string | null } | null,
settings: new Map<string, string>(),
audits: [] as unknown[],
skipped: [] as unknown[],
defaultLookIds: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = state.userId;
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.prefs,
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => {
state.settings.delete(key);
},
}),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) =>
id === "shipped-look"
? {
manifest: {
contributes: {
layouts: [
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
],
},
},
}
: undefined,
skipped: () => state.skipped,
},
}),
}));
vi.mock("../../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => state.defaultLookIds,
}));
const { default: lookRoutes } =
await import("../../../database/routes/look.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.userId = "user-1";
state.prefs = null;
state.settings.clear();
state.audits = [];
state.skipped = [];
state.defaultLookIds = [];
delete process.env.TERMIX_SAFE_LAYOUT;
const app = express();
app.use(express.json());
app.use("/user-preferences/look", lookRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function getLook() {
const res = await fetch(`${baseUrl}/user-preferences/look`);
return { status: res.status, body: await res.json() };
}
async function putDefaults(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("GET /user-preferences/look", () => {
it("is all empty on a fresh install", async () => {
expect((await getLook()).body).toEqual({
layoutId: null,
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
unavailable: [],
});
});
it("returns the user's pick, the admin defaults and the default look", async () => {
state.prefs = { layoutId: "mine", themeId: "nord" };
state.settings.set("ui.layout", "admin-layout");
state.settings.set("ui.theme", "gruvbox");
state.defaultLookIds = ["shipped-look"];
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
const { body } = await getLook();
expect(body).toMatchObject({
layoutId: "mine",
themeId: "nord",
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
);
});
});
describe("PUT /user-preferences/look/defaults", () => {
it("is for admins only", async () => {
const { status } = await putDefaults({ layout: "x" });
expect(status).toBe(403);
expect(state.settings.size).toBe(0);
});
it("sets and clears the defaults, and audits it", async () => {
state.userId = "admin-1";
let res = await putDefaults({ layout: "fancy", theme: "nord" });
expect(res).toEqual({
status: 200,
body: { layout: "fancy", theme: "nord" },
});
res = await putDefaults({ layout: null });
expect(res.body).toEqual({ layout: null, theme: "nord" });
expect(state.audits).toHaveLength(2);
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
});
it("refuses something that is not an id", async () => {
state.userId = "admin-1";
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
expect((await putDefaults({ layout: 5 })).status).toBe(400);
expect(state.settings.size).toBe(0);
});
});
@@ -433,6 +433,7 @@ describe("plugins route", () => {
"name",
"optionalDependencies",
"state",
"themeVersion",
"version",
].sort(),
);
@@ -624,10 +625,12 @@ describe("plugins route", () => {
contributes: null,
dependencies: {},
optionalDependencies: {},
defaultLook: false,
frontend: false,
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
upserts: [] as Record<string, unknown>[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.row,
upsert: async (_userId: string, updates: Record<string, unknown>) => {
state.upserts.push(updates);
state.row = { ...(state.row ?? {}), ...updates };
},
}),
}));
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
isValidKeybinding: () => true,
}));
const { default: routes } =
await import("../../../database/routes/user-preferences.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.row = null;
state.upserts = [];
const app = express();
app.use(express.json());
app.use("/user-preferences", routes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function put(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("user preferences: layout and theme", () => {
it("reads null for both until chosen", async () => {
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: null, themeId: null });
});
it("saves a layout and theme id, and null to follow the default", async () => {
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
200,
);
expect(state.upserts[0]).toMatchObject({
layoutId: "fancy",
themeId: "nord",
});
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
await put({ layoutId: null });
expect(state.upserts[1]).toMatchObject({ layoutId: null });
expect(state.upserts[1]).not.toHaveProperty("themeId");
});
it("refuses anything that is not an id", async () => {
for (const bad of ["Fancy", "../x", "a", 7]) {
expect((await put({ themeId: bad })).status).toBe(400);
}
expect(state.upserts).toHaveLength(0);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
"utf8",
);
const shell = fs.readFileSync(
new URL("../../../ui/AppShell.tsx", import.meta.url),
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
"utf8",
);
+46
View File
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
});
@@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => {
}
});
});
describe("theme stylesheets", () => {
let themed: { id: string; dir: string; manifest: never };
beforeAll(() => {
const dir = path.join(root, "inks");
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
themed = {
id: "inks",
dir,
manifest: {
id: "inks",
version: "1.0.0",
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
} as never,
};
});
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
path.join(themed.dir, "themes", "paper.css"),
);
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
});
it("versions theme files even without a frontend bundle", () => {
const info = describePluginFrontend(themed);
expect(info.frontend).toBe(false);
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
});
});
@@ -3,7 +3,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
defaultLookPluginIds,
initialPluginState,
isDefaultLookPlugin,
onboardingDefaults,
resetBundledIndexCache,
} from "../../plugins/bundled-index.js";
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
expect(initialPluginState("kept", "user", false)).toBe("disabled");
});
});
describe("default look", () => {
it("lists the plugins flagged as the default look", () => {
writeIndex({
"some-layout": { defaultLook: true },
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
other: { onboarding: { recommended: true } },
});
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
expect(isDefaultLookPlugin("some-themes")).toBe(true);
expect(isDefaultLookPlugin("other")).toBe(false);
});
it("has none without an index", () => {
expect(defaultLookPluginIds()).toEqual([]);
});
it("starts the default look enabled even on a fresh install", () => {
writeIndex({
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
});
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
});
});
+79
View File
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
});
});
describe("PluginLoader themes and skipped looks", () => {
const themed = {
capabilities: ["ui:theme", "ui:layout"],
manifestOverrides: {
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
layouts: [{ id: "fancy", label: "Fancy" }],
},
},
};
it("loads a plugin whose theme CSS is allowed", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
".paper { --background: #fff; }",
);
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
});
it("refuses a plugin whose theme CSS reaches outside", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
'@import url("https://example.com/x.css");',
);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper is not allowed: @import/);
});
it("refuses a plugin whose theme file is missing", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
});
it("remembers why a layout plugin was skipped", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
...themed,
id: "fancy-layout",
root: bundled,
manifestOverrides: {
...themed.manifestOverrides,
version: "2.0.0",
engine: { termix: ">=99.0.0", api: "1" },
},
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const [skipped] = loader.skipped();
expect(skipped).toMatchObject({
id: "fancy-layout",
version: "2.0.0",
layouts: ["fancy"],
themes: ["paper"],
});
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const index = vi.hoisted(() => ({ ids: [] as string[] }));
vi.mock("../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => index.ids,
}));
import {
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import type { SkippedPlugin } from "../../plugins/loader.js";
type Contributes = Record<string, unknown>;
function loaderWith(
plugins: Record<string, Contributes>,
skipped: SkippedPlugin[] = [],
) {
return {
get: (id: string) =>
plugins[id]
? ({ manifest: { contributes: plugins[id] } } as never)
: undefined,
skipped: () => skipped,
};
}
beforeEach(() => {
index.ids = [];
});
afterEach(() => {
delete process.env.TERMIX_SAFE_LAYOUT;
});
describe("resolveDefaultLook", () => {
it("is empty without a default-look plugin", () => {
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
it("takes the layout marked default and its theme", () => {
index.ids = ["look-a"];
const loader = loaderWith({
"look-a": {
layouts: [
{ id: "first", label: "First", defaultTheme: "one" },
{ id: "main", label: "Main", defaultTheme: "two", default: true },
],
},
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "main",
theme: "two",
});
});
it("takes the first layout, and a theme from a themes-only plugin", () => {
index.ids = ["look-a", "look-b"];
const loader = loaderWith({
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "solo",
theme: "paper",
});
});
it("skips a default-look plugin that did not load", () => {
index.ids = ["gone"];
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
});
describe("unavailableLooks", () => {
it("lists only skipped plugins that brought a layout or theme", () => {
const skipped: SkippedPlugin[] = [
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
];
expect(
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
).toEqual(["a", "c"]);
});
});
describe("safeLayoutForced", () => {
it("reads TERMIX_SAFE_LAYOUT", () => {
expect(safeLayoutForced()).toBe(false);
process.env.TERMIX_SAFE_LAYOUT = "1";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "true";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "0";
expect(safeLayoutForced()).toBe(false);
});
});
+26
View File
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
expect(loader.get("docker")?.source).toBe("user");
});
it("refuses to uninstall the default look but lets it be turned off", async () => {
createFixturePlugin({ id: "some-look", root: bundled });
fs.writeFileSync(
path.join(bundled, "bundled-index.json"),
JSON.stringify({
version: 1,
plugins: { "some-look": { defaultLook: true } },
}),
);
const { resetBundledIndexCache } =
await import("../../plugins/bundled-index.js");
resetBundledIndexCache();
try {
const loader = await boot();
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
code: "DEFAULT_LOOK",
status: 409,
});
expect(loader.get("some-look")?.state).toBe("active");
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
} finally {
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
resetBundledIndexCache();
}
});
it("deletes a plugin dropped in as a plain folder", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
const loader = await boot();
@@ -1002,3 +1002,79 @@ describe("docs and env fields", () => {
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
});
});
describe("layouts and themes", () => {
const layouts = [
{
id: "fancy",
label: "layout.fancy",
defaultTheme: "paper",
default: true,
},
];
const themes = [
{
id: "paper",
label: "theme.paper",
mode: "light",
css: "themes/paper.css",
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
},
];
it("accepts layouts and themes with their capabilities", () => {
const { manifest, errors } = parseManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: { layouts, themes },
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
});
it("needs ui:layout for layouts and ui:theme for themes", () => {
const { errors } = parseManifest(
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
);
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
});
it("refuses bad ids, modes, paths and swatches", () => {
const errors = validateManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: {
layouts: [
{ id: "Fancy", label: "x" },
{ id: "two", label: "x", default: true },
{ id: "three", label: "x", default: true, extra: 1 },
],
themes: [
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
{
id: "dusk",
label: "x",
mode: "dark",
css: "dusk.css",
swatch: { background: "red", panel: "#000", text: "#fff" },
},
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
],
},
}),
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("contributes.layouts[0].id must match")).toBe(true);
expect(has("Only one entry")).toBe(true);
expect(has('Unknown field "extra"')).toBe(true);
expect(has('mode must be "light" or "dark"')).toBe(true);
expect(has("relative path inside the plugin")).toBe(true);
expect(has("must be a .css file")).toBe(true);
expect(has("swatch.background must be a hex color")).toBe(true);
expect(has('duplicates "dusk"')).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { validateThemeCss } from "../../plugins/manifest.js";
describe("validateThemeCss", () => {
it("accepts token rules scoped under the theme class", () => {
const css = `
/* Paper */
.paper { --background: #fff; --foreground: #111; }
:root.paper, html.paper .card { --card: #f8f8f8; }
@media (prefers-reduced-motion: reduce) {
.paper * { transition: none; }
}
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
.paper .logo { background: url("logo.svg") no-repeat; }
.paper .dot { background: url(data:image/png;base64,AAAA); }
`;
expect(validateThemeCss(css, "paper")).toEqual([]);
});
it("refuses selectors outside the theme", () => {
const errors = validateThemeCss(
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
"paper",
);
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('"body"');
});
it("refuses anything that loads or runs", () => {
const errors = validateThemeCss(
`@import url("https://evil.example/x.css");
.paper { background: url(https://evil.example/a.png); }
.paper { background: url(//evil.example/a.png); }
.paper { width: expression(alert(1)); }
.paper { background: url(javascript:alert(1)); }
.paper { behavior: url(x.htc); }
@font-face { font-family: x; src: url(x.woff2); }
@keyframes spin { from { opacity: 0 } }`,
"paper",
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("@import is not allowed")).toBe(true);
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
expect(has("expression() is not allowed")).toBe(true);
expect(has("javascript: is not allowed")).toBe(true);
expect(has("Script bindings")).toBe(true);
expect(has("@font-face is not allowed")).toBe(true);
expect(has("@keyframes is not allowed")).toBe(true);
});
it("refuses nested rules and broken braces", () => {
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
'Nested rules are not allowed in ".paper"',
]);
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
"A rule is missing its closing brace",
]);
});
it("does not trip on braces inside strings", () => {
expect(
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
).toEqual([]);
});
});