From 2610e55f6cb84b50d65d69e33af1452139cadddf Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 02:11:52 +0000 Subject: [PATCH 01/11] feat(themes): published themes paint from the first frame, and can set the radius, status colors and sidebar artwork Published environment themes arrived only with the server config, so the app and the boot screen painted T3's standard theme first, and new desktop windows opened plain black or white. The page now saves the last published palettes in localStorage for the runtime and the `index.html` boot script, and sends Electron the canvas it paints, which new windows and the WSL splash open in. A published theme can also set the corner radius and the success and info colors, and keep the sidebar artwork. Importing a theme that reuses a published theme's id makes a copy instead of hiding it. Surfaces: web and desktop (one new IPC method); mobile has its own themes. Contracts: `ipc.ts` and the theme file fields. No entry point, provider, agent or connection mode changes; the cache is per browser. Docs: the plugin author README, which comes with plugins in the next PR, documents the new keys. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 13 ++ apps/desktop/src/preload.ts | 2 + .../src/settings/DesktopAppSettings.test.ts | 12 + .../src/settings/DesktopAppSettings.ts | 56 +++++ .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 28 +++ apps/desktop/src/window/DesktopWindow.ts | 46 ++-- apps/web/index.html | 22 +- .../chat/ComposerPrimaryActions.tsx | 2 +- .../components/settings/ThemeImportDialog.tsx | 10 +- .../web/src/hooks/useEnvironmentTheme.test.ts | 123 ++++++++++- apps/web/src/hooks/useEnvironmentTheme.ts | 13 +- apps/web/src/hooks/useSettings.ts | 12 +- apps/web/src/hooks/useTheme.ts | 45 +++- apps/web/src/index.css | 6 + apps/web/src/themePalette.test.ts | 10 + apps/web/src/themePalette.ts | 205 ++++++++++++++++-- packages/contracts/src/ipc.ts | 19 ++ packages/contracts/src/server.ts | 10 + packages/shared/src/themePalettes.ts | 16 ++ 22 files changed, 594 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 35333ac3d33a4..afb1806e0b415 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -49,6 +49,7 @@ import { pickProjectFavicon, pickThemeFiles, setTheme, + setWindowBackground, showContextMenu, } from "./methods/window.ts"; import { @@ -139,6 +140,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); + yield* ipc.handle(setWindowBackground); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(receiveProviderAuthCallback); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 538dd06b0b42a..fb78038046718 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const SET_WINDOW_BACKGROUND_CHANNEL = "desktop:set-window-background"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f0d661b58bc9a..cdb065bcbff56 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,6 +3,7 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + DesktopWindowBackgroundSchema, EDITORS, EditorId, PickedThemeFileSchema, @@ -38,6 +39,7 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as Electron from "electron"; import * as MacPermissions from "../../permissions/MacPermissions.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -264,6 +266,17 @@ export const setTheme = DesktopIpc.makeIpcMethod({ }), }); +export const setWindowBackground = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_WINDOW_BACKGROUND_CHANNEL, + payload: DesktopWindowBackgroundSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.setWindowBackground")(function* (background) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const change = yield* appSettings.setWindowBackground(background); + if (change.changed) yield* (yield* DesktopWindow.DesktopWindow).syncAppearance; + }), +}); + export const showContextMenu = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..29c5976fa1515 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -192,6 +192,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), + setWindowBackground: (background) => + ipcRenderer.invoke(IpcChannels.SET_WINDOW_BACKGROUND_CHANNEL, background), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..4682457cbadb7 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DesktopWindowBackgroundSchema } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -34,6 +35,7 @@ const DesktopSettingsPatch = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + windowCanvases: Schema.optionalKey(DesktopWindowBackgroundSchema), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -186,6 +188,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -218,6 +221,11 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + const windowBackground = { + light: { appearance: "light", color: "#f4f4f4" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + yield* settings.setWindowBackground(windowBackground); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), @@ -226,7 +234,9 @@ describe("DesktopSettings", () => { mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", + windowCanvases: windowBackground, } satisfies typeof DesktopSettingsPatch.Type); + assert.deepEqual((yield* settings.load).windowBackground, windowBackground); }), ), ); @@ -281,6 +291,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -310,6 +321,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a0624ced669ea..be2b823796986 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,8 +1,11 @@ import { DesktopServerExposureModeSchema, DesktopUpdateChannelSchema, + DesktopWindowBackgroundSchema, type DesktopServerExposureMode, type DesktopUpdateChannel, + type DesktopWindowBackground, + type DesktopWindowCanvas, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; @@ -39,6 +42,8 @@ export interface DesktopSettings { readonly wslDistro: string | null; // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; + /** The renderer's theme canvases, painted before the page loads. */ + readonly windowBackground: DesktopWindowBackground | null; } export interface DesktopSettingsChange { @@ -76,6 +81,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslBackendEnabled: false, wslDistro: null, wslOnly: false, + windowBackground: null, }; const DesktopWindowBoundsDocument = Schema.Struct({ @@ -99,6 +105,7 @@ const DesktopSettingsDocument = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + windowCanvases: Schema.optionalKey(Schema.Unknown), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -109,6 +116,8 @@ const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); +const decodeDesktopWindowBackground = Schema.decodeUnknownOption(DesktopWindowBackgroundSchema); +const desktopWindowBackgroundEquivalence = Schema.toEquivalence(DesktopWindowBackgroundSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -169,6 +178,9 @@ export class DesktopAppSettings extends Context.Service< readonly setWslOnly: ( enabled: boolean, ) => Effect.Effect; + readonly setWindowBackground: ( + background: DesktopWindowBackground, + ) => Effect.Effect; readonly applyWslWindowsFallback: Effect.Effect< DesktopSettingsChange, DesktopSettingsWriteError @@ -194,6 +206,21 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +function normalizeWindowColor(value: unknown): string | null { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null; +} + +function normalizeWindowBackground(value: unknown): DesktopWindowBackground | null { + const canvas = ({ appearance, color }: DesktopWindowCanvas): DesktopWindowCanvas => ({ + appearance, + color: normalizeWindowColor(color), + }); + return Option.match(decodeDesktopWindowBackground(value), { + onNone: () => null, + onSome: (background) => ({ light: canvas(background.light), dark: canvas(background.dark) }), + }); +} + export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { return Option.getOrNull(decodeDesktopWindowBounds(value)); } @@ -230,6 +257,7 @@ function normalizeDesktopSettingsDocument( wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, + windowBackground: normalizeWindowBackground(parsed.windowCanvases), }; } @@ -276,6 +304,9 @@ function toDesktopSettingsDocument( if (settings.wslOnly !== defaults.wslOnly) { document.wslOnly = settings.wslOnly; } + if (settings.windowBackground !== null) { + document.windowCanvases = settings.windowBackground; + } return document; } @@ -366,6 +397,18 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setWindowBackground( + settings: DesktopSettings, + background: DesktopWindowBackground, +): DesktopSettings { + const normalized = normalizeWindowBackground(background); + return settings.windowBackground !== null && + normalized !== null && + desktopWindowBackgroundEquivalence(settings.windowBackground, normalized) + ? settings + : { ...settings, windowBackground: normalized }; +} + function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { return settings.localEnvironmentEnabled === enabled ? settings @@ -564,6 +607,14 @@ export const make = Effect.gen(function* () { return yield* persist((settings) => setWslOnly(settings, enabled)); }), + setWindowBackground: Effect.fn("desktop.settings.setWindowBackground")(function* (background) { + yield* Effect.annotateCurrentSpan({ + light: background.light.color, + dark: background.dark.color, + }); + + return yield* persist((settings) => setWindowBackground(settings, background)); + }), setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( function* (enabled) { yield* Effect.annotateCurrentSpan({ enabled }); @@ -629,6 +680,11 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { return update((settings) => setWslOnly(settings, enabled)); }), + setWindowBackground: Effect.fn("DesktopAppSettings.setWindowBackground")( + function (background) { + return update((settings) => setWindowBackground(settings, background)); + }, + ), setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( function (enabled) { return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index d8d16b7dbfd62..1ad68c80a222d 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -228,6 +228,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setWslDistro: () => Effect.die("unexpected WSL distro change"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setWindowBackground: () => Effect.die("unexpected window background update"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 0dcb2b85ac2c5..628d82b44e539 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -267,6 +267,7 @@ function layerTest(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setWindowBackground: () => Effect.die("unexpected window background update"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), @@ -479,6 +480,33 @@ describe("DesktopWindow", () => { ); }); + it("starts windows in the canvas the page paints in the current appearance", () => { + const surface = { + light: { appearance: "light", color: "#f4f4f4" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + const pinnedDark = { + light: { appearance: "dark", color: "#151515" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + const plain = { + light: { appearance: "light", color: null }, + dark: { appearance: "dark", color: null }, + } as const; + for (const [shouldUseDarkColors, background, expected] of [ + [false, surface, { color: "#f4f4f4", dark: false }], + [true, surface, { color: "#151515", dark: true }], + [false, pinnedDark, { color: "#151515", dark: true }], + [true, plain, { color: "#000000", dark: true }], + [false, null, { color: "#ffffff", dark: false }], + ] as const) { + assert.deepEqual( + DesktopWindow.resolveWindowCanvas(shouldUseDarkColors, background), + expected, + ); + } + }); + it("rejects renderer navigations whose origin only serializes as null", () => { for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { assert.isTrue( diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..ac3d63e391a48 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,7 +8,11 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; -import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import { + type DesktopSnapShotEvent, + type DesktopWindowBackground, + DEFAULT_CLIENT_SETTINGS, +} from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -131,8 +135,14 @@ function getIconOption( }); } -function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#000000" : "#ffffff"; +/** The canvas the page paints in the current native theme, so a new window does not flash first. */ +export function resolveWindowCanvas( + shouldUseDarkColors: boolean, + background: DesktopWindowBackground | null, +): { readonly color: string; readonly dark: boolean } { + const canvas = background?.[shouldUseDarkColors ? "dark" : "light"]; + const dark = canvas === undefined ? shouldUseDarkColors : canvas.appearance === "dark"; + return { color: canvas?.color ?? (dark ? "#000000" : "#ffffff"), dark }; } type DisplayBounds = Pick; @@ -174,11 +184,13 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { - const background = getInitialWindowBackgroundColor(shouldUseDarkColors); - const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; - const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; - const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; +function buildConnectingSplashDataUrl({ + color: background, + dark, +}: ReturnType): string { + const label = dark ? "#9ca3af" : "#6b7280"; + const accent = dark ? "#f8fafc" : "#1f2937"; + const track = dark ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; const html = `
Connecting to WSL…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } @@ -258,6 +270,7 @@ function getWindowTitleBarOptions( function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + themeBackground: DesktopWindowBackground | null, platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { @@ -265,7 +278,7 @@ function syncWindowAppearance( return; } - window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + window.setBackgroundColor(resolveWindowCanvas(shouldUseDarkColors, themeBackground).color); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); @@ -386,7 +399,8 @@ export const make = Effect.gen(function* () { show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: resolveWindowCanvas(shouldUseDarkColors, persistedSettings.windowBackground) + .color, ...iconOption, title: environment.displayName, ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), @@ -896,7 +910,10 @@ export const make = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; - const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const canvas = resolveWindowCanvas( + yield* electronTheme.shouldUseDarkColors, + (yield* desktopSettings.get).windowBackground, + ); if (yield* Ref.get(desktopState.quitting)) return; const splash = yield* electronWindow.create({ width: 360, @@ -909,7 +926,7 @@ export const make = Effect.gen(function* () { center: true, show: false, skipTaskbar: false, - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: canvas.color, title: environment.displayName, webPreferences: { contextIsolation: true, @@ -931,7 +948,7 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); + void splash.loadURL(buildConnectingSplashDataUrl(canvas)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) => @@ -1038,8 +1055,9 @@ export const make = Effect.gen(function* () { }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const { windowBackground } = yield* desktopSettings.get; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + syncWindowAppearance(window, shouldUseDarkColors, windowBackground, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/web/index.html b/apps/web/index.html index a7a5e7450b3de..707111143ccbd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,6 +15,7 @@ const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#000000"; const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; + const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1"; const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; @@ -197,19 +198,20 @@ (appearance === value.appearance || isRecord(colors)), ); }; + // Custom themes first, then the palettes the server last published, in the order the + // runtime looks them up. const findCustomTheme = (themeId) => { if (RESERVED_THEME_IDS.includes(themeId)) return null; - try { - const parsed = JSON.parse( - window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", - ); - if (!Array.isArray(parsed)) return null; - return ( - parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null - ); - } catch { - return null; + for (const storageKey of [CUSTOM_THEMES_STORAGE_KEY, ENVIRONMENT_THEMES_STORAGE_KEY]) { + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null"); + const theme = Array.isArray(parsed) + ? parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) + : undefined; + if (theme) return theme; + } catch {} } + return null; }; try { diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index a4a8993a7036e..fada51f096935 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -110,7 +110,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; - const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const environmentIdentificationMode = useEnvironmentIdentificationMode("composer"); const shortcutModifiers = useShortcutModifierState(); const isQueuing = !isEditingQueuedMessage && diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index e9d37f45322d1..c199c3efcb804 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -5,6 +5,7 @@ import { useRef, useState } from "react"; import { cn } from "../../lib/utils"; import { getCustomThemes, + asImportedThemeCopy, installCustomTheme, parseThemeFile, removeCustomTheme, @@ -270,7 +271,8 @@ function ThemeImportBody({ if (!isCurrentRequest(requestId)) return; const installed: ThemeDefinition[] = []; const conflicting: ThemeDefinition[] = []; - for (const theme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) { + for (const pairedTheme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) { + const theme = asImportedThemeCopy(pairedTheme); if (getCustomThemes().some((existing) => existing.id === theme.id)) { conflicting.push(theme); continue; @@ -398,9 +400,9 @@ function ThemeImportBody({ } try { const parsed: unknown = JSON.parse(json); - const theme = isVsCodeThemeFile(parsed) - ? parseVsCodeThemeFile(parsed) - : parseThemeFile(parsed); + const theme = asImportedThemeCopy( + isVsCodeThemeFile(parsed) ? parseVsCodeThemeFile(parsed) : parseThemeFile(parsed), + ); if (getCustomThemes().some((existing) => existing.id === theme.id)) { setError(null); setConflicts([theme]); diff --git a/apps/web/src/hooks/useEnvironmentTheme.test.ts b/apps/web/src/hooks/useEnvironmentTheme.test.ts index 80b8fcbc216a0..f1f45ce5aab39 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.test.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.test.ts @@ -1,25 +1,41 @@ import type { EnvironmentTheme } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -const publishedThemes = vi.hoisted(() => ({ - atom: null as unknown as import("effect/reactivity").Atom.Writable< - ReadonlyArray - >, +// Like the real config, the themes and the latest event change together in one update. +interface ServerState { + readonly themes: ReadonlyArray; + readonly event: { readonly type: string } | null; +} +const server = vi.hoisted(() => ({ + state: null as unknown as import("effect/reactivity").Atom.Writable, })); vi.mock(import("../state/server"), async () => { const { Atom } = await import("effect/reactivity"); - publishedThemes.atom = Atom.make>([]); - return { primaryServerEnvironmentThemesAtom: publishedThemes.atom } as never; + server.state = Atom.make({ themes: [], event: null }); + return { + primaryServerEnvironmentThemesAtom: Atom.make((get) => get(server.state).themes), + primaryServerConfigEventAtom: Atom.make((get) => get(server.state).event), + } as never; }); +const publish = ( + registry: { readonly set: (atom: typeof server.state, state: ServerState) => void }, + themes: ReadonlyArray, +) => registry.set(server.state, { themes, event: { type: "environmentThemesUpdated" } }); const refreshTheme = vi.hoisted(() => vi.fn()); vi.mock(import("./useTheme"), () => ({ refreshTheme }) as never); import { appAtomRegistry } from "../rpc/atomRegistry"; import { + asImportedThemeCopy, + ENVIRONMENT_THEMES_STORAGE_KEY, getThemeDefinition, installCustomTheme, invalidateCustomThemes, + parseThemeFile, publishedThemeDefinitions, + THEME_FILE_VERSION, + themeAllowsComposerArtwork, + themeAllowsSidebarArtwork, } from "../themePalette"; const NIGHTFALL_THEME = { @@ -31,7 +47,7 @@ const NIGHTFALL_THEME = { } as const; afterEach(() => { - appAtomRegistry.set(publishedThemes.atom, []); + appAtomRegistry.set(server.state, { themes: [], event: null }); }); describe("environment themes", () => { @@ -88,11 +104,41 @@ describe("environment themes", () => { // Routes without the old root sync component (/connect, prompt-link landings) never saw them. it("resolves published themes without a React subscriber mounted", () => { - appAtomRegistry.set(publishedThemes.atom, [NIGHTFALL_THEME]); + publish(appAtomRegistry, [NIGHTFALL_THEME]); expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); + expect(themeAllowsSidebarArtwork("nightfall")).toBe(false); - appAtomRegistry.set(publishedThemes.atom, [{ ...NIGHTFALL_THEME, name: "Nightfall 2" }]); - expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall 2"); + // A published theme can keep the sidebar artwork, but not on the send button, and can set + // T3's corner radius and status colors. + publish(appAtomRegistry, [ + { + ...NIGHTFALL_THEME, + name: "Nightfall 2", + sidebarArtwork: true, + radius: "0.625rem", + colors: { success: "#3fb950", info: "not-a-color" }, + variants: { light: { info: "#0969da" } }, + }, + ]); + expect(getThemeDefinition("nightfall")).toMatchObject({ + label: "Nightfall 2", + radius: "0.625rem", + statusColors: { dark: { success: "#3fb950" }, light: { info: "#0969da" } }, + }); + expect(themeAllowsSidebarArtwork("nightfall")).toBe(true); + expect(themeAllowsComposerArtwork("nightfall")).toBe(false); + // Importing the same theme file makes a copy instead of hiding the published original. + const imported = parseThemeFile({ + version: THEME_FILE_VERSION, + id: "nightfall", + name: "Nightfall", + appearance: "dark", + colors: { canvas: "#1a1b26" }, + }); + expect(asImportedThemeCopy(imported)).toMatchObject({ + id: "nightfall-copy", + label: "Nightfall copy", + }); }); it("lets a theme the user saved win an id collision", () => { @@ -108,7 +154,7 @@ describe("environment themes", () => { }); invalidateCustomThemes(); try { - appAtomRegistry.set(publishedThemes.atom, [NIGHTFALL_THEME]); + publish(appAtomRegistry, [NIGHTFALL_THEME]); expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); const environment = getThemeDefinition("nightfall"); if (!environment) throw new Error("expected the published theme"); @@ -122,13 +168,66 @@ describe("environment themes", () => { expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); }); + it("uses the palettes saved last start until the server sends its own, then saves those", async () => { + const store = new Map([ + [ + ENVIRONMENT_THEMES_STORAGE_KEY, + JSON.stringify( + publishedThemeDefinitions([ + { + ...NIGHTFALL_THEME, + sidebarArtwork: true, + radius: "10px", + colors: { info: "#0969da" }, + }, + ]), + ), + ], + ]); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }, + }); + vi.resetModules(); + const { appAtomRegistry: registry } = await import("../rpc/atomRegistry"); + const palette = await import("../themePalette"); + const { startEnvironmentThemeRepaint } = await import("./useEnvironmentTheme"); + const stop = startEnvironmentThemeRepaint(); + try { + expect(palette.getThemeDefinition("nightfall")?.label).toBe("Nightfall"); + expect(palette.themeAllowsSidebarArtwork("nightfall")).toBe(true); + expect(palette.getThemeDefinition("nightfall")).toMatchObject({ + radius: "10px", + statusColors: { dark: { info: "#0969da" } }, + }); + + // The server no longer publishes it, so its first themes event is empty. + publish(registry, []); + expect(palette.getThemeDefinition("nightfall")).toBeNull(); + expect(store.has(ENVIRONMENT_THEMES_STORAGE_KEY)).toBe(false); + + publish(registry, [{ ...NIGHTFALL_THEME, name: "Nightfall 2" }]); + expect(JSON.parse(store.get(ENVIRONMENT_THEMES_STORAGE_KEY) ?? "[]")[0]?.label).toBe( + "Nightfall 2", + ); + } finally { + stop(); + vi.unstubAllGlobals(); + } + }); + it("repaints when the environment publishes themes nothing has read yet", async () => { vi.resetModules(); const { appAtomRegistry: registry } = await import("../rpc/atomRegistry"); const { startEnvironmentThemeRepaint } = await import("./useEnvironmentTheme"); const stop = startEnvironmentThemeRepaint(); try { - registry.set(publishedThemes.atom, [NIGHTFALL_THEME]); + publish(registry, [NIGHTFALL_THEME]); expect(refreshTheme).toHaveBeenCalledTimes(1); } finally { stop(); diff --git a/apps/web/src/hooks/useEnvironmentTheme.ts b/apps/web/src/hooks/useEnvironmentTheme.ts index ac13e38fa1541..3bff2df1a0cf4 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { subscribeAtomChanges } from "../rpc/atomRegistry"; -import { environmentThemeDefinitionsAtom, type ThemeDefinition } from "../themePalette"; +import { + environmentThemeDefinitionsAtom, + startEnvironmentThemeCache, + type ThemeDefinition, +} from "../themePalette"; import { refreshTheme } from "./useTheme"; export function useEnvironmentThemeDefinitions(): ReadonlyArray { @@ -13,7 +17,12 @@ export function useEnvironmentThemeDefinitions(): ReadonlyArray * only land if the active theme is re-applied. */ export function startEnvironmentThemeRepaint(): () => void { - return subscribeAtomChanges(environmentThemeDefinitionsAtom, () => { + const stopCache = startEnvironmentThemeCache(); + const stopRepaint = subscribeAtomChanges(environmentThemeDefinitionsAtom, () => { refreshTheme({ preservePreview: true }); }); + return () => { + stopCache(); + stopRepaint(); + }; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2c24f13760092..d58ae25dea4ce 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -36,6 +36,7 @@ import { getThemePreviewSidebarArtwork, resolveThemeHalf, subscribeToThemePreview, + themeAllowsComposerArtwork, themeAllowsSidebarArtwork, } from "~/themePalette"; import { @@ -396,7 +397,10 @@ function resolveEnvironmentIdentificationMode(input: { : input.mode; } -export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { +/** `place` is where the artwork would draw: the sidebar, or the composer's send button. */ +export function useEnvironmentIdentificationMode( + place: "sidebar" | "composer" = "sidebar", +): EnvironmentIdentificationMode { const settingsHydrated = useClientSettingsHydrated(); const mode = useClientSettings((settings) => settings.environmentIdentificationMode); const { resolvedTheme, theme, themeHalves } = useTheme(); @@ -411,7 +415,11 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod mode, settingsHydrated, paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, - paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme), + paletteThemeAllowsArtwork: + previewSidebarArtwork ?? + (place === "composer" + ? themeAllowsComposerArtwork(activeTheme) + : themeAllowsSidebarArtwork(activeTheme)), }); } diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 2467d89091b24..e02b444c3af8a 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,5 +1,5 @@ import { pipe } from "effect/Function"; -import type { DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopWindowBackground } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; import { useSyncExternalStore } from "react"; @@ -16,6 +16,7 @@ import { resolveThemeAppearance, resolveThemeHalf, subscribeToThemePreview, + themeCanvasHex, THEME_PREVIEW_ID, THEME_APPEARANCE_MODE_STORAGE_KEY, THEME_FOLLOW_SYSTEM_STORAGE_KEY, @@ -37,6 +38,8 @@ type ThemeSnapshot = { }; type DesktopThemeBridge = Pick; +// Undefined until the first report, so colors left from a removed theme are cleared too. +let lastDesktopWindowBackground: string | undefined; const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; @@ -333,11 +336,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); + const appliedTheme = resolveThemeHalf(theme, themeHalves, resolvedAppearance); + applyThemePalette(appliedTheme, resolvedAppearance); document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); syncDesktopTheme(theme, followSystem, appearanceMode); + syncDesktopWindowBackground(theme, followSystem, appearanceMode, themeHalves); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -391,6 +396,42 @@ function syncDesktopTheme( ); } +/** + * New desktop windows start in the theme's canvas instead of flashing black or white. The desktop + * app gets what the page paints while its native theme is light and while it is dark. + */ +function syncDesktopWindowBackground( + theme: Theme, + followSystem: boolean, + appearanceMode: ThemePreferenceMode, + themeHalves: ThemeHalves | null, +) { + if (typeof window === "undefined") return; + const bridge = window.desktopBridge; + if (typeof bridge?.setWindowBackground !== "function") return; + const canvas = (systemDark: boolean) => { + const appearance = resolveThemeAppearance( + theme, + systemDark, + followSystem, + appearanceMode, + themeHalves, + ); + return { + appearance, + color: themeCanvasHex(resolveThemeHalf(theme, themeHalves, appearance), appearance), + }; + }; + const background: DesktopWindowBackground = { light: canvas(false), dark: canvas(true) }; + const signature = JSON.stringify(background); + if (lastDesktopWindowBackground === signature) return; + lastDesktopWindowBackground = signature; + void bridge.setWindowBackground(background).catch((cause: unknown) => { + console.error("Could not set the desktop window background.", safeErrorLogAttributes(cause)); + if (lastDesktopWindowBackground === signature) lastDesktopWindowBackground = undefined; + }); +} + if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e186516791252..b49fc6fc542f3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1587,6 +1587,12 @@ html[data-theme-id] [data-app-sidebar] { } } +/* A published theme also colors the sidebar's row metadata, like thread times. Built-in themes + keep the sidebar's own step below read titles. */ +html[data-theme-published] [data-app-sidebar] { + --secondary-label: var(--app-theme-secondary-label); +} + /* T3 Chat's panel divider is deliberately pink, and its resize affordance keeps that color while hovered. Do not neutralize this branded edge. */ html[data-theme-id="t3-chat"] [data-app-sidebar] { diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index f6903655449b5..4d239d53d7c0e 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -100,6 +100,16 @@ describe("theme files", () => { expect(legacy.colors.searchMatchBackground).toBe( getDefaultThemeColors("dark").searchMatchBackground, ); + // A published theme's file imports too; its status colors and radius stay with the original. + const published = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Published", + appearance: "dark", + radius: "0.625rem", + colors: { canvas: "#151515", success: "#3fb950" }, + }); + expect(published).not.toHaveProperty("radius"); + expect(published).not.toHaveProperty("statusColors"); const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Search", diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index e7d9c5d5bf2fa..4fc86881f411c 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { Atom } from "effect/reactivity"; import "culori/css"; -import { converter, parse } from "culori/fn"; +import { converter, formatHex, parse } from "culori/fn"; import { BUILT_IN_THEMES, EMBER_THEME, @@ -17,10 +17,12 @@ import { T3_CODE_DARK_THEME_COLORS, RESERVED_THEME_IDS, THEME_COLOR_ROLES, + THEME_STATUS_COLOR_ROLES, type ThemeAppearance, type ThemeColorRole, type ThemeColors, type ThemeDefinition, + type ThemeStatusColors, type ThemeVariants, } from "@t3tools/shared/themePalettes"; import { @@ -30,7 +32,7 @@ import { } from "./lib/contextChipAccents"; import { appAtomRegistry, subscribeAtomChanges } from "./rpc/atomRegistry"; -import { primaryServerEnvironmentThemesAtom } from "./state/server"; +import { primaryServerConfigEventAtom, primaryServerEnvironmentThemesAtom } from "./state/server"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; @@ -45,6 +47,7 @@ export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; +export const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1"; const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; @@ -108,6 +111,34 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance { return value === "light" || value === "dark"; } +const THEME_RADIUS = /^(?:0|\d{1,2}(?:\.\d{1,3})?(?:px|rem))$/; + +function statusColorsFrom(colors: unknown): ThemeStatusColors | undefined { + if (!isRecord(colors)) return undefined; + const picked: Partial> = {}; + for (const role of THEME_STATUS_COLOR_ROLES) { + const value = colors[role]; + if (isThemeColor(value)) picked[role] = value; + } + return Object.keys(picked).length > 0 ? picked : undefined; +} + +/** A published theme's corner radius and status colors, as `ThemeDefinition` holds them. */ +function publishedThemeExtras( + radius: unknown, + statusColors: Partial>, +): Pick { + const status: Partial> = {}; + for (const appearance of ["light", "dark"] as const) { + const colors = statusColorsFrom(statusColors[appearance]); + if (colors !== undefined) status[appearance] = colors; + } + return { + ...(typeof radius === "string" && THEME_RADIUS.test(radius) ? { radius } : {}), + ...(Object.keys(status).length > 0 ? { statusColors: status } : {}), + }; +} + export function isThemeColor(value: unknown): value is string { return typeof value === "string" && toCanonicalThemeColor(value) !== null; } @@ -166,7 +197,8 @@ function parseStoredThemeVariants( return Object.keys(variants).length > 0 ? variants : undefined; } -function parseStoredTheme(value: unknown): ThemeDefinition | null { +// Only published themes keep `sidebarArtwork`; custom themes never show the artwork. +function parseStoredTheme(value: unknown, published = false): ThemeDefinition | null { if (!isRecord(value)) return null; if (!isThemeId(value.id) || RESERVED_THEME_IDS.has(value.id)) return null; if (!isThemeLabel(value.label) || !isThemeAppearance(value.appearance)) return null; @@ -184,13 +216,20 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { ...(variants ? { variants } : {}), ...(collection ? { collection } : {}), ...(value.managed === true ? { managed: true } : {}), + ...(published && value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), + ...(published + ? publishedThemeExtras(value.radius, isRecord(value.statusColors) ? value.statusColors : {}) + : {}), }; } -function parseStoredThemes(storedThemes: ReadonlyArray): ReadonlyArray { +function parseStoredThemes( + storedThemes: ReadonlyArray, + published = false, +): ReadonlyArray { const themes: ThemeDefinition[] = []; for (const value of storedThemes) { - const theme = parseStoredTheme(value); + const theme = parseStoredTheme(value, published); if (theme && !themes.some((existing) => existing.id === theme.id)) { themes.push(theme); } @@ -277,6 +316,12 @@ function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { appearance: theme.appearance, colors: publishedThemeColors(theme, theme.appearance, theme.colors), ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(theme.sidebarArtwork === true ? { sidebarArtwork: true } : {}), + ...publishedThemeExtras(theme.radius, { + [theme.appearance]: theme.colors, + [theme.appearance === "dark" ? "light" : "dark"]: + theme.variants?.[theme.appearance === "dark" ? "light" : "dark"], + }), // Only the pure seeded form is guided-generator output. An exported file, // or seeds carrying explicit role overrides, must open Duplicate in the // advanced editor -- the guided one regenerates from canvas and accent and @@ -320,14 +365,95 @@ const publishedThemesAtom = Atom.make((get): ReadonlyArray => return previous !== null && Equal.equals(previous, next) ? previous : next; }).pipe(Atom.withLabel("web-published-environment-themes")); +// The server sends its palettes as their own event after it connects. Until the first one, +// the palettes it sent last time stand in, so a published theme paints from the first frame, +// and the boot screen in index.html reads the same copy. +const environmentThemesKnownAtom = Atom.make(false).pipe(Atom.keepAlive); + +function readStoredEnvironmentThemes(): ReadonlyArray { + if (typeof window === "undefined") return EMPTY_CUSTOM_THEMES; + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(ENVIRONMENT_THEMES_STORAGE_KEY) ?? "null", + ); + return Array.isArray(parsed) ? parseStoredThemes(parsed, true) : EMPTY_CUSTOM_THEMES; + } catch { + return EMPTY_CUSTOM_THEMES; + } +} + +function storeEnvironmentThemes(themes: ReadonlyArray) { + try { + if (themes.length === 0) { + window.localStorage.removeItem(ENVIRONMENT_THEMES_STORAGE_KEY); + return; + } + const stored = JSON.stringify(themes); + if (window.localStorage.getItem(ENVIRONMENT_THEMES_STORAGE_KEY) !== stored) { + window.localStorage.setItem(ENVIRONMENT_THEMES_STORAGE_KEY, stored); + } + } catch { + // Without storage the palettes still arrive with the server, a moment later. + } +} + +/** + * The environment's palettes, republished by the server on every change. The saved copy is only + * read until the server sends them. Kept alive because theme lookups read it outside React on + * every repaint. + */ +export const environmentThemeDefinitionsAtom = Atom.make((get) => { + const published = publishedThemeDefinitions(get(publishedThemesAtom)); + if (published.length > 0) return published; + // The shared empty list, so a server without themes never repaints the app. + return get(environmentThemesKnownAtom) ? EMPTY_CUSTOM_THEMES : readStoredEnvironmentThemes(); +}).pipe(Atom.keepAlive, Atom.withLabel("web-environment-theme-definitions")); + +// Derived from the palettes rather than read in the event listener: the event can be announced +// before the same update reaches the palettes. +const environmentThemesToSaveAtom = Atom.make((get) => + get(environmentThemesKnownAtom) ? get(environmentThemeDefinitionsAtom) : null, +); + +/** Switches to the server's palettes once it sends them, and saves them for the next start. */ +export function startEnvironmentThemeCache(): () => void { + const stopKnowing = appAtomRegistry.subscribe( + primaryServerConfigEventAtom, + (event) => { + if (event?.type === "environmentThemesUpdated") { + appAtomRegistry.set(environmentThemesKnownAtom, true); + } + }, + { immediate: true }, + ); + const stopSaving = appAtomRegistry.subscribe( + environmentThemesToSaveAtom, + (themes) => { + if (themes !== null) storeEnvironmentThemes(themes); + }, + { immediate: true }, + ); + return () => { + stopKnowing(); + stopSaving(); + }; +} + /** - * The environment's palettes are not saved: they are republished by the server - * on every change and would go stale the moment the machine's theme moved on. - * Kept alive because theme lookups read it outside React on every repaint. + * An imported theme file whose id a published theme already uses, renamed to a copy. A saved theme + * wins an id collision on purpose, so editing a published theme overrides it, but importing the + * published theme's own file would otherwise hide it and its radius and status colors. */ -export const environmentThemeDefinitionsAtom = Atom.make((get) => - publishedThemeDefinitions(get(publishedThemesAtom)), -).pipe(Atom.keepAlive, Atom.withLabel("web-environment-theme-definitions")); +export function asImportedThemeCopy(theme: ThemeDefinition): ThemeDefinition { + const published = getEnvironmentThemes(); + if (!published.some((candidate) => candidate.id === theme.id)) return theme; + // A saved copy is left out, so importing the file again offers to replace that copy. + const taken = new Set(published.map((candidate) => candidate.id)); + const base = `${theme.id.slice(0, 38)}-copy`; + let id = base; + for (let copy = 2; taken.has(id); copy += 1) id = `${base}-${copy}`; + return { ...theme, id, label: `${theme.label.slice(0, 43)} copy` }; +} function getEnvironmentThemes(): ReadonlyArray { return appAtomRegistry.get(environmentThemeDefinitionsAtom); @@ -1185,14 +1311,24 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu ); } -export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { +/** The send button's artwork follows built-in themes only; a published theme styles it plainly. */ +export function themeAllowsComposerArtwork(theme: ThemePreference): boolean { const themeId = themeIdFromPreference(theme); return ( - BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId)?.sidebarArtwork === + BUILT_IN_THEME_DEFINITIONS.find((candidate) => candidate.id === themeId)?.sidebarArtwork === true ); } +/** Built-in themes and published ones can allow it; custom themes can't. */ +export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { + const themeId = themeIdFromPreference(theme); + const definition = + BUILT_IN_THEME_DEFINITIONS.find((candidate) => candidate.id === themeId) ?? + getEnvironmentThemes().find((candidate) => candidate.id === themeId); + return definition?.sidebarArtwork === true; +} + /** Every selection path must check single-appearance themes to keep mix and appearance tiles in sync. */ export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null { const modes = getThemeModes(theme); @@ -1425,11 +1561,15 @@ export function removeCustomThemes(themeIds: ReadonlyArray): void { ); } +const THEME_STATUS_COLOR_ROLE_SET: ReadonlySet = new Set(THEME_STATUS_COLOR_ROLES); + function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { if (!isRecord(value)) throw new Error("Theme colors must be objects."); const overrides: Partial> = {}; for (const [role, color] of Object.entries(value)) { + // A published theme's status colors; an imported copy keeps T3's own, like its radius. + if (THEME_STATUS_COLOR_ROLE_SET.has(role)) continue; if (!THEME_COLOR_ROLE_SET.has(role)) { throw new Error(`"${role}" is not a supported theme color role.`); } @@ -1604,10 +1744,39 @@ export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeApp for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { if (isThemeColor(value)) root.style.setProperty(APP_THEME_VARIABLES[role], value); } + applyPublishedThemeExtras(root, null, appearance); applyContextChipVariables(root, colors); setThemePreviewAppearance(appearance); } +const STATUS_COLOR_VARIABLES: Readonly> = { + success: "--success", + successForeground: "--success-foreground", + info: "--info", + infoForeground: "--info-foreground", +}; + +// Set inline on the root, which beats T3's own light and dark values, and removed otherwise. +function applyPublishedThemeExtras( + root: HTMLElement, + palette: ThemeDefinition | null, + mode: ThemeAppearance, +) { + // Custom themes win an id collision, so only the published definition itself counts. + if (palette !== null && getEnvironmentThemes().includes(palette)) { + root.dataset.themePublished = ""; + } else { + delete root.dataset.themePublished; + } + const set = (variable: string, value: string | undefined) => { + if (value === undefined) root.style.removeProperty(variable); + else root.style.setProperty(variable, value); + }; + set("--radius", palette?.radius); + const status = palette?.statusColors?.[mode]; + for (const role of THEME_STATUS_COLOR_ROLES) set(STATUS_COLOR_VARIABLES[role], status?.[role]); +} + export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppearance): void { if (typeof document === "undefined") return; @@ -1625,6 +1794,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe root.style.setProperty(APP_THEME_VARIABLES[role], value); } applyContextChipVariables(root, colors); + applyPublishedThemeExtras(root, palette, mode); return; } @@ -1632,9 +1802,18 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe for (const variable of Object.values(APP_THEME_VARIABLES)) { root.style.removeProperty(variable); } + applyPublishedThemeExtras(root, null, appearance ?? "light"); applyContextChipVariables(root, getStandardPaintedThemeColors(appearance ?? "light")); } +/** The canvas `applyThemePalette` paints for `theme`, as `#rrggbb`; null without a palette. */ +export function themeCanvasHex(theme: ThemePreference, appearance: ThemeAppearance): string | null { + const palette = getThemeDefinition(theme); + if (!palette) return null; + const canvas = parse((getThemeColorsForMode(palette, appearance) ?? palette.colors).canvas); + return canvas ? formatHex(canvas) : null; +} + export function resolveThemeAppearance( theme: ThemePreference, systemDark: boolean, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f14452fec5a92..114b018385828 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1008,6 +1008,23 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ data: Schema.Uint8Array, }); +/** The canvas the page paints in one appearance, as `#rrggbb`; null for plain black or white. */ +export const DesktopWindowCanvasSchema = Schema.Struct({ + appearance: Schema.Literals(["light", "dark"]), + color: Schema.NullOr(Schema.String), +}); +export type DesktopWindowCanvas = typeof DesktopWindowCanvasSchema.Type; + +/** + * What the page paints while Electron's native theme is light and while it is dark. A theme + * pinned to one appearance paints that appearance in both. + */ +export const DesktopWindowBackgroundSchema = Schema.Struct({ + light: DesktopWindowCanvasSchema, + dark: DesktopWindowCanvasSchema, +}); +export type DesktopWindowBackground = typeof DesktopWindowBackgroundSchema.Type; + export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; @@ -1088,6 +1105,8 @@ export interface DesktopBridge { pickProjectFavicon: (initialPath?: string) => Promise; pickThemeFiles: () => Promise; setTheme: (theme: DesktopTheme) => Promise; + /** The theme's canvases, which new windows start in so they don't flash black or white. */ + setWindowBackground: (background: DesktopWindowBackground) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c36f724b8239e..defe9b7d52a0c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -565,6 +565,16 @@ const environmentThemeFields = { dark: Schema.optional(EnvironmentThemeColors), }), ), + /** Keeps the nightly and dev artwork in the sidebar, which T3 otherwise swaps for a label. */ + sidebarArtwork: Schema.optional(Schema.Boolean), + /** T3's corner radius while the theme is on, such as `0.625rem`. */ + radius: Schema.optional( + TrimmedNonEmptyString.check( + Schema.isPattern(/^(?:0|\d{1,2}(?:\.\d{1,3})?(?:px|rem))$/, { + expected: "a length such as 0.625rem or 10px", + }), + ), + ), }; export const EnvironmentThemeFile = Schema.Struct(environmentThemeFields); diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index 19e321c5886c3..225c3eac85bc6 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -94,6 +94,18 @@ export const THEME_COLOR_ROLES = [ export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; export type ThemeColors = Readonly>; export type ThemeVariants = Readonly>>; + +/** Status colors a published theme may set in its colors; T3's own stay otherwise. */ +export const THEME_STATUS_COLOR_ROLES = [ + "success", + "successForeground", + "info", + "infoForeground", +] as const; +export type ThemeStatusColors = Readonly< + Partial> +>; + export type ThemeDefinition = Readonly<{ id: string; label: string; @@ -103,6 +115,10 @@ export type ThemeDefinition = Readonly<{ collection?: Readonly<{ id: string; label: string }>; sidebarArtwork?: boolean; managed?: boolean; + /** Published themes only: T3's corner radius, such as `0.625rem`. */ + radius?: string; + /** Published themes only, per appearance. */ + statusColors?: Readonly>>; }>; export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { From 521f876652c01caec59f450a23f43af835b5dc7a Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 02:17:52 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(plugins):=20a=20plugin=20host=20publ?= =?UTF-8?q?ishes=20plugin=20themes,=20and=20Settings=20=E2=86=92=20Plugins?= =?UTF-8?q?=20turns=20plugins=20on=20and=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a theme meant copying files into an environment's themes folder by hand, with no way to turn it off or ship it as one package. The server now finds plugins, folders with a `t3-plugin.json` manifest, in its `plugins` folder and the `devPaths` in `plugins.json`. It keeps each one on or off there and forgets the switch of an id no folder has, so a folder copied in later with that id starts off. It fails a plugin that needs a newer T3, and a later folder with an id it already found, because the switch belongs to the id. It publishes the themes of plugins that are on, and the theme service reads their folders only when that list changes. Settings → Plugins shows one environment at a time over a stream of plugin states, with a switch, Reload, and a rescan on every visit. Plugins run no code yet. Surfaces: a Settings page in web and desktop; mobile shows none. No providers or agents. Contracts: five `plugins.*` RPCs. Reverse: the switch and Reload. Connections: one stream on the existing WebSocket. Docs: internals, the user guide, and a plugin author guide in `packages/plugin-sdk/README.md`, where the SDK package arrives once plugins run code. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 6 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/environmentTheme.test.ts | 78 +++++ apps/server/src/environmentTheme.ts | 82 +++-- .../src/observability/RpcInstrumentation.ts | 5 + apps/server/src/plugins/PluginHost.test.ts | 211 ++++++++++++ apps/server/src/plugins/PluginHost.ts | 304 ++++++++++++++++++ .../src/plugins/pluginDiscovery.test.ts | 67 ++++ apps/server/src/plugins/pluginDiscovery.ts | 182 +++++++++++ .../src/plugins/pluginHostTestHarness.ts | 67 ++++ apps/server/src/plugins/pluginRpcHandlers.ts | 38 +++ apps/server/src/plugins/pluginStates.ts | 167 ++++++++++ apps/server/src/plugins/pluginsConfig.ts | 109 +++++++ apps/server/src/server.ts | 8 +- apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 4 + apps/web/index.html | 4 +- .../settings/SettingsScopeSentence.tsx | 15 +- .../settings/SettingsSidebarNav.tsx | 2 + .../settings/plugins/PluginList.tsx | 58 ++++ .../components/settings/plugins/PluginRow.tsx | 93 ++++++ .../settings/plugins/PluginsSettings.tsx | 58 ++++ .../settings/plugins/pluginTargets.ts | 5 + .../src/components/settings/settingsSearch.ts | 17 +- .../web/src/hooks/useEnvironmentTheme.test.ts | 6 +- apps/web/src/index.css | 4 +- apps/web/src/routeTree.gen.ts | 21 ++ .../web/src/routes/_chat.settings.plugins.tsx | 5 + apps/web/src/routes/_chat.settings.tsx | 19 +- apps/web/src/state/plugins.ts | 5 + apps/web/src/themePalette.test.ts | 10 +- apps/web/src/themePalette.ts | 13 +- docs/README.md | 2 + docs/internals/plugins.md | 35 ++ docs/user/plugins.md | 37 +++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/commandPermissions.test.ts | 10 +- packages/client-runtime/src/state/plugins.ts | 42 +++ .../contracts/src/clientRpcPermissions.ts | 4 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/plugins.test.ts | 40 +++ packages/contracts/src/plugins.ts | 148 +++++++++ packages/contracts/src/rpc.ts | 56 ++++ packages/plugin-sdk/README.md | 35 ++ 47 files changed, 2037 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/plugins/PluginHost.test.ts create mode 100644 apps/server/src/plugins/PluginHost.ts create mode 100644 apps/server/src/plugins/pluginDiscovery.test.ts create mode 100644 apps/server/src/plugins/pluginDiscovery.ts create mode 100644 apps/server/src/plugins/pluginHostTestHarness.ts create mode 100644 apps/server/src/plugins/pluginRpcHandlers.ts create mode 100644 apps/server/src/plugins/pluginStates.ts create mode 100644 apps/server/src/plugins/pluginsConfig.ts create mode 100644 apps/web/src/components/settings/plugins/PluginList.tsx create mode 100644 apps/web/src/components/settings/plugins/PluginRow.tsx create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.tsx create mode 100644 apps/web/src/components/settings/plugins/pluginTargets.ts create mode 100644 apps/web/src/routes/_chat.settings.plugins.tsx create mode 100644 apps/web/src/state/plugins.ts create mode 100644 docs/internals/plugins.md create mode 100644 docs/user/plugins.md create mode 100644 packages/client-runtime/src/state/plugins.ts create mode 100644 packages/contracts/src/plugins.test.ts create mode 100644 packages/contracts/src/plugins.ts create mode 100644 packages/plugin-sdk/README.md diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..329cd66c6f4a4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,6 +68,12 @@ describe("RPC authorization scopes", () => { } }); + it("keeps enabling and reloading plugins behind settings write", () => { + for (const method of [WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6049121b41a5d..dd4fbf4442c01 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -205,6 +205,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 72d7f0c2de7ef..b2b2d6956398d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, threadForceStop: true, environmentThemes: true, + plugins: true, usageLimitSources: true, providerWorkspaceSnapshotsOnDemand: true, usagePriceOverrides: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e6b2e1bc67921..8e392ed7e258b 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -90,6 +90,62 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("adds plugin themes after the environment's own, which win on the same id", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + yield* fs.writeFileString( + path.join(pluginThemes, "nightfall.json"), + encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }), + ); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + assert.deepEqual( + (yield* currentThemes).map((theme) => [theme.id, theme.name]), + [ + ["nightfall", "Nightfall"], + ["shared-light", "Shared Light"], + ], + ); + + // The same folders read again, so a plugin restarted with edited files reaches windows. + const seen = yield* Queue.unbounded>(); + yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => + Queue.offer(seen, themes), + ).pipe(Effect.forkScoped); + yield* Queue.take(seen); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile({ ...SHARED_THEME, name: "Shared Light Edited" }), + ); + // Only setting the folders reads them, so a client connecting doesn't read every plugin's. + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.name), + ["Nightfall", "Shared Light"], + ); + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + assert.deepEqual( + (yield* Queue.take(seen)).map((theme) => theme.name), + ["Nightfall", "Shared Light Edited"], + ); + + yield* environmentTheme.setPluginThemeDirectories([]); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + it.effect("never replays a set older than the snapshot it started from", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, @@ -158,6 +214,28 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect.skipIf(!symlinksSupported)("ignores a plugin's themes folder linked out of it", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + const outside = path.join(base, "outside"); + yield* fs.makeDirectory(outside); + yield* fs.writeFileString( + path.join(outside, "nightfall.json"), + encodeThemeFile(NIGHTFALL_THEME), + ); + yield* fs.makeDirectory(path.join(base, "plugin")); + yield* fs.symlink(outside, path.join(base, "plugin", "themes")); + yield* environmentTheme.setPluginThemeDirectories([path.join(base, "plugin", "themes")]); + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index f0fb44b2bfda7..5306c37c320b3 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -17,6 +17,8 @@ import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -50,6 +52,14 @@ export class EnvironmentThemeService extends Context.Service< /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; + + /** + * Theme folders of active plugins, each `/themes`, read after the environment's + * own folder; one linked out of its plugin folder is skipped. The first theme with an id wins, + * so a plugin cannot replace a theme the user published. Each call reads the folders, so a + * plugin that restarts with edited files publishes them, and nothing else reads them again. + */ + readonly setPluginThemeDirectories: (directories: ReadonlyArray) => Effect.Effect; } >()("t3/environmentTheme/EnvironmentThemeService") {} @@ -155,32 +165,58 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Untraced: it runs for each plugin each time the folders are set. + const insideItsPlugin = Effect.fnUntraced(function* (directory: string) { + const real = yield* fs.realPath(directory).pipe(Effect.option); + const root = yield* fs.realPath(path.dirname(directory)).pipe(Effect.option); + return ( + Option.isSome(real) && + Option.isSome(root) && + real.value.startsWith(`${root.value}${path.sep}`) + ); + }); // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); + const pluginThemes = yield* Ref.make>([]); // Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers. const refreshSemaphore = yield* Semaphore.make(1); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); - const refresh = refreshSemaphore.withPermits(1)( - Effect.gen(function* () { - const themes = yield* readPublishedThemes(environmentThemesDir).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); - // Compare the whole value so new fields cannot silently miss republishes. - const [changed, next] = yield* Ref.modify( - published, - (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { - if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; - const updated: PublishedThemes = { seq: previous.seq + 1, themes }; - return [[true, updated], updated]; - }, - ); - if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); - return next; - }), - ); + const readThemes = (directory: string) => + readPublishedThemes(directory).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + const readPluginThemes = Effect.fnUntraced(function* (directories: ReadonlyArray) { + const themes: Array = []; + for (const directory of directories) { + if (!(yield* insideItsPlugin(directory))) continue; + themes.push(...(yield* readThemes(directory))); + } + return themes; + }); + + const publishUnlocked = Effect.gen(function* () { + const themes = yield* readThemes(environmentThemesDir); + const ids = new Set(themes.map((theme) => theme.id)); + for (const theme of yield* Ref.get(pluginThemes)) { + if (ids.has(theme.id)) continue; + ids.add(theme.id); + themes.push(theme); + } + // Compare the whole value so new fields cannot silently miss republishes. + const [changed, next] = yield* Ref.modify( + published, + (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { + if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; + const updated: PublishedThemes = { seq: previous.seq + 1, themes }; + return [[true, updated], updated]; + }, + ); + if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); + return next; + }); + const refresh = refreshSemaphore.withPermits(1)(publishUnlocked); yield* fs .makeDirectory(environmentThemesDir, { recursive: true }) @@ -198,6 +234,16 @@ const make = Effect.gen(function* () { return { current: Effect.map(refresh, (state) => state.themes), + setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")( + function* (directories) { + yield* refreshSemaphore.withPermits(1)( + readPluginThemes(directories).pipe( + Effect.flatMap((themes) => Ref.set(pluginThemes, themes)), + Effect.andThen(publishUnlocked), + ), + ); + }, + ), get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1e3388e4136ad..2836b4b8454a5 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -87,6 +87,11 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshUsageRates]: "server", [WS_METHODS.serverSignalProcess]: "server", [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.pluginsList]: "plugins", + [WS_METHODS.pluginsSubscribeStates]: "plugins", + [WS_METHODS.pluginsSetEnabled]: "plugins", + [WS_METHODS.pluginsReload]: "plugins", + [WS_METHODS.pluginsRescan]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 0000000000000..2cc16e968bb49 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,211 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + applyPluginStatesEvent, + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSettingsWriteScope, + PluginId, + type PluginStatesEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + layerConfig, + layerFileSystemWith, + layerHost, + publishedThemeDirectories, + stateOf, + waitForStatuses, + writeFile, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; +import { makePluginRpcHandlers } from "./pluginRpcHandlers.ts"; + +const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; + +const manifestJson = ( + id: string, + options: { readonly name?: string; readonly version?: string; readonly engines?: string } = {}, +) => + JSON.stringify({ + id, + name: options.name ?? id, + version: options.version ?? "0.1.0", + engines: { t3: options.engines ?? ">=0.1.0" }, + }); + +it.layer(NodeServices.layer)("PluginHost", (it) => { + it.effect("saves plugins turned on and off, reloads a changed manifest and rescans folders", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const manifestPath = (id: string) => `${stateDir}/plugins/${id}/t3-plugin.json`; + yield* writeFile(manifestPath("reloading"), manifestJson("reloading")); + yield* writeFile(manifestPath("goes"), manifestJson("goes")); + yield* writePluginsConfig({ enabled: { goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + const reloading = PluginId.make("reloading"); + yield* waitForStatuses(host, { reloading: "disabled", goes: "active" }); + assert.include(yield* host.setEnabled(reloading, true), { status: "active" }); + yield* host.setEnabled(PluginId.make("goes"), false); + assert.deepStrictEqual(JSON.parse(yield* fs.readFileString(`${stateDir}/plugins.json`)), { + enabled: { goes: false, reloading: true }, + }); + + const reloaded = manifestJson("reloading", { version: "0.2.0" }); + yield* writeFile(manifestPath("reloading"), reloaded); + assert.include(yield* host.reload(reloading), { status: "active", version: "0.2.0" }); + + // A rescan lists a folder copied in since, turned off, and drops one that was removed. + yield* writeFile(manifestPath("arrives"), manifestJson("arrives")); + yield* fs.remove(`${stateDir}/plugins/goes`, { recursive: true }); + const { plugins } = yield* host.rescan; + assert.include(stateOf(plugins, "arrives"), { status: "disabled", enabled: false }); + assert.isUndefined(stateOf(plugins, "goes")); + // plugins.json forgets the switch of a folder that is gone. + assert.deepStrictEqual(JSON.parse(yield* fs.readFileString(`${stateDir}/plugins.json`)), { + enabled: { reloading: true }, + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("publishes the theme folders of plugins that are on", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const future = manifestJson("future", { engines: ">=999.0.0" }); + yield* writeFile(`${stateDir}/plugins/future/t3-plugin.json`, future); + yield* writeFile(`${stateDir}/plugins/themed/t3-plugin.json`, manifestJson("themed")); + yield* writePluginsConfig({ enabled: { themed: true, future: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { themed: "active", future: "failed" }); + // One that needs a newer T3 fails when it starts, and offers no themes. + assert.strictEqual(stateOf(states, "future")?.failure?.stage, "compatibility"); + const themes = `${yield* fs.realPath(`${stateDir}/plugins/themed`)}/themes`; + assert.deepStrictEqual(publishedThemeDirectories.at(-1), [themes]); + publishedThemeDirectories.length = 0; + yield* host.reload(PluginId.make("themed")); + assert.deepStrictEqual(publishedThemeDirectories, [[themes]]); + yield* host.setEnabled(PluginId.make("themed"), false); + assert.deepStrictEqual(publishedThemeDirectories.at(-1), []); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps server folder paths from clients that cannot manage plugins", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { linked: "disabled" }); + const listed = (scopes: ReadonlyArray) => { + const handlers = makePluginRpcHandlers(host, scopes); + return Effect.all([ + handlers[WS_METHODS.pluginsList]({}), + handlers[WS_METHODS.pluginsSubscribeStates]({}).pipe( + Stream.runHead, + Effect.map((first) => + applyPluginStatesEvent({ plugins: [] }, Option.getOrThrow(first)), + ), + ), + ]).pipe(Effect.map((results) => results.flatMap((result) => result.plugins))); + }; + + for (const plugin of yield* listed([AuthOrchestrationReadScope])) { + assert.notProperty(plugin, "folder"); + } + const managed = yield* listed([AuthOrchestrationReadScope, AuthSettingsWriteScope]); + assert.include(stateOf(managed, "linked"), { folder }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("sends a client only the plugins that changed after the first list", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const changing = PluginId.make("changing"); + yield* writeFile(`${stateDir}/checkout/steady/t3-plugin.json`, manifestJson("steady")); + yield* writeFile(`${stateDir}/checkout/changing/t3-plugin.json`, manifestJson(changing)); + yield* writePluginsConfig({ + enabled: { [changing]: true }, + devPaths: [`${stateDir}/checkout/steady`, `${stateDir}/checkout/changing`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [changing]: "active", steady: "disabled" }); + const disabled = (event: PluginStatesEvent) => + event._tag === "changed" && + event.rows.some(({ plugin }) => plugin.id === changing && plugin.status === "disabled"); + const events = yield* host + .streamForClient(operate) + .pipe( + Stream.takeUntil(disabled), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* host.setEnabled(changing, false); + const [first, ...changes] = yield* Fiber.join(events); + assert.strictEqual(first?._tag, "snapshot"); + for (const change of changes) { + assert.deepStrictEqual( + change._tag === "changed" ? change.rows.map(({ plugin }) => plugin.id) : [], + [changing], + ); + } + const folded = [first!, ...changes].reduce(applyPluginStatesEvent, { plugins: [] }); + assert.deepStrictEqual(folded, yield* host.listForClient(operate)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("sends a client that connects during startup one empty list, not two", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + // Holds startup inside its lock, as it lists the plugins folder. + const layerHeldStartup = layerFileSystemWith((fs) => ({ + readDirectory: (target, options) => + target.endsWith("/plugins") + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(fs.readDirectory(target, options)), + ) + : fs.readDirectory(target, options), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* Deferred.await(reached); + const lists = yield* host.streamStates.pipe( + Stream.map((states) => states.map((state) => state.id)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + // Waits for startup's lock, so startup has published whatever it does by then. + yield* host.rescan; + yield* writeFile(`${stateDir}/plugins/late/t3-plugin.json`, manifestJson("late")); + yield* host.rescan; + assert.deepStrictEqual(yield* Fiber.join(lists), [[], ["late"]]); + }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerHeldStartup)))); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000000..f83601c935925 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,304 @@ +import { + type AuthEnvironmentScope, + PluginId, + type PluginListResult, + type PluginState, + type PluginStatesEvent, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { forkParked } from "../serverActivation.ts"; +import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; +import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; +import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; + +export class PluginNotFoundError extends Schema.TaggedError()( + "PluginNotFoundError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not installed.`; + } +} + +export class PluginHost extends Context.Service< + PluginHost, + { + readonly list: Effect.Effect>; + /** The current list, then the whole list after every change. */ + readonly streamStates: Stream.Stream>; + /** `list` as a client with `scopes` sees it: server folder paths only if it manages plugins. */ + readonly listForClient: ( + scopes: ReadonlyArray, + ) => Effect.Effect; + /** `listForClient` now, then the rows that differ after every change. */ + readonly streamForClient: ( + scopes: ReadonlyArray, + ) => Stream.Stream; + /** Persists the flag, then waits for activation or deactivation to finish. */ + readonly setEnabled: ( + pluginId: PluginId, + enabled: boolean, + ) => Effect.Effect; + /** Rescans the plugin folders and restarts this plugin from its files. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** + * Rescans the plugin folders without restarting anything. Plugins whose folders appeared are + * listed, turned off unless plugins.json turns them on; ones whose folders are gone stop, and + * plugins.json forgets them. + */ + readonly rescan: Effect.Effect< + { readonly plugins: ReadonlyArray }, + PluginConfigError + >; + } +>()("t3/plugins/PluginHost") {} + +/** What plugins.json says about the plugins a discovery found. */ +interface Found { + readonly enabled: Readonly>; +} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const pluginsConfig = yield* makePluginsConfig; + + const records = yield* Ref.make>([]); + const lifecycle = yield* Semaphore.make(1); + const activations = yield* FiberMap.make(); + const hostScope = yield* Effect.scope; + + const pluginStates = yield* makePluginStates({ records, config: pluginsConfig.current }); + const currentStates = pluginStates.current; + const publishStates = pluginStates.publish; + const findRecord = (pluginId: string) => + Ref.get(records).pipe(Effect.map((all) => all.find((record) => record.plugin.id === pluginId))); + const stateOf = (pluginId: PluginId) => + currentStates.pipe( + Effect.flatMap((states) => { + const state = states.find((candidate) => candidate.id === pluginId); + return state === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(state); + }), + ); + // By id, which finds the copy in use: discovery lists it before any duplicate. + const setRecord = (pluginId: PluginId, update: Omit) => + Ref.update(records, (all) => { + const index = all.findIndex((record) => record.plugin.id === pluginId); + if (index === -1) return all; + const next = [...all]; + next[index] = { plugin: all[index]!.plugin, ...update }; + return next; + }).pipe(Effect.andThen(publishStates)); + + const activatePlugin = Effect.fn("PluginHost.activate")(function* (plugin: DiscoveredPlugin) { + const manifest = manifestOf(plugin); + if (manifest === undefined) return; + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs T3 Code ${manifest.engines.t3}; this server is ${packageJson.version}.`, + }, + }); + } + return yield* setRecord(plugin.id, { status: "active" }); + }); + + /** Interrupts a pending activation; the caller sets the plugin's next status. */ + const stopPlugin = Effect.fn("PluginHost.stop")(function* (pluginId: PluginId) { + yield* FiberMap.remove(activations, pluginId); + }); + + const startPlugin = (plugin: DiscoveredPlugin) => + FiberMap.run(activations, plugin.id, activatePlugin(plugin)); + + const initialRecord = (plugin: DiscoveredPlugin, found: Found): PluginRecord => { + if ("failure" in plugin) return { plugin, status: "failed", failure: plugin.failure }; + return found.enabled[plugin.id] === true + ? { plugin, status: "loading" } + : { plugin, status: "disabled" }; + }; + const startsAtDiscovery = (plugin: DiscoveredPlugin, found: Found) => + initialRecord(plugin, found).status === "loading"; + + // Startup tolerates an unreadable plugins.json; an explicit reload reports it instead. + const discover = Effect.fn("PluginHost.discover")(function* (lenient: boolean) { + const current = yield* pluginsConfig.reread(lenient); + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: current.devPaths ?? [] }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + yield* pluginsConfig.forgetMissing(new Set(plugins.map((plugin) => plugin.id))).pipe( + Effect.catchTags({ + PluginConfigError: (error) => + lenient + ? Effect.logWarning("could not update plugins.json", { path: error.path }) + : Effect.fail(error), + }), + ); + const found: Found = { enabled: current.enabled ?? {} }; + return { plugins, found }; + }); + + // Lifecycle changes run in the host's scope, so a caller that disconnects only stops waiting + // and never leaves a plugin half torn down. + const runLifecycle = (body: Effect.Effect) => + lifecycle.withPermits(1)(body).pipe(Effect.forkIn(hostScope), Effect.flatMap(Fiber.join)); + + const awaitActivation = (pluginId: PluginId) => + FiberMap.get(activations, pluginId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (fiber) => Effect.asVoid(Fiber.await(fiber)), + }), + ), + ); + + const setEnabled: PluginHost["Service"]["setEnabled"] = Effect.fn("PluginHost.setEnabled")( + function* (pluginId, enabled) { + yield* runLifecycle( + Effect.gen(function* () { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + yield* pluginsConfig.writeEnabled(pluginId, enabled); + if ("failure" in record.plugin) return yield* publishStates; + if (enabled && (record.status === "active" || record.status === "loading")) { + return yield* publishStates; + } + yield* stopPlugin(pluginId); + if (enabled) { + yield* startPlugin(record.plugin); + } else { + yield* setRecord(pluginId, { status: "disabled" }); + } + }), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + /** Rescans the plugin folders and lists what appeared; `restart` also restarts from new files. */ + const rediscover = Effect.fn("PluginHost.rediscover")(function* (restart: PluginId | undefined) { + const { plugins, found } = yield* discover(false); + if (restart !== undefined) yield* stopPlugin(restart); + const known = yield* Ref.get(records); + const next = plugins.map((plugin) => ({ + plugin, + kept: + plugin.id === restart || "failure" in plugin + ? undefined + : known.find( + (record) => + record.plugin.id === plugin.id && + record.plugin.folder === plugin.folder && + !("failure" in record.plugin), + ), + })); + // Copies that are gone or moved stop. + for (const record of known) { + if ( + record.plugin.id !== restart && + !("failure" in record.plugin) && + !next.some(({ kept }) => kept === record) + ) { + yield* stopPlugin(record.plugin.id); + } + } + // Kept records are read again here: one still starting may have finished while others stopped. + yield* Ref.update(records, (current) => + next.map(({ plugin, kept }) => { + if (kept === undefined) return initialRecord(plugin, found); + const latest = current.find((record) => record.plugin === kept.plugin) ?? kept; + // One that runs nothing takes the files found now, so starting it loads their manifest. + return latest.status === "active" || latest.status === "loading" + ? latest + : { ...latest, plugin }; + }), + ); + yield* publishStates; + for (const { plugin, kept } of next) { + if (kept === undefined && startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + return plugins; + }); + + const reload: PluginHost["Service"]["reload"] = Effect.fn("PluginHost.reload")( + function* (pluginId) { + yield* runLifecycle( + rediscover(pluginId).pipe( + Effect.flatMap((plugins) => + plugins.some((plugin) => plugin.id === pluginId) + ? Effect.void + : Effect.fail(new PluginNotFoundError({ pluginId })), + ), + ), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + const rescan = Effect.fn("PluginHost.rescan")(function* () { + // The docs tell people to copy plugin folders here, so it has to exist. + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* runLifecycle(rediscover(undefined)); + return { plugins: yield* currentStates }; + }); + + const startup = lifecycle + .withPermits(1)( + Effect.gen(function* () { + const { plugins, found } = yield* discover(true); + yield* Ref.set( + records, + plugins.map((plugin) => initialRecord(plugin, found)), + ); + yield* publishStates; + // Each plugin activates in its own fiber, so a slow one delays nobody else. + for (const plugin of plugins) { + if (startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + }), + ) + .pipe(Effect.withSpan("PluginHost.startup")); + + yield* forkParked(startup); + + const service = PluginHost.of({ + list: currentStates, + streamStates: pluginStates.stream, + listForClient: pluginStates.listForClient, + streamForClient: pluginStates.streamForClient, + setEnabled, + reload, + rescan: rescan(), + }); + return Context.make(PluginHost, service); +}); + +export const layer = Layer.effectContext(make); diff --git a/apps/server/src/plugins/pluginDiscovery.test.ts b/apps/server/src/plugins/pluginDiscovery.test.ts new file mode 100644 index 0000000000000..8681c8130c570 --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverPlugins } from "./pluginDiscovery.ts"; + +const manifest = (id: string) => + JSON.stringify({ id, name: id, version: "1.0.0", engines: { t3: ">=0.1.0" } }); + +it.layer(NodeServices.layer)("discoverPlugins", (it) => { + it.effect("reports valid, missing, invalid, renamed and duplicate plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-discovery-" }); + const pluginsDir = path.join(root, "plugins"); + const write = Effect.fn(function* (dir: string, contents: string | undefined) { + yield* fs.makeDirectory(dir, { recursive: true }); + if (contents !== undefined) { + yield* fs.writeFileString(path.join(dir, "t3-plugin.json"), contents); + } + }); + yield* write(path.join(pluginsDir, "valid"), manifest("valid")); + yield* write(path.join(pluginsDir, "empty"), undefined); + yield* write( + path.join(pluginsDir, "invalid"), + JSON.stringify({ ...JSON.parse(manifest("invalid")), version: "1" }), + ); + yield* write(path.join(pluginsDir, "renamed"), manifest("other-name")); + yield* write(path.join(pluginsDir, "My Plugin"), manifest("spaced")); + yield* write(path.join(pluginsDir, "Not An Id"), undefined); + // Sorts before "valid", so it is the copy used. + yield* write(path.join(pluginsDir, "Copy of valid"), manifest("valid")); + const devCopy = path.join(root, "dev", "valid"); + yield* write(devCopy, manifest("valid")); + const devOnly = path.join(root, "dev", "workbench"); + yield* write(devOnly, manifest("dev-only")); + + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: [devCopy, devOnly] }); + const summary = plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + stage: "failure" in plugin ? plugin.failure.stage : "ok", + })); + + assert.deepStrictEqual(summary, [ + { id: "valid", source: "installed", stage: "ok" }, + { id: "spaced", source: "installed", stage: "ok" }, + { id: "empty", source: "installed", stage: "discovery" }, + { id: "invalid", source: "installed", stage: "manifest" }, + { id: "other-name", source: "installed", stage: "ok" }, + { id: "valid", source: "installed", stage: "duplicate" }, + { id: "valid", source: "dev", stage: "duplicate" }, + { id: "dev-only", source: "dev", stage: "ok" }, + ]); + assert.strictEqual(plugins[0]?.folder, path.join(pluginsDir, "Copy of valid")); + const failures = plugins.map((plugin) => ("failure" in plugin ? plugin.failure.message : "")); + assert.include(failures[3], "version"); + assert.strictEqual( + failures[6], + 'The installed folder "Copy of valid" already has the id valid. Remove or unlink one of them.', + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/pluginDiscovery.ts b/apps/server/src/plugins/pluginDiscovery.ts new file mode 100644 index 0000000000000..911d260970d0f --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.ts @@ -0,0 +1,182 @@ +import { + PluginId, + PluginManifest, + type PluginFailure, + type PluginSource, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +// Discovery runs at startup and on every reload, so a plugins folder full of unrelated +// directories or an oversized manifest must not stall the server. +const MAX_PLUGIN_DIRECTORIES = 64; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_FAILURE_MESSAGE_CHARS = 500; + +const decodeManifest = Schema.decodeUnknownExit(Schema.fromJsonString(PluginManifest)); +const decodeLooseId = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ id: PluginId })), +); +const isPluginId = Schema.is(PluginId); + +const causeMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +interface PluginLocation { + readonly id: PluginId; + readonly rootDir: string; + /** The folder as listed, before links resolve. Installed ones are inside the plugins folder. */ + readonly folder: string; + readonly source: PluginSource; +} + +/** A plugin directory, with its manifest when it could be read and decoded. */ +export type DiscoveredPlugin = PluginLocation & + ({ readonly manifest: PluginManifest } | { readonly failure: PluginFailure }); + +const failed = (location: PluginLocation, failure: PluginFailure): DiscoveredPlugin => ({ + ...location, + failure: { ...failure, message: failure.message.slice(0, MAX_FAILURE_MESSAGE_CHARS) }, +}); + +/** + * Lists installed plugins (`//t3-plugin.json`), then dev plugins + * (`devPaths`). An id is used once: a later folder with an id already listed is reported as failed. + */ +export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* (input: { + readonly pluginsDir: string; + readonly devPaths: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // The helpers below run for every plugin folder inside the discovery span. + const readManifest = Effect.fnUntraced(function* (rootDir: string) { + const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILE); + const info = yield* fs.stat(manifestPath).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "File") { + return { _tag: "Missing" } as const; + } + if (Number(info.value.size) > MAX_MANIFEST_BYTES) { + return { _tag: "TooLarge" } as const; + } + const raw = yield* fs.readFileString(manifestPath).pipe(Effect.option); + return raw._tag === "None" + ? ({ _tag: "Missing" } as const) + : ({ _tag: "Read", raw: raw.value } as const); + }); + + // Node caches modules by real path, so reload can only find a plugin's modules by its real root. + const inspect = Effect.fnUntraced(function* ( + location: string, + source: PluginSource, + fallbackId: PluginId | undefined, + ) { + const rootDir = yield* fs.realPath(location).pipe(Effect.orElseSucceed(() => location)); + const manifest = yield* readManifest(rootDir); + if (manifest._tag !== "Read") { + if (fallbackId === undefined) return undefined; + return failed( + { id: fallbackId, rootDir, folder: location, source }, + { + stage: "discovery", + message: + manifest._tag === "Missing" + ? `No ${PLUGIN_MANIFEST_FILE} in the plugin folder.` + : `${PLUGIN_MANIFEST_FILE} is larger than ${MAX_MANIFEST_BYTES} bytes.`, + }, + ); + } + const decoded = decodeManifest(manifest.raw); + if (decoded._tag === "Success") { + return { + id: decoded.value.id, + rootDir, + folder: location, + source, + manifest: decoded.value, + } as const; + } + const looseId = decodeLooseId(manifest.raw); + const id = looseId._tag === "Some" ? looseId.value.id : fallbackId; + if (id === undefined) return undefined; + return failed( + { id, rootDir, folder: location, source }, + { + stage: "manifest", + message: `Invalid ${PLUGIN_MANIFEST_FILE}: ${causeMessage(decoded.cause)}`, + }, + ); + }); + + const plugins: Array = []; + const firstWithId = new Map(); + // The switch belongs to the id, so two folders with one id cannot both run. + const add = (plugin: DiscoveredPlugin) => { + const first = firstWithId.get(plugin.id); + if (first === undefined) { + firstWithId.set(plugin.id, plugin); + plugins.push(plugin); + return; + } + // The folder's name only: clients that cannot manage plugins see failures but not paths. + const kind = first.source === "dev" ? "linked" : "installed"; + plugins.push( + failed(plugin, { + stage: "duplicate", + message: `The ${kind} folder "${path.basename(first.folder)}" already has the id ${plugin.id}. Remove or unlink one of them.`, + }), + ); + }; + let examined = 0; + const underLimit = Effect.fnUntraced(function* (location: string) { + examined += 1; + if (examined <= MAX_PLUGIN_DIRECTORIES) return true; + yield* Effect.logWarning("ignoring plugin folders past the limit", { + path: location, + limit: MAX_PLUGIN_DIRECTORIES, + }); + return false; + }); + + const entries = yield* fs + .readDirectory(input.pluginsDir) + .pipe(Effect.orElseSucceed((): Array => [])); + // People copy plugin folders in by hand, so the manifest names the plugin, not the folder. + for (const entry of entries.toSorted()) { + if (entry.startsWith(".")) continue; + const folder = path.join(input.pluginsDir, entry); + const info = yield* fs.stat(folder).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "Directory") continue; + if (!(yield* underLimit(input.pluginsDir))) break; + const plugin = yield* inspect(folder, "installed", isPluginId(entry) ? entry : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring plugin folder without a plugin id", { path: folder }); + continue; + } + add(plugin); + } + + for (const devPath of input.devPaths) { + const rootDir = path.resolve(devPath); + if (!(yield* underLimit(rootDir))) break; + const basename = path.basename(rootDir); + const plugin = yield* inspect(rootDir, "dev", isPluginId(basename) ? basename : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring dev plugin path without a usable plugin id", { + path: rootDir, + }); + continue; + } + add(plugin); + } + + return plugins; +}); diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts new file mode 100644 index 0000000000000..1d54c1f405e42 --- /dev/null +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -0,0 +1,67 @@ +// @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes plugins.json as plain JSON. +// The plugin host with a theme service that records what it publishes, for the host's tests. +import type { PluginState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; + +export const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-host-test-" })); + +// Every list of theme folders the host published, for the themes case. +export const publishedThemeDirectories: Array> = []; +const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => + Effect.sync(() => { + publishedThemeDirectories.push(directories); + }), +}); + +export const layerHost = () => Layer.fresh(PluginHost.layer.pipe(Layer.provide(layerThemes))); + +/** The real file system with some of its methods replaced, for the host's layer. */ +export const layerFileSystemWith = ( + replace: (fs: FileSystem.FileSystem) => Partial, +) => + Layer.effect( + FileSystem.FileSystem, + FileSystem.FileSystem.use((fs) => Effect.succeed({ ...fs, ...replace(fs) })), + ); + +export const writeFile = Effect.fn(function* (filePath: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); +}); + +export const writePluginsConfig = Effect.fn(function* (config: { + readonly enabled?: Record; + readonly devPaths?: ReadonlyArray; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + yield* writeFile(`${stateDir}/plugins.json`, JSON.stringify(config)); +}); + +export const stateOf = (states: ReadonlyArray, id: string) => + states.find((state) => state.id === id); + +/** Resolves with the first list where every listed plugin has its expected status. */ +export const waitForStatuses = ( + host: PluginHost.PluginHost["Service"], + expected: Readonly>, +) => + host.streamStates.pipe( + Stream.filter((states) => + Object.entries(expected).every(([id, status]) => stateOf(states, id)?.status === status), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts new file mode 100644 index 0000000000000..fba713fd73e54 --- /dev/null +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -0,0 +1,38 @@ +import { + type AuthEnvironmentScope, + PluginError, + type PluginId, + type PluginListInput, + type PluginReloadInput, + type PluginRescanInput, + type PluginSetEnabledInput, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type * as PluginHost from "./PluginHost.ts"; + +const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => + new PluginError({ message: error.message, pluginId }); + +/** The `plugins.*` WebSocket handlers. `scopes` are the connected session's. */ +export const makePluginRpcHandlers = ( + pluginHost: PluginHost.PluginHost["Service"], + scopes: ReadonlyArray, +) => ({ + [WS_METHODS.pluginsList]: (_input: PluginListInput) => pluginHost.listForClient(scopes), + [WS_METHODS.pluginsSubscribeStates]: (_input: PluginListInput) => + pluginHost.streamForClient(scopes), + [WS_METHODS.pluginsSetEnabled]: (input: PluginSetEnabledInput) => + pluginHost.setEnabled(input.pluginId, input.enabled).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsReload]: (input: PluginReloadInput) => + pluginHost.reload(input.pluginId).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsRescan]: (_input: PluginRescanInput) => + pluginHost.rescan.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), +}); diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts new file mode 100644 index 0000000000000..25732f7f6f4f0 --- /dev/null +++ b/apps/server/src/plugins/pluginStates.ts @@ -0,0 +1,167 @@ +import { + type AuthEnvironmentScope, + AuthSettingsWriteScope, + type PluginFailure, + type PluginManifest, + type PluginState, + type PluginStatesEvent, + type PluginStatus, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as EnvironmentTheme from "../environmentTheme.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginsConfigFile } from "./pluginsConfig.ts"; + +/** What the host knows about one listed copy of a plugin. */ +export interface PluginRecord { + readonly plugin: DiscoveredPlugin; + readonly status: PluginStatus; + readonly failure?: PluginFailure; +} + +export const manifestOf = (plugin: DiscoveredPlugin): PluginManifest | undefined => + "manifest" in plugin ? plugin.manifest : undefined; + +// Folders on the server are for clients that manage plugins; read access can be a guest's. +const withoutServerPaths = (state: PluginState): PluginState => { + const { folder: _folder, ...shown } = state; + return shown; +}; +const managesPlugins = (scopes: ReadonlyArray) => + scopes.includes(AuthSettingsWriteScope); + +interface ClientList { + readonly plugins: ReadonlyArray; +} + +// Compared per subscriber, since each one sees its own view of the list. +const statesEvent = ( + previous: ClientList | undefined, + next: ClientList, +): PluginStatesEvent | undefined => { + if (previous === undefined) return { _tag: "snapshot", ...next }; + const rows = next.plugins.flatMap((plugin, index) => + Equal.equals(plugin, previous.plugins[index]) ? [] : [{ index, plugin }], + ); + return rows.length === 0 && next.plugins.length === previous.plugins.length + ? undefined + : { _tag: "changed", length: next.plugins.length, rows }; +}; + +const toState = (record: PluginRecord, config: PluginsConfigFile): PluginState => { + const manifest = manifestOf(record.plugin); + const { id } = record.plugin; + return { + id: record.plugin.id, + name: manifest?.name ?? record.plugin.id, + ...(manifest === undefined + ? {} + : { + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + }), + permissions: manifest?.permissions ?? [], + source: record.plugin.source, + folder: record.plugin.folder, + enabled: config.enabled?.[id] === true, + status: record.status, + ...(record.failure === undefined ? {} : { failure: record.failure }), + }; +}; + +/** + * Builds the plugin list clients see from the host's records and plugins.json, and publishes it + * after each change, with the theme folders of plugins that are on when those change. + */ +export const makePluginStates = Effect.fn("PluginStates.make")(function* (input: { + readonly records: Ref.Ref>; + readonly config: Effect.Effect; +}) { + const { records } = input; + const environmentThemes = yield* EnvironmentTheme.EnvironmentThemeService; + const path = yield* Path.Path; + const changes = yield* PubSub.sliding>(1); + const publishLock = yield* Semaphore.make(1); + + const statesOf = Effect.fn("PluginHost.statesOf")(function* (all: ReadonlyArray) { + const config = yield* input.config; + return all.map((record) => toState(record, config)); + }); + const current = Ref.get(records).pipe(Effect.flatMap(statesOf)); + // What the last publish sent, starting from the empty list clients get before any. Settings + // rescans on every visit, and a list sent again would make every client stream rebuild what it + // already shows. + let published: ReadonlyArray = []; + // The copies whose theme folders the theme service last got, which reads every folder each time. + // A reload discovers its plugin afresh, so the new copy's themes are read again. + let themed: ReadonlyArray = []; + // Reading and publishing under one lock, so a slower fiber cannot publish an older list last. + const publishUnderLock = publishLock.withPermits(1)( + Effect.gen(function* () { + const all = yield* Ref.get(records); + const states = yield* statesOf(all); + if (Equal.equals(states, published)) return; + // Every enabled plugin that has not failed, so a starting or restarting one keeps its themes: + // windows would otherwise paint T3's colors, forget the saved theme, then switch back. + const copies = all.flatMap((record, index) => { + const state = states[index]; + return state?.enabled === true && state.status !== "failed" ? [record.plugin] : []; + }); + if (copies.length !== themed.length || copies.some((copy, index) => copy !== themed[index])) { + yield* environmentThemes.setPluginThemeDirectories( + copies.map((copy) => path.join(copy.rootDir, "themes")), + ); + themed = copies; + } + yield* PubSub.publish(changes, states); + published = states; + }), + ); + + const stream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* current; + return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)); + }), + ); + const forClient = + (scopes: ReadonlyArray) => + (plugins: ReadonlyArray): ClientList => ({ + plugins: managesPlugins(scopes) ? plugins : plugins.map(withoutServerPaths), + }); + + return { + current, + /** + * Sends the list if it changed. A stop that interrupts a starting plugin mid-publish cannot + * leave it unsent. + */ + publish: Effect.uninterruptible(publishUnderLock), + /** The current list, then the whole list after every change. */ + stream, + listForClient: Effect.fn("PluginHost.listForClient")(function* ( + scopes: ReadonlyArray, + ) { + return forClient(scopes)(yield* current); + }), + streamForClient: (scopes: ReadonlyArray) => + stream.pipe( + Stream.map(forClient(scopes)), + Stream.mapAccum( + (): ClientList | undefined => undefined, + (previous, next) => { + const event = statesEvent(previous, next); + return [next, event === undefined ? [] : [event]]; + }, + ), + ), + }; +}); diff --git a/apps/server/src/plugins/pluginsConfig.ts b/apps/server/src/plugins/pluginsConfig.ts new file mode 100644 index 0000000000000..8b4d6c8c1fa83 --- /dev/null +++ b/apps/server/src/plugins/pluginsConfig.ts @@ -0,0 +1,109 @@ +import type { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; + +export class PluginConfigError extends Schema.TaggedError()( + "PluginConfigError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not update ${this.path}. If it was edited by hand, fix or delete it.`; + } +} + +// Unknown keys survive a rewrite, so a newer build's additions outlive a toggle by an older one. +const PluginsConfigFile = Schema.StructWithRest( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), + devPaths: Schema.optionalKey(Schema.Array(Schema.String)), + }), + [Schema.Record(Schema.String, Schema.Json)], +); +export type PluginsConfigFile = typeof PluginsConfigFile.Type; +const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfig = Schema.encodeEffect(fromJsonStringPretty(PluginsConfigFile)); + +/** Builds the host's copy of `/plugins.json`, which it reads and writes through this. */ +export const makePluginsConfig = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(config.stateDir, "plugins.json"); + const current = yield* Ref.make({}); + + const read = Effect.gen(function* () { + const exists = yield* fs.exists(configPath); + if (!exists) { + const emptyConfig: PluginsConfigFile = {}; + return emptyConfig; + } + const raw = yield* fs.readFileString(configPath); + return yield* decodeConfig(raw); + }).pipe(Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause }))); + + const write = Effect.fn("PluginHost.writeConfig")(function* ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) { + const next = update(yield* read); + const contents = yield* encodeConfig(next).pipe( + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* writeFileStringAtomically({ filePath: configPath, contents: `${contents}\n` }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* Ref.set(current, next); + }); + + const emptyConfig: PluginsConfigFile = {}; + return { + /** plugins.json as last read or written. */ + current: Ref.get(current), + /** Reads the file again and keeps it. Startup tolerates an unreadable file; a reload reports it. */ + reread: (lenient: boolean) => + (lenient + ? read.pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("ignoring unreadable plugins.json", { path: error.path }).pipe( + Effect.as(emptyConfig), + ), + }), + ) + : read + ).pipe(Effect.tap((next) => Ref.set(current, next))), + writeEnabled: (pluginId: PluginId, enabled: boolean) => + write((current) => ({ + ...current, + enabled: { ...current.enabled, [pluginId]: enabled }, + })), + /** + * Forgets the switch of every plugin not in `found`, writing only if there are any. A switch + * left on by a folder deleted by hand would start the next folder with its id unseen. + */ + forgetMissing: (found: ReadonlySet) => { + const keep = (entries: Readonly>) => + Object.fromEntries(Object.entries(entries).filter(([id]) => found.has(id))); + const missing = (config: PluginsConfigFile) => + Object.keys(config.enabled ?? {}).some((id) => !found.has(id)); + return Ref.get(current).pipe( + Effect.flatMap((last) => + missing(last) + ? write((latest) => ({ + ...latest, + ...(latest.enabled === undefined ? {} : { enabled: keep(latest.enabled) }), + })) + : Effect.void, + ), + ); + }, + }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..9ca4e6835c7aa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,7 @@ import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -709,6 +710,11 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provide(NetService.layer), ); +// The plugin host sits on top of the runtime for its theme service. +const layerRuntimeDependenciesWithPlugins = PluginHost.layer.pipe( + Layer.provideMerge(layerRuntimeDependencies), +); + const layerCommandReadiness = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -1072,7 +1078,7 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); + }).pipe(Layer.provideMerge(layerRuntimeDependenciesWithPlugins), Layer.provide(layerLauncher)); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..c10d7ef9965cb 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -79,6 +79,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -529,6 +530,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(PluginHost.PluginHost)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..f7b7390cbcacd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,8 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import { makePluginRpcHandlers } from "./plugins/pluginRpcHandlers.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { @@ -1273,6 +1275,7 @@ export const layerWsRpc = ( const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const pluginHost = yield* PluginHost.PluginHost; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -1867,6 +1870,7 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.of({ + ...makePluginRpcHandlers(pluginHost, currentSession.scopes), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, diff --git a/apps/web/index.html b/apps/web/index.html index 707111143ccbd..1a8644231aad1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -198,8 +198,8 @@ (appearance === value.appearance || isRecord(colors)), ); }; - // Custom themes first, then the palettes the server last published, in the order the - // runtime looks them up. + // Custom themes first, then the palettes the server last published (a plugin's theme, + // say), in the order the runtime looks them up. const findCustomTheme = (themeId) => { if (RESERVED_THEME_IDS.includes(themeId)) return null; for (const storageKey of [CUSTOM_THEMES_STORAGE_KEY, ENVIRONMENT_THEMES_STORAGE_KEY]) { diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index bb6a896d45869..50890acb008ed 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -54,6 +54,9 @@ export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ "/settings/about", ]); +/** Pages whose settings belong to whole environments, so they have no project picker. */ +export const SETTINGS_ENVIRONMENT_ONLY_PATHS: ReadonlySet = new Set(["/settings/plugins"]); + interface SettingsScopeMenuProps { readonly value: SettingsScopeSearch; readonly groups: readonly SidebarProjectSnapshot[]; @@ -76,10 +79,14 @@ export function SettingsScopeSentence() { }; return (

- - Applying settings for - - + {SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname) ? ( + Applying settings + ) : ( + + Applying settings for + + + )} {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index ee061be1ebf78..32f9217494f02 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -13,6 +13,7 @@ import { BotIcon, createLucideIcon, CalendarClockIcon, + BlocksIcon, FileDiffIcon, FolderTreeIcon, GitBranchIcon, @@ -99,6 +100,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/browser": GlobeIcon, "/settings/devices": SmartphoneIcon, "/settings/scheduled-tasks": CalendarClockIcon, + "/settings/plugins": BlocksIcon, "/settings/source-control": GitBranchIcon, "/settings/worktrees": FolderTreeIcon, "/settings/connections": ServerIcon, diff --git a/apps/web/src/components/settings/plugins/PluginList.tsx b/apps/web/src/components/settings/plugins/PluginList.tsx new file mode 100644 index 0000000000000..80ee174e629db --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginList.tsx @@ -0,0 +1,58 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { useContext, useEffect } from "react"; + +import { pluginEnvironment } from "../../../state/plugins"; +import { formatEnvironmentQueryError } from "../../../state/query"; +import { SettingsRow, SettingsSection } from "../settingsLayout"; +import { PluginRow } from "./PluginRow"; + +/** Each plugin the environment has. */ +export function PluginList({ environmentId }: { readonly environmentId: EnvironmentId }) { + const result = useAtomValue(pluginEnvironment.pluginsLive({ environmentId, input: {} })); + const plugins = AsyncResult.getOrElse(result, () => null)?.plugins ?? null; + const error = result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null; + return ( + <> + + + {error === null ? null : } + {plugins === null && error === null ? ( + + ) : plugins?.length === 0 && error === null ? ( + + ) : ( + (plugins ?? []).map((plugin, index) => ( + + )) + )} + + + ); +} + +/** Has the server look at its plugins folder again, so folders copied there by hand are listed. */ +function ScanPluginsFolder({ environmentId }: { readonly environmentId: EnvironmentId }) { + const registry = useContext(RegistryContext); + const canRescan = useAtomValue(pluginEnvironment.rescanPlugins.permissionAtom(environmentId)); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The server reads its plugins folder only when asked, so opening the page asks it. + useEffect(() => { + if (!canRescan) return; + void runAtomCommand( + registry, + pluginEnvironment.rescanPlugins, + { environmentId, input: {} }, + { label: "plugin folder scan", reportFailure: false, reportDefect: false }, + ); + }, [registry, environmentId, canRescan]); + return null; +} diff --git a/apps/web/src/components/settings/plugins/PluginRow.tsx b/apps/web/src/components/settings/plugins/PluginRow.tsx new file mode 100644 index 0000000000000..08659ef686a48 --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginRow.tsx @@ -0,0 +1,93 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, PluginState } from "@t3tools/contracts"; +import { RotateCwIcon } from "lucide-react"; +import { useState } from "react"; + +import { pluginEnvironment } from "../../../state/plugins"; +import { useAtomCommand } from "../../../state/use-atom-command"; +import { Button } from "../../ui/button"; +import { Switch } from "../../ui/switch"; +import { SettingsRow } from "../settingsLayout"; + +const FAILURE_STAGES = { + discovery: "Not found", + duplicate: "Duplicate id", + manifest: "Invalid manifest", + compatibility: "Incompatible", +} as const; + +export function PluginRow({ + environmentId, + plugin, +}: { + readonly environmentId: EnvironmentId; + readonly plugin: PluginState; +}) { + const canManage = useAtomValue(pluginEnvironment.setPluginEnabled.permissionAtom(environmentId)); + const setEnabled = useAtomCommand(pluginEnvironment.setPluginEnabled, "plugin enabled"); + const reload = useAtomCommand(pluginEnvironment.reloadPlugin, "plugin reload"); + const [busy, setBusy] = useState(false); + const whileBusy = async (action: () => Promise) => { + setBusy(true); + await action(); + setBusy(false); + }; + // Actions send only the id, which names the copy in use. + const duplicate = plugin.failure?.stage === "duplicate"; + const { failure } = plugin; + const problems = + failure === undefined ? [] : [`${FAILURE_STAGES[failure.stage]}: ${failure.message}`]; + return ( + + {plugin.description} + {plugin.permissions.length > 0 ? ( + + Says it uses: {plugin.permissions.join(", ")}. T3 Code doesn't enforce this. + + ) : null} + + } + status={ + problems.length === 0 ? undefined : ( +

+ {problems.map((problem) => ( + + {problem} + + ))} +
+ ) + } + control={ + duplicate ? undefined : ( + <> + + + void whileBusy(() => + setEnabled({ environmentId, input: { pluginId: plugin.id, enabled } }), + ) + } + /> + + ) + } + /> + ); +} diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx new file mode 100644 index 0000000000000..10e9f725e3676 --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -0,0 +1,58 @@ +import { BlocksIcon } from "lucide-react"; + +import type { EnvironmentPresentation } from "../../../state/environments"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../../ui/empty"; +import { useSettingsScope } from "../SettingsScopeContext"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "../settingsLayout"; +import { PluginList } from "./PluginList"; +import { runsPlugins } from "./pluginTargets"; + +export function PluginsSettings() { + const { scope, environment } = useSettingsScope(); + if (scope.kind === "unavailable") { + return ( + + + + + + ); + } + if (environment === null) { + return ( + + + + + + + + No environment selected + Connect an environment to manage its plugins. + + + + + ); + } + return ; +} + +/** Lists the environment's plugins. */ +function PluginsPage({ environment }: { readonly environment: EnvironmentPresentation }) { + const runs = runsPlugins(environment); + return ( + + {runs ? ( + + ) : ( + + + + )} + + ); +} diff --git a/apps/web/src/components/settings/plugins/pluginTargets.ts b/apps/web/src/components/settings/plugins/pluginTargets.ts new file mode 100644 index 0000000000000..9d160bdcbc6d8 --- /dev/null +++ b/apps/web/src/components/settings/plugins/pluginTargets.ts @@ -0,0 +1,5 @@ +import type { EnvironmentPresentation } from "../../../state/environments"; + +export const runsPlugins = (environment: EnvironmentPresentation) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.plugins === true; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aabfd0300eda5..e7c05ed38e2a6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -34,6 +34,7 @@ export type SettingsPath = | "/settings/connections" | "/settings/access" | "/settings/secrets" + | "/settings/plugins" | "/settings/maintenance" | "/settings/labs" | "/settings/about" @@ -104,6 +105,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/connections": "Environments", "/settings/access": "Sharing & access", "/settings/secrets": "Secrets", + "/settings/plugins": "Plugins", "/settings/maintenance": "Maintenance", "/settings/labs": "Labs", "/settings/about": "About", @@ -150,7 +152,13 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ }, { heading: null, - paths: ["/settings/maintenance", "/settings/labs", "/settings/about", "/settings/archived"], + paths: [ + "/settings/plugins", + "/settings/maintenance", + "/settings/labs", + "/settings/about", + "/settings/archived", + ], }, ]; @@ -830,6 +838,12 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["allow disable enable open drive preview tools sessions project override"], }, + { + id: "plugins", + title: "Plugins", + to: "/settings/plugins", + searchTerms: ["plugins extensions add-ons enable disable reload plugin settings"], + }, { id: "device-sources", title: "Device sources", @@ -1197,6 +1211,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly { expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); expect(themeAllowsSidebarArtwork("nightfall")).toBe(false); - // A published theme can keep the sidebar artwork, but not on the send button, and can set - // T3's corner radius and status colors. + // A published theme, such as a plugin's, can keep the sidebar artwork, but not on the send + // button, and can set T3's corner radius and status colors. publish(appAtomRegistry, [ { ...NIGHTFALL_THEME, @@ -206,7 +206,7 @@ describe("environment themes", () => { statusColors: { dark: { info: "#0969da" } }, }); - // The server no longer publishes it, so its first themes event is empty. + // The plugin that published it is gone: the server's first themes event is empty. publish(registry, []); expect(palette.getThemeDefinition("nightfall")).toBeNull(); expect(store.has(ENVIRONMENT_THEMES_STORAGE_KEY)).toBe(false); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b49fc6fc542f3..f766772a53fcf 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1587,8 +1587,8 @@ html[data-theme-id] [data-app-sidebar] { } } -/* A published theme also colors the sidebar's row metadata, like thread times. Built-in themes - keep the sidebar's own step below read titles. */ +/* A published theme, such as a plugin's, also colors the sidebar's row metadata, like thread + times. Built-in themes keep the sidebar's own step below read titles. */ html[data-theme-published] [data-app-sidebar] { --secondary-label: var(--app-theme-secondary-label); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0e26ab6edd879..fd4ce2027f1e6 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as ChatSettingsSecretsRouteImport } from './routes/_chat.settings import { Route as ChatSettingsScheduledTasksRouteImport } from './routes/_chat.settings.scheduled-tasks' import { Route as ChatSettingsProvidersRouteImport } from './routes/_chat.settings.providers' import { Route as ChatSettingsProjectsRouteImport } from './routes/_chat.settings.projects' +import { Route as ChatSettingsPluginsRouteImport } from './routes/_chat.settings.plugins' import { Route as ChatSettingsOpenSourceLicensesRouteImport } from './routes/_chat.settings.open-source-licenses' import { Route as ChatSettingsMaintenanceRouteImport } from './routes/_chat.settings.maintenance' import { Route as ChatSettingsLabsRouteImport } from './routes/_chat.settings.labs' @@ -156,6 +157,11 @@ const ChatSettingsProjectsRoute = ChatSettingsProjectsRouteImport.update({ path: '/projects', getParentRoute: () => ChatSettingsRoute, } as any) +const ChatSettingsPluginsRoute = ChatSettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsOpenSourceLicensesRoute = ChatSettingsOpenSourceLicensesRouteImport.update({ id: '/open-source-licenses', @@ -286,6 +292,7 @@ export interface FileRoutesByFullPath { '/settings/labs': typeof ChatSettingsLabsRoute '/settings/maintenance': typeof ChatSettingsMaintenanceRoute '/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/settings/plugins': typeof ChatSettingsPluginsRoute '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute @@ -327,6 +334,7 @@ export interface FileRoutesByTo { '/settings/labs': typeof ChatSettingsLabsRoute '/settings/maintenance': typeof ChatSettingsMaintenanceRoute '/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/settings/plugins': typeof ChatSettingsPluginsRoute '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute @@ -370,6 +378,7 @@ export interface FileRoutesById { '/_chat/settings/labs': typeof ChatSettingsLabsRoute '/_chat/settings/maintenance': typeof ChatSettingsMaintenanceRoute '/_chat/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/_chat/settings/plugins': typeof ChatSettingsPluginsRoute '/_chat/settings/projects': typeof ChatSettingsProjectsRoute '/_chat/settings/providers': typeof ChatSettingsProvidersRoute '/_chat/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute @@ -413,6 +422,7 @@ export interface FileRouteTypes { | '/settings/labs' | '/settings/maintenance' | '/settings/open-source-licenses' + | '/settings/plugins' | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' @@ -454,6 +464,7 @@ export interface FileRouteTypes { | '/settings/labs' | '/settings/maintenance' | '/settings/open-source-licenses' + | '/settings/plugins' | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' @@ -496,6 +507,7 @@ export interface FileRouteTypes { | '/_chat/settings/labs' | '/_chat/settings/maintenance' | '/_chat/settings/open-source-licenses' + | '/_chat/settings/plugins' | '/_chat/settings/projects' | '/_chat/settings/providers' | '/_chat/settings/scheduled-tasks' @@ -666,6 +678,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsProjectsRouteImport parentRoute: typeof ChatSettingsRoute } + '/_chat/settings/plugins': { + id: '/_chat/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof ChatSettingsPluginsRouteImport + parentRoute: typeof ChatSettingsRoute + } '/_chat/settings/open-source-licenses': { id: '/_chat/settings/open-source-licenses' path: '/open-source-licenses' @@ -820,6 +839,7 @@ interface ChatSettingsRouteChildren { ChatSettingsLabsRoute: typeof ChatSettingsLabsRoute ChatSettingsMaintenanceRoute: typeof ChatSettingsMaintenanceRoute ChatSettingsOpenSourceLicensesRoute: typeof ChatSettingsOpenSourceLicensesRoute + ChatSettingsPluginsRoute: typeof ChatSettingsPluginsRoute ChatSettingsProjectsRoute: typeof ChatSettingsProjectsRoute ChatSettingsProvidersRoute: typeof ChatSettingsProvidersRoute ChatSettingsScheduledTasksRoute: typeof ChatSettingsScheduledTasksRoute @@ -849,6 +869,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsLabsRoute: ChatSettingsLabsRoute, ChatSettingsMaintenanceRoute: ChatSettingsMaintenanceRoute, ChatSettingsOpenSourceLicensesRoute: ChatSettingsOpenSourceLicensesRoute, + ChatSettingsPluginsRoute: ChatSettingsPluginsRoute, ChatSettingsProjectsRoute: ChatSettingsProjectsRoute, ChatSettingsProvidersRoute: ChatSettingsProvidersRoute, ChatSettingsScheduledTasksRoute: ChatSettingsScheduledTasksRoute, diff --git a/apps/web/src/routes/_chat.settings.plugins.tsx b/apps/web/src/routes/_chat.settings.plugins.tsx new file mode 100644 index 0000000000000..5eaa42d006027 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.plugins.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PluginsSettings } from "../components/settings/plugins/PluginsSettings"; + +export const Route = createFileRoute("/_chat/settings/plugins")({ component: PluginsSettings }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7758c9ca55919..01ec984edcf5e 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -14,7 +14,10 @@ import { } from "../components/settings/SettingsScopeContext"; import { useEnvironments } from "../state/environments"; import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; -import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; +import { + SETTINGS_DEVICE_ONLY_PATHS, + SETTINGS_ENVIRONMENT_ONLY_PATHS, +} from "../components/settings/SettingsScopeSentence"; import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { retainSettingsScope, @@ -111,7 +114,9 @@ function SettingsContentLayout() { const location = useLocation(); const { search, environment } = useSettingsScope(); const contentScopeKey = - location.pathname === "/settings/diagnostics" || location.pathname === "/settings/providers" + location.pathname === "/settings/diagnostics" || + location.pathname === "/settings/providers" || + location.pathname === "/settings/plugins" ? environment?.environmentId : JSON.stringify(search); const [restoreSignal, setRestoreSignal] = useState(0); @@ -149,8 +154,14 @@ function SettingsRouteLayout() { const pathname = useLocation({ select: (location) => location.pathname }); return ( { // Send every axis so retain middleware recognizes "all" as an explicit target. void navigate({ diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts new file mode 100644 index 0000000000000..5d2dc7ac5028c --- /dev/null +++ b/apps/web/src/state/plugins.ts @@ -0,0 +1,5 @@ +import { createPluginEnvironmentAtoms } from "@t3tools/client-runtime/state/plugins"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const pluginEnvironment = createPluginEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 4d239d53d7c0e..78578c41a5817 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -100,16 +100,16 @@ describe("theme files", () => { expect(legacy.colors.searchMatchBackground).toBe( getDefaultThemeColors("dark").searchMatchBackground, ); - // A published theme's file imports too; its status colors and radius stay with the original. - const published = parseThemeFile({ + // A plugin's theme file imports too; its status colors and radius stay with the plugin. + const fromPlugin = parseThemeFile({ version: THEME_FILE_VERSION, - name: "Published", + name: "From plugin", appearance: "dark", radius: "0.625rem", colors: { canvas: "#151515", success: "#3fb950" }, }); - expect(published).not.toHaveProperty("radius"); - expect(published).not.toHaveProperty("statusColors"); + expect(fromPlugin).not.toHaveProperty("radius"); + expect(fromPlugin).not.toHaveProperty("statusColors"); const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Search", diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 4fc86881f411c..031fba313ebdd 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -366,8 +366,8 @@ const publishedThemesAtom = Atom.make((get): ReadonlyArray => }).pipe(Atom.withLabel("web-published-environment-themes")); // The server sends its palettes as their own event after it connects. Until the first one, -// the palettes it sent last time stand in, so a published theme paints from the first frame, -// and the boot screen in index.html reads the same copy. +// the palettes it sent last time stand in, so a published theme (a plugin's, say) paints from +// the first frame, and the boot screen in index.html reads the same copy. const environmentThemesKnownAtom = Atom.make(false).pipe(Atom.keepAlive); function readStoredEnvironmentThemes(): ReadonlyArray { @@ -440,9 +440,10 @@ export function startEnvironmentThemeCache(): () => void { } /** - * An imported theme file whose id a published theme already uses, renamed to a copy. A saved theme - * wins an id collision on purpose, so editing a published theme overrides it, but importing the - * published theme's own file would otherwise hide it and its radius and status colors. + * An imported theme file whose id a published theme, such as a plugin's, already uses, renamed to a + * copy. A saved theme wins an id collision on purpose, so editing a published theme overrides it, + * but importing a plugin's own file would otherwise hide the plugin's theme and its radius and + * status colors. */ export function asImportedThemeCopy(theme: ThemeDefinition): ThemeDefinition { const published = getEnvironmentThemes(); @@ -1320,7 +1321,7 @@ export function themeAllowsComposerArtwork(theme: ThemePreference): boolean { ); } -/** Built-in themes and published ones can allow it; custom themes can't. */ +/** Built-in themes and published ones, such as a plugin's, can allow it; custom themes can't. */ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { const themeId = themeIdFromPreference(theme); const definition = diff --git a/docs/README.md b/docs/README.md index af3c45ce567b1..5dcd957c81990 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Project settings](./user/project-settings.md) - [Appearance and themes](./user/appearance.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Plugins](./user/plugins.md) - [SnapShots](./user/snap-shot.md) - [Visual replies](./user/html-renders.md) - [Import browser sessions](./user/browser-import.md) @@ -57,6 +58,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Terminal runtime](./internals/terminal-runtime.md) - [Devices](./internals/devices.md) - [Voice input](./internals/voice-input.md) +- [Plugins](./internals/plugins.md) ### Runbooks diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md new file mode 100644 index 0000000000000..c260f56b50d09 --- /dev/null +++ b/docs/internals/plugins.md @@ -0,0 +1,35 @@ +# Plugins + +A plugin is a folder with a `t3-plugin.json` manifest +([schema](../../packages/contracts/src/plugins.ts)). +[`PluginHost`](../../apps/server/src/plugins/PluginHost.ts) finds plugins, keeps them on or off +and publishes their themes. + +## Manifests + +Manifests ignore keys they don't know, so a newer manifest key is lost silently on an older T3. +`engines.t3` is how a plugin requires a newer T3: the server refuses to start a version outside it. + +## State lives beside T3's + +`/plugins.json` holds enabled flags and dev paths. They stay out of `settings.json`, whose +writer drops keys it doesn't know, and out of `statev2.sqlite`, whose numbered migrations refuse to +start when a fork's id collides with an upstream one. + +## Installing + +Discovery takes the id from the manifest, because people copy plugin folders in by hand. The +enabled flag belongs to the id, so a later folder with an id already listed fails as a duplicate. +Installed folders come first, in name order, then dev paths. Discovery also forgets the enabled flag +of every id no folder has, so a folder copied in later with that id starts off. Nothing watches +`plugins/`: the server reads it at startup, on reload and when Settings asks. + +## Clients + +`plugins.subscribeStates` is a stream of its own because the server config stream would resend the +whole config on every plugin change. The server starts plugins after it is listening. The mobile +app shows no plugin settings or themes. + +Plugin themes arrive over the server config stream after the first paint, so the client saves them +in localStorage for the boot script in `index.html`. The desktop main process cannot read +localStorage, so the page sends it the canvas it paints under a light and a dark native theme. diff --git a/docs/user/plugins.md b/docs/user/plugins.md new file mode 100644 index 0000000000000..1e01538702e78 --- /dev/null +++ b/docs/user/plugins.md @@ -0,0 +1,37 @@ +# Plugins + +Plugins change how T3 Code looks, with themes. A plugin belongs to one environment and lives on +that environment's T3 Code server. A plugin's row lists the permissions it says it uses, but T3 +Code doesn't enforce them. + +## Add a plugin + +Plugin folders you copy into the `plugins` folder yourself show up, turned off, the next time you +open **Settings → Plugins**. That folder is `~/.t3/userdata/plugins` on the environment's machine +by default. A folder with the same plugin id as one you already have doesn't run, and its row names +the folder that has that id. + +**Settings → Plugins** shows one environment at a time, the main one until you pick another at the +top of the page. To use a plugin on several environments, add it to each of them. + +## Turn a plugin on or off + +Use the switch on the plugin's row. A plugin that fails to start shows the reason on its row, for +example when it needs a newer version of T3 Code. **Reload** restarts a plugin with its current +files. + +## Themes + +A plugin can change how T3 Code looks without running any code. Theme files in its `themes` folder +show up as themes while the plugin is on. Themes load from the app's main environment (see [Where +plugins live](#where-plugins-live)). + +## Where plugins live + +Installed plugins are in the `plugins` folder of the environment's T3 Code data directory, one +folder per plugin, and `plugins.json` records which ones are enabled. + +Plugin themes load in the web and desktop apps, from the app's main environment: the desktop app's +own server, or the server the web app was opened from. app.t3.codes and a desktop app with its local +environment turned off have no main environment, so they load no plugin themes. The mobile app +doesn't show plugin settings or themes. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..1fa3cb1cad77b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -191,6 +191,10 @@ "types": "./src/state/git.ts", "default": "./src/state/git.ts" }, + "./state/plugins": { + "types": "./src/state/plugins.ts", + "default": "./src/state/plugins.ts" + }, "./state/command-output": { "types": "./src/state/commandOutput.ts", "default": "./src/state/commandOutput.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 2832a4e231556..bf13882bd658d 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -59,6 +59,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.pluginsSubscribeStates | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.serverGetDatabaseStorage | typeof WS_METHODS.subscribeTerminalMetadata diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index adfad46a41b89..e820879eafb6f 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -96,11 +96,17 @@ describe("command permissions", () => { ), ); - it.effect("requires the destination settings grant to run storage cleanup", () => + it.effect.each([ + WS_METHODS.serverRunStorageCleanup, + // Enabling, reloading or rescanning plugins changes what every client shows. + WS_METHODS.pluginsSetEnabled, + WS_METHODS.pluginsReload, + WS_METHODS.pluginsRescan, + ])("requires the destination settings grant for %s", (method) => Effect.scoped( Effect.gen(function* () { const registry = yield* setup; - const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + const cleanup = createCommandPermissions(runtime, method); registry.set(sessions(env), AsyncResult.success(grant(true))); expect(registry.get(cleanup.permissionAtom(env))).toBe(false); expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( diff --git a/packages/client-runtime/src/state/plugins.ts b/packages/client-runtime/src/state/plugins.ts new file mode 100644 index 0000000000000..473eaf60a4a59 --- /dev/null +++ b/packages/client-runtime/src/state/plugins.ts @@ -0,0 +1,42 @@ +import { applyPluginStatesEvent, type PluginListResult, WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +const NO_PLUGINS: PluginListResult = { plugins: [] }; + +/** Plugin management. Nothing here runs until something reads it. */ +export function createPluginEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + // The server sends the list once, then the rows that change; readers get the whole list. + pluginsLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:plugins:live", + tag: WS_METHODS.pluginsSubscribeStates, + // `scan` emits its seed first; skipping it keeps the list loading until the first event. + transform: (events) => + events.pipe( + Stream.scan(() => NO_PLUGINS, applyPluginStatesEvent), + Stream.filter((list) => list !== NO_PLUGINS), + ), + }), + setPluginEnabled: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugins:set-enabled", + tag: WS_METHODS.pluginsSetEnabled, + }), + reloadPlugin: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugins:reload", + tag: WS_METHODS.pluginsReload, + }), + rescanPlugins: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugins:rescan", + tag: WS_METHODS.pluginsRescan, + }), + }; +} diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..387781106086f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -57,6 +57,10 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, + // Enabling a plugin changes what every client of the environment shows, such as its themes. + [WS_METHODS.pluginsSetEnabled]: AuthSettingsWriteScope, + [WS_METHODS.pluginsReload]: AuthSettingsWriteScope, + [WS_METHODS.pluginsRescan]: AuthSettingsWriteScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9d60bec43fe5c..a6e2afb23a278 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -105,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadForceStop: Schema.optionalKey(Schema.Boolean), /** Every protocol-2 server sends this; older mobile builds still read it. */ environmentThemes: Schema.optionalKey(Schema.Boolean), + /** Serves the `plugins.*` methods. Clients hide every plugin surface without it. */ + plugins: Schema.optionalKey(Schema.Boolean), usageLimitSources: Schema.optionalKey(Schema.Boolean), /** Honors `workspaceSnapshotsOnDemand`; older servers send every snapshot and drop them in the stream. */ providerWorkspaceSnapshotsOnDemand: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f00976d243b37..9138cdbb11982 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -62,6 +62,7 @@ export * from "./previewAutomation.ts"; export * from "./previewCapture.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; +export * from "./plugins.ts"; export * from "./worktreeMcp.ts"; export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/plugins.test.ts b/packages/contracts/src/plugins.test.ts new file mode 100644 index 0000000000000..53640279207fa --- /dev/null +++ b/packages/contracts/src/plugins.test.ts @@ -0,0 +1,40 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { PluginManifest } from "./plugins.ts"; + +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); + +const manifest = { + id: "hello-plugin", + name: "Hello", + version: "0.1.0", + engines: { t3: ">=0.1.6" }, + permissions: ["Reads thread titles"], +}; + +describe("PluginManifest", () => { + it("decodes a valid manifest", () => { + expect(decodeManifest(manifest)).toEqual(manifest); + expect(decodeManifest({ ...manifest, version: "1.0.0-beta.2+build.5" }).version).toBe( + "1.0.0-beta.2+build.5", + ); + expect(decodeManifest({ ...manifest, engines: { t3: "^0.1 || >=1.0.0 <2" } }).engines.t3).toBe( + "^0.1 || >=1.0.0 <2", + ); + }); + + it("loads a manifest written when it could have `contributes`, without the key", () => { + expect(decodeManifest({ ...manifest, contributes: { panels: [] } })).toEqual(manifest); + }); + + it.each([ + ["a missing id", { id: undefined }], + ["an id with uppercase letters", { id: "Hello" }], + ["an id with an underscore", { id: "hello_plugin" }], + ["a version that is not semver", { version: "1.0" }], + ["an engines range the host cannot evaluate", { engines: { t3: "~0.1.6" } }], + ])("rejects %s", (_name, override) => { + expect(() => decodeManifest({ ...manifest, ...override })).toThrow(); + }); +}); diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts new file mode 100644 index 0000000000000..6f564d0395547 --- /dev/null +++ b/packages/contracts/src/plugins.ts @@ -0,0 +1,148 @@ +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +// Lowercase because the id names the plugin's directory on case-insensitive filesystems. +export const PluginId = TrimmedNonEmptyString.check( + Schema.isMaxLength(32), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +).pipe(Schema.brand("PluginId")); +export type PluginId = typeof PluginId.Type; + +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +const PluginVersion = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(SEMVER_PATTERN, { expected: "a semver version such as 1.2.3" }), +); + +// The syntax `satisfiesSemverRange` in packages/shared/src/semver.ts evaluates. Anything else, +// such as `~1.2`, would never match, so it fails here with a reason instead. +const ENGINE_COMPARATOR = String.raw`(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}`; +const ENGINE_GROUP = String.raw`${ENGINE_COMPARATOR}(?:\s+${ENGINE_COMPARATOR})*`; +const PluginEngineRange = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(new RegExp(String.raw`^${ENGINE_GROUP}(?:\s*\|\|\s*${ENGINE_GROUP})*$`), { + expected: "a version range using ^, >=, >, <=, <, = and ||, such as >=0.1.6", + }), +); + +/** `t3-plugin.json` at the root of a plugin directory. */ +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(80)), + version: PluginVersion, + description: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(500))), + engines: Schema.Struct({ t3: PluginEngineRange }), + /** Shown to the user before enabling. Not enforced. */ + permissions: Schema.optionalKey( + Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(200))).check( + Schema.isMaxLength(32), + ), + ), +}); +export type PluginManifest = typeof PluginManifest.Type; + +/** `dev` is a folder in `devPaths`, loaded where it is. */ +export const PluginSource = Schema.Literals(["installed", "dev"]); +export type PluginSource = typeof PluginSource.Type; + +export const PluginStatus = Schema.Literals(["active", "disabled", "failed", "loading"]); +export type PluginStatus = typeof PluginStatus.Type; + +/** `duplicate` is a folder whose id an earlier folder already has. */ +export const PluginFailureStage = Schema.Literals([ + "discovery", + "duplicate", + "manifest", + "compatibility", +]); +export type PluginFailureStage = typeof PluginFailureStage.Type; + +export const PluginFailure = Schema.Struct({ + stage: PluginFailureStage, + message: Schema.String, +}); +export type PluginFailure = typeof PluginFailure.Type; + +/** What the server knows about one discovered plugin, including ones whose manifest failed. */ +export const PluginState = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString, + /** Absent when the manifest could not be decoded. */ + version: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + permissions: Schema.Array(Schema.String), + source: PluginSource, + enabled: Schema.Boolean, + status: PluginStatus, + failure: Schema.optionalKey(PluginFailure), + /** + * The folder it loads from: a `dev` plugin's as plugins.json lists it, an installed one's inside + * the plugins folder. + */ + folder: Schema.optionalKey(Schema.String), +}); +export type PluginState = typeof PluginState.Type; + +export const PluginListInput = Schema.Struct({}); +export type PluginListInput = typeof PluginListInput.Type; + +export const PluginListResult = Schema.Struct({ + plugins: ForwardCompatibleArray(PluginState), +}); +export type PluginListResult = typeof PluginListResult.Type; + +/** + * `plugins.subscribeStates`: the whole list first, then only the rows that differ from the event + * before, by position, so a change to one plugin does not resend every row. + */ +export const PluginStatesEvent = Schema.Union([ + Schema.TaggedStruct("snapshot", { + plugins: Schema.Array(PluginState), + }), + Schema.TaggedStruct("changed", { + /** How many plugins the list has now; rows past it are gone. */ + length: Schema.Number, + rows: Schema.Array(Schema.Struct({ index: Schema.Number, plugin: PluginState })), + }), +]); +export type PluginStatesEvent = typeof PluginStatesEvent.Type; + +/** The list after `event`, from the list before it. Rows the event leaves out keep their value. */ +export const applyPluginStatesEvent = ( + current: PluginListResult, + event: PluginStatesEvent, +): PluginListResult => { + if (event._tag === "snapshot") return { plugins: event.plugins }; + const plugins = current.plugins.slice(0, event.length); + for (const { index, plugin } of event.rows) plugins[index] = plugin; + return { plugins }; +}; + +export const PluginSetEnabledInput = Schema.Struct({ + pluginId: PluginId, + enabled: Schema.Boolean, +}); +export type PluginSetEnabledInput = typeof PluginSetEnabledInput.Type; + +export const PluginReloadInput = Schema.Struct({ + pluginId: PluginId, +}); +export type PluginReloadInput = typeof PluginReloadInput.Type; + +export const PluginRescanInput = Schema.Struct({}); +export type PluginRescanInput = typeof PluginRescanInput.Type; + +/** Result of `plugins.setEnabled` and `plugins.reload`. */ +export const PluginMutationResult = Schema.Struct({ + plugin: PluginState, +}); +export type PluginMutationResult = typeof PluginMutationResult.Type; + +export class PluginError extends Schema.TaggedError()("PluginError", { + message: Schema.String, + pluginId: Schema.optionalKey(PluginId), +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 37ea5866ae733..01d633e328078 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -375,6 +375,16 @@ import { ScheduledTaskUpsertInput, ScheduledTaskMutationResult, } from "./scheduledTask.ts"; +import { + PluginError, + PluginListInput, + PluginListResult, + PluginRescanInput, + PluginMutationResult, + PluginReloadInput, + PluginSetEnabledInput, + PluginStatesEvent, +} from "./plugins.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; import { ProjectCloneActionInput, @@ -548,6 +558,12 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + pluginsList: "plugins.list", + pluginsSubscribeStates: "plugins.subscribeStates", + pluginsSetEnabled: "plugins.setEnabled", + pluginsReload: "plugins.reload", + pluginsRescan: "plugins.rescan", + cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -1950,6 +1966,41 @@ const WsScheduledTasksGetWebhookDeliveryRpc = Rpc.make( }, ); +const PluginRpcError = Schema.Union([PluginError, EnvironmentAuthorizationError]); + +const WsPluginsListRpc = Rpc.make(WS_METHODS.pluginsList, { + payload: PluginListInput, + success: PluginListResult, + error: PluginRpcError, +}); + +/** The current list, then the rows that change. */ +const WsPluginsSubscribeStatesRpc = Rpc.make(WS_METHODS.pluginsSubscribeStates, { + payload: PluginListInput, + success: PluginStatesEvent, + error: PluginRpcError, + stream: true, +}); + +const WsPluginsSetEnabledRpc = Rpc.make(WS_METHODS.pluginsSetEnabled, { + payload: PluginSetEnabledInput, + success: PluginMutationResult, + error: PluginRpcError, +}); + +const WsPluginsReloadRpc = Rpc.make(WS_METHODS.pluginsReload, { + payload: PluginReloadInput, + success: PluginMutationResult, + error: PluginRpcError, +}); + +/** Looks at the plugins folder again, so plugin folders put there since are listed. */ +const WsPluginsRescanRpc = Rpc.make(WS_METHODS.pluginsRescan, { + payload: PluginRescanInput, + success: PluginListResult, + error: PluginRpcError, +}); + const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, @@ -2035,6 +2086,11 @@ export const WsRpcGroup = RpcGroup.make( WsSecretsAnswerRequestRpc, WsScheduledTasksListWebhookDeliveriesRpc, WsScheduledTasksGetWebhookDeliveryRpc, + WsPluginsListRpc, + WsPluginsSubscribeStatesRpc, + WsPluginsSetEnabledRpc, + WsPluginsReloadRpc, + WsPluginsRescanRpc, WsServerReportClientActivityRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md new file mode 100644 index 0000000000000..5094c2f8c5e52 --- /dev/null +++ b/packages/plugin-sdk/README.md @@ -0,0 +1,35 @@ +# T3 Code plugins + +A plugin is a folder with a `t3-plugin.json` manifest and theme files that change how T3 Code looks. +It runs no code. + +## Manifest + +```json +{ + "id": "hello-web", + "name": "Hello web", + "version": "0.1.0", + "description": "Shown on the Plugins page.", + "engines": { "t3": ">=0.1.6" } +} +``` + +- `id` uses lowercase letters, digits and hyphens, starts with a letter and has up to 32 + characters. It names the plugin's folder. +- `engines.t3` is a version range made of `^`, `>=`, `>`, `<=`, `<`, `=` and `||`, such as + `>=0.1.6`. A server outside it refuses to start the plugin, and says why. +- `permissions` is an optional list of what the plugin says it uses. Settings → Plugins shows it on + the plugin's row, and T3 Code doesn't enforce it. +- A `themes/` folder of theme JSON files is published while the plugin is active. T3 Code + remembers the published themes, so the boot screen and the desktop window start in the active + theme's colors. A theme that sets `"sidebarArtwork": true` keeps the nightly and dev artwork in + the sidebar, which T3 otherwise swaps for a label; the send button stays plain. `"radius"`, such + as `"0.625rem"`, sets T3's corner radius, and `success`, `successForeground`, `info` and + `infoForeground` in a theme's `colors` or `variants` set its status colors. + +## Developing a plugin + +Add the plugin's folder to `devPaths` in `plugins.json` in the T3 Code data directory, turn the +plugin on in Settings → Plugins, and use **Reload** after each change. A linked folder whose id an +installed plugin already has fails as a duplicate, so remove the installed copy first. From c9a26b67c8ed290059a95cd7f8fd76a89cac971f Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 02:31:16 +0000 Subject: [PATCH 03/11] feat(plugins): plugins restyle the app with a styles/ folder, and each browser picks where plugin UI loads from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes can't restyle one part of the app, and plugin CSS aimed at T3's class names breaks with most releases. PluginHost serves an active plugin's `styles/` at `/api/plugins//…` from the plugin's record, so a failed or stopped plugin serves nothing. Once the app is idle, a small chunk adds those sheets after T3's CSS. The main areas get `data-t3-region` names, each tested on its element. "Load plugin UI from", kept per browser, picks the environments whose styles and themes load. With no plugins, the plugin system adds about 5.7 KB of minified JS to the startup chunks, about 2.1 KB gzipped, and no new chunks. A 1.2 KB watcher loads once the app is idle. Surfaces: Settings → Plugins in web and desktop; mobile shows none. No providers or agents. Contracts: `styles`, `fromPlugin`. Reverse: each environment turns off. Connections: file GETs sign in like `/api` reads, with a DPoP proof each over T3 Connect. Docs updated. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environmentTheme.test.ts | 7 +- apps/server/src/environmentTheme.ts | 2 +- apps/server/src/http.ts | 2 +- apps/server/src/plugins/PluginHost.ts | 70 +++++++- .../src/plugins/pluginHostTestHarness.ts | 6 +- apps/server/src/plugins/pluginStates.ts | 4 + .../server/src/plugins/pluginWebFiles.test.ts | 166 ++++++++++++++++++ apps/server/src/plugins/pluginWebFiles.ts | 128 ++++++++++++++ apps/server/src/server.ts | 1 + .../src/components/ChatView.browser.test.tsx | 68 ++++++- .../CommandPalette.browser.test.tsx | 4 + apps/web/src/components/CommandPalette.tsx | 1 + apps/web/src/components/NoProjectsHero.tsx | 5 +- .../ThreadTerminalDrawer.browser.test.tsx | 4 + apps/web/src/components/chat/ChatComposer.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 2 +- .../chat/ThreadDetailsCard.browser.test.tsx | 5 + .../src/components/chat/ThreadDetailsCard.tsx | 1 + .../settings/plugins/PluginList.tsx | 13 +- .../components/settings/plugins/PluginRow.tsx | 8 +- .../plugins/PluginUiEnvironmentRow.tsx | 79 +++++++++ .../settings/plugins/PluginsSettings.tsx | 5 +- .../components/ui/sidebar.browser.test.tsx | 5 + apps/web/src/components/ui/sidebar.tsx | 4 + apps/web/src/lib/workspaceFocus.ts | 24 ++- apps/web/src/plugins/PluginLoaderStarter.tsx | 60 +++++++ apps/web/src/plugins/pluginFiles.ts | 86 +++++++++ apps/web/src/plugins/pluginFollow.test.ts | 27 +++ apps/web/src/plugins/pluginFollow.ts | 103 +++++++++++ apps/web/src/plugins/pluginRegistry.ts | 42 +++++ .../src/plugins/pluginStyles.browser.test.ts | 97 ++++++++++ apps/web/src/plugins/pluginStyles.ts | 115 ++++++++++++ apps/web/src/plugins/pluginWatch.ts | 34 ++++ apps/web/src/routes/__root.tsx | 3 + apps/web/src/routes/_chat.index.tsx | 2 +- apps/web/src/themePalette.ts | 23 ++- docs/internals/plugins.md | 20 ++- docs/user/plugins.md | 24 ++- .../src/state/environmentHttpAuth.ts | 3 + packages/client-runtime/src/state/plugins.ts | 16 ++ packages/contracts/src/plugins.ts | 7 + packages/contracts/src/server.ts | 2 + packages/plugin-sdk/README.md | 40 ++++- .../fixtures/styles-plugin/styles/base.css | 4 + .../fixtures/styles-plugin/styles/compact.css | 3 + .../fixtures/styles-plugin/t3-plugin.json | 7 + 46 files changed, 1294 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/plugins/pluginWebFiles.test.ts create mode 100644 apps/server/src/plugins/pluginWebFiles.ts create mode 100644 apps/web/src/components/settings/plugins/PluginUiEnvironmentRow.tsx create mode 100644 apps/web/src/plugins/PluginLoaderStarter.tsx create mode 100644 apps/web/src/plugins/pluginFiles.ts create mode 100644 apps/web/src/plugins/pluginFollow.test.ts create mode 100644 apps/web/src/plugins/pluginFollow.ts create mode 100644 apps/web/src/plugins/pluginRegistry.ts create mode 100644 apps/web/src/plugins/pluginStyles.browser.test.ts create mode 100644 apps/web/src/plugins/pluginStyles.ts create mode 100644 apps/web/src/plugins/pluginWatch.ts create mode 100644 packages/plugin-sdk/fixtures/styles-plugin/styles/base.css create mode 100644 packages/plugin-sdk/fixtures/styles-plugin/styles/compact.css create mode 100644 packages/plugin-sdk/fixtures/styles-plugin/t3-plugin.json diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index 8e392ed7e258b..4ab2a66767c5c 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -108,11 +108,12 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ); yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + // Marked, so clients show a plugin's themes only where they load its UI. assert.deepEqual( - (yield* currentThemes).map((theme) => [theme.id, theme.name]), + (yield* currentThemes).map((theme) => [theme.id, theme.name, theme.fromPlugin]), [ - ["nightfall", "Nightfall"], - ["shared-light", "Shared Light"], + ["nightfall", "Nightfall", undefined], + ["shared-light", "Shared Light", true], ], ); diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 5306c37c320b3..c47943bbe4671 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -191,7 +191,7 @@ const make = Effect.gen(function* () { const themes: Array = []; for (const directory of directories) { if (!(yield* insideItsPlugin(directory))) continue; - themes.push(...(yield* readThemes(directory))); + for (const theme of yield* readThemes(directory)) themes.push({ ...theme, fromPlugin: true }); } return themes; }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e792a233c6f06..a92d035e85c45 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -288,7 +288,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = +export const authenticateRawRouteWithScope = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index f83601c935925..85eb0b28a1aa2 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -1,5 +1,6 @@ import { type AuthEnvironmentScope, + AuthOrchestrationReadScope, PluginId, type PluginListResult, type PluginState, @@ -18,13 +19,21 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/http"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import { authenticateRawRouteWithScope } from "../http.ts"; import { forkParked } from "../serverActivation.ts"; import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; +import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; export class PluginNotFoundError extends Schema.TaggedError()( "PluginNotFoundError", @@ -67,6 +76,15 @@ export class PluginHost extends Context.Service< { readonly plugins: ReadonlyArray }, PluginConfigError >; + /** + * What an active plugin serves at `/api/plugins/`: a file in its `styles/`. A + * `revision` that names the file's contents lets it be cached. + */ + readonly readWebFile: (input: { + readonly pluginId: string; + readonly path: string; + readonly revision: string | undefined; + }) => Effect.Effect>; } >()("t3/plugins/PluginHost") {} @@ -82,6 +100,7 @@ const make = Effect.gen(function* () { const pluginsDir = path.join(config.stateDir, "plugins"); const pluginsConfig = yield* makePluginsConfig; + const webFiles = yield* makePluginWebFiles; const records = yield* Ref.make>([]); const lifecycle = yield* Semaphore.make(1); @@ -124,12 +143,22 @@ const make = Effect.gen(function* () { }, }); } - return yield* setRecord(plugin.id, { status: "active" }); + const web = yield* webFiles.find(plugin); + return yield* setRecord(plugin.id, { + status: "active", + webFiles: web.folders, + styles: web.styles, + }); }); - /** Interrupts a pending activation; the caller sets the plugin's next status. */ + /** + * An active plugin's record stops serving files. The caller sets the plugin's next status. + */ const stopPlugin = Effect.fn("PluginHost.stop")(function* (pluginId: PluginId) { yield* FiberMap.remove(activations, pluginId); + const record = yield* findRecord(pluginId); + if (record?.status !== "active") return; + yield* setRecord(pluginId, { status: "disabled" }); }); const startPlugin = (plugin: DiscoveredPlugin) => @@ -297,8 +326,45 @@ const make = Effect.gen(function* () { setEnabled, reload, rescan: rescan(), + readWebFile: Effect.fn("PluginHost.readWebFile")(function* (request) { + const record = yield* findRecord(request.pluginId); + if (record?.status !== "active" || record.webFiles === undefined) return Option.none(); + return yield* webFiles.read(record.webFiles, request.path, request.revision); + }), }); return Context.make(PluginHost, service); }); export const layer = Layer.effectContext(make); + +const WEB_FILES_ROUTE_PREFIX = "/api/plugins"; + +/** `GET /api/plugins//`, answered with what the active plugin serves there. */ +export const layerWebFilesRoute = HttpRouter.add( + "GET", + `${WEB_FILES_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + // Same sign-in as reads on the rest of `/api`. + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const url = HttpServerRequest.toURL(yield* HttpServerRequest.HttpServerRequest); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const rest = url.value.pathname.slice(`${WEB_FILES_ROUTE_PREFIX}/`.length); + const slash = rest.indexOf("/"); + const host = yield* PluginHost; + const file = yield* host.readWebFile({ + pluginId: slash === -1 ? rest : rest.slice(0, slash), + path: slash === -1 ? "/" : rest.slice(slash), + revision: url.value.searchParams.get("v") ?? undefined, + }); + return Option.match(file, { + onNone: () => HttpServerResponse.text("Not Found", { status: 404 }), + onSome: ({ headers, body }) => HttpServerResponse.uint8Array(body, { headers }), + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts index 1d54c1f405e42..94fe2b4937cc0 100644 --- a/apps/server/src/plugins/pluginHostTestHarness.ts +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -1,5 +1,6 @@ // @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes plugins.json as plain JSON. -// The plugin host with a theme service that records what it publishes, for the host's tests. +// The plugin host with a theme service that records what it publishes, for its tests and the tests +// of the modules it builds. import type { PluginState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,6 +13,9 @@ import * as EnvironmentTheme from "../environmentTheme.ts"; import * as ServerConfig from "../config.ts"; import * as PluginHost from "./PluginHost.ts"; +export const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url) + .pathname; + export const layerConfig = () => Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-host-test-" })); diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts index 25732f7f6f4f0..6961d4cf8428d 100644 --- a/apps/server/src/plugins/pluginStates.ts +++ b/apps/server/src/plugins/pluginStates.ts @@ -18,12 +18,15 @@ import * as Stream from "effect/Stream"; import * as EnvironmentTheme from "../environmentTheme.ts"; import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; import type { PluginsConfigFile } from "./pluginsConfig.ts"; +import type { PluginWebFolders } from "./pluginWebFiles.ts"; /** What the host knows about one listed copy of a plugin. */ export interface PluginRecord { readonly plugin: DiscoveredPlugin; readonly status: PluginStatus; readonly failure?: PluginFailure; + readonly styles?: PluginState["styles"]; + readonly webFiles?: PluginWebFolders; } export const manifestOf = (plugin: DiscoveredPlugin): PluginManifest | undefined => @@ -73,6 +76,7 @@ const toState = (record: PluginRecord, config: PluginsConfigFile): PluginState = enabled: config.enabled?.[id] === true, status: record.status, ...(record.failure === undefined ? {} : { failure: record.failure }), + ...(record.styles === undefined || record.styles.length === 0 ? {} : { styles: record.styles }), }; }; diff --git a/apps/server/src/plugins/pluginWebFiles.test.ts b/apps/server/src/plugins/pluginWebFiles.test.ts new file mode 100644 index 0000000000000..5d084e4ca84bc --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.test.ts @@ -0,0 +1,166 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthSessionId, + PluginId, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerHost, + stateOf, + waitForStatuses, + writeFile, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; + +// What the host serves at `/api/plugins/`, if anything. +const served = ( + host: PluginHost.PluginHost["Service"], + pluginId: string, + path: string, + revision?: string, +) => host.readWebFile({ pluginId, path, revision }).pipe(Effect.map(Option.getOrUndefined)); + +const scopesByToken: Record> = { + "Bearer read": [AuthOrchestrationReadScope], + "Bearer none": [], +}; +const layerAuth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: (request) => { + const scopes = scopesByToken[request.headers.authorization ?? ""]; + return scopes === undefined + ? Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError()) + : Effect.succeed({ + sessionId: AuthSessionId.make("session"), + subject: "test", + method: "bearer-access-token", + scopes, + }); + }, +}); + +it.layer(NodeServices.layer)("PluginWebFiles", (it) => { + it.effect("signs in like reads on the rest of /api and answers 404 for what is not served", () => + Effect.gen(function* () { + const layerHostAnswering = Layer.mock(PluginHost.PluginHost)({ + readWebFile: ({ pluginId, path, revision }) => + Effect.succeed( + pluginId === "styles-plugin" + ? Option.some({ headers: {}, body: new TextEncoder().encode(`${path} ${revision}`) }) + : Option.none(), + ), + }); + const services = yield* Layer.build( + HttpRouter.serve(PluginHost.layerWebFilesRoute, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(layerHostAnswering), + Layer.provideMerge(layerAuth), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + const get = Effect.fn(function* (path: string, token?: string) { + const response = yield* client.execute( + HttpClientRequest.get(path).pipe( + token === undefined + ? (request) => request + : HttpClientRequest.setHeader("authorization", token), + ), + ); + return { status: response.status, body: yield* response.text }; + }); + + assert.strictEqual((yield* get("/api/plugins/styles-plugin/styles/base.css")).status, 401); + assert.strictEqual( + (yield* get("/api/plugins/styles-plugin/styles/base.css", "Bearer none")).status, + 403, + ); + assert.deepStrictEqual( + yield* get("/api/plugins/styles-plugin/styles/base.css?v=r1", "Bearer read"), + { status: 200, body: "/styles/base.css r1" }, + ); + assert.strictEqual( + (yield* get("/api/plugins/other-plugin/styles/base.css", "Bearer read")).status, + 404, + ); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(!symlinksSupported)("reads no file a link takes out of the plugin", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/linking`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "linking", + name: "Linking", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writeFile(`${stateDir}/outside/secret.css`, "Not the plugin's."); + yield* fs.symlink(`${stateDir}/outside/secret.css`, `${rootDir}/styles/secret.css`); + yield* writePluginsConfig({ enabled: { linking: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const state = stateOf(yield* waitForStatuses(host, { linking: "active" }), "linking"); + assert.deepStrictEqual( + state?.styles?.map(({ file }) => file), + ["base.css"], + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves the stylesheets of a plugin with no code while it is active", () => + Effect.gen(function* () { + const stylesPlugin = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [stylesPlugin]: true }, + devPaths: [`${fixturesDir}styles-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [stylesPlugin]: "active" }); + // In name order, which is the order clients apply them in. + const styles = stateOf(yield* host.list, stylesPlugin)?.styles ?? []; + assert.deepStrictEqual( + styles.map(({ file }) => file), + ["base.css", "compact.css"], + ); + const base = () => served(host, stylesPlugin, "/styles/base.css", styles[0]?.revision); + const response = yield* base(); + assert.strictEqual(response?.headers["content-type"], "text/css; charset=utf-8"); + assert.strictEqual( + response?.headers["cache-control"], + "private, max-age=31536000, immutable", + ); + assert.isUndefined(yield* served(host, stylesPlugin, "/styles/..%2Ft3-plugin.json")); + + yield* host.setEnabled(stylesPlugin, false); + assert.isUndefined(stateOf(yield* host.list, stylesPlugin)?.styles); + assert.isUndefined(yield* base()); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/pluginWebFiles.ts b/apps/server/src/plugins/pluginWebFiles.ts new file mode 100644 index 0000000000000..48f7e527b606c --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off - stylesheet revisions are sha256 digests. +import * as NodeCrypto from "node:crypto"; + +import type { PluginState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; + +const WEB_CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", +}; + +const contentRevision = (contents: Uint8Array) => + NodeCrypto.createHash("sha256").update(contents).digest("hex").slice(0, 16); + +/** What an active plugin serves: the real path of its `styles/`. */ +export interface PluginWebFolders { + readonly styles: string | undefined; +} + +export interface PluginWebFile { + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +/** Finds the stylesheets a starting plugin serves, and reads them for the host. */ +export const makePluginWebFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Reads a plain file whose real path stays inside the real folder `within`, so a path or link + // out of it, a link cycle, or a pipe reads nothing. Untraced: each caller has its span. + const readWithin = Effect.fnUntraced(function* (within: string, file: string) { + const resolved = yield* fs.realPath(file).pipe(Effect.option); + if (Option.isNone(resolved)) return Option.none(); + const real = resolved.value; + if (!real.startsWith(`${within}${path.sep}`)) return Option.none(); + const info = yield* fs.stat(real).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return Option.none(); + return yield* fs.readFile(real).pipe( + Effect.map((body) => Option.some({ real, body })), + Effect.orElseSucceed(() => Option.none()), + ); + }); + + // The real path of the plugin's `name` folder, unless a link takes it out of the plugin. + const folderOf = Effect.fnUntraced(function* (plugin: DiscoveredPlugin, name: "styles") { + const dir = yield* fs.realPath(path.join(plugin.rootDir, name)).pipe(Effect.option); + return Option.isSome(dir) && dir.value.startsWith(`${plugin.rootDir}${path.sep}`) + ? dir.value + : undefined; + }); + + // The `.css` files directly in `styles/`, in name order, each with the revision for its URL. + const stylesIn = Effect.fnUntraced(function* (dir: string) { + const names = yield* fs + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + // Only the ones it serves, so not a folder named like a stylesheet or a link out of `styles/`. + const styles = yield* Effect.forEach( + names.filter((name) => name.endsWith(".css")).toSorted(), + (file) => + readWithin(dir, path.join(dir, file)).pipe( + Effect.map( + Option.match({ + onNone: () => [], + onSome: ({ body }) => [{ file, revision: contentRevision(body) }], + }), + ), + ), + ); + return styles.flat(); + }); + + /** What a starting plugin serves: `styles/`. */ + const find = Effect.fn("PluginHost.findWebFiles")(function* (plugin: DiscoveredPlugin) { + const folders: PluginWebFolders = { styles: yield* folderOf(plugin, "styles") }; + const styles: PluginState["styles"] = + folders.styles === undefined ? [] : yield* stylesIn(folders.styles); + return { folders, styles }; + }); + + // The file `requestPath` names, the folder it has to stay inside, and whether a revision in + // its URL may name its contents. + const locate = (folders: PluginWebFolders, requestPath: string) => { + const [, rest] = /^\/styles\/(.*)$/.exec(requestPath) ?? []; + const dir = folders.styles; + if (dir === undefined || rest === undefined) return undefined; + try { + return { file: path.join(dir, decodeURIComponent(rest)), within: dir, revisioned: true }; + } catch { + // A malformed escape names no file. + return undefined; + } + }; + + /** + * Reads `/styles/` of an active plugin. A request whose `revision` names the file's + * contents may be cached for good. Untraced: the host's lookup has the span. + */ + const read = Effect.fnUntraced(function* ( + folders: PluginWebFolders, + requestPath: string, + revision: string | undefined, + ) { + const located = locate(folders, requestPath); + if (located === undefined) return Option.none(); + const found = yield* readWithin(located.within, located.file); + if (Option.isNone(found)) return Option.none(); + const { real, body } = found.value; + return Option.some({ + headers: { + "content-type": + WEB_CONTENT_TYPES[path.extname(real).toLowerCase()] ?? "application/octet-stream", + "cache-control": + located.revisioned && revision !== undefined && revision === contentRevision(body) + ? "private, max-age=31536000, immutable" + : "no-store", + }, + body, + }); + }); + + return { find, read }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9ca4e6835c7aa..fcbbc03b9c565 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -740,6 +740,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, GuestHttp.layer, + PluginHost.layerWebFilesRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 8fe98b78da113..5186197cf05dc 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -4,7 +4,9 @@ import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; import { AppRoot } from "~/AppRoot"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; @@ -62,3 +64,67 @@ it("a new draft does not load its reserved thread before the server lists it", a // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +it("marks the window's main areas with the region names plugin styles target", async () => { + using _environment = installFakeEnvironment([]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-regions"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { + threadId: ThreadId.make("thread-regions"), + }); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + + // Plugins style these by name, so renaming or moving one breaks them. The sidebar, thread + // details, terminal and command palette tests check the other names the SDK README lists. + expect(document.querySelector('[data-slot="sidebar-inset"]')).toHaveAttribute( + "data-t3-region", + "main", + ); + await expect + .element(page.getByRole("region", { name: "Conversation" })) + .toHaveAttribute("data-t3-region", "timeline"); + + // A region sits on the element that paints its area, so a plugin's plain rule recolors it. + const pluginSheet = document.createElement("style"); + pluginSheet.textContent = '[data-t3-region="composer"] { background: rgb(1, 2, 3); }'; + document.head.append(pluginSheet); + using _pluginSheet = { [Symbol.dispose]: () => pluginSheet.remove() }; + const composerBox = document.querySelector("[data-chat-composer-main-surface]"); + expect(composerBox && getComputedStyle(composerBox).backgroundColor).toBe("rgb(1, 2, 3)"); + + await userEvent.click(page.getByRole("button", { name: "Toggle right panel" })); + await expect + .element(page.getByRole("region", { name: "Workspace panel" })) + .toHaveAttribute("data-t3-region", "right-panel"); + + await router.navigate({ to: "/settings/general" }); + await expect + .element(page.getByRole("region", { name: "Settings content" })) + .toHaveAttribute("data-t3-region", "settings"); + await expect + .element(page.getByRole("navigation", { name: "Settings sections" })) + .toHaveAttribute("data-t3-region", "settings-nav"); + + await router.navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); + await expect + .element(page.getByRole("region", { name: "Pull requests" })) + .toHaveAttribute("data-t3-region", "pull-requests"); + + // The welcome page draws the home page under its setup dialog. + await router.navigate({ to: "/welcome" }); + const homeTitle = page.getByText("What should we work on?"); + await expect.element(homeTitle).toBeInTheDocument(); + expect(homeTitle.element().closest("[data-t3-region]")).toHaveAttribute("data-t3-region", "home"); +}); diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 3883a3e2258e9..122ede224f7e3 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -29,5 +29,9 @@ describe("CommandPalette open intents", () => { const input = page.getByPlaceholder("Search commands, projects, and threads..."); await expect.element(input).toHaveFocus(); await expect.element(input).toHaveValue("acme"); + // Plugin styles target the palette by this name. + await expect + .element(page.getByTestId("command-palette")) + .toHaveAttribute("data-t3-region", "command-palette"); }); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 486d80a988666..3e1f97e1a9819 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -340,6 +340,7 @@ function CommandPaletteDialog(props: { } className={cn("overflow-hidden", props.mode === "content" && "h-105")} data-command-palette="true" + data-t3-region="command-palette" data-palette-mode={props.mode} data-testid="command-palette" finalFocus={() => { diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index e6ebe6536c519..91432dfa5355e 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -22,7 +22,10 @@ export function NoProjectsHero() { role="main" className="h-dvh min-h-0 overflow-hidden overscroll-y-none" > -
+
{/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} {isElectron ? : null} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index a99434f6659c6..b36dfc175537c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -116,6 +116,10 @@ it("closes an exited terminal on its own thread after navigating to one reusing await using screen = await render( , ); + // Plugin styles target the drawer by this name. + await expect + .element(screen.getByRole("region", { name: "Terminal" })) + .toHaveAttribute("data-t3-region", "terminal"); using _timers = fakeTimers(); // The exit is seen once the surface loads, and its close waits for the next task. await act(async () => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..d2f4a039b6831 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3185,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4c28163766058..88c7ec2614a1c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1283,7 +1283,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ return ( <> {activityAnnouncer} -
+
); } diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index a8728d7879dd4..2cce1bb661c43 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -154,6 +154,11 @@ it("returns to full density when hidden items make the content fit", async () => await using screen = await render(); const card = page.getByRole("region", { name: "Thread details" }); await expect.element(card).toHaveAttribute("data-density", "compact"); + // Plugin styles target the card by this name, on the element that paints it. + expect(card.element().querySelector("[data-thread-details-card]")).toHaveAttribute( + "data-t3-region", + "thread-details", + ); await screen.rerender(); await expect.element(card).toHaveAttribute("data-density", "full"); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 9ffbc90de753d..0a3f8202d1db7 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -122,6 +122,7 @@ export function ThreadDetailsCard({ style={placement ? { maxHeight: height } : undefined} onContextMenu={onContextMenu} data-thread-details-card + data-t3-region="thread-details" >
{children(density)}
diff --git a/apps/web/src/components/settings/plugins/PluginList.tsx b/apps/web/src/components/settings/plugins/PluginList.tsx index 80ee174e629db..9bb3bae068a8e 100644 --- a/apps/web/src/components/settings/plugins/PluginList.tsx +++ b/apps/web/src/components/settings/plugins/PluginList.tsx @@ -2,15 +2,21 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { useContext, useEffect } from "react"; +import { type ReactNode, useContext, useEffect } from "react"; import { pluginEnvironment } from "../../../state/plugins"; import { formatEnvironmentQueryError } from "../../../state/query"; import { SettingsRow, SettingsSection } from "../settingsLayout"; import { PluginRow } from "./PluginRow"; -/** Each plugin the environment has. */ -export function PluginList({ environmentId }: { readonly environmentId: EnvironmentId }) { +/** The page's own settings, with `children` first, then each plugin the environment has. */ +export function PluginList({ + environmentId, + children, +}: { + readonly environmentId: EnvironmentId; + readonly children: ReactNode; +}) { const result = useAtomValue(pluginEnvironment.pluginsLive({ environmentId, input: {} })); const plugins = AsyncResult.getOrElse(result, () => null)?.plugins ?? null; const error = result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null; @@ -18,6 +24,7 @@ export function PluginList({ environmentId }: { readonly environmentId: Environm <> + {children} {error === null ? null : } {plugins === null && error === null ? ( diff --git a/apps/web/src/components/settings/plugins/PluginRow.tsx b/apps/web/src/components/settings/plugins/PluginRow.tsx index 08659ef686a48..6718781dd607d 100644 --- a/apps/web/src/components/settings/plugins/PluginRow.tsx +++ b/apps/web/src/components/settings/plugins/PluginRow.tsx @@ -3,6 +3,7 @@ import type { EnvironmentId, PluginState } from "@t3tools/contracts"; import { RotateCwIcon } from "lucide-react"; import { useState } from "react"; +import { pluginWebFailuresAtom } from "../../../plugins/pluginRegistry"; import { pluginEnvironment } from "../../../state/plugins"; import { useAtomCommand } from "../../../state/use-atom-command"; import { Button } from "../../ui/button"; @@ -27,6 +28,7 @@ export function PluginRow({ const setEnabled = useAtomCommand(pluginEnvironment.setPluginEnabled, "plugin enabled"); const reload = useAtomCommand(pluginEnvironment.reloadPlugin, "plugin reload"); const [busy, setBusy] = useState(false); + const webFailure = useAtomValue(pluginWebFailuresAtom(plugin.id)).get(environmentId); const whileBusy = async (action: () => Promise) => { setBusy(true); await action(); @@ -35,8 +37,10 @@ export function PluginRow({ // Actions send only the id, which names the copy in use. const duplicate = plugin.failure?.stage === "duplicate"; const { failure } = plugin; - const problems = - failure === undefined ? [] : [`${FAILURE_STAGES[failure.stage]}: ${failure.message}`]; + const problems = [ + ...(failure === undefined ? [] : [`${FAILURE_STAGES[failure.stage]}: ${failure.message}`]), + ...(webFailure === undefined ? [] : [`Styles failed: ${webFailure}`]), + ]; return (
@@ -611,6 +614,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { className, )} data-slot="sidebar-inset" + data-t3-region="main" {...props} /> ); diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts index e612a8ff54974..1db6790ec3c58 100644 --- a/apps/web/src/lib/workspaceFocus.ts +++ b/apps/web/src/lib/workspaceFocus.ts @@ -8,10 +8,32 @@ const FOCUS_BOUNDARY_SELECTOR = const FOCUSABLE_SELECTOR = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"], [tabindex]'; +/** + * Each area's `data-t3-region`, which plugin CSS targets. Plugins depend on these names, so they + * stay when an area's F6 name, label or element changes. A region goes on the element that paints + * the area, so an area painted by an element inside it is null here and that element names it. + */ +const T3_REGIONS = { + sidebar: null, + conversation: "timeline", + composer: null, + details: null, + panel: "right-panel", + terminal: "terminal", + home: null, + "pull-requests": "pull-requests", + settings: "settings", + "settings-nav": "settings-nav", +} as const; + /** Props that make an element one stop in the F6 cycle; `order` sets its place in the cycle. */ -export function workspaceAreaProps(name: string, order: number, label: string) { +export function workspaceAreaProps(name: keyof typeof T3_REGIONS, order: number, label: string) { + const region = T3_REGIONS[name]; return { "data-workspace-area": name, + // Left out rather than undefined, which would clear the region a component such as + // SidebarInset sets before spreading these props. + ...(region === null ? {} : { "data-t3-region": region }), "data-workspace-order": order, "aria-label": label, role: "region", diff --git a/apps/web/src/plugins/PluginLoaderStarter.tsx b/apps/web/src/plugins/PluginLoaderStarter.tsx new file mode 100644 index 0000000000000..925bab87b04bd --- /dev/null +++ b/apps/web/src/plugins/PluginLoaderStarter.tsx @@ -0,0 +1,60 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { pluginUiEnvironmentsAtom } from "./pluginFollow"; + +// Module level: React Compiler cannot compile a component that calls import() itself. +const loadPluginWatch = () => import("./pluginWatch"); +const loadPluginStyles = () => import("./pluginStyles"); + +const whenIdle = (run: () => void) => { + if (typeof window.requestIdleCallback === "function") { + const handle = window.requestIdleCallback(run); + return () => window.cancelIdleCallback(handle); + } + const handle = window.setTimeout(run, 1); + return () => window.clearTimeout(handle); +}; + +/** + * Follows the plugins of each environment this browser loads plugin UI from, the main one unless + * the user turned it off. + */ +export function PluginLoaderStarter() { + const environmentIds = useAtomValue(pluginUiEnvironmentsAtom); + return environmentIds.map((environmentId) => ( + + )); +} + +/** + * Once the app is idle, downloads the small stylesheet runtime only when an active plugin of the + * environment has a `styles` folder. + */ +function PluginEnvironmentLoader({ environmentId }: { readonly environmentId: EnvironmentId }) { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts and stops a runtime that lives outside React + useEffect(() => { + let cancelled = false; + const stops: Array<() => void> = []; + const cancelIdle = whenIdle(() => { + void loadPluginWatch().then(({ watchForPlugins, hasLoadableStyles }) => { + if (cancelled) return; + stops.push( + watchForPlugins(environmentId, hasLoadableStyles, () => { + void loadPluginStyles().then((module) => { + if (cancelled) return; + stops.push(module.startPluginStyles(environmentId)); + }); + }), + ); + }); + }); + return () => { + cancelled = true; + cancelIdle(); + for (const stop of stops) stop(); + }; + }, [environmentId]); + return null; +} diff --git a/apps/web/src/plugins/pluginFiles.ts b/apps/web/src/plugins/pluginFiles.ts new file mode 100644 index 0000000000000..9a3eea9cb44e7 --- /dev/null +++ b/apps/web/src/plugins/pluginFiles.ts @@ -0,0 +1,86 @@ +import type { PreparedHttpAuthorization } from "@t3tools/client-runtime/connection"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, PluginState } from "@t3tools/contracts"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { pluginEnvironment } from "../state/plugins"; +import { readPreparedConnection } from "../state/session"; + +// Bearer and cookie sessions can fetch plugin files directly. A DPoP proof needs the relay +// signer, which only the client runtime holds. +async function pluginFileRequestInit( + authorization: PreparedHttpAuthorization | null, + url: URL, +): Promise { + if (authorization === null) return { credentials: "include" }; + if (authorization._tag === "Bearer") { + return { headers: { authorization: `Bearer ${authorization.token}` } }; + } + const headers = await runAtomCommand( + appAtomRegistry, + pluginEnvironment.pluginFileHeaders, + { authorization, url: url.href }, + { reportFailure: false }, + ); + if (headers._tag !== "Success") + throw new Error("Could not authorize the request over T3 Connect."); + return { headers: { ...headers.value } }; +} + +class PluginFileRequestError extends Error { + constructor( + readonly status: number, + filePath: string, + ) { + super(`The request for ${filePath} failed with ${status}.`); + } +} + +// A dropped connection or a gateway error, such as one bad gateway over T3 Connect, can pass. +const isPassingDownloadFailure = (error: unknown) => + error instanceof TypeError || + (error instanceof PluginFileRequestError && [502, 503, 504].includes(error.status)); +const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 5_000, 30_000]; + +/** + * Fetches a file the plugin's server serves with the connection's credentials. `revision` names + * the file's contents, so the server lets browsers cache its URL for good. + */ +export async function fetchPluginFile( + environmentId: EnvironmentId, + plugin: PluginState, + filePath: string, + revision: string, +): Promise { + const connection = readPreparedConnection(environmentId); + if (connection === null) throw new Error("The environment is not connected."); + const authorization = connection.httpAuthorization; + const url = new URL( + `/api/plugins/${encodeURIComponent(plugin.id)}${filePath}`, + connection.httpBaseUrl, + ); + url.searchParams.set("v", revision); + const response = await fetch(url, await pluginFileRequestInit(authorization, url)); + if (!response.ok) throw new PluginFileRequestError(response.status, filePath); + return response; +} + +/** + * Runs `download` again after a failure that can pass, 1, 5 and then 30 seconds later. Resolves + * undefined when `cancelled` turns true during a wait. + */ +export async function withDownloadRetries( + download: () => Promise, + cancelled: () => boolean, +): Promise { + for (const delay of [...DOWNLOAD_RETRY_DELAYS_MS, undefined]) { + try { + return await download(); + } catch (error) { + if (delay === undefined || !isPassingDownloadFailure(error)) throw error; + await new Promise((resume) => setTimeout(resume, delay)); + if (cancelled()) return undefined; + } + } + return undefined; +} diff --git a/apps/web/src/plugins/pluginFollow.test.ts b/apps/web/src/plugins/pluginFollow.test.ts new file mode 100644 index 0000000000000..5da9ab33291c5 --- /dev/null +++ b/apps/web/src/plugins/pluginFollow.test.ts @@ -0,0 +1,27 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { chosenPluginUiEnvironments, withPluginUiChoice } from "./pluginFollow"; + +describe("plugin UI choices", () => { + it("loads the main environment until it is turned off, and others only once turned on", () => { + const main = EnvironmentId.make("environment:main"); + const remote = EnvironmentId.make("environment:remote"); + const other = EnvironmentId.make("environment:other"); + const none = new Map(); + + expect(chosenPluginUiEnvironments(none, main)).toEqual([main]); + expect(chosenPluginUiEnvironments(none, null)).toEqual([]); + const both = withPluginUiChoice( + withPluginUiChoice(none, other, true, main), + remote, + true, + main, + ); + // The main environment first, then the others in the order chosen. + expect(chosenPluginUiEnvironments(both, main)).toEqual([main, other, remote]); + const mainOff = withPluginUiChoice(both, main, false, main); + expect(chosenPluginUiEnvironments(mainOff, main)).toEqual([other, remote]); + expect(withPluginUiChoice(mainOff, main, true, main)).toEqual(both); + }); +}); diff --git a/apps/web/src/plugins/pluginFollow.ts b/apps/web/src/plugins/pluginFollow.ts new file mode 100644 index 0000000000000..84399ef53bf28 --- /dev/null +++ b/apps/web/src/plugins/pluginFollow.ts @@ -0,0 +1,103 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/reactivity"; + +import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { serverEnvironment } from "../state/server"; + +/** Choices the user made in Settings; an environment left out keeps its default. */ +type PluginUiChoices = ReadonlyMap; + +const CHOICES_KEY = "t3.plugins.uiEnvironments"; +const isEnvironmentId = Schema.is(EnvironmentId); + +// Reading localStorage throws where the browser blocks storage; only the main environment loads. +const choicesStorage = () => { + try { + return window.localStorage; + } catch { + return undefined; + } +}; + +const readChoices = (): PluginUiChoices => { + try { + const stored: unknown = JSON.parse(choicesStorage()?.getItem(CHOICES_KEY) ?? "{}"); + if (typeof stored !== "object" || stored === null) return new Map(); + return new Map( + Object.entries(stored).flatMap(([environmentId, on]) => + isEnvironmentId(environmentId) && typeof on === "boolean" ? [[environmentId, on]] : [], + ), + ); + } catch { + return new Map(); + } +}; + +/** Which environments this browser loads plugin UI from, kept in this browser alone. */ +export const pluginUiChoicesAtom = Atom.writable(readChoices, (ctx, choices: PluginUiChoices) => { + try { + if (choices.size === 0) choicesStorage()?.removeItem(CHOICES_KEY); + else choicesStorage()?.setItem(CHOICES_KEY, JSON.stringify(Object.fromEntries(choices))); + } catch { + // A full or blocked storage keeps the choice for this page only. + } + ctx.setSelf(choices); +}).pipe(Atom.keepAlive, Atom.withLabel("web-plugin-ui-choices")); + +/** Whether plugin UI loads from the environment: the main one unless turned off, others once on. */ +export const loadsPluginUi = ( + choices: PluginUiChoices, + environmentId: EnvironmentId, + primaryEnvironmentId: EnvironmentId | null, +) => choices.get(environmentId) ?? environmentId === primaryEnvironmentId; + +/** `choices` with the environment turned on or off; a choice equal to the default is dropped. */ +export function withPluginUiChoice( + choices: PluginUiChoices, + environmentId: EnvironmentId, + on: boolean, + primaryEnvironmentId: EnvironmentId | null, +): PluginUiChoices { + const next = new Map(choices); + next.delete(environmentId); + if (on !== (environmentId === primaryEnvironmentId)) next.set(environmentId, on); + return next; +} + +/** The chosen environments in order: the main one first, then the others in the order chosen. */ +export const chosenPluginUiEnvironments = ( + choices: PluginUiChoices, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray => [ + ...(primaryEnvironmentId !== null && + loadsPluginUi(choices, primaryEnvironmentId, primaryEnvironmentId) + ? [primaryEnvironmentId] + : []), + ...[...choices].flatMap(([environmentId, on]) => + on && environmentId !== primaryEnvironmentId ? [environmentId] : [], + ), +]; + +/** + * The chosen environments that run plugins, in `chosenPluginUiEnvironments` order. Each loads its + * plugins' styles and themes. + */ +export const pluginUiEnvironmentsAtom = Atom.make((get): ReadonlyArray => { + const next = chosenPluginUiEnvironments( + get(pluginUiChoicesAtom), + get(primaryEnvironmentIdAtom), + ).filter( + (environmentId) => + get(serverEnvironment.configValueAtom(environmentId))?.environment.capabilities.plugins === + true, + ); + // Config pushes recompute this; the same list keeps every loader running. + const previous = Option.getOrNull(get.self>()); + return previous !== null && + previous.length === next.length && + previous.every((environmentId, index) => environmentId === next[index]) + ? previous + : next; +}).pipe(Atom.withLabel("web-plugin-ui-environments")); diff --git a/apps/web/src/plugins/pluginRegistry.ts b/apps/web/src/plugins/pluginRegistry.ts new file mode 100644 index 0000000000000..8aef922a0d108 --- /dev/null +++ b/apps/web/src/plugins/pluginRegistry.ts @@ -0,0 +1,42 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; + +// A plain map with one version: Settings reads it on render and re-renders on the version alone, +// so a plugin never adds a subscription per row. +const webFailures = new Map(); + +// Reads go through atoms derived from the version, so React Compiler cannot cache a stale lookup. +const versionAtom = Atom.make(0).pipe(Atom.keepAlive); + +const bump = () => appAtomRegistry.set(versionAtom, appAtomRegistry.get(versionAtom) + 1); + +const pluginContributionKey = (environmentId: EnvironmentId, pluginId: string) => + `${environmentId}\u0000${pluginId}`; + +/** Records why a plugin's stylesheets did not load; `undefined` clears it. */ +export function setPluginWebFailure( + environmentId: EnvironmentId, + pluginId: string, + message: string | undefined, +) { + const key = pluginContributionKey(environmentId, pluginId); + if (webFailures.get(key) === message) return; + if (message === undefined) webFailures.delete(key); + else webFailures.set(key, message); + bump(); +} + +/** Why the plugin's styles failed, by environment, for its row in Settings. */ +export const pluginWebFailuresAtom = Atom.family((pluginId: string) => + Atom.make((get): ReadonlyMap => { + get(versionAtom); + const suffix = `\u0000${pluginId}`; + return new Map( + [...webFailures].flatMap(([key, message]) => + key.endsWith(suffix) ? [[key.slice(0, -suffix.length), message] as const] : [], + ), + ); + }), +); diff --git a/apps/web/src/plugins/pluginStyles.browser.test.ts b/apps/web/src/plugins/pluginStyles.browser.test.ts new file mode 100644 index 0000000000000..16fbb49d6eae2 --- /dev/null +++ b/apps/web/src/plugins/pluginStyles.browser.test.ts @@ -0,0 +1,97 @@ +import { EnvironmentId, PluginId, type PluginState } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock(import("../state/session"), async (original) => ({ + ...(await original()), + readPreparedConnection: (() => ({ + httpBaseUrl: "http://plugins.test/", + httpAuthorization: { _tag: "Bearer", token: "token" }, + })) as never, +})); +const state = await vi.hoisted(async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + return { + live: Atom.make }>>( + AsyncResult.initial(), + ), + }; +}); +vi.mock(import("../state/plugins"), () => ({ + pluginEnvironment: { pluginsLive: () => state.live } as never, +})); + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { startPluginStyles } from "./pluginStyles"; + +const stylesPluginState: PluginState = { + id: PluginId.make("styles-plugin"), + name: "styles-plugin", + version: "0.1.0", + permissions: [], + source: "dev", + enabled: true, + status: "active", +}; + +describe("startPluginStyles", () => { + it("adds an active plugin's stylesheets in order, swaps changed ones and removes them when it is off", async () => { + // Each sheet names the file and revision it was fetched as. + using fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = new URL(String(input)); + return new Response(`/* ${url.pathname.split("/").pop()} ${url.searchParams.get("v")} */`); + }); + const environmentId = EnvironmentId.make("environment:plugin-styles"); + const stylesPlugin: PluginState = { + ...stylesPluginState, + styles: [ + { file: "base.css", revision: "r1" }, + { file: "compact.css", revision: "r2" }, + ], + }; + const sheets = () => + Array.from(document.querySelectorAll('style[data-plugin="styles-plugin"]'), (sheet) => + sheet.textContent?.trim(), + ); + appAtomRegistry.set(state.live, AsyncResult.success({ plugins: [stylesPlugin] })); + const stop = startPluginStyles(environmentId); + using _styles = { [Symbol.dispose]: stop }; + + await vi.waitFor(() => expect(sheets()).toEqual(["/* base.css r1 */", "/* compact.css r2 */"])); + // After the app's own styles, so a rule of the same specificity wins. + expect(document.head.lastElementChild?.textContent).toBe("/* compact.css r2 */"); + expect(String(fetchSpy.mock.calls[0]?.[0])).toBe( + "http://plugins.test/api/plugins/styles-plugin/styles/base.css?v=r1", + ); + expect(fetchSpy.mock.calls[0]?.[1]).toEqual({ headers: { authorization: "Bearer token" } }); + + appAtomRegistry.set( + state.live, + AsyncResult.success({ + plugins: [ + { + ...stylesPlugin, + styles: [ + { file: "base.css", revision: "r1" }, + { file: "compact.css", revision: "r3" }, + ], + }, + ], + }), + ); + await vi.waitFor(() => expect(sheets()).toEqual(["/* base.css r1 */", "/* compact.css r3 */"])); + + appAtomRegistry.set( + state.live, + AsyncResult.success({ + plugins: [{ ...stylesPluginState, enabled: false, status: "disabled" }], + }), + ); + await vi.waitFor(() => expect(sheets()).toEqual([])); + + appAtomRegistry.set(state.live, AsyncResult.success({ plugins: [stylesPlugin] })); + await vi.waitFor(() => expect(sheets()).toHaveLength(2)); + stop(); + expect(sheets()).toEqual([]); + }); +}); diff --git a/apps/web/src/plugins/pluginStyles.ts b/apps/web/src/plugins/pluginStyles.ts new file mode 100644 index 0000000000000..f6bf00a3cf8dc --- /dev/null +++ b/apps/web/src/plugins/pluginStyles.ts @@ -0,0 +1,115 @@ +import type { EnvironmentId, PluginState } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { pluginEnvironment } from "../state/plugins"; +import { fetchPluginFile, withDownloadRetries } from "./pluginFiles"; +import { setPluginWebFailure } from "./pluginRegistry"; +import { hasLoadableStyles } from "./pluginWatch"; + +// Names every file and its contents, so a restart with the same files downloads nothing. +const stylesKey = (plugin: PluginState) => + (plugin.styles ?? []).map(({ file, revision }) => `${file}@${revision}`).join(","); + +/** + * Adds the stylesheets of the environment's active plugins to the end of the page's head, after + * T3's own CSS and the theme, and follows plugin state until stopped. Changed files replace a + * plugin's old ones once all of them have downloaded. + */ +export function startPluginStyles(environmentId: EnvironmentId): () => void { + const applied = new Map< + PluginState["id"], + { readonly key: string; readonly sheets: Element[] } + >(); + // The download each plugin waits for; one that a newer change replaced is dropped. + const pending = new Map(); + // Plugins whose row shows a styles failure, which only this module clears. + const failed = new Set(); + let stopped = false; + + const remove = (pluginId: PluginState["id"]) => { + for (const sheet of applied.get(pluginId)?.sheets ?? []) sheet.remove(); + applied.delete(pluginId); + }; + const clearFailure = (pluginId: PluginState["id"]) => { + if (failed.delete(pluginId)) setPluginWebFailure(environmentId, pluginId, undefined); + }; + + const apply = async (plugin: PluginState, key: string) => { + pending.set(plugin.id, key); + const current = () => !stopped && pending.get(plugin.id) === key; + try { + const texts = await Promise.all( + (plugin.styles ?? []).map(({ file, revision }) => + withDownloadRetries( + () => + fetchPluginFile( + environmentId, + plugin, + `/styles/${encodeURIComponent(file)}`, + revision, + ).then((response) => response.text()), + () => !current(), + ), + ), + ); + if (!current()) return; + pending.delete(plugin.id); + remove(plugin.id); + const sheets = texts.map((css) => { + const sheet = document.createElement("style"); + sheet.dataset.plugin = plugin.id; + sheet.textContent = css ?? ""; + return sheet; + }); + document.head.append(...sheets); + applied.set(plugin.id, { key, sheets }); + clearFailure(plugin.id); + } catch (error) { + if (!current()) return; + // Tried again on the next plugin update. + pending.delete(plugin.id); + failed.add(plugin.id); + setPluginWebFailure( + environmentId, + plugin.id, + error instanceof Error ? error.message : "The plugin's styles failed to load.", + ); + } + }; + + const reconcile = (plugins: ReadonlyArray) => { + const wanted = new Map( + plugins.filter(hasLoadableStyles).map((plugin) => [plugin.id, plugin] as const), + ); + for (const pluginId of new Set([...applied.keys(), ...pending.keys(), ...failed])) { + if (wanted.has(pluginId)) continue; + pending.delete(pluginId); + remove(pluginId); + clearFailure(pluginId); + } + for (const plugin of wanted.values()) { + const key = stylesKey(plugin); + if (applied.get(plugin.id)?.key !== key && pending.get(plugin.id) !== key) { + void apply(plugin, key); + } + } + }; + + const stopFollowing = appAtomRegistry.subscribe( + pluginEnvironment.pluginsLive({ environmentId, input: {} }), + (result) => { + if (!stopped && AsyncResult.isSuccess(result)) reconcile(result.value.plugins); + }, + { immediate: true }, + ); + + return () => { + stopped = true; + stopFollowing(); + pending.clear(); + // Deleting the entry a Map or Set iteration is on is safe. + for (const pluginId of applied.keys()) remove(pluginId); + for (const pluginId of failed) clearFailure(pluginId); + }; +} diff --git a/apps/web/src/plugins/pluginWatch.ts b/apps/web/src/plugins/pluginWatch.ts new file mode 100644 index 0000000000000..9f93e8edbcfb1 --- /dev/null +++ b/apps/web/src/plugins/pluginWatch.ts @@ -0,0 +1,34 @@ +import type { EnvironmentId, PluginState } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { pluginEnvironment } from "../state/plugins"; + +/** Whether this plugin has stylesheets for the page now. */ +export const hasLoadableStyles = (plugin: PluginState) => + plugin.styles !== undefined && + plugin.styles.length > 0 && + plugin.enabled && + plugin.status === "active"; + +/** + * Calls `onFound` once the environment has a plugin that `wanted` accepts; returns the function + * that stops watching. The styles runtime follows the same atom, so it opens no second stream. + */ +export function watchForPlugins( + environmentId: EnvironmentId, + wanted: (plugin: PluginState) => boolean, + onFound: () => void, +): () => void { + let found = false; + return appAtomRegistry.subscribe( + pluginEnvironment.pluginsLive({ environmentId, input: {} }), + (result) => { + if (found || !AsyncResult.isSuccess(result)) return; + if (!result.value.plugins.some(wanted)) return; + found = true; + onFound(); + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1207b5017100d..36978ffce666f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -59,6 +59,7 @@ import { readProject } from "../state/entities"; import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; +import { PluginLoaderStarter } from "../plugins/PluginLoaderStarter"; export const Route = createRootRoute({ beforeLoad: async ({ location }) => { @@ -216,6 +217,8 @@ function RootRouteBranch() { {primaryEnvironmentAuthenticated ? ( ) : null} + {/* Also without a primary environment, where it loads plugin UI only from one the user picked. */} + {appShell} {/* Keep the theme editor above the router so drafts survive navigation. */} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 75bd468f7520d..52ca8f44160b0 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -99,7 +99,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { className="h-dvh min-h-0 overflow-hidden overscroll-y-none" > {isElectron ? : null} - + Couldn’t start a new thread diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 031fba313ebdd..3adeb63c6af69 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -31,8 +31,13 @@ import { type ContextChipAccentKind, } from "./lib/contextChipAccents"; +import { pluginUiEnvironmentsAtom } from "./plugins/pluginFollow"; import { appAtomRegistry, subscribeAtomChanges } from "./rpc/atomRegistry"; -import { primaryServerConfigEventAtom, primaryServerEnvironmentThemesAtom } from "./state/server"; +import { + primaryServerConfigEventAtom, + primaryServerEnvironmentThemesAtom, + serverEnvironment, +} from "./state/server"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; @@ -357,10 +362,20 @@ export function publishedThemeDefinitions( .map(environmentThemeDefinition); } -// Every reconnect snapshot delivers a fresh but usually identical array; keeping the -// previous one skips regenerating palettes and repainting the document for it. +// The main environment's own palettes, then the plugin palettes of each environment plugin UI +// loads from, as plugin styles do. Every reconnect snapshot delivers a fresh but usually identical +// array; keeping the previous one skips regenerating palettes and repainting the document for it. const publishedThemesAtom = Atom.make((get): ReadonlyArray => { - const next = get(primaryServerEnvironmentThemesAtom); + const next = get(primaryServerEnvironmentThemesAtom).filter((theme) => theme.fromPlugin !== true); + const ids = new Set(next.map((theme) => theme.id)); + for (const environmentId of get(pluginUiEnvironmentsAtom)) { + const themes = get(serverEnvironment.configValueAtom(environmentId))?.environmentThemes ?? []; + for (const theme of themes) { + if (theme.fromPlugin !== true || ids.has(theme.id)) continue; + ids.add(theme.id); + next.push(theme); + } + } const previous = Option.getOrNull(get.self>()); return previous !== null && Equal.equals(previous, next) ? previous : next; }).pipe(Atom.withLabel("web-published-environment-themes")); diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md index c260f56b50d09..cbcfed4ff4757 100644 --- a/docs/internals/plugins.md +++ b/docs/internals/plugins.md @@ -26,10 +26,20 @@ of every id no folder has, so a folder copied in later with that id starts off. ## Clients -`plugins.subscribeStates` is a stream of its own because the server config stream would resend the -whole config on every plugin change. The server starts plugins after it is listening. The mobile -app shows no plugin settings or themes. +Each browser keeps its own choice of the environments it loads plugin styles and themes from, the +main one by default. + +With no plugins, the plugin system adds about 5.7 KB of minified JS to the startup chunks of a +production build, about 2.1 KB gzipped, and no new chunks. Most of it is the plugin contract +schemas and RPC definitions, +[`PluginLoaderStarter`](../../apps/web/src/plugins/PluginLoaderStarter.tsx) and `pluginFollow`. +Once the app is idle, the starter downloads a 1.2 KB watcher and follows `plugins.subscribeStates` +for each chosen environment, a stream of its own because the server config stream would resend the +whole config on every plugin change. It downloads a small styles chunk only when an active plugin +has a `styles/` folder. The server starts plugins after it is listening. The mobile app shows no +plugin settings, styles or themes. Plugin themes arrive over the server config stream after the first paint, so the client saves them -in localStorage for the boot script in `index.html`. The desktop main process cannot read -localStorage, so the page sends it the canvas it paints under a light and a dark native theme. +in localStorage for the boot script in `index.html`. The server marks plugin themes, so the client +can show only those of chosen environments. The desktop main process cannot read localStorage, so +the page sends it the canvas it paints under a light and a dark native theme. diff --git a/docs/user/plugins.md b/docs/user/plugins.md index 1e01538702e78..732216d389860 100644 --- a/docs/user/plugins.md +++ b/docs/user/plugins.md @@ -1,8 +1,8 @@ # Plugins -Plugins change how T3 Code looks, with themes. A plugin belongs to one environment and lives on -that environment's T3 Code server. A plugin's row lists the permissions it says it uses, but T3 -Code doesn't enforce them. +Plugins change how T3 Code looks, with themes and styles. A plugin belongs to one environment and +lives on that environment's T3 Code server. A plugin's row lists the permissions it says it uses, +but T3 Code doesn't enforce them. ## Add a plugin @@ -20,10 +20,12 @@ Use the switch on the plugin's row. A plugin that fails to start shows the reaso example when it needs a newer version of T3 Code. **Reload** restarts a plugin with its current files. -## Themes +## Themes and styles A plugin can change how T3 Code looks without running any code. Theme files in its `themes` folder -show up as themes while the plugin is on. Themes load from the app's main environment (see [Where +show up as themes while the plugin is on. CSS files in its `styles` folder apply to the app while +the plugin is on, on top of T3 Code's own styles and your theme. A plugin can be just a `styles` +folder and its manifest. Themes and styles load from the same environments as plugin UI (see [Where plugins live](#where-plugins-live)). ## Where plugins live @@ -31,7 +33,11 @@ plugins live](#where-plugins-live)). Installed plugins are in the `plugins` folder of the environment's T3 Code data directory, one folder per plugin, and `plugins.json` records which ones are enabled. -Plugin themes load in the web and desktop apps, from the app's main environment: the desktop app's -own server, or the server the web app was opened from. app.t3.codes and a desktop app with its local -environment turned off have no main environment, so they load no plugin themes. The mobile app -doesn't show plugin settings or themes. +Plugin styles and themes load in the web and desktop apps. They load from the app's main +environment, which is the desktop app's own server or the server the web app was opened from, and +from any other environment you turn on under **Load plugin UI from** in **Settings → Plugins**. You +can turn the main environment off there too. app.t3.codes and a desktop app with its local +environment turned off have no main environment, so no plugin styles or themes load until you +choose one. The choice applies to this browser only. + +The mobile app doesn't show plugin settings, styles or themes. diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index e3fcbb5ffadfa..385d5e57355f6 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -221,3 +221,6 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* < }), ); }); + +// Plugin file requests sign the same way. +export { buildEnvironmentAuthHeaders }; diff --git a/packages/client-runtime/src/state/plugins.ts b/packages/client-runtime/src/state/plugins.ts index 473eaf60a4a59..b3ccbaa4fa75f 100644 --- a/packages/client-runtime/src/state/plugins.ts +++ b/packages/client-runtime/src/state/plugins.ts @@ -1,11 +1,16 @@ import { applyPluginStatesEvent, type PluginListResult, WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { Atom } from "effect/reactivity"; +import type { PreparedHttpAuthorization } from "../connection/model.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { buildEnvironmentAuthHeaders } from "./environmentHttpAuth.ts"; import { createEnvironmentRpcCommand, createEnvironmentRpcSubscriptionAtomFamily, + createRuntimeCommand, } from "./runtime.ts"; const NO_PLUGINS: PluginListResult = { plugins: [] }; @@ -38,5 +43,16 @@ export function createPluginEnvironmentAtoms( label: "environment-data:plugins:rescan", tag: WS_METHODS.pluginsRescan, }), + // Plugin stylesheets are plain GETs; over T3 Connect each needs its own DPoP proof. + pluginFileHeaders: createRuntimeCommand(runtime, { + label: "environment-data:plugins:file-headers", + execute: (input: { + readonly authorization: PreparedHttpAuthorization; + readonly url: string; + }) => + Effect.flatMap(Effect.serviceOption(ManagedRelayDpopSigner), (signer) => + buildEnvironmentAuthHeaders(input.authorization, "GET", input.url, signer), + ).pipe(Effect.map((headers): Readonly> => ({ ...headers }))), + }), }; } diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index 6f564d0395547..15e6d9183856e 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -79,6 +79,13 @@ export const PluginState = Schema.Struct({ enabled: Schema.Boolean, status: PluginStatus, failure: Schema.optionalKey(PluginFailure), + /** + * The `.css` files in its `styles` folder while it runs, in the order clients apply them. Each + * `revision` names the file's contents and is sent as its URL's `v`. + */ + styles: Schema.optionalKey( + Schema.Array(Schema.Struct({ file: Schema.String, revision: Schema.String })), + ), /** * The folder it loads from: a `dev` plugin's as plugins.json lists it, an installed one's inside * the plugins folder. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index defe9b7d52a0c..f4d5509c96507 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -583,6 +583,8 @@ export type EnvironmentThemeFile = typeof EnvironmentThemeFile.Type; export const EnvironmentTheme = Schema.Struct({ id: EnvironmentThemeId, ...environmentThemeFields, + /** Published by an active plugin, so it shows only where the client loads that plugin's UI. */ + fromPlugin: Schema.optionalKey(Schema.Literal(true)), }); export type EnvironmentTheme = typeof EnvironmentTheme.Type; diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 5094c2f8c5e52..f03ce445ee896 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -1,7 +1,10 @@ # T3 Code plugins -A plugin is a folder with a `t3-plugin.json` manifest and theme files that change how T3 Code looks. -It runs no code. +A plugin is a folder with a `t3-plugin.json` manifest, plus theme files and stylesheets that change +how T3 Code looks. It runs no code. + +The plugins in [`fixtures/`](./fixtures) back the tests and are small and working too. They are +written by hand and need no build. ## Manifest @@ -27,6 +30,39 @@ It runs no code. the sidebar, which T3 otherwise swaps for a label; the send button stays plain. `"radius"`, such as `"0.625rem"`, sets T3's corner radius, and `success`, `successForeground`, `info` and `infoForeground` in a theme's `colors` or `variants` set its status colors. +- A `styles/` folder of `.css` files is added to the page while the plugin is active, in name + order, after T3's own styles and the theme. A manifest and `styles/` are a whole plugin, as in + the `styles-plugin` fixture. Styles and themes load only from the environments turned on under + **Load plugin UI from** in Settings → Plugins, which starts with just the main environment. + **Reload** picks up edits. The page gets each file's text, so a `url()` can't reach the + plugin's folder; use a `data:` URL for an image. + +## Keeping a plugin working across T3 updates + +T3 changes its markup, class names, labels and layout in most releases. You don't need to drop +parts of your design to survive that. Build each part on the most stable thing T3 offers for it: + +- **Style with theme tokens.** Colors, radii and fonts come from the theme. Set them in a theme + file in `themes/`, and use `var(--border)`, `var(--radius)` and the other tokens in your styles + instead of hex or rgb values. +- **Scope CSS to a region.** T3 keeps the `data-t3-region` names of the window's main areas + across releases: `sidebar`, `main` (everything beside the sidebar), `timeline`, `composer`, + `thread-details`, `right-panel`, `terminal`, `home`, `pull-requests`, `settings`, + `settings-nav` and `command-palette`. A plain `background` rule recolors most regions, such as + `[data-t3-region="composer"] { background: #101820; }`. The surfaces inside `main`, + `right-panel` and `pull-requests` paint over them, so set `--background` on those three instead. + For `terminal`, set `--terminal-background`, which a terminal reads when it opens. `terminal` is + the drawer under the chat. A terminal opened in the right panel has no `terminal` region, so set + `--terminal-background` on `right-panel` to reach it. +- **Reach into T3's markup only as a last resort.** Selectors on `aria-label`, `data-slot`, class + names such as `[class~="border-border/65"]` or `.lucide-shield-off`, and `:has()` break when T3 + renames or moves things. Use one only when no theme value or region does the job. Keep each rule + small and in one commented place, prefer one that falls back to T3's own look when the markup + changes, and list it in the plugin's README with the seam that would replace it. +- **Leave T3's frame to T3.** The title bar row, the room for the window buttons on Windows and + Linux, drag areas, phone safe areas and breakpoints are T3's, so don't resize or pad them. +- **Ask for a seam.** When T3 doesn't hand over what you need, ask for a region or token on the + T3 Code repository instead of reaching into its markup. ## Developing a plugin diff --git a/packages/plugin-sdk/fixtures/styles-plugin/styles/base.css b/packages/plugin-sdk/fixtures/styles-plugin/styles/base.css new file mode 100644 index 0000000000000..31bfb7989b490 --- /dev/null +++ b/packages/plugin-sdk/fixtures/styles-plugin/styles/base.css @@ -0,0 +1,4 @@ +/* Stable region names and theme tokens, so the rule survives T3 updates. */ +[data-t3-region="sidebar"] { + border-inline-end: 1px solid var(--border); +} diff --git a/packages/plugin-sdk/fixtures/styles-plugin/styles/compact.css b/packages/plugin-sdk/fixtures/styles-plugin/styles/compact.css new file mode 100644 index 0000000000000..131aa227a6ce9 --- /dev/null +++ b/packages/plugin-sdk/fixtures/styles-plugin/styles/compact.css @@ -0,0 +1,3 @@ +[data-t3-region="composer"] { + padding-block: 0.25rem; +} diff --git a/packages/plugin-sdk/fixtures/styles-plugin/t3-plugin.json b/packages/plugin-sdk/fixtures/styles-plugin/t3-plugin.json new file mode 100644 index 0000000000000..24c89d07025b8 --- /dev/null +++ b/packages/plugin-sdk/fixtures/styles-plugin/t3-plugin.json @@ -0,0 +1,7 @@ +{ + "id": "styles-plugin", + "name": "Styles plugin", + "version": "0.1.0", + "description": "Test fixture that changes the app with CSS alone, without server or web code.", + "engines": { "t3": ">=0.1.0" } +} From cc79312188cb047da3a02e6f239673d650bb6850 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 02:42:33 +0000 Subject: [PATCH 04/11] feat(plugins): install a dropped plugin folder over HTTP, and remove or unlink plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins could only be added by copying a folder onto the server's machine, which a remote or app.t3.codes user can't do. Dropping a folder on Settings → Plugins uploads its files over HTTP to a single-use URL the server signs, so a large folder doesn't hold up the WebSocket. The server stages them and swaps them in under the lifecycle lock, and startup restores a version an interrupted swap left in staging. A new plugin installs turned off, so its switch is the confirm step; a replaced one keeps its switch, and a dropped folder can't replace a linked one. Each row can remove its plugin, or unlink a `devPaths` one and keep the folder. A duplicate's row removes or unlinks only that copy, named by its folder. Surfaces: drag or Add plugin in Settings, web and desktop; mobile shows none. No providers or agents. Contracts: `createUploadUrl`, and `uninstall` with an optional `folder`. Reverse: Remove and Unlink. Connections: the signature is the credential, so uploads work on every connection. Docs updated. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http.test.ts | 3 + apps/server/src/http.ts | 4 +- .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/plugins/PluginHost.test.ts | 230 +++++++++++- apps/server/src/plugins/PluginHost.ts | 25 +- apps/server/src/plugins/PluginInstaller.ts | 328 ++++++++++++++++++ apps/server/src/plugins/pluginRpcHandlers.ts | 12 + apps/server/src/plugins/pluginUploads.test.ts | 128 +++++++ apps/server/src/plugins/pluginUploads.ts | 210 +++++++++++ apps/server/src/plugins/pluginsConfig.ts | 1 + apps/server/src/server.ts | 4 + apps/server/src/ws.test.ts | 4 + apps/server/src/ws.ts | 6 +- .../plugins/PluginInstallControls.tsx | 178 ++++++++++ .../settings/plugins/PluginList.tsx | 7 +- .../components/settings/plugins/PluginRow.tsx | 92 ++++- .../settings/plugins/PluginsSettings.tsx | 122 ++++++- apps/web/src/plugins/pluginFiles.ts | 40 ++- apps/web/src/plugins/pluginFolders.test.ts | 81 +++++ apps/web/src/plugins/pluginFolders.ts | 116 +++++++ docs/internals/plugins.md | 28 +- docs/user/plugins.md | 24 +- .../src/state/commandPermissions.test.ts | 5 +- packages/client-runtime/src/state/plugins.ts | 9 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/plugins.test.ts | 26 +- packages/contracts/src/plugins.ts | 59 +++- packages/contracts/src/rpc.ts | 21 ++ packages/plugin-sdk/README.md | 10 +- 29 files changed, 1738 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/plugins/PluginInstaller.ts create mode 100644 apps/server/src/plugins/pluginUploads.test.ts create mode 100644 apps/server/src/plugins/pluginUploads.ts create mode 100644 apps/web/src/components/settings/plugins/PluginInstallControls.tsx create mode 100644 apps/web/src/plugins/pluginFolders.test.ts create mode 100644 apps/web/src/plugins/pluginFolders.ts diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..8138850282bfb 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -18,6 +18,7 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/pluginUploads.ts"; import { injectMcpAppCsp, @@ -164,6 +165,7 @@ describe("untraced requests", () => { `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty(), ); + yield* router.add("POST", `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); }), ); @@ -182,6 +184,7 @@ describe("untraced requests", () => { HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.post(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), ]) { expect((yield* client.execute(request)).status).toBe(204); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index a92d035e85c45..b1fe75f983754 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -48,6 +48,7 @@ import { failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/pluginUploads.ts"; import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; import { browserApiCorsAllowedHeaders, @@ -396,7 +397,8 @@ const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)(( UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || - path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) || + path.startsWith(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`) ); }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2836b4b8454a5..473539487a3ca 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -91,7 +91,9 @@ const RPC_AGGREGATES = { [WS_METHODS.pluginsSubscribeStates]: "plugins", [WS_METHODS.pluginsSetEnabled]: "plugins", [WS_METHODS.pluginsReload]: "plugins", + [WS_METHODS.pluginsCreateUploadUrl]: "plugins", [WS_METHODS.pluginsRescan]: "plugins", + [WS_METHODS.pluginsUninstall]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 2cc16e968bb49..1923e7627c177 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as PluginHost from "./PluginHost.ts"; import { @@ -30,8 +31,11 @@ import { writeFile, writePluginsConfig, } from "./pluginHostTestHarness.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; import { makePluginRpcHandlers } from "./pluginRpcHandlers.ts"; +import * as PluginUploads from "./pluginUploads.ts"; +const hello = PluginId.make("hello-plugin"); const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; const manifestJson = ( @@ -106,6 +110,170 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerConfig())), ); + it.effect("unlinks a plugin loaded from devPaths and leaves its folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ enabled: { linked: true }, devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "active" }); + + yield* installer.uninstall(PluginId.make("linked")); + // Forgotten, not only stopped: a rescan does not find it again. + assert.isUndefined(stateOf((yield* host.rescan).plugins, "linked")); + const saved = yield* fs.readFileString(`${stateDir}/plugins.json`); + assert.deepStrictEqual(JSON.parse(saved), { enabled: {}, devPaths: [] }); + assert.isTrue(yield* fs.exists(`${folder}/t3-plugin.json`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a dropped folder that would replace a linked plugin", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "disabled" }); + const refused = yield* installer + .installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(manifestJson("linked")) }, + ]) + .pipe(Effect.flip); + assert.strictEqual(refused.message, `linked is linked from ${folder}. Unlink it first.`); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a later folder with a plugin's id, and removes or unlinks it alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const id = PluginId.make("twice"); + const installed = `${stateDir}/plugins/twice-copy`; + const linked = `${stateDir}/checkout/twice`; + yield* writeFile(`${stateDir}/plugins/twice/t3-plugin.json`, manifestJson(id)); + yield* writeFile(`${installed}/t3-plugin.json`, manifestJson(id, { version: "0.2.0" })); + yield* writeFile(`${linked}/t3-plugin.json`, manifestJson(id, { version: "0.3.0" })); + yield* writePluginsConfig({ enabled: { [id]: true }, devPaths: [linked] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { [id]: "active" }); + const copies = host.list.pipe( + Effect.map((states) => + states + .filter((state) => state.id === id) + .map((state) => [state.version, state.status, state.failure?.message]), + ), + ); + const refused = + 'The installed folder "twice" already has the id twice. Remove or unlink one of them.'; + assert.deepStrictEqual(yield* copies, [ + ["0.1.0", "active", undefined], + ["0.2.0", "failed", refused], + ["0.3.0", "failed", refused], + ]); + + yield* installer.uninstall(id, linked); + yield* installer.uninstall(id, installed); + assert.deepStrictEqual(yield* copies, [["0.1.0", "active", undefined]]); + assert.isFalse(yield* fs.exists(installed)); + assert.isTrue(yield* fs.exists(linked)); + + const elsewhere = yield* installer.uninstall(id, `${stateDir}/elsewhere`).pipe(Effect.flip); + assert.strictEqual(elsewhere.message, `No copy of twice is in ${stateDir}/elsewhere.`); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a later folder with a removed plugin's id afresh, turned off", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const id = PluginId.make("twice"); + yield* writeFile(`${stateDir}/plugins/twice/t3-plugin.json`, manifestJson(id)); + yield* writeFile(`${stateDir}/plugins/twice-copy/t3-plugin.json`, manifestJson(id)); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { [id]: "active" }); + + yield* installer.uninstall(id); + const left = (yield* host.list).filter((state) => state.id === id); + assert.deepStrictEqual( + left.map((state) => [state.folder, state.status, state.enabled, state.failure]), + [[`${stateDir}/plugins/twice-copy`, "disabled", false, undefined]], + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("installs a dropped folder turned off, and one dropped again keeps its switch", () => + Effect.gen(function* () { + const helloFiles = (version: string) => [ + { + path: "t3-plugin.json", + contents: new TextEncoder().encode(manifestJson(hello, { version })), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // Nothing of a new plugin runs until its switch is turned on. + const installed = yield* installer.installFiles(helloFiles("0.1.0")); + assert.include(installed, { id: hello, status: "disabled", enabled: false }); + yield* host.setEnabled(hello, true); + const replaced = yield* installer.installFiles(helloFiles("0.1.1")); + assert.include(replaced, { status: "active", enabled: true, version: "0.1.1" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "lists a folder copied in by hand turned off, and a dropped one replaces it there", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const notesManifest = (version: string) => + JSON.stringify({ id: "notes", name: "Notes", version, engines: { t3: ">=0.1.0" } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // The docs point people at the plugins folder, so a rescan makes sure it exists. + yield* host.rescan; + assert.isTrue(yield* fs.exists(`${stateDir}/plugins`)); + + // Copied in by hand under any name, a plugin is listed on the next rescan, turned off. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.0")); + const { plugins } = yield* host.rescan; + assert.include(stateOf(plugins, "notes"), { status: "disabled", enabled: false }); + // A new version copied over one that is off is what Settings lists and what starts. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.5")); + assert.include(stateOf((yield* host.rescan).plugins, "notes"), { version: "1.0.5" }); + + // A dropped folder replaces a plugin copied in by hand, in the folder it was copied to. + yield* installer.installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(notesManifest("1.1.0")) }, + ]); + assert.include(stateOf(yield* host.list, "notes"), { version: "1.1.0", enabled: false }); + assert.deepStrictEqual(yield* fs.readDirectory(`${stateDir}/plugins`), ["My Notes"]); + yield* installer.uninstall(PluginId.make("notes")); + assert.isFalse(yield* fs.exists(`${stateDir}/plugins/My Notes`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("keeps server folder paths from clients that cannot manage plugins", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -114,9 +282,15 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { yield* writePluginsConfig({ devPaths: [folder] }); yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; yield* waitForStatuses(host, { linked: "disabled" }); const listed = (scopes: ReadonlyArray) => { - const handlers = makePluginRpcHandlers(host, scopes); + // Listing never signs an upload URL. + const uploads = PluginUploads.PluginUploads.of({ + issueUrl: Effect.die("unused"), + claim: () => Effect.die("unused"), + }); + const handlers = makePluginRpcHandlers(host, installer, uploads, scopes); return Effect.all([ handlers[WS_METHODS.pluginsList]({}), handlers[WS_METHODS.pluginsSubscribeStates]({}).pipe( @@ -175,6 +349,60 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerConfig())), ); + it.effect("refuses a dropped folder this server can't run and keeps the old one", () => + Effect.gen(function* () { + const upgrade = (version: string, options: { readonly engines?: string } = {}) => [ + { + path: "t3-plugin.json", + contents: new TextEncoder().encode( + manifestJson("upgrade", { name: "Upgrade", version, ...options }), + ), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const refused = yield* installer + .installFiles(upgrade("2.0.0", { engines: ">=999.0.0" })) + .pipe(Effect.flip); + assert.strictEqual( + refused.message, + `Upgrade 2.0.0 needs T3 Code >=999.0.0, and this server is ${packageJson.version}.`, + ); + assert.include(stateOf(yield* host.list, "upgrade"), { + version: "1.0.0", + status: "active", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("restores a version an interrupted install left in staging", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const previous = `${stateDir}/plugin-staging/crashed/previous`; + yield* writeFile( + `${previous}/t3-plugin.json`, + JSON.stringify({ + id: "stranded", + name: "Stranded", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { stranded: "disabled" }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/stranded/t3-plugin.json`)); + assert.isFalse(yield* fs.exists(`${stateDir}/plugin-staging/crashed`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("sends a client that connects during startup one empty list, not two", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 85eb0b28a1aa2..8940fb626008a 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -31,6 +31,7 @@ import * as ServerConfig from "../config.ts"; import { authenticateRawRouteWithScope } from "../http.ts"; import { forkParked } from "../serverActivation.ts"; import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; @@ -299,9 +300,28 @@ const make = Effect.gen(function* () { return { plugins: yield* currentStates }; }); + const { installer, recoverStaging } = yield* PluginInstaller.make({ + lifecycle: runLifecycle, + copies: Ref.get(records).pipe(Effect.map((all) => all.map((record) => record.plugin))), + copyOf: (pluginId) => + findRecord(pluginId).pipe( + Effect.flatMap((record) => + record === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(record.plugin), + ), + ), + stop: stopPlugin, + rescan: rediscover(undefined).pipe(Effect.asVoid), + reload, + config: pluginsConfig.current, + writeConfig: pluginsConfig.write, + }); + const startup = lifecycle .withPermits(1)( Effect.gen(function* () { + yield* recoverStaging; const { plugins, found } = yield* discover(true); yield* Ref.set( records, @@ -332,9 +352,12 @@ const make = Effect.gen(function* () { return yield* webFiles.read(record.webFiles, request.path, request.revision); }), }); - return Context.make(PluginHost, service); + return Context.make(PluginHost, service).pipe( + Context.add(PluginInstaller.PluginInstaller, installer), + ); }); +/** Provides the host and the installer it builds. */ export const layer = Layer.effectContext(make); const WEB_FILES_ROUTE_PREFIX = "/api/plugins"; diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts new file mode 100644 index 0000000000000..e4d62ca245612 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,328 @@ +import { + type PluginId, + PluginManifest, + type PluginState, + type PluginUploadFile, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginNotFoundError } from "./PluginHost.ts"; +import type { PluginConfigError, PluginsConfigFile } from "./pluginsConfig.ts"; + +// `reason` is shown to the user as is, so it says what to fix. +export class PluginInstallError extends Schema.TaggedError()( + "PluginInstallError", + { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * Installs and removes plugins. The host builds it and hands it the lifecycle lock, the plugin + * list and plugins.json, so the host keeps discovery, activation and state, and a new install + * source only changes this file. + */ +export class PluginInstaller extends Context.Service< + PluginInstaller, + { + /** + * Installs a plugin folder a client sent as files into the plugins folder. A new plugin is + * turned off; a replaced one keeps its switch and restarts if on. It is staged first and + * swapped in with a rename, so a failed install leaves the previous version. + */ + readonly installFiles: ( + files: ReadonlyArray, + ) => Effect.Effect; + /** + * Stops a plugin and deletes its folder; a linked folder is only forgotten. `folder` picks a + * copy. A duplicate copy's folder goes alone, and the copy in use keeps running. + */ + readonly uninstall: ( + pluginId: PluginId, + folder?: string, + ) => Effect.Effect; + } +>()("t3/plugins/PluginInstaller") {} + +/** What the installer needs from the host that builds it. */ +export interface PluginInstallerHost { + /** + * Runs `body` under the lifecycle lock in the host's scope, so a caller that disconnects only + * stops waiting and never leaves a plugin half replaced. + */ + readonly lifecycle: (body: Effect.Effect) => Effect.Effect; + /** Every listed copy in discovery's order: an id's first copy is the one in use. */ + readonly copies: Effect.Effect>; + /** The copy of `pluginId` in use. */ + readonly copyOf: (pluginId: PluginId) => Effect.Effect; + /** Stops a plugin; the caller sets what comes next. Only under `lifecycle`. */ + readonly stop: (pluginId: PluginId) => Effect.Effect; + /** Rescans the plugin folders without restarting anything. Only under `lifecycle`. */ + readonly rescan: Effect.Effect; + /** Rescans the plugin folders and restarts the plugin, waiting until it has started. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** plugins.json as last read or written. */ + readonly config: Effect.Effect; + readonly writeConfig: ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) => Effect.Effect; +} + +/** What an install takes a plugin from: files a client sent. */ +type InstallOrigin = { readonly files: ReadonlyArray }; + +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +const installFailed = (reason: string) => (cause: unknown) => + new PluginInstallError({ reason, cause }); + +/** Builds the installer for the host. `recoverStaging` runs before the host's first discovery. */ +export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginInstallerHost) { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const scope = yield* Effect.scope; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const stagingRoot = path.join(config.stateDir, "plugin-staging"); + + // An install interrupted mid-swap (a crash) can leave the old version only in staging. Listed + // while the layer builds, before the server serves requests, so recovery never touches the + // staging folder of an install that starts after that. + const leftoverStaging = yield* fs + .readDirectory(stagingRoot) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const recoverStaging = Effect.gen(function* () { + for (const entry of leftoverStaging) { + const previous = path.join(stagingRoot, entry, "previous"); + const manifest = yield* fs + .readFileString(path.join(previous, "t3-plugin.json")) + .pipe(Effect.flatMap(decodeManifestJson), Effect.option); + if (Option.isSome(manifest)) { + const target = path.join(pluginsDir, manifest.value.id); + const replaced = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (!replaced) { + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(previous, target).pipe(Effect.ignore); + } + } + yield* fs + .remove(path.join(stagingRoot, entry), { recursive: true, force: true }) + .pipe(Effect.ignore); + } + }).pipe(Effect.withSpan("PluginInstaller.recoverStaging")); + + // A caller that disconnects stops waiting but cannot interrupt a swap or a staging cleanup. + const inHostScope = (body: Effect.Effect) => + body.pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)); + + const readSourceManifest = (folder: string) => + fs.readFileString(path.join(folder, "t3-plugin.json")).pipe( + Effect.mapError(installFailed("The source has no t3-plugin.json.")), + Effect.flatMap((raw) => + decodeManifestJson(raw).pipe( + Effect.mapError(installFailed("The source's t3-plugin.json is not a valid manifest.")), + ), + ), + ); + + // The upload schema checks that every path stays inside `into`. + const writeFiles = Effect.fn("PluginInstaller.writeFiles")(function* ( + files: ReadonlyArray<{ readonly path: string; readonly contents: Uint8Array }>, + into: string, + ) { + for (const file of files) { + const target = path.join(into, ...file.path.split("/")); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.andThen(fs.writeFile(target, file.contents)), + Effect.mapError(installFailed(`Could not write ${file.path}.`)), + ); + } + }); + + /** Why this server cannot run `manifest`, checked before anything is stopped or moved. */ + const refusal = (manifest: PluginManifest) => + satisfiesSemverRange(packageJson.version, manifest.engines.t3) + ? undefined + : `${manifest.name} ${manifest.version} needs T3 Code ${manifest.engines.t3}, and this server is ${packageJson.version}.`; + + /** + * Swaps `/plugin` into the plugins folder under the lifecycle lock and starts it if it + * is on. The replaced version waits in `/previous` during the swap, so startup + * restores it if a crash leaves it there. `stagingDir` is removed afterwards. + */ + const swapIn = Effect.fn("PluginInstaller.swapIn")(function* ( + manifest: PluginManifest, + stagingDir: string, + ) { + const staged = path.join(stagingDir, "plugin"); + const previous = path.join(stagingDir, "previous"); + let stopped = false; + yield* Effect.addFinalizer(() => removeStaging(stagingDir)); + yield* host + .lifecycle( + // Read under the lock, so another install or a removal cannot change them before the swap. + Effect.gen(function* () { + const copies = yield* host.copies; + const existing = copies.find((copy) => copy.id === manifest.id); + if (existing?.source === "dev") { + return yield* new PluginInstallError({ + reason: `${manifest.id} is linked from ${existing.folder}. Unlink it first.`, + }); + } + const target = + existing === undefined ? path.join(pluginsDir, manifest.id) : existing.folder; + // A folder put there by hand holds another plugin, or none, which the swap would delete. + if ( + existing === undefined && + (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true))) + ) { + const owner = copies.find((copy) => copy.folder === target); + return yield* new PluginInstallError({ + reason: `The plugins folder already has a ${manifest.id} folder${owner === undefined ? "" : ` holding ${owner.id}`}. Move it, then install again.`, + }); + } + // A new plugin starts off, so none of it runs until the user has seen its row and turned + // it on. A replaced one keeps its switch. + const enabled = + existing !== undefined && (yield* host.config).enabled?.[manifest.id] === true; + stopped = true; + yield* host.stop(manifest.id); + yield* fs + .makeDirectory(pluginsDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the plugins folder."))); + const hadPrevious = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + if (hadPrevious) { + yield* fs + .rename(target, previous) + .pipe(Effect.mapError(installFailed("Could not move the installed version aside."))); + } + yield* fs.rename(staged, target).pipe( + Effect.mapError(installFailed("Could not move the new version into place.")), + Effect.tapError(() => + hadPrevious ? fs.rename(previous, target).pipe(Effect.ignore) : Effect.void, + ), + ); + yield* host.writeConfig((current) => ({ + ...current, + enabled: { ...current.enabled, [manifest.id]: enabled }, + })); + }), + ) + .pipe( + // Whatever failed after the stop, the plugin folder holds one version; start it again. + Effect.onError(() => + stopped ? host.reload(manifest.id).pipe(Effect.ignore) : Effect.void, + ), + ); + return yield* host.reload(manifest.id); + }, Effect.scoped); + + /** Copies `origin` into a new staging folder and returns the folder and what it holds. */ + const stage = Effect.fn("PluginInstaller.stage")(function* (origin: InstallOrigin) { + const stagingDir = path.join(stagingRoot, yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const staged = path.join(stagingDir, "plugin"); + yield* fs + .makeDirectory(stagingDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the staging folder."))); + return yield* Effect.gen(function* () { + yield* writeFiles(origin.files, staged); + return { stagingDir, manifest: yield* readSourceManifest(staged) }; + }).pipe(Effect.tapError(() => removeStaging(stagingDir))); + }); + + const removeStaging = (stagingDir: string) => + fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore); + + const installOrigin = Effect.fn("PluginInstaller.installOrigin")(function* ( + origin: InstallOrigin, + ) { + const { stagingDir, manifest } = yield* stage(origin); + const reason = refusal(manifest); + if (reason !== undefined) { + yield* removeStaging(stagingDir); + return yield* new PluginInstallError({ reason }); + } + return yield* swapIn(manifest, stagingDir); + }); + + // `PluginUploadFiles` bounds the folder's size before it gets here. + const installFiles: PluginInstaller["Service"]["installFiles"] = Effect.fn( + "PluginInstaller.installFiles", + )((files) => inHostScope(installOrigin({ files }))); + + const removeFromConfig = (pluginId: PluginId) => + host.writeConfig((current) => { + const { [pluginId]: _removed, ...enabled } = current.enabled ?? {}; + return { ...current, enabled }; + }); + + const forgetDevPath = (folder: string) => + host.writeConfig((current) => ({ + ...current, + devPaths: (current.devPaths ?? []).filter((entry) => path.resolve(entry) !== folder), + })); + + // The folder as listed, not `rootDir`: that is the real path, so a symlinked plugin would delete + // the user's own folder behind the link. A linked folder is the author's own; only plugins.json + // forgets it. + const removeFolder = (copy: DiscoveredPlugin) => + copy.source === "dev" + ? forgetDevPath(copy.folder) + : fs + .remove(copy.folder, { recursive: true, force: true }) + .pipe(Effect.mapError(installFailed(`Could not delete ${copy.folder}.`))); + + const uninstall: PluginInstaller["Service"]["uninstall"] = Effect.fn("PluginInstaller.uninstall")( + (pluginId, folder) => + host.lifecycle( + Effect.gen(function* () { + const inUse = yield* host.copyOf(pluginId); + const copy = + folder === undefined + ? inUse + : (yield* host.copies).find( + (candidate) => + candidate.id === pluginId && candidate.folder === path.resolve(folder), + ); + if (copy === undefined) { + return yield* new PluginInstallError({ + reason: `No copy of ${pluginId} is in ${folder}.`, + }); + } + if (copy !== inUse) { + yield* removeFolder(copy); + return yield* host.rescan; + } + yield* host.stop(pluginId); + yield* removeFolder(copy); + yield* removeFromConfig(pluginId); + // Lists a duplicate copy afresh, turned off. + yield* host.rescan; + }), + ), + ); + + return { + installer: PluginInstaller.of({ installFiles, uninstall }), + recoverStaging, + }; +}); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts index fba713fd73e54..c449ad559a6ff 100644 --- a/apps/server/src/plugins/pluginRpcHandlers.ts +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -1,16 +1,20 @@ import { type AuthEnvironmentScope, PluginError, + type PluginCreateUploadUrlInput, type PluginId, type PluginListInput, type PluginReloadInput, type PluginRescanInput, type PluginSetEnabledInput, + type PluginUninstallInput, WS_METHODS, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type * as PluginHost from "./PluginHost.ts"; +import type * as PluginInstaller from "./PluginInstaller.ts"; +import type * as PluginUploads from "./pluginUploads.ts"; const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => new PluginError({ message: error.message, pluginId }); @@ -18,6 +22,8 @@ const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) /** The `plugins.*` WebSocket handlers. `scopes` are the connected session's. */ export const makePluginRpcHandlers = ( pluginHost: PluginHost.PluginHost["Service"], + installer: PluginInstaller.PluginInstaller["Service"], + uploads: PluginUploads.PluginUploads["Service"], scopes: ReadonlyArray, ) => ({ [WS_METHODS.pluginsList]: (_input: PluginListInput) => pluginHost.listForClient(scopes), @@ -33,6 +39,12 @@ export const makePluginRpcHandlers = ( Effect.map((plugin) => ({ plugin })), Effect.mapError(toRpcError(input.pluginId)), ), + [WS_METHODS.pluginsCreateUploadUrl]: (_input: PluginCreateUploadUrlInput) => + uploads.issueUrl.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), [WS_METHODS.pluginsRescan]: (_input: PluginRescanInput) => pluginHost.rescan.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), + [WS_METHODS.pluginsUninstall]: (input: PluginUninstallInput) => + installer + .uninstall(input.pluginId, input.folder) + .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), }); diff --git a/apps/server/src/plugins/pluginUploads.test.ts b/apps/server/src/plugins/pluginUploads.test.ts new file mode 100644 index 0000000000000..4732d433735e0 --- /dev/null +++ b/apps/server/src/plugins/pluginUploads.test.ts @@ -0,0 +1,128 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PLUGIN_UPLOAD_MAX_BYTES } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { layerConfig, layerHost, waitForStatuses } from "./pluginHostTestHarness.ts"; +import * as PluginUploads from "./pluginUploads.ts"; + +/** Posts `files` to `url` through the upload route, over a running plugin host. */ +const uploader = Effect.fn(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, {}); + const app = yield* HttpRouter.toHttpEffect(PluginUploads.layerRoute); + return (url: string, files: Readonly>>) => { + const body = new FormData(); + for (const [path, contents] of Object.entries(files)) { + body.append("file", new Blob([contents]), encodeURIComponent(path)); + } + const request = new Request(`http://localhost${url}`, { method: "POST", body }); + return app.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(request), + ), + Effect.scoped, + Effect.flatMap((response) => + Effect.promise(() => HttpServerResponse.toWeb(response).text()).pipe( + Effect.map((text) => ({ status: response.status, text })), + ), + ), + ); + }; +}); + +const issueUrl = PluginUploads.PluginUploads.use((uploads) => uploads.issueUrl); + +// A plugin with only a manifest. +const helloFiles = Effect.succeed({ + "t3-plugin.json": JSON.stringify({ + id: "hello-plugin", + name: "Hello", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), +}); + +const withHost = (body: Effect.Effect) => + body.pipe( + Effect.provide( + Layer.merge(layerHost(), PluginUploads.layer).pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(layerConfig()), + ), + ), + ); + +it.layer(NodeServices.layer)("PluginUploads", (it) => { + it.effect("installs an uploaded folder under the paths it was sent with", () => + withHost( + Effect.gen(function* () { + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "notes/read me.md": "Hello.", + }); + assert.strictEqual(response.status, 200); + assert.include(JSON.parse(response.text).plugin, { + id: "hello-plugin", + status: "disabled", + }); + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + assert.strictEqual( + yield* fs.readFileString(`${stateDir}/plugins/hello-plugin/notes/read me.md`), + "Hello.", + ); + }), + ), + ); + + it.effect("installs nothing from a forged, expired or used URL", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const files = yield* helloFiles; + const { relativeUrl: expiring } = yield* issueUrl; + const forged = `${expiring.slice(0, expiring.lastIndexOf(".") + 1)}AAAA`; + assert.strictEqual((yield* upload(forged, files)).status, 404); + yield* TestClock.adjust("6 minutes"); + assert.strictEqual((yield* upload(expiring, files)).status, 404); + assert.deepStrictEqual(yield* host.list, []); + + // It installs a plugin, so a URL that leaked after its upload installs nothing more. + const { relativeUrl } = yield* issueUrl; + assert.strictEqual((yield* upload(relativeUrl, files)).status, 200); + assert.strictEqual((yield* upload(relativeUrl, files)).status, 404); + }), + ), + ); + + it.effect("refuses a folder over the size limit before installing it", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "big.bin": new Uint8Array(PLUGIN_UPLOAD_MAX_BYTES + 1), + }); + assert.deepStrictEqual(response, { + status: 413, + text: "A plugin folder can have up to 1000 files and 25 MB.", + }); + assert.deepStrictEqual(yield* host.list, []); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/pluginUploads.ts b/apps/server/src/plugins/pluginUploads.ts new file mode 100644 index 0000000000000..0da56e6e078bd --- /dev/null +++ b/apps/server/src/plugins/pluginUploads.ts @@ -0,0 +1,210 @@ +import { + PLUGIN_UPLOAD_MAX_BYTES, + PLUGIN_UPLOAD_MAX_FILES, + type PluginCreateUploadUrlResult, + PluginMutationResult, + PluginUploadFiles, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerRequest, HttpServerResponse, Multipart } from "effect/http"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; + +/** + * A client installs a dropped plugin folder by uploading it here, to a URL `plugins.createUploadUrl` + * signed. Up to 25 MB as one WebSocket message would hold up every other stream on the socket. + * The signature is the upload's credential, as for attachments, so the client sends the request + * the same way on a local, remote or T3 Connect connection. Each URL installs once. + */ +export const PLUGIN_UPLOAD_ROUTE_PREFIX = "/api/plugin-uploads"; + +const SIGNING_SECRET_NAME = "plugin-upload-signing-key"; +// The client uploads as soon as it has the URL. +const UPLOAD_URL_TTL_MS = 5 * 60_000; + +const UploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("plugin-upload"), + expiresAt: Schema.Number, + // Tells apart URLs signed in the same millisecond, so each one is used up on its own. + nonce: Schema.String, +}); +const uploadClaimsJson = Schema.fromJsonString(UploadClaims); +const encodeUploadClaims = Schema.encodeSync(uploadClaimsJson); +const decodeUploadClaims = Schema.decodeUnknownOption(uploadClaimsJson); +const claimsOf = (payload: string) => { + try { + return decodeUploadClaims(base64UrlDecodeUtf8(payload)); + } catch { + return Option.none(); + } +}; +const decodeUploadFiles = Schema.decodeUnknownEffect(PluginUploadFiles); +const mutationResponse = HttpServerResponse.schemaJson(PluginMutationResult); + +export class PluginUploadUrlError extends Schema.TaggedError()( + "PluginUploadUrlError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not sign a plugin upload URL."; + } +} + +export class PluginUploads extends Context.Service< + PluginUploads, + { + /** Signs a URL that installs one uploaded folder within 5 minutes. */ + readonly issueUrl: Effect.Effect; + /** + * Whether `token` is a URL this server signed that is unexpired and unused. A valid one is used + * up, because it installs a plugin. + */ + readonly claim: (token: string) => Effect.Effect; + } +>()("t3/plugins/pluginUploads") {} + +const make = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const loadSigningSecret = secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + // Unused URLs by nonce, with when each expires. A restart forgets them, which only refuses more. + const unused = new Map(); + + const issueUrl = Effect.fn("PluginUploads.issueUrl")(function* () { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new PluginUploadUrlError({ cause })), + ); + const now = yield* Clock.currentTimeMillis; + for (const [nonce, expiresAt] of unused) if (expiresAt <= now) unused.delete(nonce); + const expiresAt = now + UPLOAD_URL_TTL_MS; + const nonce = yield* crypto + .randomUUIDv4() + .pipe(Effect.mapError((cause) => new PluginUploadUrlError({ cause }))); + const payload = base64UrlEncode( + encodeUploadClaims({ version: 1, kind: "plugin-upload", expiresAt, nonce }), + ); + unused.set(nonce, expiresAt); + const result: PluginCreateUploadUrlResult = { + relativeUrl: `${PLUGIN_UPLOAD_ROUTE_PREFIX}/${payload}.${signPayload(payload, secret)}`, + expiresAt, + }; + return result; + }); + + const claim = Effect.fn("PluginUploads.claim")(function* (token: string) { + const [payload, signature, unexpected] = token.split("."); + if (!payload || !signature || unexpected !== undefined) return false; + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the plugin upload signing key.", { cause }), + ), + Effect.option, + ); + if ( + Option.isNone(secret) || + !timingSafeEqualBase64Url(signature, signPayload(payload, secret.value)) + ) { + return false; + } + const claims = claimsOf(payload); + if (Option.isNone(claims) || claims.value.expiresAt <= (yield* Clock.currentTimeMillis)) { + return false; + } + return unused.delete(claims.value.nonce); + }); + + return PluginUploads.of({ issueUrl: issueUrl(), claim }); +}); + +export const layer = Layer.effect(PluginUploads, make); + +// The total leaves room for each part's headers, whose file name is a path escaped with +// encodeURIComponent; the schema then checks the exact limits. +const UPLOAD_LIMITS = Multipart.limitsServices({ + maxParts: PLUGIN_UPLOAD_MAX_FILES, + maxFileSize: PLUGIN_UPLOAD_MAX_BYTES, + maxTotalSize: PLUGIN_UPLOAD_MAX_BYTES + PLUGIN_UPLOAD_MAX_FILES * 10 * 1024, + // By default a JSON part is read as a field, which would drop t3-plugin.json. + fieldMimeTypes: [], +}); + +const text = (body: string, status: number) => HttpServerResponse.text(body, { status }); +const notAnUpload = text("The request is not a plugin folder.", 400); + +// A part named with a malformed escape names no file. +const decodePath = (name: string) => { + try { + return Option.some(decodeURIComponent(name)); + } catch { + return Option.none(); + } +}; + +/** `POST /`: installs the folder like `PluginInstaller.installFiles`. */ +export const layerRoute = HttpRouter.add( + "POST", + `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return text("Bad Request", 400); + const uploads = yield* PluginUploads; + // An expired, forged or used URL looks like no route at all. + if ( + !(yield* uploads.claim(url.value.pathname.slice(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`.length))) + ) { + return text("Not Found", 404); + } + // In memory, as `installFiles` takes them. + const received = yield* request.multipartStream.pipe( + Stream.filter(Multipart.isFile), + Stream.mapEffect((file) => + file.contentEffect.pipe( + Effect.map((contents) => + Option.map(decodePath(file.name), (path) => ({ path, contents })), + ), + ), + ), + Stream.runCollect, + Effect.provideContext(UPLOAD_LIMITS), + Effect.result, + ); + if (received._tag === "Failure") { + return received.failure.reason._tag === "Parse" || + received.failure.reason._tag === "InternalError" + ? notAnUpload + : text( + `A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_FILES} files and ${PLUGIN_UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, + 413, + ); + } + const named = Option.all(received.success); + if (Option.isNone(named)) return notAnUpload; + const files = yield* decodeUploadFiles(named.value).pipe(Effect.result); + if (files._tag === "Failure") return text(files.failure.message, 400); + const installer = yield* PluginInstaller.PluginInstaller; + return yield* installer.installFiles(files.success).pipe( + Effect.flatMap((plugin) => mutationResponse({ plugin }).pipe(Effect.orDie)), + Effect.catchTags({ + PluginInstallError: (error) => Effect.succeed(text(error.message, 400)), + PluginNotFoundError: (error) => Effect.succeed(text(error.message, 404)), + PluginConfigError: (error) => Effect.succeed(text(error.message, 500)), + }), + ); + }), +); diff --git a/apps/server/src/plugins/pluginsConfig.ts b/apps/server/src/plugins/pluginsConfig.ts index 8b4d6c8c1fa83..448e6d1f20a0a 100644 --- a/apps/server/src/plugins/pluginsConfig.ts +++ b/apps/server/src/plugins/pluginsConfig.ts @@ -80,6 +80,7 @@ export const makePluginsConfig = Effect.gen(function* () { ) : read ).pipe(Effect.tap((next) => Ref.set(current, next))), + write, writeEnabled: (pluginId: PluginId, enabled: boolean) => write((current) => ({ ...current, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fcbbc03b9c565..001d4b902c640 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -89,6 +89,7 @@ import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -682,6 +683,8 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), Layer.provideMerge(CodexManagedTokenBroker.layer), + // The upload route and plugins.createUploadUrl share one set of upload URLs. + Layer.provideMerge(PluginUploads.layer), Layer.provideMerge(layerServerEnvironment), Layer.provideMerge(layerAuth), Layer.provideMerge(ServerSecretStore.layer), @@ -741,6 +744,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAttachmentUploadRoute, GuestHttp.layer, PluginHost.layerWebFilesRoute, + PluginUploads.layerRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index c10d7ef9965cb..4e6c3b7a9a17c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -80,6 +80,8 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -531,6 +533,8 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(PluginHost.PluginHost)({}), + Layer.mock(PluginInstaller.PluginInstaller)({}), + Layer.mock(PluginUploads.PluginUploads)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f7b7390cbcacd..52a553cb94f43 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -122,6 +122,8 @@ import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; import { makePluginRpcHandlers } from "./plugins/pluginRpcHandlers.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; @@ -1276,6 +1278,8 @@ export const layerWsRpc = ( const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const pluginHost = yield* PluginHost.PluginHost; + const pluginInstaller = yield* PluginInstaller.PluginInstaller; + const pluginUploads = yield* PluginUploads.PluginUploads; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -1870,7 +1874,7 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.of({ - ...makePluginRpcHandlers(pluginHost, currentSession.scopes), + ...makePluginRpcHandlers(pluginHost, pluginInstaller, pluginUploads, currentSession.scopes), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, diff --git a/apps/web/src/components/settings/plugins/PluginInstallControls.tsx b/apps/web/src/components/settings/plugins/PluginInstallControls.tsx new file mode 100644 index 0000000000000..33c53d099052f --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginInstallControls.tsx @@ -0,0 +1,178 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { FolderDownIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn } from "../../../lib/utils"; +import { uploadPluginFolder } from "../../../plugins/pluginFiles"; +import { type PluginFolder, pickedFolder } from "../../../plugins/pluginFolders"; +import type { EnvironmentPresentation } from "../../../state/environments"; +import { pluginEnvironment } from "../../../state/plugins"; +import { Button } from "../../ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../../ui/dialog"; + +export function AddPluginButton(props: { + readonly environmentId: EnvironmentId; + readonly disabled: boolean; + readonly onClick: () => void; +}) { + const canInstall = useAtomValue( + pluginEnvironment.createPluginUploadUrl.permissionAtom(props.environmentId), + ); + return ( + + ); +} + +export function InstallProblems({ problems }: { readonly problems: ReadonlyArray }) { + if (problems.length === 0) return null; + return ( +
    + {problems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ); +} + +/** + * Installs plugins on `environment`. Plugins only change the app, so one does nothing there unless + * the app loads plugin UI from it, which `loadsUi` says. + */ +export function usePluginInstaller( + environment: Pick, + loadsUi: boolean, +) { + const [installing, setInstalling] = useState(null); + const [problems, setProblems] = useState>([]); + + const run = async (work: (report: (problem: string) => void) => Promise) => { + const found: Array = []; + setProblems([]); + await work((problem) => found.push(problem)); + setInstalling(null); + setProblems(found); + return found.length === 0; + }; + + return { + installing, + problems, + clearProblems: () => setProblems([]), + installFolders: (folders: ReadonlyArray) => { + // Outside `run`, which would end the install in progress as far as the page can tell. + if (installing !== null) { + setProblems([`${installing} is still installing. Add more once it's done.`]); + return Promise.resolve(false); + } + return run(async (report) => { + if (folders.length === 0) return report("Drop a plugin's folder, not the files in it."); + for (const folder of folders) { + setInstalling(folder.name); + const files = await folder.read().catch((error: unknown) => { + report(error instanceof Error ? error.message : `Could not read ${folder.name}.`); + return null; + }); + if (files === null) continue; + const plugin = await uploadPluginFolder(environment.environmentId, files).catch( + (error: unknown) => { + report(error instanceof Error ? error.message : "Could not install the plugin."); + return null; + }, + ); + if (plugin === null) continue; + if (!loadsUi) { + report( + `${plugin.name} only changes the app, and this app doesn't load plugin UI from ${environment.label}, so it does nothing there.`, + ); + } + } + }); + }, + }; +} + +export function AddPluginDialog({ + open, + onOpenChange, + installer, + dragActive, + scopeNote, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly installer: ReturnType; + readonly dragActive: boolean; + readonly scopeNote: string; +}) { + const folderInput = useRef(null); + const busy = installer.installing !== null; + return ( + + + + Add a plugin + {scopeNote} Install only plugins you trust. + + +
+ +

+ {busy ? `Installing ${installer.installing}…` : "Drop plugin folders here"} +

+

+ T3 Code copies each one into its plugins folder. +

+ + { + // Not in React's input props; it makes the picker choose folders, not files. + if (input !== null) input.webkitdirectory = true; + folderInput.current = input; + }} + type="file" + hidden + onChange={(event) => { + const folder = pickedFolder(Array.from(event.currentTarget.files ?? [])); + // Cleared so picking the same folder again still reports a change. + event.currentTarget.value = ""; + if (folder === undefined) return; + void installer + .installFolders([folder]) + .then((installed) => installed && onOpenChange(false)); + }} + /> +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/plugins/PluginList.tsx b/apps/web/src/components/settings/plugins/PluginList.tsx index 9bb3bae068a8e..ecba7e05ee7e6 100644 --- a/apps/web/src/components/settings/plugins/PluginList.tsx +++ b/apps/web/src/components/settings/plugins/PluginList.tsx @@ -22,14 +22,17 @@ export function PluginList({ const error = result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null; return ( <> + {children} - {children} {error === null ? null : } {plugins === null && error === null ? ( ) : plugins?.length === 0 && error === null ? ( - + ) : ( (plugins ?? []).map((plugin, index) => ( Promise) => { @@ -34,9 +36,10 @@ export function PluginRow({ await action(); setBusy(false); }; - // Actions send only the id, which names the copy in use. + const unlink = plugin.source === "dev"; + // Other actions send only the id, which names the copy in use. const duplicate = plugin.failure?.stage === "duplicate"; - const { failure } = plugin; + const { failure, folder } = plugin; const problems = [ ...(failure === undefined ? [] : [`${FAILURE_STAGES[failure.stage]}: ${failure.message}`]), ...(webFailure === undefined ? [] : [`Styles failed: ${webFailure}`]), @@ -66,7 +69,21 @@ export function PluginRow({ ) } control={ - duplicate ? undefined : ( + duplicate ? ( + folder !== undefined ? ( + + whileBusy(() => + uninstall({ environmentId, input: { pluginId: plugin.id, folder } }), + ) + } + /> + ) : undefined + ) : ( <> + + whileBusy(() => uninstall({ environmentId, input: { pluginId: plugin.id } })) + } + /> ); } + +/** The remove button and the popup that confirms it, or Unlink for a plugin loaded from a folder. */ +function RemovePluginPopover({ + name, + unlink, + folder, + disabled, + onRemove, +}: { + readonly name: string; + readonly unlink: boolean; + /** Named in the popup: the folder unlinked, or the duplicate's folder deleted. */ + readonly folder: string | undefined; + readonly disabled: boolean; + readonly onRemove: () => Promise; +}) { + const label = unlink ? "Unlink" : "Remove"; + return ( + + + } + > + + + +
+ {folder === undefined ? null : ( +

+ {unlink ? "Stops loading " : "Deletes "} + {folder} + {unlink ? ". The folder stays." : "."} +

+ )} +
+ }> + Cancel + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx index b01dd4152f377..e5ec8a75a0292 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.tsx +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -1,9 +1,22 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; import { BlocksIcon } from "lucide-react"; +import { type DragEvent, useState } from "react"; +import { cn } from "../../../lib/utils"; +import { pluginUiEnvironmentsAtom } from "../../../plugins/pluginFollow"; +import { droppedFolders } from "../../../plugins/pluginFolders"; import type { EnvironmentPresentation } from "../../../state/environments"; +import { pluginEnvironment } from "../../../state/plugins"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../../ui/empty"; import { useSettingsScope } from "../SettingsScopeContext"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "../settingsLayout"; +import { + AddPluginButton, + AddPluginDialog, + InstallProblems, + usePluginInstaller, +} from "./PluginInstallControls"; import { PluginList } from "./PluginList"; import { runsPlugins } from "./pluginTargets"; import { PluginUiEnvironmentRow } from "./PluginUiEnvironmentRow"; @@ -39,23 +52,108 @@ export function PluginsSettings() { return ; } -/** Lists the environment's plugins. */ +const NO_INSTALL_PERMISSION = Atom.make(false); + +/** Lists the environment's plugins. Plugin folders dropped anywhere on it are installed. */ function PluginsPage({ environment }: { readonly environment: EnvironmentPresentation }) { + const loadsUi = useAtomValue(pluginUiEnvironmentsAtom).includes(environment.environmentId); + const installer = usePluginInstaller(environment, loadsUi); + const [adding, setAdding] = useState(false); + const [dragActive, setDragActive] = useState(false); const runs = runsPlugins(environment); + const canInstall = useAtomValue( + runs + ? pluginEnvironment.createPluginUploadUrl.permissionAtom(environment.environmentId) + : NO_INSTALL_PERMISSION, + ); + const isFileDrag = (event: DragEvent) => event.dataTransfer.types.includes("Files"); return ( - {runs ? ( - - - - ) : ( - - { + if (!isFileDrag(event)) return; + event.preventDefault(); + setDragActive(canInstall); + }} + onDragOver={(event) => { + if (!isFileDrag(event)) return; + // Taken even without permission, so the browser doesn't open the folder instead. + event.preventDefault(); + event.dataTransfer.dropEffect = canInstall ? "copy" : "none"; + setDragActive(canInstall); + }} + onDragLeave={(event) => { + if (!isFileDrag(event)) return; + if ( + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + setDragActive(false); + }} + onDrop={(event) => { + if (!isFileDrag(event)) return; + event.preventDefault(); + setDragActive(false); + if (!canInstall) return; + void installer + .installFolders(droppedFolders(event.dataTransfer)) + .then((installed) => installed && setAdding(false)); + }} + > + {runs ? ( + + { + installer.clearProblems(); + setAdding(true); + }} + /> + } + > + + + + + ) : ( + + + + )} + {runs ? ( + - - )} + ) : null} +
); } diff --git a/apps/web/src/plugins/pluginFiles.ts b/apps/web/src/plugins/pluginFiles.ts index 9a3eea9cb44e7..e155751d20e72 100644 --- a/apps/web/src/plugins/pluginFiles.ts +++ b/apps/web/src/plugins/pluginFiles.ts @@ -1,10 +1,13 @@ import type { PreparedHttpAuthorization } from "@t3tools/client-runtime/connection"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, PluginState } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { type EnvironmentId, PluginMutationResult, type PluginState } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { pluginEnvironment } from "../state/plugins"; import { readPreparedConnection } from "../state/session"; +import type { PluginFolderFile } from "./pluginFolders"; // Bearer and cookie sessions can fetch plugin files directly. A DPoP proof needs the relay // signer, which only the client runtime holds. @@ -84,3 +87,36 @@ export async function withDownloadRetries( } return undefined; } + +const decodeMutationResult = Schema.decodeUnknownSync(Schema.fromJsonString(PluginMutationResult)); + +/** + * Installs a dropped plugin folder on the environment. The files go over HTTP to a URL the server + * signs, which needs no other credentials, so a large folder does not hold up the WebSocket. + */ +export async function uploadPluginFolder( + environmentId: EnvironmentId, + files: ReadonlyArray, +): Promise { + const minted = await runAtomCommand( + appAtomRegistry, + pluginEnvironment.createPluginUploadUrl, + { environmentId, input: {} }, + { reportFailure: false }, + ); + if (minted._tag !== "Success") { + const error = squashAtomCommandFailure(minted); + throw error instanceof Error ? error : new Error("Could not start the upload."); + } + const connection = readPreparedConnection(environmentId); + const url = + connection === null ? null : resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl); + if (url === null) throw new Error("The environment is not connected."); + const body = new FormData(); + // Escaped, so no browser or parser changes a path's quotes, slashes or other characters. + for (const { path, file } of files) body.append("file", file, encodeURIComponent(path)); + const response = await fetch(url, { method: "POST", body }); + const text = await response.text(); + if (!response.ok) throw new Error(text || `The upload failed with ${response.status}.`); + return decodeMutationResult(text).plugin; +} diff --git a/apps/web/src/plugins/pluginFolders.test.ts b/apps/web/src/plugins/pluginFolders.test.ts new file mode 100644 index 0000000000000..c081b99b82029 --- /dev/null +++ b/apps/web/src/plugins/pluginFolders.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { droppedFolders, pickedFolder } from "./pluginFolders"; + +const file = (name: string, text: string) => ({ + isFile: true, + isDirectory: false, + name, + file: (resolve: (file: File) => void) => resolve(new File([text], name)), +}); + +// Hands out entries 2 at a time, like Chromium does 100 at a time. +const folder = (name: string, entries: ReadonlyArray) => ({ + isFile: false, + isDirectory: true, + name, + createReader: () => { + let next = 0; + return { + readEntries: (resolve: (entries: ReadonlyArray) => void) => { + resolve(entries.slice(next, next + 2)); + next += 2; + }, + }; + }, +}); + +const drop = (entry: object) => + droppedFolders({ + items: [{ kind: "file", webkitGetAsEntry: () => entry }], + } as unknown as DataTransfer)[0]!; + +const picked = (path: string, text: string) => + Object.defineProperty(new File([text], path.split("/").at(-1)!), "webkitRelativePath", { + value: path, + }); + +describe("plugin folders", () => { + it("reads every batch of every dropped folder and leaves out git history and dependencies", async () => { + const files = await drop( + folder("surface", [ + file("t3-plugin.json", "{}"), + file("web.js", "draw()"), + folder("themes", [file("a.json", "a"), file("b.json", "b"), file("c.json", "c")]), + folder(".git", [file("HEAD", "ref")]), + folder("node_modules", [folder("react", [file("index.js", "react")])]), + ]), + ).read(); + expect(files.map((entry) => entry.path)).toEqual([ + "t3-plugin.json", + "web.js", + "themes/a.json", + "themes/b.json", + "themes/c.json", + ]); + expect(await files[1]?.file.text()).toBe("draw()"); + }); + + it("names a picked folder's files from inside it", async () => { + const folder = pickedFolder([ + picked("surface/t3-plugin.json", "{}"), + picked("surface/themes/dark.json", "dark"), + picked("surface/.git/HEAD", "ref"), + picked("surface/node_modules/react/index.js", "react"), + ]); + expect(folder?.name).toBe("surface"); + expect((await folder!.read()).map((entry) => entry.path)).toEqual([ + "t3-plugin.json", + "themes/dark.json", + ]); + }); + + it("refuses a folder that is not a plugin", async () => { + await expect( + drop(folder("photos", [file("a.png", ""), folder("2024", [])])).read(), + ).rejects.toThrow("photos has no t3-plugin.json"); + await expect(pickedFolder([picked("photos/a.png", "")])!.read()).rejects.toThrow( + "photos has no t3-plugin.json", + ); + }); +}); diff --git a/apps/web/src/plugins/pluginFolders.ts b/apps/web/src/plugins/pluginFolders.ts new file mode 100644 index 0000000000000..af46b2574e879 --- /dev/null +++ b/apps/web/src/plugins/pluginFolders.ts @@ -0,0 +1,116 @@ +import { PLUGIN_UPLOAD_MAX_BYTES, PLUGIN_UPLOAD_MAX_FILES } from "@t3tools/contracts"; + +const MANIFEST_FILE = "t3-plugin.json"; +const MAX_DEPTH = 32; + +/** A plugin folder the user dropped or picked, listed as the files `uploadPluginFolder` sends. */ +export interface PluginFolder { + readonly name: string; + /** Rejects with an error meant for the user when the folder is not a plugin or too large. */ + readonly read: () => Promise>; +} + +export interface PluginFolderFile { + /** Relative to the plugin folder, with `/` separators. */ + readonly path: string; + readonly file: File; +} + +// Version control history and installed dependencies are never part of a built plugin. +const LEFT_OUT = new Set([".git", "node_modules"]); +const isLeftOut = (path: string) => path.split("/").some((segment) => LEFT_OUT.has(segment)); + +const notAPlugin = (name: string) => + new Error(`${name} has no ${MANIFEST_FILE}, so it is not a T3 Code plugin.`); + +// Names the top-level entry holding the most bytes, which is usually the reason. +const tooLarge = (name: string, files: ReadonlyArray) => { + const bytesByEntry = new Map(); + for (const { path, file } of files) { + const entry = path.split("/")[0] ?? path; + bytesByEntry.set(entry, (bytesByEntry.get(entry) ?? 0) + file.size); + } + const [largest] = [...bytesByEntry].toSorted((a, b) => b[1] - a[1]); + return new Error( + `${name} is too large. A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_FILES} files and ${PLUGIN_UPLOAD_MAX_BYTES / 1024 / 1024} MB${largest === undefined ? "" : `, and most of it is in ${largest[0]}`}. Build the plugin and add the folder with only what it needs.`, + ); +}; + +function withinLimits(name: string, files: ReadonlyArray) { + const bytes = files.reduce((total, { file }) => total + file.size, 0); + if (files.length > PLUGIN_UPLOAD_MAX_FILES || bytes > PLUGIN_UPLOAD_MAX_BYTES) { + throw tooLarge(name, files); + } + return files; +} + +// `readEntries` hands out a folder's entries in batches and signals the end with an empty one. +async function entriesOf(folder: FileSystemDirectoryEntry) { + const reader = folder.createReader(); + const entries: Array = []; + for (;;) { + const batch = await new Promise>((resolve, reject) => + reader.readEntries(resolve, reject), + ); + if (batch.length === 0) return entries; + entries.push(...batch); + } +} + +async function readDroppedFolder(folder: FileSystemDirectoryEntry) { + const top = await entriesOf(folder); + // Checked first, so dropping a large folder that is not a plugin reads nothing more. + if (!top.some((entry) => entry.isFile && entry.name === MANIFEST_FILE)) { + throw notAPlugin(folder.name); + } + const files: Array = []; + let bytes = 0; + const visit = async (entries: ReadonlyArray, prefix: string, depth: number) => { + if (depth > MAX_DEPTH) throw tooLarge(folder.name, files); + for (const entry of entries) { + const path = `${prefix}${entry.name}`; + if (entry.isDirectory) { + if (isLeftOut(path)) continue; + await visit(await entriesOf(entry as FileSystemDirectoryEntry), `${path}/`, depth + 1); + continue; + } + const file = await new Promise((resolve, reject) => + (entry as FileSystemFileEntry).file(resolve, reject), + ); + bytes += file.size; + // Checked while walking, so a huge folder stops early. + if (files.length >= PLUGIN_UPLOAD_MAX_FILES || bytes > PLUGIN_UPLOAD_MAX_BYTES) { + throw tooLarge(folder.name, [...files, { path, file }]); + } + files.push({ path, file }); + } + }; + await visit(top, "", 0); + return withinLimits(folder.name, files); +} + +/** The dropped folders. Read during the drop event: the browser empties the transfer after it. */ +export function droppedFolders(dataTransfer: DataTransfer): ReadonlyArray { + return Array.from(dataTransfer.items).flatMap((item) => { + const entry = item.kind === "file" ? item.webkitGetAsEntry() : null; + if (entry?.isDirectory !== true) return []; + const folder = entry as FileSystemDirectoryEntry; + return [{ name: folder.name, read: () => readDroppedFolder(folder) }]; + }); +} + +/** The folder picked with a `webkitdirectory` file input, from the files it lists. */ +export function pickedFolder(files: ReadonlyArray): PluginFolder | undefined { + const name = files[0]?.webkitRelativePath.split("/")[0]; + if (name === undefined || name === "") return undefined; + return { + name, + read: async () => { + const inside = files + .map((file) => ({ path: file.webkitRelativePath.slice(name.length + 1), file })) + .filter(({ path }) => !isLeftOut(path)); + if (!inside.some(({ path }) => path === MANIFEST_FILE)) throw notAPlugin(name); + return withinLimits(name, inside); + }, + }; +} diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md index cbcfed4ff4757..8e92bd9688341 100644 --- a/docs/internals/plugins.md +++ b/docs/internals/plugins.md @@ -3,12 +3,14 @@ A plugin is a folder with a `t3-plugin.json` manifest ([schema](../../packages/contracts/src/plugins.ts)). [`PluginHost`](../../apps/server/src/plugins/PluginHost.ts) finds plugins, keeps them on or off -and publishes their themes. +and publishes their themes, and +[`PluginInstaller`](../../apps/server/src/plugins/PluginInstaller.ts) installs them. ## Manifests Manifests ignore keys they don't know, so a newer manifest key is lost silently on an older T3. -`engines.t3` is how a plugin requires a newer T3: the server refuses to start a version outside it. +`engines.t3` is how a plugin requires a newer T3: the server refuses to install or start a version +outside it. ## State lives beside T3's @@ -18,11 +20,23 @@ start when a fork's id collides with an upstream one. ## Installing -Discovery takes the id from the manifest, because people copy plugin folders in by hand. The -enabled flag belongs to the id, so a later folder with an id already listed fails as a duplicate. -Installed folders come first, in name order, then dev paths. Discovery also forgets the enabled flag -of every id no folder has, so a folder copied in later with that id starts off. Nothing watches -`plugins/`: the server reads it at startup, on reload and when Settings asks. +Installs copy into `plugin-staging/`, outside `plugins/`, so discovery never sees a half-written +plugin. Only the swap takes the lifecycle lock, so a slow copy does not block other plugins. The +old version waits in staging during the swap, and startup puts it back if a crash left it there. + +Clients upload a dropped folder's files, never its path, which would mean nothing on a remote +environment. They post them over HTTP to a URL `plugins.createUploadUrl` signs for 5 minutes, +because one large WebSocket message holds up every other stream on the socket. As for attachments, +the signature is the credential, so the upload works the same on every kind of connection. Each URL +installs once. The server keeps its unused URLs in memory, so a restart refuses those it signed +before. + +Discovery takes the id from the manifest, because people copy plugin folders in by hand. The enabled +flag belongs to the id, so a later folder with an id already listed fails as a duplicate. Installed +folders come first, in name order, then dev paths. Removing or unlinking a duplicate deletes only +its folder or link. Discovery also forgets the enabled flag of every id no folder has, so a folder +copied in later with that id starts off. Nothing watches `plugins/`: the server reads it at startup, +on reload and when Settings asks. ## Clients diff --git a/docs/user/plugins.md b/docs/user/plugins.md index 732216d389860..4e5da12a34724 100644 --- a/docs/user/plugins.md +++ b/docs/user/plugins.md @@ -2,14 +2,24 @@ Plugins change how T3 Code looks, with themes and styles. A plugin belongs to one environment and lives on that environment's T3 Code server. A plugin's row lists the permissions it says it uses, -but T3 Code doesn't enforce them. +but T3 Code doesn't enforce them. Install only plugins you trust. ## Add a plugin +Open **Settings → Plugins** and drag a plugin's folder onto the page. **Add plugin** also lets you +choose the folder in a file window. + +T3 Code copies a plugin folder into the environment's `plugins` folder, so later changes to your +copy do not affect the installed plugin. To update it, add the new version's folder the same way. +A new plugin is installed turned off, so none of it runs until you have read its row and turned it +on. A new version of a plugin stays on or off as the old one was. If a new version fails to install, +you keep the version you had, and T3 Code says why. A plugin that needs a newer version of T3 Code +isn't installed, and T3 Code says why. + Plugin folders you copy into the `plugins` folder yourself show up, turned off, the next time you open **Settings → Plugins**. That folder is `~/.t3/userdata/plugins` on the environment's machine by default. A folder with the same plugin id as one you already have doesn't run, and its row names -the folder that has that id. +the folder that has that id. Remove one of them. **Settings → Plugins** shows one environment at a time, the main one until you pick another at the top of the page. To use a plugin on several environments, add it to each of them. @@ -20,6 +30,16 @@ Use the switch on the plugin's row. A plugin that fails to start shows the reaso example when it needs a newer version of T3 Code. **Reload** restarts a plugin with its current files. +## Remove a plugin + +Choose **Remove** on the plugin's row and confirm in the popup. + +A plugin loaded from its own folder shows **Unlink** instead. Unlinking stops loading it and leaves +the folder where it is. + +On a duplicate's row, **Remove** deletes only that folder and **Unlink** only stops loading it. The +plugin in use keeps running. + ## Themes and styles A plugin can change how T3 Code looks without running any code. Theme files in its `themes` folder diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index e820879eafb6f..fcd7a3e0b4b87 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -98,10 +98,13 @@ describe("command permissions", () => { it.effect.each([ WS_METHODS.serverRunStorageCleanup, - // Enabling, reloading or rescanning plugins changes what every client shows. + // Installing, removing, enabling, reloading or rescanning plugins changes what every client + // shows. WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload, + WS_METHODS.pluginsCreateUploadUrl, WS_METHODS.pluginsRescan, + WS_METHODS.pluginsUninstall, ])("requires the destination settings grant for %s", (method) => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/plugins.ts b/packages/client-runtime/src/state/plugins.ts index b3ccbaa4fa75f..34f8300e1eb68 100644 --- a/packages/client-runtime/src/state/plugins.ts +++ b/packages/client-runtime/src/state/plugins.ts @@ -39,10 +39,19 @@ export function createPluginEnvironmentAtoms( label: "environment-data:plugins:reload", tag: WS_METHODS.pluginsReload, }), + // Signs the URL a dropped folder is uploaded to; see `PluginCreateUploadUrlResult`. + createPluginUploadUrl: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugins:create-upload-url", + tag: WS_METHODS.pluginsCreateUploadUrl, + }), rescanPlugins: createEnvironmentRpcCommand(runtime, { label: "environment-data:plugins:rescan", tag: WS_METHODS.pluginsRescan, }), + uninstallPlugin: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugins:uninstall", + tag: WS_METHODS.pluginsUninstall, + }), // Plugin stylesheets are plain GETs; over T3 Connect each needs its own DPoP proof. pluginFileHeaders: createRuntimeCommand(runtime, { label: "environment-data:plugins:file-headers", diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 387781106086f..a467aaafae381 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -60,7 +60,9 @@ export const CLIENT_GUARDED_RPC_SCOPES = { // Enabling a plugin changes what every client of the environment shows, such as its themes. [WS_METHODS.pluginsSetEnabled]: AuthSettingsWriteScope, [WS_METHODS.pluginsReload]: AuthSettingsWriteScope, + [WS_METHODS.pluginsCreateUploadUrl]: AuthSettingsWriteScope, [WS_METHODS.pluginsRescan]: AuthSettingsWriteScope, + [WS_METHODS.pluginsUninstall]: AuthSettingsWriteScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/plugins.test.ts b/packages/contracts/src/plugins.test.ts index 53640279207fa..cd28c296538c3 100644 --- a/packages/contracts/src/plugins.test.ts +++ b/packages/contracts/src/plugins.test.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { PluginManifest } from "./plugins.ts"; +import { PLUGIN_UPLOAD_MAX_BYTES, PluginManifest, PluginUploadFiles } from "./plugins.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); @@ -38,3 +38,27 @@ describe("PluginManifest", () => { expect(() => decodeManifest({ ...manifest, ...override })).toThrow(); }); }); + +describe("PluginUploadFiles", () => { + const decodeFiles = Schema.decodeUnknownSync(PluginUploadFiles); + + it("refuses paths outside the folder", () => { + const contents = new TextEncoder().encode("hi"); + expect(decodeFiles([{ path: "dist/web.js", contents }])[0]).toEqual({ + path: "dist/web.js", + contents, + }); + for (const path of ["../outside.js", "/etc/passwd", "C:/plugin/web.js", "dist\\web.js"]) { + expect(() => decodeFiles([{ path, contents }])).toThrow(); + } + }); + + it("refuses a folder over the size limit with a reason", () => { + const half = new Uint8Array(PLUGIN_UPLOAD_MAX_BYTES / 2 + 1); + const files = [ + { path: "a.bin", contents: half }, + { path: "b.bin", contents: half }, + ]; + expect(() => decodeFiles(files)).toThrow("A plugin folder can have up to 25 MB."); + }); +}); diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index 15e6d9183856e..c8dff11849d9e 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -29,6 +29,16 @@ const PluginEngineRange = TrimmedNonEmptyString.check( }), ); +// Lexical check only. Code that reads or serves the file still resolves symlinks. +const isInsidePluginDirectory = Schema.makeFilter( + (path: string) => + !path.startsWith("/") && + !path.includes("\\") && + !/^[a-zA-Z]:/.test(path) && + path.split("/").every((segment) => segment !== ".."), + { expected: "a relative path inside the plugin directory" }, +); + /** `t3-plugin.json` at the root of a plugin directory. */ export const PluginManifest = Schema.Struct({ id: PluginId, @@ -140,10 +150,57 @@ export const PluginReloadInput = Schema.Struct({ }); export type PluginReloadInput = typeof PluginReloadInput.Type; +/** Limits on a plugin folder a client uploads to the URL `plugins.createUploadUrl` returns. */ +export const PLUGIN_UPLOAD_MAX_FILES = 1_000; +export const PLUGIN_UPLOAD_MAX_BYTES = 25 * 1024 * 1024; + +/** One file of a plugin folder dropped into a client, by its path inside that folder. */ +export const PluginUploadFile = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(1024), isInsidePluginDirectory), + contents: Schema.Uint8Array, +}); +export type PluginUploadFile = typeof PluginUploadFile.Type; + +/** The files of an uploaded plugin folder, which the server checks before it installs them. */ +export const PluginUploadFiles = Schema.Array(PluginUploadFile).check( + Schema.isMaxLength(PLUGIN_UPLOAD_MAX_FILES, { + message: `A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_FILES} files.`, + }), + Schema.makeFilter( + (files) => + files.reduce((total, file) => total + file.contents.byteLength, 0) <= PLUGIN_UPLOAD_MAX_BYTES, + { message: `A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_BYTES / 1024 / 1024} MB.` }, + ), +); + +export const PluginCreateUploadUrlInput = Schema.Struct({}); +export type PluginCreateUploadUrlInput = typeof PluginCreateUploadUrlInput.Type; + +/** + * A signed URL, relative to the environment, that takes one plugin folder as multipart form data: + * a `file` part per file, named with its path through `encodeURIComponent`. It answers with a + * `PluginMutationResult`, or a status and the reason as text. + */ +export const PluginCreateUploadUrlResult = Schema.Struct({ + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: Schema.Number, +}); +export type PluginCreateUploadUrlResult = typeof PluginCreateUploadUrlResult.Type; + export const PluginRescanInput = Schema.Struct({}); export type PluginRescanInput = typeof PluginRescanInput.Type; -/** Result of `plugins.setEnabled` and `plugins.reload`. */ +export const PluginUninstallInput = Schema.Struct({ + pluginId: PluginId, + /** A row's `folder`, to remove or unlink that copy. A `duplicate` copy goes alone. */ + folder: Schema.optionalKey(Schema.String), +}); +export type PluginUninstallInput = typeof PluginUninstallInput.Type; + +export const PluginUninstallResult = Schema.Struct({ pluginId: PluginId }); +export type PluginUninstallResult = typeof PluginUninstallResult.Type; + +/** Result of `plugins.setEnabled`, `plugins.reload` and a folder upload. */ export const PluginMutationResult = Schema.Struct({ plugin: PluginState, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 01d633e328078..1cc56b3f9a03b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -376,7 +376,11 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { + PluginCreateUploadUrlInput, + PluginCreateUploadUrlResult, PluginError, + PluginUninstallInput, + PluginUninstallResult, PluginListInput, PluginListResult, PluginRescanInput, @@ -562,7 +566,9 @@ export const WS_METHODS = { pluginsSubscribeStates: "plugins.subscribeStates", pluginsSetEnabled: "plugins.setEnabled", pluginsReload: "plugins.reload", + pluginsCreateUploadUrl: "plugins.createUploadUrl", pluginsRescan: "plugins.rescan", + pluginsUninstall: "plugins.uninstall", cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -1994,6 +2000,13 @@ const WsPluginsReloadRpc = Rpc.make(WS_METHODS.pluginsReload, { error: PluginRpcError, }); +/** Signs the URL a client uploads a dropped plugin folder to, over HTTP. */ +const WsPluginsCreateUploadUrlRpc = Rpc.make(WS_METHODS.pluginsCreateUploadUrl, { + payload: PluginCreateUploadUrlInput, + success: PluginCreateUploadUrlResult, + error: PluginRpcError, +}); + /** Looks at the plugins folder again, so plugin folders put there since are listed. */ const WsPluginsRescanRpc = Rpc.make(WS_METHODS.pluginsRescan, { payload: PluginRescanInput, @@ -2001,6 +2014,12 @@ const WsPluginsRescanRpc = Rpc.make(WS_METHODS.pluginsRescan, { error: PluginRpcError, }); +const WsPluginsUninstallRpc = Rpc.make(WS_METHODS.pluginsUninstall, { + payload: PluginUninstallInput, + success: PluginUninstallResult, + error: PluginRpcError, +}); + const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, @@ -2090,7 +2109,9 @@ export const WsRpcGroup = RpcGroup.make( WsPluginsSubscribeStatesRpc, WsPluginsSetEnabledRpc, WsPluginsReloadRpc, + WsPluginsCreateUploadUrlRpc, WsPluginsRescanRpc, + WsPluginsUninstallRpc, WsServerReportClientActivityRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index f03ce445ee896..2a6428dd5822d 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -21,7 +21,7 @@ written by hand and need no build. - `id` uses lowercase letters, digits and hyphens, starts with a letter and has up to 32 characters. It names the plugin's folder. - `engines.t3` is a version range made of `^`, `>=`, `>`, `<=`, `<`, `=` and `||`, such as - `>=0.1.6`. A server outside it refuses to start the plugin, and says why. + `>=0.1.6`. A server outside it refuses to install the plugin or start it, and says why. - `permissions` is an optional list of what the plugin says it uses. Settings → Plugins shows it on the plugin's row, and T3 Code doesn't enforce it. - A `themes/` folder of theme JSON files is published while the plugin is active. T3 Code @@ -67,5 +67,9 @@ parts of your design to survive that. Build each part on the most stable thing T ## Developing a plugin Add the plugin's folder to `devPaths` in `plugins.json` in the T3 Code data directory, turn the -plugin on in Settings → Plugins, and use **Reload** after each change. A linked folder whose id an -installed plugin already has fails as a duplicate, so remove the installed copy first. +plugin on in Settings → Plugins, and use **Reload** after each change. **Unlink** on its row removes +it from `devPaths` and leaves the folder alone. A linked folder whose id an installed plugin already +has fails as a duplicate, so remove the installed copy first. + +To install a finished plugin, drag its folder onto Settings → Plugins, or use **Add plugin** to +choose it. Unlink a linked copy first. It installs turned off, so turn it on on its row. From 4366daefdb72489b1fd948cdfd3de98aacd88f50 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 02:57:09 +0000 Subject: [PATCH 05/11] feat(plugins): plugins run server code A plugin could only restyle the app; it had no way to run logic on the server. A manifest's `server` entry loads through Node's CommonJS loader and activates with a small API: RPC methods and channels that clients reach through `plugins.invoke` and `plugins.subscribe`, a logger and `onDeactivate`. Activation, calls and cleanup have deadlines. A failure guard puts a plugin's unhandled rejection on its row instead of stopping the server, a plugin that does stop it stays off after the restart, and a new version that fails to start puts the old one back. Below Node 24, where tracking plugin code slows every promise, a server half fails as incompatible. The `@t3tools/plugin-sdk` package starts here, with the types a server half is written against and an `api.version` a plugin checks before using a newer API member. Surfaces: failures show in Settings, web and desktop; mobile shows none. No providers; agent tools come later. Contracts: two RPCs, three failure stages. Reverse: turning off or reloading releases what a plugin registered. Connections: WebSocket only. Docs updated. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 2 +- apps/server/src/auth/RpcAuthorization.ts | 4 + .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/plugins/PluginApi.ts | 108 ++++ apps/server/src/plugins/PluginHost.test.ts | 592 +++++++++++++++++- apps/server/src/plugins/PluginHost.ts | 419 ++++++++++++- apps/server/src/plugins/PluginInstaller.ts | 48 +- apps/server/src/plugins/api/facade.ts | 68 ++ .../src/plugins/pluginFailureGuard.test.ts | 119 ++++ apps/server/src/plugins/pluginFailureGuard.ts | 146 +++++ apps/server/src/plugins/pluginFailures.ts | 82 +++ .../src/plugins/pluginHostTestHarness.ts | 24 +- apps/server/src/plugins/pluginRpcHandlers.ts | 22 +- apps/server/src/plugins/pluginStates.ts | 25 +- apps/server/src/plugins/pluginUploads.test.ts | 2 +- apps/server/src/plugins/pluginUploads.ts | 2 +- .../server/src/plugins/pluginWebFiles.test.ts | 4 + apps/server/src/plugins/pluginsConfig.ts | 57 +- .../plugins/PluginInstallControls.tsx | 10 +- .../components/settings/plugins/PluginRow.tsx | 14 +- .../settings/plugins/PluginsSettings.tsx | 2 +- .../src/plugins/pluginStyles.browser.test.ts | 1 + docs/internals/plugins.md | 65 +- docs/user/plugins.md | 22 +- knip.jsonc | 4 + package.json | 2 +- .../src/state/commandPermissions.test.ts | 3 +- .../contracts/src/clientRpcPermissions.ts | 2 +- packages/contracts/src/plugins.test.ts | 5 + packages/contracts/src/plugins.ts | 56 +- packages/contracts/src/rpc.ts | 21 + packages/plugin-sdk/README.md | 53 +- packages/plugin-sdk/package.json | 30 + packages/plugin-sdk/src/server.ts | 78 +++ packages/plugin-sdk/src/shared.ts | 20 + packages/plugin-sdk/tsconfig.json | 10 + pnpm-lock.yaml | 9 + scripts/release-smoke.ts | 1 + 39 files changed, 2033 insertions(+), 102 deletions(-) create mode 100644 apps/server/src/plugins/PluginApi.ts create mode 100644 apps/server/src/plugins/api/facade.ts create mode 100644 apps/server/src/plugins/pluginFailureGuard.test.ts create mode 100644 apps/server/src/plugins/pluginFailureGuard.ts create mode 100644 apps/server/src/plugins/pluginFailures.ts create mode 100644 packages/plugin-sdk/package.json create mode 100644 packages/plugin-sdk/src/server.ts create mode 100644 packages/plugin-sdk/src/shared.ts create mode 100644 packages/plugin-sdk/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 0dacbbe90720c..a06e2346d534c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -77,6 +77,7 @@ "@effect/vitest": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/secret-provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 329cd66c6f4a4..0095cf478d260 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,7 +68,7 @@ describe("RPC authorization scopes", () => { } }); - it("keeps enabling and reloading plugins behind settings write", () => { + it("keeps enabling and reloading plugins, which runs their code, behind settings write", () => { for (const method of [WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index dd4fbf4442c01..a42b96976d4c6 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -205,8 +205,12 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, + // `plugins.invoke` also needs the operate scope for methods a plugin registered as writes; + // PluginHost checks that against the plugin's own declaration. Channels are reads. [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 473539487a3ca..9754f075a8d08 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -94,6 +94,8 @@ const RPC_AGGREGATES = { [WS_METHODS.pluginsCreateUploadUrl]: "plugins", [WS_METHODS.pluginsRescan]: "plugins", [WS_METHODS.pluginsUninstall]: "plugins", + [WS_METHODS.pluginsInvoke]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginApi.ts b/apps/server/src/plugins/PluginApi.ts new file mode 100644 index 0000000000000..4d54affaded1b --- /dev/null +++ b/apps/server/src/plugins/PluginApi.ts @@ -0,0 +1,108 @@ +import { PluginRpcName, type PluginId } from "@t3tools/contracts"; +import type { PluginApi, PluginRpcAccess, PluginRpcHandler } from "@t3tools/plugin-sdk/server"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type * as Cause from "effect/Cause"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; + +import { makeFacadeContext, pluginCodeScope } from "./api/facade.ts"; + +const isJson = Schema.is(Schema.Json); +const isPluginRpcName = Schema.is(PluginRpcName); +const isPluginRpcAccess = Schema.is(Schema.Literals(["read", "write"])); + +/** Everything one activation registered. The host routes calls through it and tears it down. */ +export interface PluginActivation { + readonly methods: Map< + string, + { readonly handler: PluginRpcHandler; readonly access: PluginRpcAccess } + >; + readonly cleanups: Array<() => void | Promise>; + /** Aborted when deactivation starts; handler signals follow it. */ + readonly controller: AbortController; + /** Aborted once the cleanups have run; channels end then. */ + readonly ended: AbortController; + /** + * Each subscriber's buffer, by channel, so a busy channel or a slow subscriber drops only its + * own values. The host ends them when `ended` aborts. + */ + readonly channels: Map>>; +} + +export const makePluginActivation = (): PluginActivation => ({ + methods: new Map(), + cleanups: [], + controller: new AbortController(), + ended: new AbortController(), + channels: new Map(), +}); + +const assertActive = (activation: PluginActivation) => { + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } +}; + +/** Builds the object a plugin's `activate` receives. Effect stays on this side. */ +export const makePluginApi = (input: { + readonly id: PluginId; + readonly rootDir: string; + readonly activation: PluginActivation; + readonly context: Context.Context; +}): PluginApi => { + const { id, activation, context } = input; + const facade = makeFacadeContext({ pluginId: id, context }); + const log = + (level: "Debug" | "Info" | "Warn" | "Error") => (message: string, fields?: Schema.JsonObject) => + facade.fork( + Effect.logWithLevel(level)(message, fields ?? {}).pipe( + Effect.annotateLogs({ pluginId: id }), + ), + ); + + return { + version: 1, + id, + rootDir: input.rootDir, + log: { debug: log("Debug"), info: log("Info"), warn: log("Warn"), error: log("Error") }, + rpc: { + register: (method, handler, options) => { + assertActive(activation); + if (!isPluginRpcName(method)) + throw new TypeError(`"${method}" is not a valid method name.`); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isPluginRpcAccess(options.access)) { + throw new TypeError(`Method "${method}" needs access "read" or "write".`); + } + if (activation.methods.has(method)) { + throw new Error(`Method "${method}" is already registered.`); + } + activation.methods.set(method, { handler, access: options.access }); + }, + publish: (channel, value) => { + assertActive(activation); + if (!isPluginRpcName(channel)) { + throw new TypeError(`"${channel}" is not a valid channel name.`); + } + if (!isJson(value)) throw new TypeError("Published values must be JSON."); + // Waking a subscriber schedules T3's work, which must not run in the plugin's scope. + pluginCodeScope.exit(() => { + for (const queue of activation.channels.get(channel) ?? []) { + Queue.offerUnsafe(queue, value); + } + }); + }, + }, + // Something created after deactivation has nothing left to wait for, so it cleans up now. + onDeactivate: (cleanup) => { + if (activation.controller.signal.aborted) { + void Promise.resolve() + .then(cleanup) + .catch(() => undefined); + return; + } + activation.cleanups.push(cleanup); + }, + }; +}; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 1923e7627c177..b8e83d79800a6 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -17,9 +17,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; import * as PluginHost from "./PluginHost.ts"; import { layerConfig, @@ -29,6 +31,7 @@ import { stateOf, waitForStatuses, writeFile, + writeInstalledPlugin, writePluginsConfig, } from "./pluginHostTestHarness.ts"; import * as PluginInstaller from "./PluginInstaller.ts"; @@ -49,43 +52,43 @@ const manifestJson = ( engines: { t3: options.engines ?? ">=0.1.0" }, }); -it.layer(NodeServices.layer)("PluginHost", (it) => { - it.effect("saves plugins turned on and off, reloads a changed manifest and rescans folders", () => - Effect.gen(function* () { - const { stateDir } = yield* ServerConfig.ServerConfig; - const manifestPath = (id: string) => `${stateDir}/plugins/${id}/t3-plugin.json`; - yield* writeFile(manifestPath("reloading"), manifestJson("reloading")); - yield* writeFile(manifestPath("goes"), manifestJson("goes")); - yield* writePluginsConfig({ enabled: { goes: true } }); - yield* Effect.gen(function* () { - const host = yield* PluginHost.PluginHost; - const fs = yield* FileSystem.FileSystem; - const reloading = PluginId.make("reloading"); - yield* waitForStatuses(host, { reloading: "disabled", goes: "active" }); - assert.include(yield* host.setEnabled(reloading, true), { status: "active" }); - yield* host.setEnabled(PluginId.make("goes"), false); - assert.deepStrictEqual(JSON.parse(yield* fs.readFileString(`${stateDir}/plugins.json`)), { - enabled: { goes: false, reloading: true }, - }); +/** A `linked` plugin in a checkout folder, which the plugins folder holds a symlink to. */ +const linkPluginFolder = Effect.fn(function* (entries: { readonly server?: string }) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const realDir = `${stateDir}/checkout/linked`; + yield* writeFile( + `${realDir}/t3-plugin.json`, + JSON.stringify({ + id: "linked", + name: "Linked", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + ...entries, + }), + ); + yield* fs.makeDirectory(`${stateDir}/plugins`, { recursive: true }); + yield* fs.symlink(realDir, `${stateDir}/plugins/linked`); + return realDir; +}); - const reloaded = manifestJson("reloading", { version: "0.2.0" }); - yield* writeFile(manifestPath("reloading"), reloaded); - assert.include(yield* host.reload(reloading), { status: "active", version: "0.2.0" }); - - // A rescan lists a folder copied in since, turned off, and drops one that was removed. - yield* writeFile(manifestPath("arrives"), manifestJson("arrives")); - yield* fs.remove(`${stateDir}/plugins/goes`, { recursive: true }); - const { plugins } = yield* host.rescan; - assert.include(stateOf(plugins, "arrives"), { status: "disabled", enabled: false }); - assert.isUndefined(stateOf(plugins, "goes")); - // plugins.json forgets the switch of a folder that is gone. - assert.deepStrictEqual(JSON.parse(yield* fs.readFileString(`${stateDir}/plugins.json`)), { - enabled: { reloading: true }, - }); - }).pipe(Effect.provide(layerHost())); - }).pipe(Effect.provide(layerConfig())), +const leaky = PluginId.make("leaky"); +// Fails outside a call: in a timer a method sets, or in a read a method never awaits. +const leakyServer = `module.exports = { activate(api) { + api.rpc.register("rejectLater", () => { setTimeout(() => { Promise.reject(new Error("rejected in a timer")); }, 0); }, { access: "read" }); + api.rpc.register("readMissing", () => { void require("node:fs/promises").readFile("/t3-plugin-test-missing-file"); }, { access: "read" }); +} };`; + +/** The next failure outside a call that the plugin's row shows. */ +const nextServerFailure = (host: PluginHost.PluginHost["Service"], pluginId: PluginId) => + host.streamStates.pipe( + Stream.map((states) => stateOf(states, pluginId)?.serverFailure), + Stream.filter((failure) => failure !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), ); +it.layer(NodeServices.layer)("PluginHost", (it) => { it.effect("publishes the theme folders of plugins that are on", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -110,6 +113,128 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerConfig())), ); + it.effect("reload picks up a changed manifest and changed code", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "reloading", + `module.exports = { activate(api) { api.rpc.register("which", () => "v1", { access: "read" }); } };`, + ); + yield* writePluginsConfig({ enabled: { reloading: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("reloading"); + yield* waitForStatuses(host, { reloading: "active" }); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "reloading", + name: "Reloaded", + version: "0.2.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile( + `${rootDir}/server.cjs`, + `module.exports = { activate(api) { api.rpc.register("which", () => "v2", { access: "read" }); } };`, + ); + const reloaded = yield* host.reload(id); + assert.include(reloaded, { status: "active", version: "0.2.0", name: "Reloaded" }); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin that registers a method with an unknown access level", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "typo", + `module.exports = { activate(api) { api.rpc.register("go", () => 1, { access: "Write" }); } };`, + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writePluginsConfig({ enabled: { typo: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { typo: "failed" }); + assert.deepStrictEqual(stateOf(states, "typo")?.failure, { + stage: "activate", + message: 'Method "go" needs access "read" or "write".', + }); + // Nor does it serve its files. + const served = yield* host.readWebFile({ + pluginId: "typo", + path: "/styles/base.css", + revision: undefined, + }); + assert.isTrue(Option.isNone(served)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload stops a plugin whose folder was removed", () => + Effect.gen(function* () { + const stays = yield* writeInstalledPlugin("stays", `module.exports = { activate() {} };`); + const goes = yield* writeInstalledPlugin( + "goes", + `const marker = require("node:path").join(__dirname, "..", "..", "goes-cleaned"); +module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").writeFileSync(marker, "")); } };`, + ); + yield* writePluginsConfig({ enabled: { stays: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { stays: "active", goes: "active" }); + yield* fs.remove(goes, { recursive: true }); + + yield* host.reload(PluginId.make("stays")); + + assert.isTrue(yield* fs.exists(`${stays}/../../goes-cleaned`)); + assert.isUndefined(stateOf(yield* host.list, "goes")); + + // A folder copied in later with its id is a plugin the user hasn't seen, so it starts off, + // and reloading the gone plugin still lists it. + yield* writeInstalledPlugin("goes", `module.exports = { activate() {} };`); + const missing = yield* host.reload(PluginId.make("arrives")).pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotFoundError"); + assert.include(stateOf(yield* host.list, "goes"), { status: "disabled", enabled: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload runs new code from a symlinked plugin folder", () => + Effect.gen(function* () { + const realDir = yield* linkPluginFolder({ server: "./server.cjs" }); + const plugin = (version: string) => + `module.exports = { activate(api) { api.rpc.register("which", () => "${version}", { access: "read" }); } };`; + yield* writeFile(`${realDir}/server.cjs`, plugin("v1")); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("linked"); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + yield* waitForStatuses(host, { linked: "active" }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile(`${realDir}/server.cjs`, plugin("v2")); + yield* host.reload(id); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("unlinks a plugin loaded from devPaths and leaves its folder", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -216,6 +341,285 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerConfig())), ); + it.effect("lists a plugin's rejection in a timer on its row instead of stopping the server", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "rejectLater", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* nextServerFailure(host, leaky), "rejected in a timer"); + assert.include(stateOf(yield* host.list, leaky), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failed read a plugin never awaited, though its stack is all Node's", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "readMissing", + input: undefined, + scopes: operate, + }); + assert.include(yield* nextServerFailure(host, leaky), "ENOENT"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failure of code a turned-off plugin left running on its row", () => + Effect.gen(function* () { + const id = PluginId.make("leftover"); + yield* writeInstalledPlugin( + id, + `module.exports = { activate(api) { + api.onDeactivate(() => { setTimeout(() => { Promise.reject(new Error("left running")); }, 0); }); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "active" }); + yield* host.setEnabled(id, false); + assert.strictEqual(yield* nextServerFailure(host, id), "left running"); + assert.include(stateOf(yield* host.list, id), { enabled: false, status: "disabled" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("clears a failure from a start that failed once the plugin is turned off", () => + Effect.gen(function* () { + const id = PluginId.make("leaky-start"); + // A timer it sets while starting fails, then it fails to start. + yield* writeInstalledPlugin( + id, + `module.exports = { activate() { + setTimeout(() => { Promise.reject(new Error("rejected while starting")); }, 0); + return new Promise((_, reject) => setTimeout(() => reject(new Error("activate failed")), 0)); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "failed" }); + assert.strictEqual(yield* nextServerFailure(host, id), "rejected while starting"); + assert.isUndefined((yield* host.setEnabled(id, false)).serverFailure); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not start a plugin that stopped the server until it is turned on again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootDir = yield* writeInstalledPlugin( + "crashing", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* writePluginsConfig({ enabled: { crashing: true } }); + const id = PluginId.make("crashing"); + const thrown = new Error("thrown in a timer"); + thrown.stack = `Error: thrown in a timer\n at ${yield* fs.realPath(rootDir)}/server.cjs:1:1`; + yield* Effect.gen(function* () { + yield* waitForStatuses(yield* PluginHost.PluginHost, { crashing: "active" }); + // As Node reports an uncaught exception, just before it exits. + process.emit("uncaughtExceptionMonitor", thrown); + }).pipe(Effect.provide(layerHost())); + yield* fs.remove(`${rootDir}/loaded`); + + // The next start lists it as failed and leaves its code unloaded. + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { crashing: "failed" }); + // Its switch shows it off, so turning it on is how the user retries it. + assert.deepInclude(stateOf(states, id), { + enabled: false, + failure: { stage: "crash", message: "thrown in a timer" }, + }); + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + assert.include(yield* host.setEnabled(id, true), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + // Turned on again, it starts with the server again. + yield* Effect.gen(function* () { + yield* waitForStatuses(yield* PluginHost.PluginHost, { crashing: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps a quiet channel's value while a busy channel overflows its buffer", () => + Effect.gen(function* () { + yield* writeInstalledPlugin( + "channels", + `module.exports = { activate(api) { + api.rpc.register("wake", () => api.rpc.publish("quiet", "wake"), { access: "read" }); + api.rpc.register("burst", () => { + for (let index = 0; index < 100; index++) api.rpc.publish("busy", index); + api.rpc.publish("quiet", "after"); + }, { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { channels: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("channels"); + const call = (method: string) => + host.invoke({ pluginId: id, method, input: undefined, scopes: operate }); + yield* waitForStatuses(host, { channels: "active" }); + // A subscriber still busy with its first value, as one whose socket is slow to send. + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const quiet = yield* host.subscribe({ pluginId: id, channel: "quiet" }).pipe( + Stream.mapEffect((value) => + Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(value), + ), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* call("wake"); + yield* Deferred.await(reached); + yield* call("burst"); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(yield* Fiber.join(quiet), ["wake", "after"]); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("gives up on a method that does not answer within 30 seconds, and aborts it", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostInvokeStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostInvokeAborted", aborted.resolve); + yield* writeInstalledPlugin( + "hangs", + `module.exports = { activate(api) { + api.rpc.register("wait", (_input, { signal }) => new Promise(() => { + signal.addEventListener("abort", () => globalThis.__t3PluginHostInvokeAborted()); + globalThis.__t3PluginHostInvokeStarted(); + }), { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { hangs: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("hangs"); + yield* waitForStatuses(host, { hangs: "active" }); + const call = yield* host + .invoke({ pluginId: id, method: "wait", input: undefined, scopes: operate }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual( + (yield* Fiber.join(call)).message, + "Plugin hangs did not answer wait within 30 seconds.", + ); + yield* Effect.promise(() => aborted.promise); + assert.include(stateOf(yield* host.list, id), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeAborted"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("finishes disabling a plugin when the caller gives up, or while it starts", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", gate.promise); + yield* writeInstalledPlugin( + "stubborn", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { stubborn: true } }); + // Every publish that changes the theme folders hands them over, and holds there while `held` + // is set. + const themeFolders: Array> = []; + let held: + | { readonly reached: Deferred.Deferred; readonly release: Deferred.Deferred } + | undefined; + const layerHeldPublish = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => { + themeFolders.push(directories); + return held === undefined + ? Effect.void + : Deferred.succeed(held.reached, undefined).pipe( + Effect.andThen(Deferred.await(held.release)), + ); + }, + }); + // A disable's last wait before it stops the plugin is removing the temporary folder of its + // plugins.json write. A fork runs only once the disable waits again, which is at that stop. + let releaseOnWrite: Deferred.Deferred | undefined; + const layerReleaseOnWrite = layerFileSystemWith((fs) => ({ + remove: (target, options) => + fs + .remove(target, options) + .pipe( + Effect.andThen( + releaseOnWrite !== undefined && target.includes("plugins.json.") + ? Effect.forkChild(Deferred.succeed(releaseOnWrite, undefined)) + : Effect.void, + ), + ), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("stubborn"); + yield* waitForStatuses(host, { stubborn: "active" }); + + const disabling = yield* host + .setEnabled(id, false) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* Fiber.interrupt(disabling); + gate.resolve(); + + yield* waitForStatuses(host, { stubborn: "disabled" }); + const error = yield* host + .invoke({ pluginId: id, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + // A stop that interrupts a start while it hands over its theme folder still lets that + // list go out, or the disabled list would match the last one sent and keep the folder. + const starting = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + held = starting; + const enabling = yield* host + .setEnabled(id, true) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(starting.reached); + releaseOnWrite = starting.release; + assert.include(yield* host.setEnabled(id, false), { status: "disabled" }); + yield* Fiber.join(enabling); + assert.deepStrictEqual(themeFolders.at(-1), []); + }).pipe(Effect.provide(layerHost(layerHeldPublish).pipe(Layer.provide(layerReleaseOnWrite)))); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("installs a dropped folder turned off, and one dropped again keeps its switch", () => Effect.gen(function* () { const helloFiles = (version: string) => [ @@ -274,6 +678,54 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerConfig())), ); + it.effect("puts the working version back when a new dropped version fails to start", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const encode = (text: string) => new TextEncoder().encode(text); + const upgrade = (version: string, activate = "") => [ + { + path: "t3-plugin.json", + contents: encode( + JSON.stringify({ + id: "upgrade", + name: "Upgrade", + version, + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ), + }, + { + path: "server.cjs", + contents: encode(`module.exports = { activate() { ${activate} } };`), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const failed = yield* installer + .installFiles(upgrade("2.0.0", `throw new Error("2.0.0 refuses to start");`)) + .pipe(Effect.flip); + assert.strictEqual( + failed.message, + "Upgrade 2.0.0 failed to start, so 1.0.0 is back. It failed with: 2.0.0 refuses to start", + ); + assert.include(stateOf(yield* host.list, "upgrade"), { + version: "1.0.0", + status: "active", + }); + assert.notInclude( + yield* fs.readFileString(`${stateDir}/plugins/upgrade/server.cjs`), + "refuses to start", + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("keeps server folder paths from clients that cannot manage plugins", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -436,4 +888,76 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerHeldStartup)))); }).pipe(Effect.provide(layerConfig())), ); + + it.effect("does not load a disabled plugin at startup", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "dormant", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { dormant: "disabled" }); + assert.include(stateOf(states, "dormant"), { enabled: false, hasServer: true }); + const fs = yield* FileSystem.FileSystem; + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "finishes starting before a slow plugin finishes activating, and a rescan meanwhile keeps it", + () => + Effect.gen(function* () { + const gate = Promise.withResolvers(); + const cleanupStarted = Promise.withResolvers(); + const cleanupGate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostTestGate", gate.promise); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", cleanupStarted.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", cleanupGate.promise); + yield* writeInstalledPlugin( + "slow", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + return globalThis.__t3PluginHostTestGate; +} };`, + ); + const goes = yield* writeInstalledPlugin( + "goes", + `module.exports = { activate(api) { + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { slow: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { slow: "loading", goes: "active" }); + + // The rescan stops the removed plugin while the slow one finishes starting. + yield* fs.remove(goes, { recursive: true }); + const rescanning = yield* host.rescan.pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => cleanupStarted.promise); + gate.resolve(); + yield* waitForStatuses(host, { slow: "active" }); + cleanupGate.resolve(); + yield* Fiber.join(rescanning); + + assert.strictEqual(stateOf(yield* host.list, "slow")?.status, "active"); + assert.strictEqual( + yield* host.invoke({ + pluginId: PluginId.make("slow"), + method: "ping", + input: undefined, + scopes: operate, + }), + "pong", + ); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostTestGate"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); }); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 8940fb626008a..98c8e294875a8 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -1,13 +1,22 @@ +// @effect-diagnostics nodeBuiltinImport:off - plugins load through Node's CommonJS loader, which +// the FileSystem service does not wrap. +import * as NodeModule from "node:module"; + import { - type AuthEnvironmentScope, + AuthEnvironmentScope, + AuthOrchestrationOperateScope, AuthOrchestrationReadScope, PluginId, type PluginListResult, type PluginState, type PluginStatesEvent, } from "@t3tools/contracts"; +import type { PluginDefinition } from "@t3tools/plugin-sdk/server"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FiberMap from "effect/FiberMap"; @@ -15,6 +24,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -30,12 +40,24 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { authenticateRawRouteWithScope } from "../http.ts"; import { forkParked } from "../serverActivation.ts"; +import { makePluginActivation, makePluginApi, type PluginActivation } from "./PluginApi.ts"; +import { errorText, PluginCodeError, pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; +import { makePluginFailures } from "./pluginFailures.ts"; import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; import * as PluginInstaller from "./PluginInstaller.ts"; import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; +// Plugin code that never settles must not keep a plugin loading, a client's call open, or Disable +// and Reload waiting, forever. The cleanup deadline covers all of a plugin's cleanups together. +const ACTIVATE_DEADLINE = Duration.seconds(30); +const INVOKE_DEADLINE = Duration.seconds(30); +const CLEANUP_DEADLINE = Duration.seconds(5); +// Values of one channel a subscriber has not taken yet; past this the oldest go. +const CHANNEL_BUFFER = 64; + export class PluginNotFoundError extends Schema.TaggedError()( "PluginNotFoundError", { pluginId: PluginId }, @@ -45,6 +67,64 @@ export class PluginNotFoundError extends Schema.TaggedError } } +export class PluginNotActiveError extends Schema.TaggedError()( + "PluginNotActiveError", + { pluginId: PluginId, status: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is ${this.status}.`; + } +} + +export class PluginNoServerError extends Schema.TaggedError()( + "PluginNoServerError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has no server half, so it has no methods or channels.`; + } +} + +export class PluginMethodNotFoundError extends Schema.TaggedError()( + "PluginMethodNotFoundError", + { pluginId: PluginId, method: Schema.String, methods: Schema.Array(Schema.String) }, +) { + // The methods it has, since nothing else lists them. + override get message(): string { + return `Plugin ${this.pluginId} has no method ${this.method}. Its methods: ${this.methods.join(", ") || "none"}.`; + } +} + +export class PluginAccessDeniedError extends Schema.TaggedError()( + "PluginAccessDeniedError", + { pluginId: PluginId, method: Schema.String, requiredScope: AuthEnvironmentScope }, +) { + override get message(): string { + return `Calling ${this.method} on plugin ${this.pluginId} needs the ${this.requiredScope} scope.`; + } +} +export const isPluginAccessDeniedError = Schema.is(PluginAccessDeniedError); + +// `reason` is the plugin's own error text, bounded. Plugins are trusted code, and their UI needs +// to show why its own call failed. +export class PluginHandlerError extends Schema.TaggedError()( + "PluginHandlerError", + { pluginId: PluginId, method: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.method}: ${this.reason}`; + } +} + +export class PluginHandlerTimeoutError extends Schema.TaggedError()( + "PluginHandlerTimeoutError", + { pluginId: PluginId, method: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer ${this.method} within ${Duration.toSeconds(INVOKE_DEADLINE)} seconds.`; + } +} + export class PluginHost extends Context.Service< PluginHost, { @@ -64,7 +144,7 @@ export class PluginHost extends Context.Service< pluginId: PluginId, enabled: boolean, ) => Effect.Effect; - /** Rescans the plugin folders and restarts this plugin from its files. */ + /** Rescans the plugin folders and restarts this plugin with fresh code. */ readonly reload: ( pluginId: PluginId, ) => Effect.Effect; @@ -77,6 +157,25 @@ export class PluginHost extends Context.Service< { readonly plugins: ReadonlyArray }, PluginConfigError >; + /** + * `scopes` are the caller's; methods the plugin registered as writes need the operate scope. A + * handler that has not answered within 30 seconds is aborted. + */ + readonly invoke: (input: { + readonly pluginId: PluginId; + readonly method: string; + readonly input: Schema.Json | undefined; + readonly scopes: ReadonlyArray; + }) => Effect.Effect< + Schema.Json, + | PluginNotFoundError + | PluginNotActiveError + | PluginNoServerError + | PluginMethodNotFoundError + | PluginAccessDeniedError + | PluginHandlerError + | PluginHandlerTimeoutError + >; /** * What an active plugin serves at `/api/plugins/`: a file in its `styles/`. A * `revision` that names the file's contents lets it be cached. @@ -86,29 +185,60 @@ export class PluginHost extends Context.Service< readonly path: string; readonly revision: string | undefined; }) => Effect.Effect>; + /** Values the plugin publishes on `channel` until it deactivates. */ + readonly subscribe: (input: { + readonly pluginId: PluginId; + readonly channel: string; + }) => Stream.Stream< + Schema.Json, + PluginNotFoundError | PluginNotActiveError | PluginNoServerError + >; } >()("t3/plugins/PluginHost") {} +const isJson = Schema.is(Schema.Json); + /** What plugins.json says about the plugins a discovery found. */ interface Found { readonly enabled: Readonly>; + readonly crashed: Readonly>; } +// One loader for the packaged single executable and plain Node: a require created from an +// absolute path loads files from disk in both, and plugin code resolves its own imports. +const requireFromDisk = NodeModule.createRequire(process.execPath); + +const isPluginDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const context = yield* Effect.context(); const pluginsDir = path.join(config.stateDir, "plugins"); const pluginsConfig = yield* makePluginsConfig; const webFiles = yield* makePluginWebFiles; const records = yield* Ref.make>([]); + // Changes on every start, so a client can tell a restarted plugin from one that kept running. + // Seeded with the clock so a restarted server never repeats a revision a client already saw. + let revision = yield* Clock.currentTimeMillis; + const nextRevision = () => ++revision; const lifecycle = yield* Semaphore.make(1); const activations = yield* FiberMap.make(); const hostScope = yield* Effect.scope; - const pluginStates = yield* makePluginStates({ records, config: pluginsConfig.current }); + const failures = yield* makePluginFailures; + const pluginStates = yield* makePluginStates({ + records, + config: pluginsConfig.current, + failures, + }); const currentStates = pluginStates.current; const publishStates = pluginStates.publish; const findRecord = (pluginId: string) => @@ -132,6 +262,81 @@ const make = Effect.gen(function* () { return next; }).pipe(Effect.andThen(publishStates)); + // Untraced: a plugin's timer can fail every few milliseconds. + const recordServerFailure = Effect.fnUntraced(function* (pluginId: PluginId, error: unknown) { + const record = yield* findRecord(pluginId); + if (yield* failures.recordServer(pluginId, record?.revision, error)) yield* publishStates; + }); + // Recorded at once and outside the plugin's scope, where the guard hears of it, and in the + // host's scope, so a publish still waiting ends with the host. + const reportServerFailure = (pluginId: PluginId, error: unknown) => + pluginCodeScope.exit(() => { + Effect.runForkWith(context)( + Effect.forkIn(recordServerFailure(pluginId, error), hostScope, { startImmediately: true }), + ); + }); + const failureGuard = makePluginFailureGuard({ + onRejection: reportServerFailure, + onCrash: (pluginId, error) => pluginsConfig.recordCrash(pluginId, errorText(error)), + }); + // Added before the teardown finalizer, so it runs after plugin cleanups. + yield* Effect.addFinalizer(() => Effect.sync(failureGuard.uninstall)); + + const runCleanup = (pluginId: PluginId, cleanup: () => void | Promise) => + Effect.tryPromise({ + try: () => pluginCodeScope.run(pluginId, () => Promise.resolve().then(cleanup)), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe( + Effect.catchTags({ + PluginCodeError: (error) => + Effect.logWarning("plugin cleanup failed", { pluginId, reason: error.message }), + }), + ); + + // Aborts first so handlers stop. Teardown also runs where interruption is off, so the deadline + // gets an interruptible region to cut. + const teardown = Effect.fn("PluginHost.teardown")(function* ( + pluginId: PluginId, + activation: PluginActivation, + ) { + activation.controller.abort(); + const timedOut = yield* Effect.forEach( + activation.cleanups.toReversed(), + (cleanup) => runCleanup(pluginId, cleanup), + { discard: true }, + ).pipe(Effect.interruptible, Effect.timeoutOption(CLEANUP_DEADLINE), Effect.map(Option.isNone)); + if (timedOut) yield* Effect.logWarning("plugin cleanup timed out", { pluginId }); + // In one step with the abort, so a subscriber that joins meanwhile sees one or the other. + activation.ended.abort(); + for (const queues of activation.channels.values()) { + for (const queue of queues) Queue.endUnsafe(queue); + } + }); + + const loadDefinition = (plugin: DiscoveredPlugin, entry: string) => + Effect.try({ + try: () => { + const entryPath = path.join(plugin.rootDir, entry); + // Drop every cached module under the plugin so reload runs current code. Node still + // holds the old instances until the server restarts. + const prefix = `${plugin.rootDir}${path.sep}`; + for (const cached of Object.keys(requireFromDisk.cache)) { + if (cached.startsWith(prefix)) Reflect.deleteProperty(requireFromDisk.cache, cached); + } + const exported: unknown = pluginCodeScope.run(plugin.id, () => requireFromDisk(entryPath)); + // Bundlers that compile `export default` to CommonJS put the definition on `default`. + const definition = + typeof exported === "object" && exported !== null && "default" in exported + ? exported.default + : exported; + if (!isPluginDefinition(definition)) { + throw new Error("The server entry does not export a plugin with an activate function."); + } + return definition; + }, + catch: (cause) => new PluginCodeError({ cause }), + }); + const activatePlugin = Effect.fn("PluginHost.activate")(function* (plugin: DiscoveredPlugin) { const manifest = manifestOf(plugin); if (manifest === undefined) return; @@ -144,29 +349,104 @@ const make = Effect.gen(function* () { }, }); } + // Before Node 24, `pluginCodeScope` slows every promise in the process about 3x, and without + // it a plugin's rejection with only Node's frames in its stack stops the server. + const node = process.versions.node; + if (manifest.server !== undefined && Number(node.split(".")[0]) < 24) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs Node 24 or later for its server code; this server runs Node ${node}.`, + }, + }); + } const web = yield* webFiles.find(plugin); - return yield* setRecord(plugin.id, { - status: "active", + const started = { + status: "active" as const, webFiles: web.folders, styles: web.styles, + }; + if (manifest.server === undefined) { + return yield* setRecord(plugin.id, { ...started, revision: nextRevision() }); + } + yield* setRecord(plugin.id, { status: "loading" }); + // Before loading, since a module can start timers as it loads. + failureGuard.watch(`${plugin.rootDir}${path.sep}`, plugin.id); + const definition = yield* Effect.exit(loadDefinition(plugin, manifest.server)); + if (definition._tag === "Failure") { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { stage: "load", message: errorText(Cause.squash(definition.cause)) }, + }); + } + const activation = makePluginActivation(); + const api = makePluginApi({ + id: plugin.id, + rootDir: plugin.rootDir, + activation, + context, }); + // Only `activate` can be interrupted. A stop that lands after it waits until the record holds + // the activation, so `stopPlugin` always finds what to tear down. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const activated = yield* restore( + Effect.tryPromise({ + try: () => + pluginCodeScope.run(plugin.id, () => + Promise.resolve().then(() => definition.value.activate(api)), + ), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe(Effect.timeoutOption(ACTIVATE_DEADLINE)), + ).pipe(Effect.exit); + if (activated._tag === "Success" && Option.isSome(activated.value)) { + yield* setRecord(plugin.id, { ...started, activation, revision: nextRevision() }); + return yield* Effect.logInfo("plugin activated", { pluginId: plugin.id }); + } + yield* teardown(plugin.id, activation); + // Whatever stopped the activation sets the plugin's next status. + if (activated._tag === "Failure" && Cause.hasInterruptsOnly(activated.cause)) return; + yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "activate", + message: + activated._tag === "Failure" + ? errorText(Cause.squash(activated.cause)) + : `activate did not finish within ${Duration.toSeconds(ACTIVATE_DEADLINE)} seconds.`, + }, + }); + }), + ); }); /** - * An active plugin's record stops serving files. The caller sets the plugin's next status. + * An active plugin's record stops routing calls and serving files before cleanups run. The caller + * sets the plugin's next status. */ const stopPlugin = Effect.fn("PluginHost.stop")(function* (pluginId: PluginId) { yield* FiberMap.remove(activations, pluginId); + // A failure from before the stop, even one while loading, which has no revision, belongs to + // that start; failures of code left running after it are recorded again. + yield* failures.clearServer(pluginId); + // A plugin without server code is active with no activation. const record = yield* findRecord(pluginId); if (record?.status !== "active") return; yield* setRecord(pluginId, { status: "disabled" }); + if (record.activation !== undefined) yield* teardown(pluginId, record.activation); }); const startPlugin = (plugin: DiscoveredPlugin) => FiberMap.run(activations, plugin.id, activatePlugin(plugin)); + // A plugin that stopped the server was turned off then, or the restart would only crash again. const initialRecord = (plugin: DiscoveredPlugin, found: Found): PluginRecord => { if ("failure" in plugin) return { plugin, status: "failed", failure: plugin.failure }; + const crashed = found.crashed[plugin.id]; + if (crashed !== undefined) { + return { plugin, status: "failed", failure: { stage: "crash", message: crashed } }; + } return found.enabled[plugin.id] === true ? { plugin, status: "loading" } : { plugin, status: "disabled" }; @@ -189,7 +469,7 @@ const make = Effect.gen(function* () { : Effect.fail(error), }), ); - const found: Found = { enabled: current.enabled ?? {} }; + const found: Found = { enabled: current.enabled ?? {}, crashed: current.crashed ?? {} }; return { plugins, found }; }); @@ -232,8 +512,10 @@ const make = Effect.gen(function* () { }, ); - /** Rescans the plugin folders and lists what appeared; `restart` also restarts from new files. */ + /** Rescans the plugin folders and lists what appeared; `restart` also restarts with new code. */ const rediscover = Effect.fn("PluginHost.rediscover")(function* (restart: PluginId | undefined) { + // Reloading a plugin that stopped the server clears that failure; it stays off until turned on. + if (restart !== undefined) yield* pluginsConfig.clearCrash(restart); const { plugins, found } = yield* discover(false); if (restart !== undefined) yield* stopPlugin(restart); const known = yield* Ref.get(records); @@ -249,7 +531,7 @@ const make = Effect.gen(function* () { !("failure" in record.plugin), ), })); - // Copies that are gone or moved stop. + // Copies that are gone or moved stop, or they would keep running unlisted. for (const record of known) { if ( record.plugin.id !== restart && @@ -300,6 +582,20 @@ const make = Effect.gen(function* () { return { plugins: yield* currentStates }; }); + // Lookups inside an operation that already has its span. + const activeRecord = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest !== undefined && manifest.server === undefined) { + return yield* new PluginNoServerError({ pluginId }); + } + if (record.status !== "active" || record.activation === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return record.activation; + }); + const { installer, recoverStaging } = yield* PluginInstaller.make({ lifecycle: runLifecycle, copies: Ref.get(records).pipe(Effect.map((all) => all.map((record) => record.plugin))), @@ -337,6 +633,20 @@ const make = Effect.gen(function* () { .pipe(Effect.withSpan("PluginHost.startup")); yield* forkParked(startup); + yield* Effect.addFinalizer(() => + Ref.get(records).pipe( + Effect.flatMap((all) => + Effect.forEach( + all, + (record) => + record.activation === undefined + ? Effect.void + : teardown(record.plugin.id, record.activation), + { concurrency: "unbounded", discard: true }, + ), + ), + ), + ); const service = PluginHost.of({ list: currentStates, @@ -346,11 +656,102 @@ const make = Effect.gen(function* () { setEnabled, reload, rescan: rescan(), + invoke: Effect.fn("PluginHost.invoke")(function* (request) { + yield* Effect.annotateCurrentSpan({ + "plugin.id": request.pluginId, + "plugin.method": request.method, + }); + const activation = yield* activeRecord(request.pluginId); + const registered = activation.methods.get(request.method); + if (registered === undefined) { + return yield* new PluginMethodNotFoundError({ + pluginId: request.pluginId, + method: request.method, + methods: [...activation.methods.keys()], + }); + } + const access = request.scopes.includes(AuthOrchestrationOperateScope) ? "write" : "read"; + if (registered.access === "write" && access !== "write") { + return yield* new PluginAccessDeniedError({ + pluginId: request.pluginId, + method: request.method, + requiredScope: AuthOrchestrationOperateScope, + }); + } + const handlerError = (cause: unknown) => + new PluginHandlerError({ + pluginId: request.pluginId, + method: request.method, + reason: errorText(cause), + cause, + }); + const deactivated = activation.controller.signal; + const answered = yield* Effect.raceFirst( + // The deadline interrupts the call, which aborts the handler's signal. + Effect.tryPromise({ + try: (signal) => + pluginCodeScope.run(request.pluginId, () => + Promise.resolve().then(() => + registered.handler(request.input, { + signal: AbortSignal.any([signal, deactivated]), + access, + }), + ), + ), + catch: handlerError, + }).pipe(Effect.timeoutOption(INVOKE_DEADLINE)), + // A handler that ignores its abort signal must not hold the call open after disable. + Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.fail( + new PluginNotActiveError({ pluginId: request.pluginId, status: "disabled" }), + ), + ); + if (deactivated.aborted) return onAbort(); + deactivated.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => deactivated.removeEventListener("abort", onAbort)); + }), + ); + if (Option.isNone(answered)) { + return yield* new PluginHandlerTimeoutError({ + pluginId: request.pluginId, + method: request.method, + }); + } + const output = answered.value; + if (output === undefined) return null; + if (!isJson(output)) { + return yield* handlerError(new TypeError("The method returned a value that is not JSON.")); + } + return output; + }), readWebFile: Effect.fn("PluginHost.readWebFile")(function* (request) { const record = yield* findRecord(request.pluginId); if (record?.status !== "active" || record.webFiles === undefined) return Option.none(); return yield* webFiles.read(record.webFiles, request.path, request.revision); }), + subscribe: (request) => + Stream.unwrap( + Effect.gen(function* () { + const activation = yield* activeRecord(request.pluginId); + const queue = yield* Queue.sliding(CHANNEL_BUFFER); + yield* Effect.acquireRelease( + Effect.sync(() => { + if (activation.ended.signal.aborted) return Queue.endUnsafe(queue); + const queues = activation.channels.get(request.channel) ?? new Set(); + activation.channels.set(request.channel, queues.add(queue)); + }), + () => + Effect.sync(() => { + const queues = activation.channels.get(request.channel); + queues?.delete(queue); + if (queues?.size === 0) activation.channels.delete(request.channel); + }), + ); + return Stream.fromQueue(queue); + }), + ), }); return Context.make(PluginHost, service).pipe( Context.add(PluginInstaller.PluginInstaller, installer), diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts index e4d62ca245612..96949d8d5a6b3 100644 --- a/apps/server/src/plugins/PluginInstaller.ts +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -18,7 +18,7 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; import type { PluginNotFoundError } from "./PluginHost.ts"; -import type { PluginConfigError, PluginsConfigFile } from "./pluginsConfig.ts"; +import { type PluginConfigError, type PluginsConfigFile, withoutCrash } from "./pluginsConfig.ts"; // `reason` is shown to the user as is, so it says what to fix. export class PluginInstallError extends Schema.TaggedError()( @@ -166,8 +166,9 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns /** * Swaps `/plugin` into the plugins folder under the lifecycle lock and starts it if it - * is on. The replaced version waits in `/previous` during the swap, so startup - * restores it if a crash leaves it there. `stagingDir` is removed afterwards. + * is on. The replaced version waits in `/previous` and goes back if the new one fails + * to start. `stagingDir` is removed unless it holds a version's only copy, which startup then + * restores. */ const swapIn = Effect.fn("PluginInstaller.swapIn")(function* ( manifest: PluginManifest, @@ -176,8 +177,9 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns const staged = path.join(stagingDir, "plugin"); const previous = path.join(stagingDir, "previous"); let stopped = false; - yield* Effect.addFinalizer(() => removeStaging(stagingDir)); - yield* host + let stranded = false; + yield* Effect.addFinalizer(() => (stranded ? Effect.void : removeStaging(stagingDir))); + const swapped = yield* host .lifecycle( // Read under the lock, so another install or a removal cannot change them before the swap. Effect.gen(function* () { @@ -225,6 +227,14 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns ...current, enabled: { ...current.enabled, [manifest.id]: enabled }, })); + return { + target, + hadPrevious, + replacedVersion: + existing !== undefined && "manifest" in existing + ? existing.manifest.version + : undefined, + }; }), ) .pipe( @@ -233,7 +243,31 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns stopped ? host.reload(manifest.id).pipe(Effect.ignore) : Effect.void, ), ); - return yield* host.reload(manifest.id); + const started = yield* host.reload(manifest.id); + if (!swapped.hadPrevious || started.status !== "failed") return started; + yield* host + .lifecycle( + Effect.gen(function* () { + yield* host.stop(manifest.id); + yield* fs + .rename(swapped.target, path.join(stagingDir, "failed")) + .pipe(Effect.mapError(installFailed("Could not move the failed version aside."))); + yield* fs.rename(previous, swapped.target).pipe( + Effect.tapError(() => + Effect.sync(() => { + stranded = true; + }), + ), + Effect.mapError( + installFailed("Could not put the previous version back. Restart T3 Code to finish."), + ), + ); + }), + ) + .pipe(Effect.ensuring(host.reload(manifest.id).pipe(Effect.ignore))); + return yield* new PluginInstallError({ + reason: `${manifest.name} ${manifest.version} failed to start, so ${swapped.replacedVersion ?? "the previous version"} is back. It failed with: ${started.failure?.message ?? "an unknown error"}`, + }); }, Effect.scoped); /** Copies `origin` into a new staging folder and returns the folder and what it holds. */ @@ -272,7 +306,7 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns const removeFromConfig = (pluginId: PluginId) => host.writeConfig((current) => { const { [pluginId]: _removed, ...enabled } = current.enabled ?? {}; - return { ...current, enabled }; + return { ...withoutCrash(current, pluginId), enabled }; }); const forgetDevPath = (folder: string) => diff --git a/apps/server/src/plugins/api/facade.ts b/apps/server/src/plugins/api/facade.ts new file mode 100644 index 0000000000000..f6e41de47a170 --- /dev/null +++ b/apps/server/src/plugins/api/facade.ts @@ -0,0 +1,68 @@ +// @effect-diagnostics nodeBuiltinImport:off - AsyncLocalStorage has no Effect counterpart, and it +// must follow plugin code through Node's own callbacks. +import * as NodeAsyncHooks from "node:async_hooks"; + +import type { PluginId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const MAX_ERROR_CHARS = 500; + +/** A plugin error's text for logs, states and replies, bounded. */ +export const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_ERROR_CHARS); +}; + +/** Plugin code threw or rejected. Its message is the plugin's own. */ +export class PluginCodeError extends Schema.TaggedError()("PluginCodeError", { + cause: Schema.Defect(), +}) { + override get message(): string { + return errorText(this.cause); + } +} + +/** + * The plugin whose code is running, carried through every timer, promise and Node callback that + * code starts, so a failure whose stack shows only Node's internals is still the plugin's. The host + * runs no server code before Node 24, where this needs promise hooks. + */ +export const pluginCodeScope = new NodeAsyncHooks.AsyncLocalStorage(); + +/** What every facade group needs from the activation it belongs to. */ +export interface FacadeContext { + /** Runs an effect in the background for the plugin; failures are logged, not thrown. */ + readonly fork: (effect: Effect.Effect) => void; +} + +export const makeFacadeContext = (input: { + readonly pluginId: PluginId; + readonly context: Context.Context; +}): FacadeContext => { + const { context, pluginId } = input; + return { + fork: (effect) => { + pluginCodeScope.exit(() => + Effect.runForkWith(context)( + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("plugin background work failed", { + pluginId, + cause: Cause.pretty(cause).slice(0, 500), + }), + ), + ), + ), + ); + }, + }; +}; diff --git a/apps/server/src/plugins/pluginFailureGuard.test.ts b/apps/server/src/plugins/pluginFailureGuard.test.ts new file mode 100644 index 0000000000000..1c6ce9f4c3be7 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.test.ts @@ -0,0 +1,119 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; + +import { pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; + +const leaky = PluginId.make("leaky"); + +/** Takes the runner's crash listeners off, which would report the fake crashes below as real. */ +const withoutRunnerListeners = () => { + const exceptions = process.listeners("uncaughtException"); + const rejections = process.listeners("unhandledRejection"); + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + return { + [Symbol.dispose]: () => { + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + for (const listener of exceptions) process.on("uncaughtException", listener); + for (const listener of rejections) process.on("unhandledRejection", listener); + }, + }; +}; + +/** A guard watching the `leaky` plugin, with what it recorded. */ +const watchLeaky = () => { + const rejected: Array = []; + const crashed: Array = []; + const guard = makePluginFailureGuard({ + onRejection: (pluginId) => rejected.push(pluginId), + onCrash: (pluginId) => crashed.push(pluginId), + }); + guard.watch("/plugins/leaky/", leaky); + return { rejected, crashed, uninstall: guard.uninstall, [Symbol.dispose]: guard.uninstall }; +}; + +const pluginError = (message: string) => { + const error = new Error(message); + error.stack = `Error: ${message}\n at /plugins/leaky/server.cjs:1:1`; + return error; +}; + +// As Node reports a rejection nobody handled. +const reject = (reason: Error) => process.emit("unhandledRejection", reason, Promise.resolve()); +const messageOf = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason)); + +it("records a plugin's uncaught exception and leaves the exit to Node", () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + + // Node tells the monitor before the listeners, or its own exit when there are none. + process.emit("uncaughtExceptionMonitor", pluginError("thrown in a timer")); + process.emit("uncaughtExceptionMonitor", new Error("T3 crashed")); + + assert.deepStrictEqual(guard.crashed, ["leaky"]); + // It adds no listener, so Node, or a guard that exits only while it listens alone, still exits. + assert.strictEqual(process.listenerCount("uncaughtException"), 0); + guard.uninstall(); + assert.strictEqual(process.listenerCount("uncaughtExceptionMonitor"), 0); +}); + +it("hands T3's own rejections to a listener added after it, and only those", async () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + const seen: Array = []; + // Such as a provider SDK's guard, when its layer builds after a plugin started. + const late = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", late); + await Promise.resolve(); + + reject(pluginError("rejected in a timer")); + reject(new Error("T3 rejected")); + + assert.deepStrictEqual(guard.rejected, ["leaky"]); + assert.deepStrictEqual(seen, ["T3 rejected"]); + // It listens alone, so a guard that acts only then still does. + assert.strictEqual(process.listenerCount("unhandledRejection"), 1); + guard.uninstall(); + assert.deepStrictEqual(process.listeners("unhandledRejection"), [late]); +}); + +it("calls a listener added with once for one rejection", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + process.once("unhandledRejection", (reason) => seen.push(messageOf(reason))); + using _guard = watchLeaky(); + + reject(new Error("first")); + // With nothing else listening, the second stops the server as Node would. + assert.throws(() => reject(new Error("second")), "second"); + assert.deepStrictEqual(seen, ["first"]); +}); + +it("stops calling a listener its owner removes", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", listener); + using _guard = watchLeaky(); + + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); + +it("stops calling a plugin's own listener once the plugin removes it on disable", async () => { + using _runner = withoutRunnerListeners(); + using _guard = watchLeaky(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + // As the plugin's `activate`, then its `onDeactivate`. + pluginCodeScope.run(leaky, () => process.on("unhandledRejection", listener)); + await Promise.resolve(); + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); diff --git a/apps/server/src/plugins/pluginFailureGuard.ts b/apps/server/src/plugins/pluginFailureGuard.ts new file mode 100644 index 0000000000000..ba6c8a5c17509 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics globalConsole:off - The guard runs in Node's process callbacks, outside an Effect runtime, where printing is all it can do with its own failure. +import type { PluginId } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +import { pluginCodeScope } from "./api/facade.ts"; + +const REJECTION = "unhandledRejection"; +type Listener = (...args: ReadonlyArray) => unknown; +const isListener = (value: unknown): value is Listener => typeof value === "function"; + +/** + * Ties plugin failures outside a call to the plugin. An uncaught exception goes to `onCrash` and + * still stops the server, since Node calls resuming unsafe. A plugin's unhandled rejection goes to + * `onRejection` and skips the other listeners, which the guard wraps; it listens only while none do. + */ +export const makePluginFailureGuard = (handlers: { + readonly onRejection: (pluginId: PluginId, reason: unknown) => void; + /** Runs just before Node reports the exception, so it has to finish synchronously. */ + readonly onCrash: (pluginId: PluginId, error: unknown) => void; +}) => { + // Node keeps loaded plugin code and its timers until exit, so a root is never dropped. + const roots = new Map(); + // Each wrapper and what `process.rawListeners` held before it: the listener or its `once` wrapper. + const wrapped = new WeakMap(); + let installed = false; + let syncing = false; + + // The plugin whose scope the failure ran in, else one whose files its stack names. + const pluginOf = (error: unknown): PluginId | undefined => { + const running = pluginCodeScope.getStore(); + if (running !== undefined) return running; + let stack: unknown; + try { + stack = Predicate.isObject(error) ? error.stack : undefined; + } catch { + // A plugin can throw an object whose `stack` getter throws. + return undefined; + } + if (typeof stack !== "string") return undefined; + for (const [root, pluginId] of roots) if (stack.includes(root)) return pluginId; + return undefined; + }; + + // Node calls monitors before the exception's listeners or its own exit. It adds no listener, so + // whatever stops the server for T3's crashes stops it for a plugin's too. + const onUncaught = (error: unknown) => { + const pluginId = pluginOf(error); + if (pluginId === undefined) return; + try { + handlers.onCrash(pluginId, error); + } catch (recordError) { + // Node still reports the crash itself. + console.error(recordError); + } + }; + + // What Node does with no listener, for rejections that aren't a plugin's. + const alone = (reason: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) return handlers.onRejection(pluginId, reason); + throw reason; + }; + + // Node's `off` and `listeners()` read `listener`, as on its own `once` wrappers. + const wrap = (raw: Listener) => { + const listener: unknown = Reflect.get(raw, "listener"); + const original = isListener(listener) ? listener : raw; + const gated = Object.assign( + (reason: unknown, promise: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) { + // Every wrapper sees the rejection; the first records it. + const first = process + .rawListeners(REJECTION) + .find((other) => isListener(other) && wrapped.has(other)); + if (first === gated) handlers.onRejection(pluginId, reason); + return; + } + if (original !== raw) process.off(REJECTION, gated); + Reflect.apply(original, process, [reason, promise]); + }, + { listener: original }, + ); + wrapped.set(gated, raw); + return gated; + }; + + // The server always installs the Cursor SDK's listener (CursorSdk.ts). It exits when it listens + // alone and leaves a rejection to the others otherwise, so beside a plain listener T3's own + // rejections would no longer stop the server. Wrapped, it still sees those, and only those. + const sync = () => { + if (!installed || syncing) return; + syncing = true; + try { + const listeners = process.rawListeners(REJECTION).filter(isListener); + const others = listeners.filter((other) => other !== alone); + if ( + others.every((other) => wrapped.has(other)) && + (others.length === 0) === listeners.includes(alone) + ) { + return; + } + // Re-adding them all keeps their order. + process.removeAllListeners(REJECTION); + for (const other of others) { + process.on(REJECTION, wrapped.has(other) ? other : wrap(other)); + } + if (others.length === 0) process.on(REJECTION, alone); + } finally { + syncing = false; + } + }; + // Node adds a listener after announcing it, so it is wrapped once it is in. + const onNewListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) queueMicrotask(sync); + }; + const onRemoveListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) sync(); + }; + + return { + /** Ties failures whose stack names a file under `root` to the plugin; the first call installs. */ + watch: (root: string, pluginId: PluginId) => { + roots.set(root, pluginId); + if (installed) return; + installed = true; + sync(); + process.on("newListener", onNewListener); + process.on("removeListener", onRemoveListener); + process.on("uncaughtExceptionMonitor", onUncaught); + }, + /** Puts back the listeners it wrapped. */ + uninstall: () => { + if (!installed) return; + installed = false; + process.off("newListener", onNewListener); + process.off("removeListener", onRemoveListener); + process.off("uncaughtExceptionMonitor", onUncaught); + const others = process.rawListeners(REJECTION).filter(isListener); + process.removeAllListeners(REJECTION); + for (const other of others) { + if (other !== alone) process.on(REJECTION, wrapped.get(other) ?? other); + } + }, + }; +}; diff --git a/apps/server/src/plugins/pluginFailures.ts b/apps/server/src/plugins/pluginFailures.ts new file mode 100644 index 0000000000000..0d55610b87012 --- /dev/null +++ b/apps/server/src/plugins/pluginFailures.ts @@ -0,0 +1,82 @@ +import type { PluginId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { errorText } from "./api/facade.ts"; + +// A plugin's failures outside a call reach clients at most this often, the latest one last, so a +// timer failing every few milliseconds with new text does not resend the list each time. +const FAILURE_PUBLISH_INTERVAL = Duration.seconds(5); + +interface Failure { + readonly revision: number | undefined; + readonly message: string; +} + +/** + * Builds the host's record of the failures a plugin's row shows: server code failing outside a + * call. Each belongs to the start, the revision, it happened in. + */ +export const makePluginFailures = Effect.gen(function* () { + // By plugin id, for the start it happened in: a new start's revision hides the old one. A + // stopped plugin's leftover timers fail with no revision. + const serverFailures = yield* Ref.make>(new Map()); + // When each plugin's failure was last published, and the plugins whose next one is waiting. + const publishedAt = new Map(); + const waiting = new Set(); + + return { + /** Looks up a plugin's server failure for the start `revision`. */ + current: Effect.gen(function* () { + const server = yield* Ref.get(serverFailures); + return (pluginId: PluginId, revision: number | undefined) => { + const failure = server.get(pluginId); + return failure !== undefined && failure.revision === revision ? failure.message : undefined; + }; + }), + /** + * Records a failure of the start `revision` outside a call, then succeeds with whether to + * publish: a timer that fails every second with the same text logs and publishes once. + * Untraced: a plugin's timer can fail every few milliseconds. + */ + recordServer: Effect.fnUntraced(function* ( + pluginId: PluginId, + revision: number | undefined, + error: unknown, + ) { + const message = errorText(error); + const known = (yield* Ref.get(serverFailures)).get(pluginId); + if (known?.revision === revision && known?.message === message) return false; + yield* Ref.update(serverFailures, (all) => new Map(all).set(pluginId, { revision, message })); + if (waiting.has(pluginId)) return false; + const last = publishedAt.get(pluginId); + const wait = + last === undefined + ? 0 + : last + Duration.toMillis(FAILURE_PUBLISH_INTERVAL) - (yield* Clock.currentTimeMillis); + if (wait > 0) { + waiting.add(pluginId); + yield* Effect.sleep(Duration.millis(wait)).pipe( + Effect.ensuring(Effect.sync(() => waiting.delete(pluginId))), + ); + } + publishedAt.set(pluginId, yield* Clock.currentTimeMillis); + yield* Effect.logWarning("plugin code failed outside a call", { + pluginId, + reason: (yield* Ref.get(serverFailures)).get(pluginId)?.message ?? message, + }); + return true; + }), + /** Forgets the plugin's server failures, as a stop does. */ + clearServer: (pluginId: PluginId) => + Ref.update(serverFailures, (all) => { + const next = new Map(all); + next.delete(pluginId); + return next; + }), + }; +}); + +export type PluginFailures = Effect.Success; diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts index 94fe2b4937cc0..a6be7ce8b263b 100644 --- a/apps/server/src/plugins/pluginHostTestHarness.ts +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes plugins.json as plain JSON. +// @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes manifests as plain JSON. // The plugin host with a theme service that records what it publishes, for its tests and the tests // of the modules it builds. import type { PluginState } from "@t3tools/contracts"; @@ -28,7 +28,9 @@ const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ }), }); -export const layerHost = () => Layer.fresh(PluginHost.layer.pipe(Layer.provide(layerThemes))); +/** `themes` replaces the recording theme service, such as to hold a publish where it calls it. */ +export const layerHost = (themes = layerThemes) => + Layer.fresh(PluginHost.layer.pipe(Layer.provide(themes))); /** The real file system with some of its methods replaced, for the host's layer. */ export const layerFileSystemWith = ( @@ -54,6 +56,24 @@ export const writePluginsConfig = Effect.fn(function* (config: { yield* writeFile(`${stateDir}/plugins.json`, JSON.stringify(config)); }); +/** A plugin written for one test, under `/plugins/`. */ +export const writeInstalledPlugin = Effect.fn(function* (id: string, serverSource: string) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/${id}`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id, + name: id, + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile(`${rootDir}/server.cjs`, serverSource); + return rootDir; +}); + export const stateOf = (states: ReadonlyArray, id: string) => states.find((state) => state.id === id); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts index c449ad559a6ff..049a9c7844e8e 100644 --- a/apps/server/src/plugins/pluginRpcHandlers.ts +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -3,21 +3,27 @@ import { PluginError, type PluginCreateUploadUrlInput, type PluginId, + type PluginInvokeInput, type PluginListInput, type PluginReloadInput, type PluginRescanInput, type PluginSetEnabledInput, + type PluginSubscribeInput, type PluginUninstallInput, WS_METHODS, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; -import type * as PluginHost from "./PluginHost.ts"; +import { rpcAuthorizationError } from "../auth/RpcAuthorization.ts"; +import * as PluginHost from "./PluginHost.ts"; import type * as PluginInstaller from "./PluginInstaller.ts"; import type * as PluginUploads from "./pluginUploads.ts"; const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => - new PluginError({ message: error.message, pluginId }); + PluginHost.isPluginAccessDeniedError(error) + ? rpcAuthorizationError(error.requiredScope) + : new PluginError({ message: error.message, pluginId }); /** The `plugins.*` WebSocket handlers. `scopes` are the connected session's. */ export const makePluginRpcHandlers = ( @@ -47,4 +53,16 @@ export const makePluginRpcHandlers = ( installer .uninstall(input.pluginId, input.folder) .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), + [WS_METHODS.pluginsInvoke]: (input: PluginInvokeInput) => + pluginHost + .invoke({ pluginId: input.pluginId, method: input.method, input: input.input, scopes }) + .pipe( + Effect.map((output) => ({ output })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSubscribe]: (input: PluginSubscribeInput) => + pluginHost.subscribe(input).pipe( + Stream.map((value) => ({ value })), + Stream.mapError(toRpcError(input.pluginId)), + ), }); diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts index 6961d4cf8428d..4e175a670e392 100644 --- a/apps/server/src/plugins/pluginStates.ts +++ b/apps/server/src/plugins/pluginStates.ts @@ -16,7 +16,9 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as EnvironmentTheme from "../environmentTheme.ts"; +import type { PluginActivation } from "./PluginApi.ts"; import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginFailures } from "./pluginFailures.ts"; import type { PluginsConfigFile } from "./pluginsConfig.ts"; import type { PluginWebFolders } from "./pluginWebFiles.ts"; @@ -25,6 +27,8 @@ export interface PluginRecord { readonly plugin: DiscoveredPlugin; readonly status: PluginStatus; readonly failure?: PluginFailure; + readonly activation?: PluginActivation; + readonly revision?: number; readonly styles?: PluginState["styles"]; readonly webFiles?: PluginWebFolders; } @@ -58,7 +62,11 @@ const statesEvent = ( : { _tag: "changed", length: next.plugins.length, rows }; }; -const toState = (record: PluginRecord, config: PluginsConfigFile): PluginState => { +const toState = ( + record: PluginRecord, + config: PluginsConfigFile, + serverFailure: string | undefined, +): PluginState => { const manifest = manifestOf(record.plugin); const { id } = record.plugin; return { @@ -76,19 +84,23 @@ const toState = (record: PluginRecord, config: PluginsConfigFile): PluginState = enabled: config.enabled?.[id] === true, status: record.status, ...(record.failure === undefined ? {} : { failure: record.failure }), + ...(record.revision === undefined ? {} : { revision: record.revision }), ...(record.styles === undefined || record.styles.length === 0 ? {} : { styles: record.styles }), + hasServer: manifest?.server !== undefined, + ...(serverFailure === undefined ? {} : { serverFailure }), }; }; /** - * Builds the plugin list clients see from the host's records and plugins.json, and publishes it - * after each change, with the theme folders of plugins that are on when those change. + * Builds the plugin list clients see from the host's records, plugins.json and the failures, and + * publishes it after each change, with the theme folders of plugins that are on when those change. */ export const makePluginStates = Effect.fn("PluginStates.make")(function* (input: { readonly records: Ref.Ref>; readonly config: Effect.Effect; + readonly failures: PluginFailures; }) { - const { records } = input; + const { records, failures } = input; const environmentThemes = yield* EnvironmentTheme.EnvironmentThemeService; const path = yield* Path.Path; const changes = yield* PubSub.sliding>(1); @@ -96,7 +108,10 @@ export const makePluginStates = Effect.fn("PluginStates.make")(function* (input: const statesOf = Effect.fn("PluginHost.statesOf")(function* (all: ReadonlyArray) { const config = yield* input.config; - return all.map((record) => toState(record, config)); + const failureOf = yield* failures.current; + return all.map((record) => + toState(record, config, failureOf(record.plugin.id, record.revision)), + ); }); const current = Ref.get(records).pipe(Effect.flatMap(statesOf)); // What the last publish sent, starting from the empty list clients get before any. Settings diff --git a/apps/server/src/plugins/pluginUploads.test.ts b/apps/server/src/plugins/pluginUploads.test.ts index 4732d433735e0..cdf8d271b7eca 100644 --- a/apps/server/src/plugins/pluginUploads.test.ts +++ b/apps/server/src/plugins/pluginUploads.test.ts @@ -99,7 +99,7 @@ it.layer(NodeServices.layer)("PluginUploads", (it) => { assert.strictEqual((yield* upload(expiring, files)).status, 404); assert.deepStrictEqual(yield* host.list, []); - // It installs a plugin, so a URL that leaked after its upload installs nothing more. + // It installs code, so a URL that leaked after its upload installs nothing more. const { relativeUrl } = yield* issueUrl; assert.strictEqual((yield* upload(relativeUrl, files)).status, 200); assert.strictEqual((yield* upload(relativeUrl, files)).status, 404); diff --git a/apps/server/src/plugins/pluginUploads.ts b/apps/server/src/plugins/pluginUploads.ts index 0da56e6e078bd..7828bb43a83ff 100644 --- a/apps/server/src/plugins/pluginUploads.ts +++ b/apps/server/src/plugins/pluginUploads.ts @@ -72,7 +72,7 @@ export class PluginUploads extends Context.Service< readonly issueUrl: Effect.Effect; /** * Whether `token` is a URL this server signed that is unexpired and unused. A valid one is used - * up, because it installs a plugin. + * up, because it installs code. */ readonly claim: (token: string) => Effect.Effect; } diff --git a/apps/server/src/plugins/pluginWebFiles.test.ts b/apps/server/src/plugins/pluginWebFiles.test.ts index 5d084e4ca84bc..793e0234dcad3 100644 --- a/apps/server/src/plugins/pluginWebFiles.test.ts +++ b/apps/server/src/plugins/pluginWebFiles.test.ts @@ -142,6 +142,10 @@ it.layer(NodeServices.layer)("PluginWebFiles", (it) => { yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; yield* waitForStatuses(host, { [stylesPlugin]: "active" }); + const call = yield* host + .invoke({ pluginId: stylesPlugin, method: "ping", input: undefined, scopes: [] }) + .pipe(Effect.flip); + assert.strictEqual(call._tag, "PluginNoServerError"); // In name order, which is the order clients apply them in. const styles = stateOf(yield* host.list, stylesPlugin)?.styles ?? []; assert.deepStrictEqual( diff --git a/apps/server/src/plugins/pluginsConfig.ts b/apps/server/src/plugins/pluginsConfig.ts index 448e6d1f20a0a..78b96621fcdce 100644 --- a/apps/server/src/plugins/pluginsConfig.ts +++ b/apps/server/src/plugins/pluginsConfig.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - a crash is saved while the process exits, which +// needs synchronous writes the FileSystem service does not offer. +import * as NodeFS from "node:fs"; + import type { PluginId } from "@t3tools/contracts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; @@ -23,12 +27,22 @@ const PluginsConfigFile = Schema.StructWithRest( Schema.Struct({ enabled: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), devPaths: Schema.optionalKey(Schema.Array(Schema.String)), + /** Why each plugin stopped the server, kept until the user turns it on again or reloads it. */ + crashed: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }), [Schema.Record(Schema.String, Schema.Json)], ); export type PluginsConfigFile = typeof PluginsConfigFile.Type; const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginsConfigFile)); const encodeConfig = Schema.encodeEffect(fromJsonStringPretty(PluginsConfigFile)); +const decodeConfigSync = Schema.decodeUnknownSync(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfigSync = Schema.encodeSync(fromJsonStringPretty(PluginsConfigFile)); + +export const withoutCrash = (current: PluginsConfigFile, pluginId: PluginId): PluginsConfigFile => { + if (current.crashed?.[pluginId] === undefined) return current; + const { [pluginId]: _cleared, ...crashed } = current.crashed; + return { ...current, crashed }; +}; /** Builds the host's copy of `/plugins.json`, which it reads and writes through this. */ export const makePluginsConfig = Effect.gen(function* () { @@ -81,30 +95,65 @@ export const makePluginsConfig = Effect.gen(function* () { : read ).pipe(Effect.tap((next) => Ref.set(current, next))), write, + /** Also forgets that the plugin crashed the server: the user chose what happens next. */ writeEnabled: (pluginId: PluginId, enabled: boolean) => write((current) => ({ - ...current, + ...withoutCrash(current, pluginId), enabled: { ...current.enabled, [pluginId]: enabled }, })), /** - * Forgets the switch of every plugin not in `found`, writing only if there are any. A switch - * left on by a folder deleted by hand would start the next folder with its id unseen. + * Forgets the switch and crash of every plugin not in `found`, writing only if there are any. + * A switch left on by a folder deleted by hand would start the next folder with its id unseen. */ forgetMissing: (found: ReadonlySet) => { const keep = (entries: Readonly>) => Object.fromEntries(Object.entries(entries).filter(([id]) => found.has(id))); const missing = (config: PluginsConfigFile) => - Object.keys(config.enabled ?? {}).some((id) => !found.has(id)); + [...Object.keys(config.enabled ?? {}), ...Object.keys(config.crashed ?? {})].some( + (id) => !found.has(id), + ); return Ref.get(current).pipe( Effect.flatMap((last) => missing(last) ? write((latest) => ({ ...latest, ...(latest.enabled === undefined ? {} : { enabled: keep(latest.enabled) }), + ...(latest.crashed === undefined ? {} : { crashed: keep(latest.crashed) }), })) : Effect.void, ), ); }, + /** Forgets that the plugin crashed the server, writing only if it had. */ + clearCrash: (pluginId: PluginId) => + read.pipe( + Effect.flatMap((latest) => + latest.crashed?.[pluginId] === undefined + ? Effect.void + : write((last) => withoutCrash(last, pluginId)), + ), + ), + /** + * Saves that the plugin crashed the server and turns it off, so the next start lists it as + * failed with its switch off. Synchronous, because Node exits right after; it throws if the + * write fails. + */ + recordCrash: (pluginId: PluginId, message: string) => { + // From disk, like `write`, unless it can't be read. + let last = Ref.getUnsafe(current); + try { + last = decodeConfigSync(NodeFS.readFileSync(configPath, "utf8")); + } catch { + // Such as a file edited by hand into invalid JSON. + } + const next = { + ...last, + enabled: { ...last.enabled, [pluginId]: false }, + crashed: { ...last.crashed, [pluginId]: message }, + }; + const temporary = `${configPath}.crash-${process.pid}.tmp`; + NodeFS.writeFileSync(temporary, `${encodeConfigSync(next)}\n`); + NodeFS.renameSync(temporary, configPath); + }, }; }); diff --git a/apps/web/src/components/settings/plugins/PluginInstallControls.tsx b/apps/web/src/components/settings/plugins/PluginInstallControls.tsx index 33c53d099052f..a2576e27f5e05 100644 --- a/apps/web/src/components/settings/plugins/PluginInstallControls.tsx +++ b/apps/web/src/components/settings/plugins/PluginInstallControls.tsx @@ -50,8 +50,8 @@ export function InstallProblems({ problems }: { readonly problems: ReadonlyArray } /** - * Installs plugins on `environment`. Plugins only change the app, so one does nothing there unless - * the app loads plugin UI from it, which `loadsUi` says. + * Installs plugins on `environment`. A plugin without server code only changes the app, so it does + * nothing there unless the app loads plugin UI from it, which `loadsUi` says. */ export function usePluginInstaller( environment: Pick, @@ -95,7 +95,7 @@ export function usePluginInstaller( }, ); if (plugin === null) continue; - if (!loadsUi) { + if (!plugin.hasServer && !loadsUi) { report( `${plugin.name} only changes the app, and this app doesn't load plugin UI from ${environment.label}, so it does nothing there.`, ); @@ -126,7 +126,9 @@ export function AddPluginDialog({ Add a plugin - {scopeNote} Install only plugins you trust. + + {scopeNote} Plugins run with the same access as T3 Code, so install only code you trust. +
) : null} diff --git a/apps/web/src/plugins/pluginStyles.browser.test.ts b/apps/web/src/plugins/pluginStyles.browser.test.ts index 16fbb49d6eae2..ba384816de76e 100644 --- a/apps/web/src/plugins/pluginStyles.browser.test.ts +++ b/apps/web/src/plugins/pluginStyles.browser.test.ts @@ -32,6 +32,7 @@ const stylesPluginState: PluginState = { source: "dev", enabled: true, status: "active", + hasServer: false, }; describe("startPluginStyles", () => { diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md index 8e92bd9688341..1ca2d1535a5ae 100644 --- a/docs/internals/plugins.md +++ b/docs/internals/plugins.md @@ -1,28 +1,69 @@ # Plugins A plugin is a folder with a `t3-plugin.json` manifest -([schema](../../packages/contracts/src/plugins.ts)). -[`PluginHost`](../../apps/server/src/plugins/PluginHost.ts) finds plugins, keeps them on or off -and publishes their themes, and +([schema](../../packages/contracts/src/plugins.ts)), typed with +[`@t3tools/plugin-sdk`](../../packages/plugin-sdk). +[`PluginHost`](../../apps/server/src/plugins/PluginHost.ts) runs plugins and [`PluginInstaller`](../../apps/server/src/plugins/PluginInstaller.ts) installs them. -## Manifests +## Trust and failure + +Plugins are trusted code the user chose to install. Server entries run in the server process, with +no sandbox. Manifest `permissions` are shown on the plugin's row and not enforced. Do not shape the +API around a future sandbox. + +The host isolates failure instead. A plugin that throws or hangs while starting is marked failed, +and a throw in a handler fails that call. + +Plugin code can still fail where nothing T3 runs is waiting, such as in a timer or a child process +with no `'error'` listener. The [failure guard](../../apps/server/src/plugins/pluginFailureGuard.ts) +ties such a failure to a plugin when it can. A plugin's unhandled rejection becomes that plugin's +`serverFailure`, and Node does not exit. The plugin stays active. Disabling or reloading it releases +what it registered through the API, but timers it started itself keep running, and Node keeps its +code until the server restarts. This is accepted because the user chose to install that code, and +the alternative is one plugin bug stopping the server and every running agent. + +An uncaught exception still stops the server, because Node calls resuming after one unsafe. When the +guard ties one to a plugin, it saves the error to `plugins.json` before Node exits. The next start +lists that plugin as failed with its switch off until the user turns it on again, so a plugin that +throws at startup can't stop every start. A failure the guard can't tie to a plugin +stops the server too. + +The host runs plugin code in an `AsyncLocalStorage` scope, which Node carries into the timers and +callbacks that code starts, so a failure with only Node's frames in its stack is still the plugin's. +Before Node 24 that scope slows every promise in the process about 3x, so the host fails plugins +with a server half there as incompatible, though the server still allows Node 22.16+ and 23.11+. +Plugins without one still run. + +## The API and manifests + +The server bundle inlines `effect`, so plugins never share the host's Effect runtime or `Schema` +instances. They get Promises, `AbortSignal` and the SDK's own types, never T3's contracts, which +change between releases. + +`plugins.invoke` and `plugins.subscribe` need only the read scope, so the host checks a method's +`access` itself: a `write` method needs the caller's operate scope, as T3's own changes do. A call +fails after 30 seconds and aborts the handler's signal, so a handler that never answers can't hold +a client's call open. Each subscriber gets its own sliding buffer per channel, so a busy channel or +a slow subscriber drops only that channel's oldest values. Manifests ignore keys they don't know, so a newer manifest key is lost silently on an older T3. `engines.t3` is how a plugin requires a newer T3: the server refuses to install or start a version -outside it. +outside it. `api.version` is how a plugin detects newer API members and still runs on older +releases, so a change that adds a member raises it and names that version in the member's doc. ## State lives beside T3's -`/plugins.json` holds enabled flags and dev paths. They stay out of `settings.json`, whose -writer drops keys it doesn't know, and out of `statev2.sqlite`, whose numbered migrations refuse to -start when a fork's id collides with an upstream one. +`/plugins.json` holds enabled flags, dev paths and the plugins that stopped the server. +They stay out of `settings.json`, whose writer drops keys it doesn't know, and out of +`statev2.sqlite`, whose numbered migrations refuse to start when a fork's id collides with an +upstream one. ## Installing Installs copy into `plugin-staging/`, outside `plugins/`, so discovery never sees a half-written plugin. Only the swap takes the lifecycle lock, so a slow copy does not block other plugins. The -old version waits in staging during the swap, and startup puts it back if a crash left it there. +old version waits in staging until the new one starts and goes back if it fails. Clients upload a dropped folder's files, never its path, which would mean nothing on a remote environment. They post them over HTTP to a URL `plugins.createUploadUrl` signs for 5 minutes, @@ -34,9 +75,9 @@ before. Discovery takes the id from the manifest, because people copy plugin folders in by hand. The enabled flag belongs to the id, so a later folder with an id already listed fails as a duplicate. Installed folders come first, in name order, then dev paths. Removing or unlinking a duplicate deletes only -its folder or link. Discovery also forgets the enabled flag of every id no folder has, so a folder -copied in later with that id starts off. Nothing watches `plugins/`: the server reads it at startup, -on reload and when Settings asks. +its folder or link. Discovery also forgets the enabled flag and crash record of every id no folder +has, so a folder copied in later with that id starts off. Nothing watches `plugins/`: the server +reads it at startup, on reload and when Settings asks. ## Clients diff --git a/docs/user/plugins.md b/docs/user/plugins.md index 4e5da12a34724..d8f7ff6a1dcbe 100644 --- a/docs/user/plugins.md +++ b/docs/user/plugins.md @@ -1,8 +1,9 @@ # Plugins -Plugins change how T3 Code looks, with themes and styles. A plugin belongs to one environment and -lives on that environment's T3 Code server. A plugin's row lists the permissions it says it uses, -but T3 Code doesn't enforce them. Install only plugins you trust. +Plugins add themes, styles and server code to T3 Code. A plugin belongs to one environment and runs +inside that environment's T3 Code server, with the same access to your files, threads and +credentials as T3 Code itself. A plugin's row lists the permissions it says it uses, but T3 Code +doesn't enforce them. Install only plugins you trust. ## Add a plugin @@ -12,9 +13,9 @@ choose the folder in a file window. T3 Code copies a plugin folder into the environment's `plugins` folder, so later changes to your copy do not affect the installed plugin. To update it, add the new version's folder the same way. A new plugin is installed turned off, so none of it runs until you have read its row and turned it -on. A new version of a plugin stays on or off as the old one was. If a new version fails to install, -you keep the version you had, and T3 Code says why. A plugin that needs a newer version of T3 Code -isn't installed, and T3 Code says why. +on. A new version of a plugin stays on or off as the old one was. If a new version fails to install +or start, you keep the version you had, and T3 Code says why. A plugin that needs a newer version +of T3 Code isn't installed, and T3 Code says why. Plugin folders you copy into the `plugins` folder yourself show up, turned off, the next time you open **Settings → Plugins**. That folder is `~/.t3/userdata/plugins` on the environment's machine @@ -27,8 +28,13 @@ top of the page. To use a plugin on several environments, add it to each of them ## Turn a plugin on or off Use the switch on the plugin's row. A plugin that fails to start shows the reason on its row, for -example when it needs a newer version of T3 Code. **Reload** restarts a plugin with its current -files. +example when it needs a newer version of T3 Code. A plugin with server code needs the environment's +T3 Code server to run on Node 24 or later, as the desktop app's does. **Reload** restarts a plugin +with its current files. Turning a plugin off doesn't stop code it started on its own, such as a +timer. If its row says some of its code is still running, restart T3 Code to stop it. + +A plugin can stop T3 Code's server with an error. When the server starts again, that plugin stays +off and its row says it stopped the server. To try it again, turn it back on. ## Remove a plugin diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..025cbd6609e77 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -140,6 +140,10 @@ // These workspace packages are private to this monorepo. "includeEntryExports": true, }, + "packages/plugin-sdk": { + // Plugin authors outside this repository import these exports. + "includeEntryExports": false, + }, "packages/effect-acp": { "includeEntryExports": true, "entry": ["test/fixtures/*.ts"], diff --git a/package.json b/package.json index c78b879301b9e..e80dc1015c9f3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "report:renders": "node scripts/render-metrics.ts", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/plugin-sdk --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index fcd7a3e0b4b87..370e1c1591e88 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -98,8 +98,7 @@ describe("command permissions", () => { it.effect.each([ WS_METHODS.serverRunStorageCleanup, - // Installing, removing, enabling, reloading or rescanning plugins changes what every client - // shows. + // Installing, enabling or reloading a plugin runs its code in the server. WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload, WS_METHODS.pluginsCreateUploadUrl, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index a467aaafae381..5dc148424693f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -57,7 +57,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, - // Enabling a plugin changes what every client of the environment shows, such as its themes. + // Enabling a plugin runs its code in the server, like pointing a provider at a binary. [WS_METHODS.pluginsSetEnabled]: AuthSettingsWriteScope, [WS_METHODS.pluginsReload]: AuthSettingsWriteScope, [WS_METHODS.pluginsCreateUploadUrl]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/plugins.test.ts b/packages/contracts/src/plugins.test.ts index cd28c296538c3..41570968f0111 100644 --- a/packages/contracts/src/plugins.test.ts +++ b/packages/contracts/src/plugins.test.ts @@ -10,6 +10,7 @@ const manifest = { name: "Hello", version: "0.1.0", engines: { t3: ">=0.1.6" }, + server: "./dist/server.cjs", permissions: ["Reads thread titles"], }; @@ -34,6 +35,10 @@ describe("PluginManifest", () => { ["an id with an underscore", { id: "hello_plugin" }], ["a version that is not semver", { version: "1.0" }], ["an engines range the host cannot evaluate", { engines: { t3: "~0.1.6" } }], + ["a server entry that escapes the plugin directory", { server: "../other/server.cjs" }], + ["an absolute server entry", { server: "/usr/lib/server.cjs" }], + ["a Windows server entry", { server: "C:\\plugins\\server.cjs" }], + ["a server entry that is not CommonJS", { server: "dist/server.js" }], ])("rejects %s", (_name, override) => { expect(() => decodeManifest({ ...manifest, ...override })).toThrow(); }); diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index c8dff11849d9e..460da634a7a96 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -38,6 +38,16 @@ const isInsidePluginDirectory = Schema.makeFilter( path.split("/").every((segment) => segment !== ".."), { expected: "a relative path inside the plugin directory" }, ); +const PluginEntryPath = TrimmedNonEmptyString.check( + Schema.isMaxLength(256), + isInsidePluginDirectory, +); + +// The packaged server is a Node single executable that can only `require` plugin code, and a +// `.js` file under a `"type": "module"` package.json would load as ESM. +const PluginServerEntryPath = PluginEntryPath.check( + Schema.isPattern(/\.cjs$/, { expected: "a CommonJS file ending in .cjs" }), +); /** `t3-plugin.json` at the root of a plugin directory. */ export const PluginManifest = Schema.Struct({ @@ -46,7 +56,8 @@ export const PluginManifest = Schema.Struct({ version: PluginVersion, description: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(500))), engines: Schema.Struct({ t3: PluginEngineRange }), - /** Shown to the user before enabling. Not enforced. */ + server: Schema.optionalKey(PluginServerEntryPath), + /** Shown to the user before enabling. Not enforced: plugins are trusted code. */ permissions: Schema.optionalKey( Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(200))).check( Schema.isMaxLength(32), @@ -62,12 +73,18 @@ export type PluginSource = typeof PluginSource.Type; export const PluginStatus = Schema.Literals(["active", "disabled", "failed", "loading"]); export type PluginStatus = typeof PluginStatus.Type; -/** `duplicate` is a folder whose id an earlier folder already has. */ +/** + * `crash` is a plugin that stopped the server, turned off until the user turns it on again. + * `duplicate` is a folder whose id an earlier folder already has. + */ export const PluginFailureStage = Schema.Literals([ "discovery", "duplicate", "manifest", "compatibility", + "load", + "activate", + "crash", ]); export type PluginFailureStage = typeof PluginFailureStage.Type; @@ -89,6 +106,8 @@ export const PluginState = Schema.Struct({ enabled: Schema.Boolean, status: PluginStatus, failure: Schema.optionalKey(PluginFailure), + /** Changes each time the plugin starts. */ + revision: Schema.optionalKey(Schema.Number), /** * The `.css` files in its `styles` folder while it runs, in the order clients apply them. Each * `revision` names the file's contents and is sent as its URL's `v`. @@ -101,9 +120,19 @@ export const PluginState = Schema.Struct({ * the plugins folder. */ folder: Schema.optionalKey(Schema.String), + hasServer: Schema.Boolean, + /** Why its server code failed outside a call since it last started, such as in a timer. */ + serverFailure: Schema.optionalKey(Schema.String), }); export type PluginState = typeof PluginState.Type; +/** Name of a method or channel a plugin registered for `plugins.invoke` and `plugins.subscribe`. */ +export const PluginRpcName = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9._-]*$/), +); +export type PluginRpcName = typeof PluginRpcName.Type; + export const PluginListInput = Schema.Struct({}); export type PluginListInput = typeof PluginListInput.Type; @@ -206,6 +235,29 @@ export const PluginMutationResult = Schema.Struct({ }); export type PluginMutationResult = typeof PluginMutationResult.Type; +export const PluginInvokeInput = Schema.Struct({ + pluginId: PluginId, + method: PluginRpcName, + input: Schema.optionalKey(Schema.Json), +}); +export type PluginInvokeInput = typeof PluginInvokeInput.Type; + +export const PluginInvokeResult = Schema.Struct({ + output: Schema.Json, +}); +export type PluginInvokeResult = typeof PluginInvokeResult.Type; + +export const PluginSubscribeInput = Schema.Struct({ + pluginId: PluginId, + channel: PluginRpcName, +}); +export type PluginSubscribeInput = typeof PluginSubscribeInput.Type; + +export const PluginSubscriptionEvent = Schema.Struct({ + value: Schema.Json, +}); +export type PluginSubscriptionEvent = typeof PluginSubscriptionEvent.Type; + export class PluginError extends Schema.TaggedError()("PluginError", { message: Schema.String, pluginId: Schema.optionalKey(PluginId), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1cc56b3f9a03b..c2fd78b0ceafc 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -379,6 +379,8 @@ import { PluginCreateUploadUrlInput, PluginCreateUploadUrlResult, PluginError, + PluginInvokeInput, + PluginInvokeResult, PluginUninstallInput, PluginUninstallResult, PluginListInput, @@ -387,6 +389,8 @@ import { PluginMutationResult, PluginReloadInput, PluginSetEnabledInput, + PluginSubscribeInput, + PluginSubscriptionEvent, PluginStatesEvent, } from "./plugins.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; @@ -569,6 +573,8 @@ export const WS_METHODS = { pluginsCreateUploadUrl: "plugins.createUploadUrl", pluginsRescan: "plugins.rescan", pluginsUninstall: "plugins.uninstall", + pluginsInvoke: "plugins.invoke", + pluginsSubscribe: "plugins.subscribe", cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -2020,6 +2026,19 @@ const WsPluginsUninstallRpc = Rpc.make(WS_METHODS.pluginsUninstall, { error: PluginRpcError, }); +const WsPluginsInvokeRpc = Rpc.make(WS_METHODS.pluginsInvoke, { + payload: PluginInvokeInput, + success: PluginInvokeResult, + error: PluginRpcError, +}); + +const WsPluginsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSubscribe, { + payload: PluginSubscribeInput, + success: PluginSubscriptionEvent, + error: PluginRpcError, + stream: true, +}); + const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, @@ -2112,6 +2131,8 @@ export const WsRpcGroup = RpcGroup.make( WsPluginsCreateUploadUrlRpc, WsPluginsRescanRpc, WsPluginsUninstallRpc, + WsPluginsInvokeRpc, + WsPluginsSubscribeRpc, WsServerReportClientActivityRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 2a6428dd5822d..85e03d2915a4f 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -1,7 +1,8 @@ -# T3 Code plugins +# @t3tools/plugin-sdk -A plugin is a folder with a `t3-plugin.json` manifest, plus theme files and stylesheets that change -how T3 Code looks. It runs no code. +Types for T3 Code plugins. A plugin is a folder with a `t3-plugin.json` manifest, theme files and +stylesheets that change how T3 Code looks, and an optional server half that runs inside the T3 Code +server. Plugins are trusted code: they run with the same access as T3 Code. The plugins in [`fixtures/`](./fixtures) back the tests and are small and working too. They are written by hand and need no build. @@ -14,7 +15,8 @@ written by hand and need no build. "name": "Hello web", "version": "0.1.0", "description": "Shown on the Plugins page.", - "engines": { "t3": ">=0.1.6" } + "engines": { "t3": ">=0.1.6" }, + "server": "./server.cjs" } ``` @@ -24,6 +26,9 @@ written by hand and need no build. `>=0.1.6`. A server outside it refuses to install the plugin or start it, and says why. - `permissions` is an optional list of what the plugin says it uses. Settings → Plugins shows it on the plugin's row, and T3 Code doesn't enforce it. +- `server` must be CommonJS ending in `.cjs`. A plugin with a `server` runs only on a T3 Code + server running Node 24 or later, such as the desktop app's. On an older Node, such as `npx t3` on + Node 22, it fails to start and says why. - A `themes/` folder of theme JSON files is published while the plugin is active. T3 Code remembers the published themes, so the boot screen and the desktop window start in the active theme's colors. A theme that sets `"sidebarArtwork": true` keeps the nightly and dev artwork in @@ -37,6 +42,41 @@ written by hand and need no build. **Reload** picks up edits. The page gets each file's text, so a `url()` can't reach the plugin's folder; use a `data:` URL for an image. +## Server half + +```js +/** @type {import("@t3tools/plugin-sdk/server").PluginDefinition} */ +module.exports = { + activate(api) { + api.rpc.register("greeting", () => "Hello", { access: "read" }); + }, +}; +``` + +`activate` receives the [`PluginApi`](./src/server.ts): RPC methods and channels for clients. + +Everything a plugin registers is released when it is disabled, reloaded or uninstalled; add your +own cleanup with `api.onDeactivate`. + +`rpc.register` takes an `access`. Any session that can read the environment may call a `read` +method, a guest's read-only session included. A `write` method needs the `orchestration:operate` +scope, which the owner's sessions have and a read-only session lacks. The handler's second argument +has the caller's `access` and a `signal`. The signal aborts when the caller goes away, the plugin +stops, or 30 seconds pass, and a method that hasn't answered by then fails. A client can send any +JSON as input, so check it before acting on it. + +`rpc.publish` sends a value to every session that follows the channel, and any session that can +read the environment may follow one, so never publish secrets. A subscriber gets only the values +published after it subscribes, so give subscribers a method that returns the current value. A +subscriber that falls behind keeps the latest 64 values of each channel and loses older ones. + +A promise rejection in plugin code that nothing handles, such as in a timer, doesn't stop T3 Code. +It shows on the plugin's row in Settings → Plugins, and the plugin keeps running. An exception +nothing catches does stop the server. When T3 Code can tell it was your plugin's, the plugin stays +off after the restart until the user turns it on again. That includes a throw in a timer callback +and an `'error'` event with no listener, so catch errors in callbacks and give every child process, +socket and stream you create an `'error'` listener. + ## Keeping a plugin working across T3 updates T3 changes its markup, class names, labels and layout in most releases. You don't need to drop @@ -61,6 +101,11 @@ parts of your design to survive that. Build each part on the most stable thing T changes, and list it in the plugin's README with the seam that would replace it. - **Leave T3's frame to T3.** The title bar row, the room for the window buttons on Windows and Linux, drag areas, phone safe areas and breakpoints are T3's, so don't resize or pad them. +- **Check `api.version` before using a newer API member.** A member added after version 1 names + the version that added it in its doc, so a plugin can skip that feature on an older T3 and keep + running. To require a T3 release instead, set `engines.t3`. +- **Ship what the plugin runs.** Installing a folder leaves out `.git` and `node_modules`, so + build everything into the files the manifest names. - **Ask for a seam.** When T3 doesn't hand over what you need, ask for a region or token on the T3 Code repository instead of reaching into its markup. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 0000000000000..c7bdd2bd6e098 --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,30 @@ +{ + "name": "@t3tools/plugin-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./shared": { + "types": "./src/shared.ts", + "import": "./src/shared.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } +} diff --git a/packages/plugin-sdk/src/server.ts b/packages/plugin-sdk/src/server.ts new file mode 100644 index 0000000000000..6170b6ac46431 --- /dev/null +++ b/packages/plugin-sdk/src/server.ts @@ -0,0 +1,78 @@ +import type { JsonObject, PluginRpcMap } from "./shared.ts"; + +/** `read` methods need the caller's `orchestration:read` scope, `write` methods `orchestration:operate`. */ +export type PluginRpcAccess = "read" | "write"; + +export interface PluginRpcContext { + /** + * Aborts when the caller goes away, the plugin deactivates, or the handler has not answered + * within 30 seconds, when the call fails. + */ + readonly signal: AbortSignal; + /** + * What the caller's session may call: `write` if it may call `write` methods, else `read`. A + * `read` method can use it to tell a read-only session, such as a guest's, from the owner's. + */ + readonly access: PluginRpcAccess; +} + +/** Answers one method of a `PluginRpcMap`. */ +export type PluginRpcHandler< + Method extends PluginRpcMap["methods"][string] = PluginRpcMap["methods"][string], +> = ( + input: Method["input"], + context: PluginRpcContext, +) => Method["output"] | Promise; + +export interface PluginLogger { + readonly debug: (message: string, fields?: JsonObject) => void; + readonly info: (message: string, fields?: JsonObject) => void; + readonly warn: (message: string, fields?: JsonObject) => void; + readonly error: (message: string, fields?: JsonObject) => void; +} + +/** + * What `activate` receives. Everything is scoped to one activation and released on deactivate. + * `Rpc` types the methods and channels clients call. + */ +export interface PluginApi { + /** + * This API's version, 1 so far. T3 raises it when the API gains a member, whose doc then names + * the version that added it, so a plugin can check it before using a member an older T3 lacks. + * To refuse older T3 releases outright, set `engines.t3` in the manifest. + */ + readonly version: number; + readonly id: string; + readonly rootDir: string; + readonly log: PluginLogger; + readonly rpc: { + /** + * Answers `plugins.invoke` calls for `method` from clients. `input` has the type `Rpc` gives + * it, but a client can send any JSON, so check input before acting on it. + */ + readonly register: >( + method: Method, + handler: PluginRpcHandler, + options: { readonly access: PluginRpcAccess }, + ) => void; + /** Sends `value` to every `plugins.subscribe` caller on `channel`. Any session that can read this environment may subscribe, so never publish secrets. */ + readonly publish: >( + channel: Channel, + value: Rpc["channels"][Channel], + ) => void; + }; + /** Runs when the plugin is disabled or reloaded, or the server stops; at once if that already happened. */ + readonly onDeactivate: (cleanup: () => void | Promise) => void; +} + +export interface PluginDefinition { + readonly activate: (api: PluginApi) => void | Promise; +} + +/** + * Types a server plugin. The CommonJS entry sets `module.exports` to the result. Pass the plugin's + * `PluginRpcMap`, as in `definePlugin({ ... })`, to type `api.rpc`. + */ +export const definePlugin = ( + definition: PluginDefinition, +): PluginDefinition => definition; diff --git a/packages/plugin-sdk/src/shared.ts b/packages/plugin-sdk/src/shared.ts new file mode 100644 index 0000000000000..6c6d1ec8a4cce --- /dev/null +++ b/packages/plugin-sdk/src/shared.ts @@ -0,0 +1,20 @@ +export type JsonValue = null | boolean | number | string | ReadonlyArray | JsonObject; + +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +/** + * A plugin's server methods and channels, as in `{ methods: { add: { input: { text: string }; + * output: string } }; channels: {} }`. Pass it to `definePlugin` so its calls are checked. Use + * `input: undefined` for no input, and `type`, not `interface`, for values. + */ +export interface PluginRpcMap { + readonly methods: { + readonly [method: string]: { + readonly input: JsonValue | undefined; + readonly output: JsonValue; + }; + }; + readonly channels: { readonly [channel: string]: JsonValue }; +} diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 0000000000000..c792d0cfb0be6 --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ES2023"], + "allowJs": true, + "checkJs": true + }, + "include": ["src", "fixtures"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb4789ac81439..e28db8020688a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@t3tools/provider-testing': specifier: workspace:* version: link:../../packages/provider-testing @@ -1590,6 +1593,12 @@ importers: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/plugin-sdk: + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + packages/provider-acp: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 8f300533523ae..4cf7a186d2240 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -27,6 +27,7 @@ const workspaceFiles = [ "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", + "packages/plugin-sdk/package.json", "packages/ssh/package.json", "packages/tailscale/package.json", "packages/provider-core/package.json", From 4b2a2b9ba993c16024cd7c07f4f07579d976b197 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 03:06:18 +0000 Subject: [PATCH 06/11] feat(plugins): plugin storage and settings, with secrets in the secret store Plugins had no place to keep configuration or data between restarts. A manifest can declare a `settingsSchema`, which discovery and install check, so a schema T3 can't validate fails the plugin with the reason instead of refusing every save. Clients read and save settings with `plugins.settings.get` and `.update`, and every save is checked against the schema. Top-level `t3-secret` fields go to the server's secret store, all of a plugin's in one `plugin.` entry, and read back redacted. Plugins get `api.settings`, which reads from memory after the first call, and a key-value `api.storage` in `plugins.sqlite`. Every API promise, iterator pulls included, rejects with a handled `PluginApiError`. Removing a plugin deletes its settings, secrets and storage, and nothing of another plugin's or of T3's own. Surfaces: the remove popup says what it deletes; the form comes next. Web and desktop; mobile shows none. No providers or agents. Contracts: two settings RPCs and `settingsSchema`. Reverse: removal deletes the data. Connections: WebSocket only. Docs updated. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 8 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/plugins/PluginApi.ts | 42 +- apps/server/src/plugins/PluginHost.test.ts | 206 +++++++- apps/server/src/plugins/PluginHost.ts | 56 ++- .../src/plugins/PluginInstaller.test.ts | 80 +++ apps/server/src/plugins/PluginInstaller.ts | 32 +- .../src/plugins/PluginSettingsStore.test.ts | 219 +++++++++ .../server/src/plugins/PluginSettingsStore.ts | 463 ++++++++++++++++++ apps/server/src/plugins/PluginStorage.test.ts | 115 +++++ apps/server/src/plugins/PluginStorage.ts | 265 ++++++++++ apps/server/src/plugins/api/facade.test.ts | 84 ++++ apps/server/src/plugins/api/facade.ts | 100 +++- .../src/plugins/pluginDiscovery.test.ts | 12 +- apps/server/src/plugins/pluginDiscovery.ts | 21 +- apps/server/src/plugins/pluginFailureGuard.ts | 3 +- .../src/plugins/pluginHostTestHarness.ts | 14 +- apps/server/src/plugins/pluginRpcHandlers.ts | 12 + apps/server/src/plugins/pluginUploads.test.ts | 16 +- apps/server/src/server.ts | 5 +- .../components/settings/plugins/PluginRow.tsx | 10 + docs/internals/plugins.md | 42 +- docs/user/plugins.md | 15 +- knip.jsonc | 2 + .../src/state/commandPermissions.test.ts | 3 +- .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/plugins.test.ts | 1 + packages/contracts/src/plugins.ts | 28 +- packages/contracts/src/rpc.ts | 19 + packages/plugin-sdk/README.md | 15 +- .../fixtures/broken-plugin/server.cjs | 6 + .../fixtures/broken-plugin/t3-plugin.json | 8 + .../fixtures/hello-plugin/server.cjs | 36 ++ .../fixtures/hello-plugin/t3-plugin.json | 16 + packages/plugin-sdk/src/server.ts | 22 +- 36 files changed, 1893 insertions(+), 87 deletions(-) create mode 100644 apps/server/src/plugins/PluginInstaller.test.ts create mode 100644 apps/server/src/plugins/PluginSettingsStore.test.ts create mode 100644 apps/server/src/plugins/PluginSettingsStore.ts create mode 100644 apps/server/src/plugins/PluginStorage.test.ts create mode 100644 apps/server/src/plugins/PluginStorage.ts create mode 100644 apps/server/src/plugins/api/facade.test.ts create mode 100644 packages/plugin-sdk/fixtures/broken-plugin/server.cjs create mode 100644 packages/plugin-sdk/fixtures/broken-plugin/t3-plugin.json create mode 100644 packages/plugin-sdk/fixtures/hello-plugin/server.cjs create mode 100644 packages/plugin-sdk/fixtures/hello-plugin/t3-plugin.json diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 0095cf478d260..d43b5a5c2f734 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,8 +68,12 @@ describe("RPC authorization scopes", () => { } }); - it("keeps enabling and reloading plugins, which runs their code, behind settings write", () => { - for (const method of [WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload]) { + it("keeps enabling, reloading and configuring plugins, which runs their code, behind settings write", () => { + for (const method of [ + WS_METHODS.pluginsSetEnabled, + WS_METHODS.pluginsReload, + WS_METHODS.pluginsSettingsUpdate, + ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); } }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a42b96976d4c6..0fb91a610c9e1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -209,6 +209,7 @@ export const RPC_REQUIRED_SCOPES = { // PluginHost checks that against the plugin's own declaration. Channels are reads. [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope, [WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope, [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 9754f075a8d08..ef4ee80cccad2 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -94,6 +94,8 @@ const RPC_AGGREGATES = { [WS_METHODS.pluginsCreateUploadUrl]: "plugins", [WS_METHODS.pluginsRescan]: "plugins", [WS_METHODS.pluginsUninstall]: "plugins", + [WS_METHODS.pluginsSettingsGet]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", [WS_METHODS.pluginsInvoke]: "plugins", [WS_METHODS.pluginsSubscribe]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginApi.ts b/apps/server/src/plugins/PluginApi.ts index 4d54affaded1b..795d6b78197d4 100644 --- a/apps/server/src/plugins/PluginApi.ts +++ b/apps/server/src/plugins/PluginApi.ts @@ -3,10 +3,13 @@ import type { PluginApi, PluginRpcAccess, PluginRpcHandler } from "@t3tools/plug import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; -import { makeFacadeContext, pluginCodeScope } from "./api/facade.ts"; +import { makeFacadeContext, pluginCodeScope, refused } from "./api/facade.ts"; +import type * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import type * as PluginStorage from "./PluginStorage.ts"; const isJson = Schema.is(Schema.Json); const isPluginRpcName = Schema.is(PluginRpcName); @@ -19,9 +22,9 @@ export interface PluginActivation { { readonly handler: PluginRpcHandler; readonly access: PluginRpcAccess } >; readonly cleanups: Array<() => void | Promise>; - /** Aborted when deactivation starts; handler signals follow it. */ + /** Aborted when deactivation starts; handler signals and iterators follow it. */ readonly controller: AbortController; - /** Aborted once the cleanups have run; channels end then. */ + /** Aborted once the cleanups have run; API calls are refused from then on. */ readonly ended: AbortController; /** * Each subscriber's buffer, by channel, so a busy channel or a slow subscriber drops only its @@ -44,15 +47,27 @@ const assertActive = (activation: PluginActivation) => { } }; -/** Builds the object a plugin's `activate` receives. Effect stays on this side. */ +/** The host services the facade reaches. The host builds this once and shares it. */ +export interface PluginApiServices { + readonly settings: PluginSettingsStore.PluginSettingsStore["Service"]; + readonly storage: PluginStorage.PluginStorage["Service"]; +} + +/** + * Builds the object a plugin's `activate` receives. Effect stays on this side: every call runs + * with the host's services and settles as a plain Promise that rejects with an `Error`. + */ export const makePluginApi = (input: { readonly id: PluginId; readonly rootDir: string; + readonly settingsSchema: Schema.JsonObject | undefined; readonly activation: PluginActivation; readonly context: Context.Context; + readonly services: PluginApiServices; }): PluginApi => { - const { id, activation, context } = input; - const facade = makeFacadeContext({ pluginId: id, context }); + const { id, activation, context, services } = input; + const facade = makeFacadeContext({ pluginId: id, activation, context }); + const { call } = facade; const log = (level: "Debug" | "Info" | "Warn" | "Error") => (message: string, fields?: Schema.JsonObject) => facade.fork( @@ -61,11 +76,26 @@ export const makePluginApi = (input: { ), ); + const storage: PluginApi["storage"] = { + get: (key) => call(services.storage.get(id, key).pipe(Effect.map(Option.getOrUndefined))), + set: (key, value) => { + if (!isJson(value)) return refused(new TypeError("Storage values must be JSON.")); + return call(services.storage.set(id, key, value)); + }, + delete: (key) => call(services.storage.remove(id, key)), + list: (prefix) => call(services.storage.list(id, prefix)), + }; + return { version: 1, id, rootDir: input.rootDir, log: { debug: log("Debug"), info: log("Info"), warn: log("Warn"), error: log("Error") }, + settings: { + get: () => call(services.settings.get(id, input.settingsSchema)), + changes: () => facade.iterate(services.settings.changes(id)), + }, + storage, rpc: { register: (method, handler, options) => { assertActive(activation); diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index b8e83d79800a6..d3e62cfb50ad7 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -24,6 +24,7 @@ import * as ServerConfig from "../config.ts"; import * as EnvironmentTheme from "../environmentTheme.ts"; import * as PluginHost from "./PluginHost.ts"; import { + fixturesDir, layerConfig, layerFileSystemWith, layerHost, @@ -36,22 +37,35 @@ import { } from "./pluginHostTestHarness.ts"; import * as PluginInstaller from "./PluginInstaller.ts"; import { makePluginRpcHandlers } from "./pluginRpcHandlers.ts"; +import * as PluginStorage from "./PluginStorage.ts"; import * as PluginUploads from "./pluginUploads.ts"; const hello = PluginId.make("hello-plugin"); +const broken = PluginId.make("broken-plugin"); const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; const manifestJson = ( id: string, - options: { readonly name?: string; readonly version?: string; readonly engines?: string } = {}, + options: { + readonly name?: string; + readonly version?: string; + readonly engines?: string; + readonly settingsSchema?: object; + } = {}, ) => JSON.stringify({ id, name: options.name ?? id, version: options.version ?? "0.1.0", engines: { t3: options.engines ?? ">=0.1.0" }, + settingsSchema: options.settingsSchema, }); +const enableHello = writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], +}); + /** A `linked` plugin in a checkout folder, which the plugins folder holds a symlink to. */ const linkPluginFolder = Effect.fn(function* (entries: { readonly server?: string }) { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -89,6 +103,138 @@ const nextServerFailure = (host: PluginHost.PluginHost["Service"], pluginId: Plu ); it.layer(NodeServices.layer)("PluginHost", (it) => { + it.effect("activates an enabled plugin and serves its methods with the caller's access", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { source: "dev", hasServer: true, enabled: true }); + + const ping = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "ping", input: "a", scopes }); + assert.deepStrictEqual(yield* ping(operate), { + pong: "a", + greeting: "hello", + access: "write", + }); + // A read method tells a session that can only read from one that can also write. + assert.deepStrictEqual(yield* ping([AuthOrchestrationReadScope]), { + pong: "a", + greeting: "hello", + access: "read", + }); + + const count = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "count", input: undefined, scopes }); + const counted = yield* host + .subscribe({ pluginId: hello, channel: "counted" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.strictEqual(yield* count(operate), 1); + assert.deepStrictEqual(yield* Fiber.join(counted), Option.some(1)); + const denied = yield* count([AuthOrchestrationReadScope]).pipe(Effect.flip); + assert.strictEqual(denied._tag, "PluginAccessDeniedError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("hands an active plugin its saved settings and tells it when they change", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [hello]: "active" }); + + const changed = yield* host + .subscribe({ pluginId: hello, channel: "settings" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + yield* host.updateSettings(hello, { greeting: "hey" }); + assert.deepStrictEqual(yield* Fiber.join(changed), Option.some({ greeting: "hey" })); + assert.deepInclude( + yield* host.invoke({ pluginId: hello, method: "ping", input: "b", scopes: operate }), + { greeting: "hey" }, + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("marks a plugin that throws in activate as failed and keeps the others running", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true, [broken]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [broken]: "failed", [hello]: "active" }); + assert.deepStrictEqual(stateOf(states, broken)?.failure, { + stage: "activate", + message: "broken-plugin refuses to start", + }); + const missing = yield* host + .invoke({ pluginId: broken, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotActiveError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin with server code below Node 24 and runs one without", () => + Effect.gen(function* () { + const styles = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [hello]: true, [styles]: true }, + devPaths: [`${fixturesDir}hello-plugin`, `${fixturesDir}styles-plugin`], + }); + // As `npx t3` runs on Node 22. + yield* Effect.acquireRelease( + Effect.sync(() => { + const running = process.versions.node; + Object.defineProperty(process.versions, "node", { value: "22.16.0" }); + return running; + }), + (running) => + Effect.sync(() => Object.defineProperty(process.versions, "node", { value: running })), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "failed", [styles]: "active" }); + assert.deepStrictEqual(stateOf(states, hello)?.failure, { + stage: "compatibility", + message: "Needs Node 24 or later for its server code; this server runs Node 22.16.0.", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.scoped, Effect.provide(layerConfig())), + ); + + it.effect("runs cleanup on disable and stops serving the plugin", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [hello]: "active" }); + + const disabled = yield* host.setEnabled(hello, false); + assert.include(disabled, { status: "disabled", enabled: false }); + assert.deepStrictEqual(yield* storage.get(hello, "deactivated"), Option.some(true)); + const error = yield* host + .invoke({ pluginId: hello, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + const { stateDir } = yield* ServerConfig.ServerConfig; + const saved = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(`${stateDir}/plugins.json`), + ); + assert.deepStrictEqual(JSON.parse(saved).enabled, { [hello]: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + it.effect("publishes the theme folders of plugins that are on", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -291,7 +437,9 @@ module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").wri yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; const installer = yield* PluginInstaller.PluginInstaller; + const storage = yield* PluginStorage.PluginStorage; yield* waitForStatuses(host, { [id]: "active" }); + yield* storage.set(id, "kept", true); const copies = host.list.pipe( Effect.map((states) => states @@ -312,6 +460,8 @@ module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").wri assert.deepStrictEqual(yield* copies, [["0.1.0", "active", undefined]]); assert.isFalse(yield* fs.exists(installed)); assert.isTrue(yield* fs.exists(linked)); + // The copy in use keeps the id's data. + assert.deepStrictEqual(yield* storage.get(id, "kept"), Option.some(true)); const elsewhere = yield* installer.uninstall(id, `${stateDir}/elsewhere`).pipe(Effect.flip); assert.strictEqual(elsewhere.message, `No copy of twice is in ${stateDir}/elsewhere.`); @@ -622,22 +772,23 @@ module.exports = { activate() {} };`, it.effect("installs a dropped folder turned off, and one dropped again keeps its switch", () => Effect.gen(function* () { - const helloFiles = (version: string) => [ - { - path: "t3-plugin.json", - contents: new TextEncoder().encode(manifestJson(hello, { version })), - }, - ]; + const fs = yield* FileSystem.FileSystem; + const helloFiles = yield* Effect.forEach(["t3-plugin.json", "server.cjs"], (name) => + fs + .readFile(`${fixturesDir}hello-plugin/${name}`) + .pipe(Effect.map((contents) => ({ path: name, contents }))), + ); yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; const installer = yield* PluginInstaller.PluginInstaller; yield* waitForStatuses(host, {}); // Nothing of a new plugin runs until its switch is turned on. - const installed = yield* installer.installFiles(helloFiles("0.1.0")); + const installed = yield* installer.installFiles(helloFiles); assert.include(installed, { id: hello, status: "disabled", enabled: false }); - yield* host.setEnabled(hello, true); - const replaced = yield* installer.installFiles(helloFiles("0.1.1")); - assert.include(replaced, { status: "active", enabled: true, version: "0.1.1" }); + const started = yield* host.setEnabled(hello, true); + const replaced = yield* installer.installFiles(helloFiles); + assert.include(replaced, { status: "active", enabled: true }); + assert.notStrictEqual(replaced.revision, started.revision); }).pipe(Effect.provide(layerHost())); }).pipe(Effect.provide(layerConfig())), ); @@ -765,20 +916,16 @@ module.exports = { activate() {} };`, it.effect("sends a client only the plugins that changed after the first list", () => Effect.gen(function* () { - const { stateDir } = yield* ServerConfig.ServerConfig; - const changing = PluginId.make("changing"); - yield* writeFile(`${stateDir}/checkout/steady/t3-plugin.json`, manifestJson("steady")); - yield* writeFile(`${stateDir}/checkout/changing/t3-plugin.json`, manifestJson(changing)); yield* writePluginsConfig({ - enabled: { [changing]: true }, - devPaths: [`${stateDir}/checkout/steady`, `${stateDir}/checkout/changing`], + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], }); yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; - yield* waitForStatuses(host, { [changing]: "active", steady: "disabled" }); + yield* waitForStatuses(host, { [hello]: "active", [broken]: "disabled" }); const disabled = (event: PluginStatesEvent) => event._tag === "changed" && - event.rows.some(({ plugin }) => plugin.id === changing && plugin.status === "disabled"); + event.rows.some(({ plugin }) => plugin.id === hello && plugin.status === "disabled"); const events = yield* host .streamForClient(operate) .pipe( @@ -786,13 +933,13 @@ module.exports = { activate() {} };`, Stream.runCollect, Effect.forkChild({ startImmediately: true }), ); - yield* host.setEnabled(changing, false); + yield* host.setEnabled(hello, false); const [first, ...changes] = yield* Fiber.join(events); assert.strictEqual(first?._tag, "snapshot"); for (const change of changes) { assert.deepStrictEqual( change._tag === "changed" ? change.rows.map(({ plugin }) => plugin.id) : [], - [changing], + [hello], ); } const folded = [first!, ...changes].reduce(applyPluginStatesEvent, { plugins: [] }); @@ -803,7 +950,10 @@ module.exports = { activate() {} };`, it.effect("refuses a dropped folder this server can't run and keeps the old one", () => Effect.gen(function* () { - const upgrade = (version: string, options: { readonly engines?: string } = {}) => [ + const upgrade = ( + version: string, + options: { readonly engines?: string; readonly settingsSchema?: object } = {}, + ) => [ { path: "t3-plugin.json", contents: new TextEncoder().encode( @@ -824,6 +974,18 @@ module.exports = { activate() {} };`, refused.message, `Upgrade 2.0.0 needs T3 Code >=999.0.0, and this server is ${packageJson.version}.`, ); + const unchecked = yield* installer + .installFiles( + upgrade("2.0.0", { + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ) + .pipe(Effect.flip); + assert.include( + unchecked.message, + "Upgrade 2.0.0 has a settingsSchema T3 Code cannot validate", + ); + assert.include(unchecked.message, 'Missing definition "no"'); assert.include(stateOf(yield* host.list, "upgrade"), { version: "1.0.0", status: "active", diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 98c8e294875a8..36a395dd36444 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -40,13 +40,20 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { authenticateRawRouteWithScope } from "../http.ts"; import { forkParked } from "../serverActivation.ts"; -import { makePluginActivation, makePluginApi, type PluginActivation } from "./PluginApi.ts"; +import { + makePluginActivation, + makePluginApi, + type PluginActivation, + type PluginApiServices, +} from "./PluginApi.ts"; import { errorText, PluginCodeError, pluginCodeScope } from "./api/facade.ts"; import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; import { makePluginFailures } from "./pluginFailures.ts"; import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; import * as PluginInstaller from "./PluginInstaller.ts"; import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; @@ -157,6 +164,19 @@ export class PluginHost extends Context.Service< { readonly plugins: ReadonlyArray }, PluginConfigError >; + readonly getSettings: ( + pluginId: PluginId, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + readonly updateSettings: ( + pluginId: PluginId, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; /** * `scopes` are the caller's; methods the plugin registered as writes need the operate scope. A * handler that has not answered within 30 seconds is aborted. @@ -196,6 +216,11 @@ export class PluginHost extends Context.Service< } >()("t3/plugins/PluginHost") {} +type PluginSettingsError = + | PluginSettingsStore.PluginSettingsPersistenceError + | PluginSettingsStore.PluginSettingsSchemaError + | PluginSettingsStore.PluginSettingsValidationError; + const isJson = Schema.is(Schema.Json); /** What plugins.json says about the plugins a discovery found. */ @@ -219,6 +244,11 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const context = yield* Effect.context(); + const services: PluginApiServices = { + settings: yield* PluginSettingsStore.PluginSettingsStore, + storage: yield* PluginStorage.PluginStorage, + }; + const settingsStore = services.settings; const pluginsDir = path.join(config.stateDir, "plugins"); const pluginsConfig = yield* makePluginsConfig; @@ -293,8 +323,8 @@ const make = Effect.gen(function* () { }), ); - // Aborts first so handlers stop. Teardown also runs where interruption is off, so the deadline - // gets an interruptible region to cut. + // Aborts first so handlers and iterators stop. Teardown also runs where interruption is off, so + // the deadline gets an interruptible region to cut. const teardown = Effect.fn("PluginHost.teardown")(function* ( pluginId: PluginId, activation: PluginActivation, @@ -384,8 +414,10 @@ const make = Effect.gen(function* () { const api = makePluginApi({ id: plugin.id, rootDir: plugin.rootDir, + settingsSchema: manifest.settingsSchema, activation, context, + services, }); // Only `activate` can be interrupted. A stop that lands after it waits until the record holds // the activation, so `stopPlugin` always finds what to tear down. @@ -583,6 +615,16 @@ const make = Effect.gen(function* () { }); // Lookups inside an operation that already has its span. + const manifestFor = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return manifest; + }); + const activeRecord = Effect.fnUntraced(function* (pluginId: PluginId) { const record = yield* findRecord(pluginId); if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); @@ -656,6 +698,14 @@ const make = Effect.gen(function* () { setEnabled, reload, rescan: rescan(), + getSettings: Effect.fn("PluginHost.getSettings")(function* (pluginId) { + const manifest = yield* manifestFor(pluginId); + return yield* settingsStore.getForClient(pluginId, manifest.settingsSchema); + }), + updateSettings: Effect.fn("PluginHost.updateSettings")(function* (pluginId, patch) { + const manifest = yield* manifestFor(pluginId); + return yield* settingsStore.update(pluginId, manifest.settingsSchema, patch); + }), invoke: Effect.fn("PluginHost.invoke")(function* (request) { yield* Effect.annotateCurrentSpan({ "plugin.id": request.pluginId, diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 0000000000000..c0744d9fc1d61 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,80 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import { PluginNotFoundError } from "./PluginHost.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const linked = PluginId.make("linked"); +const linkedCopy: DiscoveredPlugin = { + id: linked, + rootDir: "/plugins/linked", + folder: "/plugins/linked", + source: "dev", + manifest: { id: linked, name: "Linked", version: "1.0.0", engines: { t3: ">=0.0.1" } }, +}; + +/** + * A host holding one linked plugin, whose lifecycle lock records whether it is held, and stores + * that record what they deleted and whether the lock was held then. + */ +const harness = Effect.fn(function* () { + let locked = false; + const cleared: Array<{ readonly what: string; readonly locked: boolean }> = []; + const host: PluginInstaller.PluginInstallerHost = { + lifecycle: (body) => + Effect.acquireUseRelease( + Effect.sync(() => { + locked = true; + }), + () => body, + () => + Effect.sync(() => { + locked = false; + }), + ), + copies: Effect.succeed([linkedCopy]), + copyOf: (pluginId) => + pluginId === linked + ? Effect.succeed(linkedCopy) + : Effect.fail(new PluginNotFoundError({ pluginId })), + stop: () => Effect.void, + rescan: Effect.void, + reload: (pluginId) => Effect.fail(new PluginNotFoundError({ pluginId })), + config: Effect.succeed({}), + writeConfig: () => Effect.void, + }; + const { installer } = yield* PluginInstaller.make(host).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(PluginSettingsStore.PluginSettingsStore)({ + clear: () => Effect.sync(() => cleared.push({ what: "settings", locked })), + }), + Layer.mock(PluginStorage.PluginStorage)({ + clear: () => Effect.sync(() => cleared.push({ what: "storage", locked })), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-installer-test-" }), + ), + ), + ); + return { installer, cleared }; +}); + +it.layer(NodeServices.layer)("PluginInstaller", (it) => { + it.effect("deletes a removed plugin's data before another plugin change can start", () => + Effect.gen(function* () { + const { installer, cleared } = yield* harness(); + yield* installer.uninstall(linked); + assert.deepStrictEqual(cleared, [ + { what: "settings", locked: true }, + { what: "storage", locked: true }, + ]); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts index 96949d8d5a6b3..eabef26cd3600 100644 --- a/apps/server/src/plugins/PluginInstaller.ts +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -19,6 +19,8 @@ import * as ServerConfig from "../config.ts"; import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; import type { PluginNotFoundError } from "./PluginHost.ts"; import { type PluginConfigError, type PluginsConfigFile, withoutCrash } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; // `reason` is shown to the user as is, so it says what to fix. export class PluginInstallError extends Schema.TaggedError()( @@ -47,8 +49,9 @@ export class PluginInstaller extends Context.Service< files: ReadonlyArray, ) => Effect.Effect; /** - * Stops a plugin and deletes its folder; a linked folder is only forgotten. `folder` picks a - * copy. A duplicate copy's folder goes alone, and the copy in use keeps running. + * Stops a plugin and deletes its folder, settings, secrets and storage; a linked folder is + * only forgotten. `folder` picks a copy. A duplicate copy's folder goes alone, and the copy in + * use keeps running with the data. */ readonly uninstall: ( pluginId: PluginId, @@ -97,6 +100,8 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + const settingsStore = yield* PluginSettingsStore.PluginSettingsStore; + const storage = yield* PluginStorage.PluginStorage; const scope = yield* Effect.scope; const pluginsDir = path.join(config.stateDir, "plugins"); @@ -159,10 +164,18 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns }); /** Why this server cannot run `manifest`, checked before anything is stopped or moved. */ - const refusal = (manifest: PluginManifest) => - satisfiesSemverRange(packageJson.version, manifest.engines.t3) + const refusal = (manifest: PluginManifest) => { + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return `${manifest.name} ${manifest.version} needs T3 Code ${manifest.engines.t3}, and this server is ${packageJson.version}.`; + } + const schemaProblem = + manifest.settingsSchema === undefined + ? undefined + : PluginSettingsStore.settingsSchemaProblem(manifest.settingsSchema); + return schemaProblem === undefined ? undefined - : `${manifest.name} ${manifest.version} needs T3 Code ${manifest.engines.t3}, and this server is ${packageJson.version}.`; + : `${manifest.name} ${manifest.version} has a settingsSchema T3 Code cannot validate: ${schemaProblem}`; + }; /** * Swaps `/plugin` into the plugins folder under the lifecycle lock and starts it if it @@ -351,6 +364,15 @@ export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginIns yield* removeFromConfig(pluginId); // Lists a duplicate copy afresh, turned off. yield* host.rescan; + // Under the lock, or an install that starts meanwhile could lose its new settings. + yield* settingsStore + .clear(pluginId) + .pipe( + Effect.mapError(installFailed("The plugin is removed, but its settings are not.")), + ); + yield* storage + .clear(pluginId) + .pipe(Effect.mapError(installFailed("The plugin is removed, but its storage is not."))); }), ), ); diff --git a/apps/server/src/plugins/PluginSettingsStore.test.ts b/apps/server/src/plugins/PluginSettingsStore.test.ts new file mode 100644 index 0000000000000..b965885668d52 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.test.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; + +const hello = PluginId.make("hello"); + +const settingsSchema: Schema.JsonObject = { + type: "object", + properties: { + count: { type: "integer", minimum: 0 }, + name: { type: "string" }, + token: { type: "string", format: "t3-secret" }, + }, + additionalProperties: false, +}; + +// Like a keychain, this store can't list what it holds. +const layerSecretStoreWithoutFolder = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, ({ directory: _directory, ...store }) => + ServerSecretStore.ServerSecretStore.of(store), + ), +); + +const layerTest = () => + Layer.mergeAll(PluginSettingsStore.layer, ServerSettings.layer).pipe( + Layer.provideMerge(layerSecretStoreWithoutFolder), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-settings-test-" }), + ), + ), + ); + +const readSettingsFile = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString( + path.join(stateDir, "plugin-data", hello, "settings.json"), + ); +}); + +it.layer(NodeServices.layer)("PluginSettingsStore", (it) => { + it.effect("rejects values outside the schema without writing them", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + yield* store.update(hello, settingsSchema, { count: 1 }); + + const error = yield* Effect.flip( + store.update(hello, settingsSchema, { count: -1, extra: true }), + ); + assert.strictEqual(error._tag, "PluginSettingsValidationError"); + assert.include(error.message, 'at ["count"]: Expected a value greater than or equal to 0'); + assert.include(error.message, 'at ["extra"]: Expected no excess property'); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { count: 1 }); + + const unreadable = yield* Effect.flip( + store.update(hello, { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, {}), + ); + assert.strictEqual(unreadable._tag, "PluginSettingsSchemaError"); + assert.include(unreadable.message, 'Missing definition "no"'); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("drops keys a newer schema no longer has instead of refusing every save", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withFoo: Schema.JsonObject = { + ...settingsSchema, + properties: { + ...(settingsSchema.properties as Schema.JsonObject), + foo: { type: "string" }, + }, + }; + yield* store.update(hello, withFoo, { foo: "old", count: 1 }); + + assert.deepStrictEqual(yield* store.update(hello, settingsSchema, { count: 2 }), { + count: 2, + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { count: 2 }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("gives the plugin its schema's defaults and clients only what is saved", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withDefaults: Schema.JsonObject = { + type: "object", + properties: { + enabled: { type: "boolean", default: true }, + name: { type: "string", default: "T3" }, + token: { type: "string", format: "t3-secret", default: "not-a-secret" }, + }, + // A required field with a default must not block a save that leaves it unsaved. + required: ["enabled"], + }; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { enabled: true, name: "T3" }); + + yield* store.update(hello, withDefaults, { name: "Mine" }); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { + enabled: true, + name: "Mine", + }); + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ enabled: true, name: "Mine" })); + assert.deepStrictEqual(yield* store.getForClient(hello, withDefaults), { name: "Mine" }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps secrets out of settings.json and redacts them for clients", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + + assert.deepStrictEqual( + yield* store.update(hello, settingsSchema, { name: "a", token: "s3cret" }), + { name: "a", token: SECRET_REDACTED }, + ); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "a", + token: "s3cret", + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { name: "a" }); + + yield* store.update(hello, settingsSchema, { name: "b", token: SECRET_REDACTED }); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "b", + token: "s3cret", + }); + + yield* store.update(hello, settingsSchema, { token: null }); + assert.deepStrictEqual(yield* store.getForClient(hello, settingsSchema), { name: "b" }); + assert.isEmpty( + yield* FileSystem.FileSystem.use((fileSystem) => fileSystem.readDirectory(secretsDir)), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("clears every secret a plugin saved, and only its own", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir, stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const longerId = PluginId.make("hello-world"); + yield* store.update(hello, settingsSchema, { token: "old-secret" }); + yield* store.update(longerId, settingsSchema, { token: "other-secret" }); + // A longer id's secrets are kept even when it has no settings folder. + yield* fileSystem.remove(path.join(stateDir, "plugin-data", longerId), { recursive: true }); + + // T3's own upload signing key, whose name starts like this plugin's secrets would. + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingKey = yield* secretStore.getOrCreateRandom("plugin-upload-signing-key", 32); + + yield* store.clear(hello); + yield* store.clear(PluginId.make("upload-signing-key")); + + assert.sameMembers(yield* fileSystem.readDirectory(secretsDir), [ + `plugin.${longerId}.bin`, + "plugin-upload-signing-key.bin", + ]); + assert.deepStrictEqual(yield* store.get(longerId, settingsSchema), { token: "other-secret" }); + // Reinstalling starts fresh, not from the copy the store keeps in memory. + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), {}); + assert.deepStrictEqual( + yield* secretStore.get("plugin-upload-signing-key"), + Option.some(signingKey), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps plugin settings when the server saves its own settings", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* store.update(hello, settingsSchema, { count: 2, token: "s3cret" }); + + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/next" }); + + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + count: 2, + token: "s3cret", + }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("emits the plugin view after an update of that plugin", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + + yield* store.update(PluginId.make("other"), undefined, { count: 9 }); + yield* store.update(hello, settingsSchema, { count: 3, token: "s3cret" }); + + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ count: 3, token: "s3cret" })); + }).pipe(Effect.provide(layerTest())), + ); +}); diff --git a/apps/server/src/plugins/PluginSettingsStore.ts b/apps/server/src/plugins/PluginSettingsStore.ts new file mode 100644 index 0000000000000..d3c7c0f51f4d6 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.ts @@ -0,0 +1,463 @@ +import { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as JsonSchema from "effect/JsonSchema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaRepresentation from "effect/SchemaRepresentation"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; + +// The Settings page shows the summary, so it stays readable when a patch breaks many fields. +const MAX_VALIDATION_SUMMARY_LENGTH = 1000; +const MAX_SCHEMA_REASON_LENGTH = 500; + +const persistenceOperations = { + read: "read the settings", + write: "save the settings", + readSecret: "read a secret setting", + writeSecret: "save a secret setting", +} as const; + +export class PluginSettingsPersistenceError extends Schema.TaggedError()( + "PluginSettingsPersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["read", "write", "readSecret", "writeSecret"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${persistenceOperations[this.operation]} of plugin ${this.pluginId}.`; + } +} + +export class PluginSettingsSchemaError extends Schema.TaggedError()( + "PluginSettingsSchemaError", + { + pluginId: PluginId, + /** The importer's reason, which comes from the plugin's own manifest. */ + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has a settings schema T3 Code cannot validate: ${this.reason}`; + } +} + +export class PluginSettingsValidationError extends Schema.TaggedError()( + "PluginSettingsValidationError", + { + pluginId: PluginId, + summary: Schema.String, + }, +) { + override get message(): string { + return `Invalid settings for plugin ${this.pluginId}: ${this.summary}`; + } +} + +export class PluginSettingsStore extends Context.Service< + PluginSettingsStore, + { + /** Stored settings with secret values, over the schema's top-level defaults, for the plugin. */ + readonly get: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Stored settings for clients, without defaults. A secret that has a value reads as + * `SECRET_REDACTED`. + */ + readonly getForClient: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Merges `patch` over the stored settings at the top level, where `null` removes a key and + * `SECRET_REDACTED` keeps a secret, validates the result, saves it and returns the client view. + * Stored keys a schema with `additionalProperties: false` no longer lists are dropped first. + */ + readonly update: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginSettingsPersistenceError | PluginSettingsSchemaError | PluginSettingsValidationError + >; + /** The plugin view after each successful `update` of that plugin. */ + readonly changes: (pluginId: PluginId) => Stream.Stream; + /** Deletes the plugin's settings file and every secret it saved. */ + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginSettingsStore") {} + +interface StoredSettings { + readonly values: Schema.JsonObject; + readonly secrets: Readonly>; +} + +/** The text of settings.json and of the plugin's secret entry, undefined when there is none. */ +interface SavedFiles { + readonly settings: string | undefined; + readonly secrets: string | undefined; +} + +interface SettingsChange { + readonly pluginId: PluginId; + readonly settings: Schema.JsonObject; +} + +const SettingsJson = fromJsonStringPretty(Schema.JsonObject); +const decodeSettingsJson = Schema.decodeUnknownEffect(SettingsJson); +const encodeSettingsJson = Schema.encodeEffect(SettingsJson); +const SecretsJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeSecretsJson = Schema.decodeUnknownEffect(SecretsJson); +const encodeSecretsJson = Schema.encodeEffect(SecretsJson); +const isJson = Schema.is(Schema.Json); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// Top-level string fields with `format: "t3-secret"` live in ServerSecretStore, never settings.json. +const secretFields = (settingsSchema: Schema.JsonObject | undefined): ReadonlySet => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return new Set(); + return new Set( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && property.type === "string" && property.format === "t3-secret" + ? [field] + : [], + ), + ); +}; + +// Top-level `default`s, which the plugin reads for fields nobody saved. Secrets have none. +const defaultsOf = (settingsSchema: Schema.JsonObject | undefined): Schema.JsonObject => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return {}; + const secrets = secretFields(settingsSchema); + return Object.fromEntries( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && !secrets.has(field) && isJson(property.default) + ? [[field, property.default]] + : [], + ), + ); +}; + +// Keys saved under an older schema that a closed schema no longer lists. Validating them would +// refuse every save, and the form cannot remove a key it does not show. +const keysOutside = (settingsSchema: Schema.JsonObject | undefined, values: Schema.JsonObject) => { + const properties = settingsSchema?.properties; + if (settingsSchema?.additionalProperties !== false || !Predicate.isObject(properties)) return []; + return Object.keys(values).filter((key) => !Object.hasOwn(properties, key)); +}; + +// One entry holds all of a plugin's secrets, so removal finds them in a store that can't list +// names. T3's own secret names have no `.`, so this name can't be one of them. +const secretName = (pluginId: PluginId) => `plugin.${pluginId}`; + +const formatPathSegment = (segment: PropertyKey | { readonly key: PropertyKey }) => { + const key = Predicate.isObject(segment) ? segment.key : segment; + return typeof key === "number" ? `[${key}]` : `["${String(key)}"]`; +}; + +// Issues never carry input values because decoding does not set `reportInput`. +const summarizeIssue = (issue: SchemaIssue.Issue) => { + const summary = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + .issues.map(({ path, message }) => + path === undefined || path.length === 0 + ? message + : `at ${path.map(formatPathSegment).join("")}: ${message}`, + ) + .join("; "); + return summary.length > MAX_VALIDATION_SUMMARY_LENGTH + ? `${summary.slice(0, MAX_VALIDATION_SUMMARY_LENGTH - 1)}…` + : summary; +}; + +const compileSettingsSchema = (settingsSchema: Schema.JsonObject) => + Result.try(() => + Schema.decodeUnknownResult( + // Imported JSON Schemas need no decoding services, which `Schema.Top` cannot express. + Schema.make>( + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(settingsSchema), + // Plugins are trusted code, so their patterns are applied rather than rejected. + { patterns: "apply" }, + ).ast, + ), + { onExcessProperty: "error", errors: "all" }, + ), + ); + +const importerReason = (cause: unknown) => + (cause instanceof Error ? cause.message : String(cause)).slice(0, MAX_SCHEMA_REASON_LENGTH); + +/** Why settings cannot be validated against `settingsSchema`, or `undefined` when they can. */ +export const settingsSchemaProblem = (settingsSchema: Schema.JsonObject) => { + const compiled = compileSettingsSchema(settingsSchema); + return Result.isFailure(compiled) ? importerReason(compiled.failure) : undefined; +}; + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const writeSemaphore = yield* Semaphore.make(1); + // Sliding, so a subscriber that stops pulling drops old changes instead of blocking `update`. + const changesPubSub = yield* PubSub.sliding(64); + // Keyed by schema identity: a reloaded plugin brings a new manifest object. + const validators = new WeakMap>(); + // Only `update` and `clear` write these files, under the write lock, so the copy stays true and + // a hand edit shows after a restart. Text, so a plugin that mutates what it read can't change it. + const savedFiles = new Map(); + + const settingsPath = (pluginId: PluginId) => + path.join(stateDir, "plugin-data", pluginId, "settings.json"); + + const validatorFor = (pluginId: PluginId, settingsSchema: Schema.JsonObject) => { + let validator = validators.get(settingsSchema); + if (validator === undefined) { + validator = compileSettingsSchema(settingsSchema); + validators.set(settingsSchema, validator); + } + return Result.isSuccess(validator) + ? Effect.succeed(validator.success) + : Effect.fail( + new PluginSettingsSchemaError({ + pluginId, + reason: importerReason(validator.failure), + cause: validator.failure, + }), + ); + }; + + // The caller holds the write lock, so a read from disk can't save a copy older than a write. + const readSavedFiles = Effect.fnUntraced(function* (pluginId: PluginId) { + const cached = savedFiles.get(pluginId); + if (cached !== undefined) return cached; + const settings = yield* fileSystem.readFileString(settingsPath(pluginId)).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "read", cause }), + ), + ); + const secrets = yield* secretStore.get(secretName(pluginId)).pipe( + Effect.map((bytes) => + Option.getOrUndefined(Option.map(bytes, (value) => textDecoder.decode(value))), + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + const files: SavedFiles = { settings, secrets }; + savedFiles.set(pluginId, files); + return files; + }); + + // Shares the span of the public operation that reads. + const load = Effect.fnUntraced( + function* (pluginId: PluginId, fields: ReadonlySet, files: SavedFiles) { + const stored = files.settings === undefined ? {} : yield* decodeSettingsJson(files.settings); + const values = Object.fromEntries(Object.entries(stored).filter(([key]) => !fields.has(key))); + const saved = yield* decodeSecretsJson(files.secrets ?? "{}").pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + return { + values, + secrets: Object.fromEntries(Object.entries(saved).filter(([field]) => fields.has(field))), + } satisfies StoredSettings; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail(new PluginSettingsPersistenceError({ pluginId, operation: "read", cause })), + }), + ), + ); + + const loadCurrent = (pluginId: PluginId, fields: ReadonlySet) => { + const cached = savedFiles.get(pluginId); + return ( + cached === undefined + ? writeSemaphore.withPermits(1)(readSavedFiles(pluginId)) + : Effect.succeed(cached) + ).pipe(Effect.flatMap((files) => load(pluginId, fields, files))); + }; + + const clientView = ({ values, secrets }: StoredSettings): Schema.JsonObject => ({ + ...values, + ...Object.fromEntries(Object.keys(secrets).map((field) => [field, SECRET_REDACTED])), + }); + + const get = Effect.fn("PluginSettingsStore.get")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + const { values, secrets } = yield* loadCurrent(pluginId, secretFields(settingsSchema)); + return { ...defaultsOf(settingsSchema), ...values, ...secrets } satisfies Schema.JsonObject; + }); + + const getForClient = Effect.fn("PluginSettingsStore.getForClient")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + return clientView(yield* loadCurrent(pluginId, secretFields(settingsSchema))); + }); + + const update = Effect.fn("PluginSettingsStore.update")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) { + const fields = secretFields(settingsSchema); + const validate = + settingsSchema === undefined ? undefined : yield* validatorFor(pluginId, settingsSchema); + return yield* writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* load(pluginId, fields, yield* readSavedFiles(pluginId)); + const stale = keysOutside(settingsSchema, current.values); + if (stale.length > 0) { + yield* Effect.logWarning("dropping plugin settings its schema no longer has", { + pluginId, + keys: stale, + }); + } + const merged: Record = { ...current.values, ...current.secrets }; + for (const key of stale) delete merged[key]; + for (const [key, value] of Object.entries(patch)) { + if (fields.has(key) && value === SECRET_REDACTED) continue; + if (value === null) delete merged[key]; + else merged[key] = value; + } + + // Checked as the plugin reads them: over the schema's defaults, and with secrets' real + // values, so a required field that has a default or a secret passes. Defaults aren't saved. + const validation = validate?.({ ...defaultsOf(settingsSchema), ...merged }); + if (validation !== undefined && Result.isFailure(validation)) { + return yield* new PluginSettingsValidationError({ + pluginId, + summary: summarizeIssue(validation.failure.issue), + }); + } + + const next: StoredSettings = { + values: Object.fromEntries(Object.entries(merged).filter(([key]) => !fields.has(key))), + secrets: Object.fromEntries( + Object.entries(merged).filter( + (entry): entry is [string, string] => + fields.has(entry[0]) && typeof entry[1] === "string", + ), + ), + }; + const secrets = + Object.keys(next.secrets).length === 0 + ? undefined + : yield* encodeSecretsJson(next.secrets).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ + pluginId, + operation: "writeSecret", + cause, + }), + ), + ); + const settings = yield* encodeSettingsJson(next.values).pipe( + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + // A write that fails partway leaves the next read to the disk. + savedFiles.delete(pluginId); + // Written on every save, which drops the secrets of fields the schema no longer has. + yield* ( + secrets === undefined + ? secretStore.remove(secretName(pluginId)) + : secretStore.set(secretName(pluginId), textEncoder.encode(secrets)) + ).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* writeFileStringAtomically({ + filePath: settingsPath(pluginId), + contents: settings, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + savedFiles.set(pluginId, { settings, secrets }); + + yield* PubSub.publish(changesPubSub, { + pluginId, + settings: { ...defaultsOf(settingsSchema), ...next.values, ...next.secrets }, + }); + return clientView(next); + }), + ); + }); + + const changes = (pluginId: PluginId) => + Stream.fromPubSub(changesPubSub).pipe( + Stream.filter((change) => change.pluginId === pluginId), + Stream.map((change) => change.settings), + ); + + const clear: PluginSettingsStore["Service"]["clear"] = Effect.fn("PluginSettingsStore.clear")( + (pluginId) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + savedFiles.delete(pluginId); + yield* secretStore + .remove(secretName(pluginId)) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* fileSystem + .remove(path.dirname(settingsPath(pluginId)), { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + }), + ), + ); + + return PluginSettingsStore.of({ get, getForClient, update, changes, clear }); +}); + +export const layer = Layer.effect(PluginSettingsStore, make); diff --git a/apps/server/src/plugins/PluginStorage.test.ts b/apps/server/src/plugins/PluginStorage.test.ts new file mode 100644 index 0000000000000..71a8874f51289 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.test.ts @@ -0,0 +1,115 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const hello = PluginId.make("hello"); +const other = PluginId.make("other"); + +const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-storage-test-" })); + +// A fresh layer per call, so closing it closes its SQLite connection. +const withStorage = ( + use: (storage: PluginStorage.PluginStorage["Service"]) => Effect.Effect, +) => PluginStorage.PluginStorage.use(use).pipe(Effect.provide(Layer.fresh(PluginStorage.layer))); + +it.layer(NodeServices.layer)("PluginStorage", (it) => { + it.effect("creates its own plugins.sqlite on first use", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filename = path.join(stateDir, "plugins.sqlite"); + + yield* withStorage((storage) => + Effect.gen(function* () { + // Removing a plugin's data on a server that never stored any leaves no file behind. + yield* storage.clear(other); + assert.isFalse(yield* fileSystem.exists(filename)); + yield* storage.set(hello, "greeting", { text: "hi" }); + yield* storage.set(other, "gone", true); + yield* storage.clear(other); + assert.deepStrictEqual(yield* storage.list(other), []); + }), + ); + + const rows = yield* SqlClient.SqlClient.use( + (sql) => sql<{ readonly key: string }>`SELECT key FROM plugin_storage`, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename, readonly: true }))); + assert.deepStrictEqual( + rows.map((row) => row.key), + ["greeting"], + ); + assert.isFalse(yield* fileSystem.exists(path.join(stateDir, "statev2.sqlite"))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("round-trips values and keeps them after the layer closes", () => + Effect.gen(function* () { + yield* withStorage((storage) => + Effect.gen(function* () { + yield* storage.set(hello, "b", [1, "two", null]); + yield* storage.set(hello, "a", { nested: { ok: true } }); + yield* storage.set(hello, "gone", 1); + yield* storage.set(other, "a", "other plugin"); + yield* storage.set(hello, "a", { nested: { ok: false } }); + yield* storage.remove(hello, "gone"); + + assert.deepStrictEqual(yield* storage.list(hello), ["a", "b"]); + assert.deepStrictEqual(yield* storage.get(hello, "gone"), Option.none()); + }), + ); + + yield* withStorage((storage) => + Effect.gen(function* () { + assert.deepStrictEqual( + yield* storage.get(hello, "a"), + Option.some({ nested: { ok: false } }), + ); + assert.deepStrictEqual(yield* storage.get(hello, "b"), Option.some([1, "two", null])); + assert.deepStrictEqual(yield* storage.get(other, "a"), Option.some("other plugin")); + }), + ); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("matches list prefixes literally", () => + withStorage((storage) => + Effect.gen(function* () { + for (const key of ["a_b", "axb", "A_b", "a%c", "abc"]) { + yield* storage.set(hello, key, true); + } + yield* storage.set(other, "a_other", true); + + assert.deepStrictEqual(yield* storage.list(hello, "a_"), ["a_b"]); + assert.deepStrictEqual(yield* storage.list(hello, "a%"), ["a%c"]); + }), + ).pipe(Effect.provide(layerConfig())), + ); + + it.effect("rejects oversize values and keys", () => + withStorage((storage) => + Effect.gen(function* () { + const tooLarge = yield* Effect.flip(storage.set(hello, "big", "x".repeat(1024 * 1024))); + assert.strictEqual(tooLarge._tag, "PluginStorageValueTooLargeError"); + + const emptyKey = yield* Effect.flip(storage.set(hello, "", 1)); + assert.strictEqual(emptyKey._tag, "PluginStorageKeyError"); + const longKey = yield* Effect.flip(storage.get(hello, "k".repeat(513))); + assert.strictEqual(longKey._tag, "PluginStorageKeyError"); + + assert.deepStrictEqual(yield* storage.list(hello), []); + }), + ).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginStorage.ts b/apps/server/src/plugins/PluginStorage.ts new file mode 100644 index 0000000000000..e521a5663c957 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.ts @@ -0,0 +1,265 @@ +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; + +const MAX_KEY_LENGTH = 512; +const MAX_VALUE_BYTES = 1024 * 1024; + +export class PluginStoragePersistenceError extends Schema.TaggedError()( + "PluginStoragePersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["open", "get", "set", "remove", "list"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin storage ${this.operation} failed for plugin ${this.pluginId}.`; + } +} + +export class PluginStorageKeyError extends Schema.TaggedError()( + "PluginStorageKeyError", + { + pluginId: PluginId, + length: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage keys must be 1 to ${MAX_KEY_LENGTH} characters long, got ${this.length}.`; + } +} + +export class PluginStorageInvalidValueError extends Schema.TaggedError()( + "PluginStorageInvalidValueError", + { + pluginId: PluginId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Plugin storage values must be JSON."; + } +} + +export class PluginStorageValueTooLargeError extends Schema.TaggedError()( + "PluginStorageValueTooLargeError", + { + pluginId: PluginId, + bytes: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage values must be at most ${MAX_VALUE_BYTES} bytes of JSON, got ${this.bytes}.`; + } +} + +export class PluginStorage extends Context.Service< + PluginStorage, + { + readonly get: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect< + Option.Option, + PluginStoragePersistenceError | PluginStorageKeyError + >; + readonly set: ( + pluginId: PluginId, + key: string, + value: Schema.Json, + ) => Effect.Effect< + void, + | PluginStoragePersistenceError + | PluginStorageKeyError + | PluginStorageInvalidValueError + | PluginStorageValueTooLargeError + >; + readonly remove: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect; + /** Keys that start with `prefix`, sorted. */ + readonly list: ( + pluginId: PluginId, + prefix?: string, + ) => Effect.Effect, PluginStoragePersistenceError>; + /** Deletes every key of the plugin, without creating the database when there is none. */ + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginStorage") {} + +const ValueJson = Schema.fromJsonString(Schema.Json); +const encodeValue = Schema.encodeUnknownEffect(ValueJson); +const decodeValue = Schema.decodeUnknownEffect(ValueJson); + +const checkKey = (pluginId: PluginId, key: string) => + key.length >= 1 && key.length <= MAX_KEY_LENGTH + ? Effect.void + : Effect.fail(new PluginStorageKeyError({ pluginId, length: key.length })); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layerScope = yield* Effect.scope; + const filename = path.join(stateDir, "plugins.sqlite"); + + // Opened on first use so a server without plugins never creates the file. A failed open is + // retried on the next call; a successful one stays open until the layer closes. + const sqlClient = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + const scope = yield* Scope.fork(layerScope); + return yield* Layer.buildWithScope(NodeSqliteClient.layer({ filename }), scope).pipe( + Effect.map((context) => Context.get(context, SqlClient.SqlClient)), + Effect.tap( + (sql) => sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + plugin_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, key) + ) + `, + ), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }), + (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + ); + + const open = (pluginId: PluginId) => + sqlClient.pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + + const get = Effect.fn("PluginStorage.get")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key} + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* decodeValue(row.value_json)); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + SchemaError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + }), + ), + ); + + const set = Effect.fn("PluginStorage.set")( + function* (pluginId: PluginId, key: string, value: Schema.Json) { + yield* checkKey(pluginId, key); + const valueJson = yield* encodeValue(value).pipe( + Effect.mapError((cause) => new PluginStorageInvalidValueError({ pluginId, cause })), + ); + const bytes = Buffer.byteLength(valueJson, "utf8"); + if (bytes > MAX_VALUE_BYTES) { + return yield* new PluginStorageValueTooLargeError({ pluginId, bytes }); + } + const sql = yield* open(pluginId); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO plugin_storage (plugin_id, key, value_json, updated_at) + VALUES (${pluginId}, ${key}, ${valueJson}, ${updatedAt}) + ON CONFLICT (plugin_id, key) + DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at + `; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "set", cause })), + }), + ), + ); + + const remove = Effect.fn("PluginStorage.remove")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + const list = Effect.fn("PluginStorage.list")( + function* (pluginId: PluginId, prefix = "") { + const sql = yield* open(pluginId); + // Not LIKE: it treats `%` and `_` as wildcards and ignores ASCII case. + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage + WHERE plugin_id = ${pluginId} AND substr(key, 1, length(${prefix})) = ${prefix} + ORDER BY key + `; + return rows.map((row) => row.key); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "list", cause })), + }), + ), + ); + + const clear = Effect.fn("PluginStorage.clear")( + function* (pluginId: PluginId) { + const exists = yield* fileSystem + .exists(filename) + .pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + if (!exists) return; + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + return PluginStorage.of({ get, set, remove, list, clear }); +}); + +export const layer = Layer.effect(PluginStorage, make); diff --git a/apps/server/src/plugins/api/facade.test.ts b/apps/server/src/plugins/api/facade.test.ts new file mode 100644 index 0000000000000..6425fb2c83c19 --- /dev/null +++ b/apps/server/src/plugins/api/facade.test.ts @@ -0,0 +1,84 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { makePluginActivation } from "../PluginApi.ts"; +import { makeFacadeContext, pluginCodeScope } from "./facade.ts"; + +class CallFailedError extends Schema.TaggedError()("CallFailedError", {}) {} + +// Two macrotask turns: Node reports a rejection nobody observed after the first. +const afterNodeReportsRejections = Effect.promise(async () => { + for (let turn = 0; turn < 2; turn++) { + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- Node emits unhandledRejection only between macrotasks; no promise can mark that point. + await new Promise((resolve) => setImmediate(resolve)); + } +}); + +it.effect("keeps a failed call the plugin never awaits from stopping the server", () => + Effect.gen(function* () { + const activation = makePluginActivation(); + const facade = makeFacadeContext({ + pluginId: PluginId.make("unawaited"), + activation, + context: yield* Effect.context(), + }); + const unobserved: Array = []; + const onUnhandled = (reason: unknown) => unobserved.push(reason); + process.on("unhandledRejection", onUnhandled); + yield* Effect.addFinalizer(() => + Effect.sync(() => process.off("unhandledRejection", onUnhandled)), + ); + + void facade.call(Effect.fail(new CallFailedError())); + // A stream it reads, such as `api.settings.changes()`, fails like a call. + const pull = () => + facade.iterate(Stream.fail(new CallFailedError()))[Symbol.asyncIterator]().next(); + void pull(); + const pulled = yield* Effect.promise(() => + pull().then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(pulled, "CallFailedError"); + activation.controller.abort(); + activation.ended.abort(); + // A timer such as `setInterval(() => void api.storage.set(...))` outliving the plugin. + void facade.call(Effect.void); + yield* afterNodeReportsRejections; + assert.deepStrictEqual(unobserved, []); + + const awaited = yield* Effect.promise(() => + facade.call(Effect.void).then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(awaited, "PluginDeactivated"); + }).pipe(Effect.scoped), +); + +// A failure there would be tied to the plugin, and the guard would not stop the server for it. +it.effect("runs T3's side of a stream the plugin reads outside the plugin's scope", () => + Effect.gen(function* () { + const pluginId = PluginId.make("reading"); + const facade = makeFacadeContext({ + pluginId, + activation: makePluginActivation(), + context: yield* Effect.context(), + }); + const scopes = facade.iterate(Stream.fromEffect(Effect.sync(() => pluginCodeScope.getStore()))); + // As a plugin's `for await` over `api.settings.changes()`. + const seen = yield* Effect.promise(() => + pluginCodeScope.run(pluginId, async () => { + const values: Array = []; + for await (const value of scopes) values.push(value); + return values; + }), + ); + assert.deepStrictEqual(seen, [undefined]); + }), +); diff --git a/apps/server/src/plugins/api/facade.ts b/apps/server/src/plugins/api/facade.ts index f6e41de47a170..18de44c41a944 100644 --- a/apps/server/src/plugins/api/facade.ts +++ b/apps/server/src/plugins/api/facade.ts @@ -6,7 +6,11 @@ import type { PluginId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { PluginActivation } from "../PluginApi.ts"; const MAX_ERROR_CHARS = 500; @@ -40,16 +44,89 @@ export const pluginCodeScope = new NodeAsyncHooks.AsyncLocalStorage(); /** What every facade group needs from the activation it belongs to. */ export interface FacadeContext { + /** + * Runs an effect as a Promise that rejects with an `Error` carrying the failure's `_tag` as `code`. + * Map results inside the effect: a `.then` on the Promise is a new one that nothing handles. + */ + readonly call: (effect: Effect.Effect) => Promise; /** Runs an effect in the background for the plugin; failures are logged, not thrown. */ readonly fork: (effect: Effect.Effect) => void; + /** Exposes a stream as an iterable that ends when the plugin deactivates. */ + readonly iterate: (stream: Stream.Stream) => AsyncIterable; +} + +/** + * Hands a plugin a promise whose failure only the plugin sees: one it never awaits must not be an + * unhandled rejection, which stops the server. + */ +const forPlugin = (promise: Promise): Promise => { + promise.catch(() => undefined); + return promise; +}; + +/** A plugin API call refused before it ran, such as one with a bad argument. */ +export const refused = (error: Error): Promise => forPlugin(Promise.reject(error)); + +/** Carries its plugin, so a failure no plugin code handles is still the plugin's. */ +export class PluginApiCallError extends Error { + readonly code: string; + readonly pluginId: PluginId; + constructor(pluginId: PluginId, code: string, message: string) { + super(message); + this.name = "PluginApiError"; + this.code = code; + this.pluginId = pluginId; + } } +/** Completes when `signal` aborts, at once if it already has. */ +const abortEffect = (signal: AbortSignal) => + Effect.callback((resume) => { + const onAbort = () => resume(Effect.void); + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => signal.removeEventListener("abort", onAbort)); + }); + export const makeFacadeContext = (input: { readonly pluginId: PluginId; + readonly activation: PluginActivation; readonly context: Context.Context; }): FacadeContext => { - const { context, pluginId } = input; + const { activation, context, pluginId } = input; + // T3's work for a call is not the plugin's code, so it leaves the plugin's scope. + const runExit = (effect: Effect.Effect) => + pluginCodeScope.exit(() => Effect.runPromiseExitWith(context)(effect)); + // Cleanups may still save state; afterwards a disabled plugin's leftovers cannot act. + const call: FacadeContext["call"] = (effect) => + activation.ended.signal.aborted + ? refused( + new PluginApiCallError( + pluginId, + "PluginDeactivated", + "This plugin activation has ended.", + ), + ) + : forPlugin( + runExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.squash(exit.cause); + const code = + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" + ? error._tag + : "PluginApiFailure"; + throw new PluginApiCallError( + pluginId, + code, + error instanceof Error ? error.message : "The plugin API call failed.", + ); + }), + ); return { + call, fork: (effect) => { pluginCodeScope.exit(() => Effect.runForkWith(context)( @@ -64,5 +141,26 @@ export const makeFacadeContext = (input: { ), ); }, + // The plugin pulls, but each pull runs T3's side of the stream, so it is a call too. + iterate: (stream) => { + const iterable = Stream.toAsyncIterableWith( + stream.pipe(Stream.interruptWhen(abortEffect(activation.controller.signal))), + context, + ); + return { + [Symbol.asyncIterator]: () => { + const iterator = pluginCodeScope.exit(() => iterable[Symbol.asyncIterator]()); + return { + next: () => call(Effect.promise(() => iterator.next())), + return: () => + call( + Effect.promise( + () => iterator.return?.() ?? Promise.resolve({ done: true, value: undefined }), + ), + ), + }; + }, + }; + }, }; }; diff --git a/apps/server/src/plugins/pluginDiscovery.test.ts b/apps/server/src/plugins/pluginDiscovery.test.ts index 8681c8130c570..aacc06f5258e5 100644 --- a/apps/server/src/plugins/pluginDiscovery.test.ts +++ b/apps/server/src/plugins/pluginDiscovery.test.ts @@ -29,6 +29,14 @@ it.layer(NodeServices.layer)("discoverPlugins", (it) => { JSON.stringify({ ...JSON.parse(manifest("invalid")), version: "1" }), ); yield* write(path.join(pluginsDir, "renamed"), manifest("other-name")); + // A schema the settings validator cannot compile would refuse every save. + yield* write( + path.join(pluginsDir, "unchecked"), + JSON.stringify({ + ...JSON.parse(manifest("unchecked")), + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ); yield* write(path.join(pluginsDir, "My Plugin"), manifest("spaced")); yield* write(path.join(pluginsDir, "Not An Id"), undefined); // Sorts before "valid", so it is the copy used. @@ -51,6 +59,7 @@ it.layer(NodeServices.layer)("discoverPlugins", (it) => { { id: "empty", source: "installed", stage: "discovery" }, { id: "invalid", source: "installed", stage: "manifest" }, { id: "other-name", source: "installed", stage: "ok" }, + { id: "unchecked", source: "installed", stage: "manifest" }, { id: "valid", source: "installed", stage: "duplicate" }, { id: "valid", source: "dev", stage: "duplicate" }, { id: "dev-only", source: "dev", stage: "ok" }, @@ -58,8 +67,9 @@ it.layer(NodeServices.layer)("discoverPlugins", (it) => { assert.strictEqual(plugins[0]?.folder, path.join(pluginsDir, "Copy of valid")); const failures = plugins.map((plugin) => ("failure" in plugin ? plugin.failure.message : "")); assert.include(failures[3], "version"); + assert.include(failures[5], 'Missing definition "no"'); assert.strictEqual( - failures[6], + failures[7], 'The installed folder "Copy of valid" already has the id valid. Remove or unlink one of them.', ); }).pipe(Effect.scoped), diff --git a/apps/server/src/plugins/pluginDiscovery.ts b/apps/server/src/plugins/pluginDiscovery.ts index 911d260970d0f..9aae338cc9882 100644 --- a/apps/server/src/plugins/pluginDiscovery.ts +++ b/apps/server/src/plugins/pluginDiscovery.ts @@ -10,6 +10,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { settingsSchemaProblem } from "./PluginSettingsStore.ts"; + const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; // Discovery runs at startup and on every reload, so a plugins folder full of unrelated @@ -96,13 +98,16 @@ export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* ( } const decoded = decodeManifest(manifest.raw); if (decoded._tag === "Success") { - return { - id: decoded.value.id, - rootDir, - folder: location, - source, - manifest: decoded.value, - } as const; + const copy = { id: decoded.value.id, rootDir, folder: location, source }; + const { settingsSchema } = decoded.value; + const schemaProblem = + settingsSchema === undefined ? undefined : settingsSchemaProblem(settingsSchema); + return schemaProblem === undefined + ? { ...copy, manifest: decoded.value } + : failed(copy, { + stage: "manifest", + message: `T3 Code cannot validate its settingsSchema: ${schemaProblem}`, + }); } const looseId = decodeLooseId(manifest.raw); const id = looseId._tag === "Some" ? looseId.value.id : fallbackId; @@ -118,7 +123,7 @@ export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* ( const plugins: Array = []; const firstWithId = new Map(); - // The switch belongs to the id, so two folders with one id cannot both run. + // Settings, storage and the switch belong to the id, so two folders with one id cannot both run. const add = (plugin: DiscoveredPlugin) => { const first = firstWithId.get(plugin.id); if (first === undefined) { diff --git a/apps/server/src/plugins/pluginFailureGuard.ts b/apps/server/src/plugins/pluginFailureGuard.ts index ba6c8a5c17509..43e1240adb5d1 100644 --- a/apps/server/src/plugins/pluginFailureGuard.ts +++ b/apps/server/src/plugins/pluginFailureGuard.ts @@ -2,7 +2,7 @@ import type { PluginId } from "@t3tools/contracts"; import * as Predicate from "effect/Predicate"; -import { pluginCodeScope } from "./api/facade.ts"; +import { PluginApiCallError, pluginCodeScope } from "./api/facade.ts"; const REJECTION = "unhandledRejection"; type Listener = (...args: ReadonlyArray) => unknown; @@ -29,6 +29,7 @@ export const makePluginFailureGuard = (handlers: { const pluginOf = (error: unknown): PluginId | undefined => { const running = pluginCodeScope.getStore(); if (running !== undefined) return running; + if (error instanceof PluginApiCallError) return error.pluginId; let stack: unknown; try { stack = Predicate.isObject(error) ? error.stack : undefined; diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts index a6be7ce8b263b..6ec18475a658a 100644 --- a/apps/server/src/plugins/pluginHostTestHarness.ts +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -1,6 +1,5 @@ // @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes manifests as plain JSON. -// The plugin host with a theme service that records what it publishes, for its tests and the tests -// of the modules it builds. +// The plugin host with its real stores, for its tests and the tests of the modules it builds. import type { PluginState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -9,9 +8,12 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as EnvironmentTheme from "../environmentTheme.ts"; import * as ServerConfig from "../config.ts"; import * as PluginHost from "./PluginHost.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; export const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url) .pathname; @@ -30,7 +32,13 @@ const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ /** `themes` replaces the recording theme service, such as to hold a publish where it calls it. */ export const layerHost = (themes = layerThemes) => - Layer.fresh(PluginHost.layer.pipe(Layer.provide(themes))); + Layer.fresh( + PluginHost.layer.pipe( + Layer.provideMerge(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), + Layer.provide(ServerSecretStore.layer), + Layer.provide(themes), + ), + ); /** The real file system with some of its methods replaced, for the host's layer. */ export const layerFileSystemWith = ( diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts index 049a9c7844e8e..85979f167abac 100644 --- a/apps/server/src/plugins/pluginRpcHandlers.ts +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -8,6 +8,8 @@ import { type PluginReloadInput, type PluginRescanInput, type PluginSetEnabledInput, + type PluginSettingsGetInput, + type PluginSettingsUpdateInput, type PluginSubscribeInput, type PluginUninstallInput, WS_METHODS, @@ -53,6 +55,16 @@ export const makePluginRpcHandlers = ( installer .uninstall(input.pluginId, input.folder) .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), + [WS_METHODS.pluginsSettingsGet]: (input: PluginSettingsGetInput) => + pluginHost.getSettings(input.pluginId).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSettingsUpdate]: (input: PluginSettingsUpdateInput) => + pluginHost.updateSettings(input.pluginId, input.patch).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), [WS_METHODS.pluginsInvoke]: (input: PluginInvokeInput) => pluginHost .invoke({ pluginId: input.pluginId, method: input.method, input: input.input, scopes }) diff --git a/apps/server/src/plugins/pluginUploads.test.ts b/apps/server/src/plugins/pluginUploads.test.ts index cdf8d271b7eca..619cf01ee4282 100644 --- a/apps/server/src/plugins/pluginUploads.test.ts +++ b/apps/server/src/plugins/pluginUploads.test.ts @@ -10,7 +10,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as PluginHost from "./PluginHost.ts"; -import { layerConfig, layerHost, waitForStatuses } from "./pluginHostTestHarness.ts"; +import { fixturesDir, layerConfig, layerHost, waitForStatuses } from "./pluginHostTestHarness.ts"; import * as PluginUploads from "./pluginUploads.ts"; /** Posts `files` to `url` through the upload route, over a running plugin host. */ @@ -41,14 +41,12 @@ const uploader = Effect.fn(function* () { const issueUrl = PluginUploads.PluginUploads.use((uploads) => uploads.issueUrl); -// A plugin with only a manifest. -const helloFiles = Effect.succeed({ - "t3-plugin.json": JSON.stringify({ - id: "hello-plugin", - name: "Hello", - version: "0.1.0", - engines: { t3: ">=0.1.0" }, - }), +const helloFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return { + "t3-plugin.json": yield* fs.readFileString(`${fixturesDir}hello-plugin/t3-plugin.json`), + "server.cjs": yield* fs.readFileString(`${fixturesDir}hello-plugin/server.cjs`), + }; }); const withHost = (body: Effect.Effect) => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 001d4b902c640..8339fce0c4333 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -90,6 +90,8 @@ import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; import * as PluginUploads from "./plugins/pluginUploads.ts"; +import * as PluginSettingsStore from "./plugins/PluginSettingsStore.ts"; +import * as PluginStorage from "./plugins/PluginStorage.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -713,8 +715,9 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provide(NetService.layer), ); -// The plugin host sits on top of the runtime for its theme service. +// The plugin host sits on top of the runtime for its theme service and secret store. const layerRuntimeDependenciesWithPlugins = PluginHost.layer.pipe( + Layer.provide(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), Layer.provideMerge(layerRuntimeDependencies), ); diff --git a/apps/web/src/components/settings/plugins/PluginRow.tsx b/apps/web/src/components/settings/plugins/PluginRow.tsx index 5763ced3ae72f..e7c78f715c1e7 100644 --- a/apps/web/src/components/settings/plugins/PluginRow.tsx +++ b/apps/web/src/components/settings/plugins/PluginRow.tsx @@ -87,6 +87,7 @@ export function PluginRow({ name={plugin.name} unlink={unlink} folder={folder} + deletesData={false} disabled={busy || !canManage} onRemove={() => whileBusy(() => @@ -112,6 +113,7 @@ export function PluginRow({ name={plugin.name} unlink={unlink} folder={unlink ? folder : undefined} + deletesData disabled={busy || !canManage} onRemove={() => whileBusy(() => uninstall({ environmentId, input: { pluginId: plugin.id } })) @@ -139,6 +141,7 @@ function RemovePluginPopover({ name, unlink, folder, + deletesData, disabled, onRemove, }: { @@ -146,6 +149,8 @@ function RemovePluginPopover({ readonly unlink: boolean; /** Named in the popup: the folder unlinked, or the duplicate's folder deleted. */ readonly folder: string | undefined; + /** False for a duplicate: the copy in use keeps the data. */ + readonly deletesData: boolean; readonly disabled: boolean; readonly onRemove: () => Promise; }) { @@ -173,6 +178,11 @@ function RemovePluginPopover({ {unlink ? ". The folder stays." : "."}

)} + {deletesData ? ( +

+ This also deletes its settings, secrets and stored data, and can't be undone. +

+ ) : null}
}> Cancel diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md index 1ca2d1535a5ae..b0a457bebc171 100644 --- a/docs/internals/plugins.md +++ b/docs/internals/plugins.md @@ -9,11 +9,14 @@ A plugin is a folder with a `t3-plugin.json` manifest ## Trust and failure Plugins are trusted code the user chose to install. Server entries run in the server process, with -no sandbox. Manifest `permissions` are shown on the plugin's row and not enforced. Do not shape the -API around a future sandbox. +no sandbox. Manifest `permissions` are shown on the plugin's row and not enforced, and +`settings.get()` hands plugins secret values in plain text. Do not shape the API around a future +sandbox. The host isolates failure instead. A plugin that throws or hangs while starting is marked failed, -and a throw in a handler fails that call. +and a throw in a handler fails that call. Node exits on a rejection nobody handles, so every promise +the API hands a plugin comes from `call` or `refused` in +[`facade.ts`](../../apps/server/src/plugins/api/facade.ts), which mark it handled. Plugin code can still fail where nothing T3 runs is waiting, such as in a timer or a child process with no `'error'` listener. The [failure guard](../../apps/server/src/plugins/pluginFailureGuard.ts) @@ -38,8 +41,8 @@ Plugins without one still run. ## The API and manifests The server bundle inlines `effect`, so plugins never share the host's Effect runtime or `Schema` -instances. They get Promises, `AbortSignal` and the SDK's own types, never T3's contracts, which -change between releases. +instances. They get Promises, `AsyncIterable` streams, `AbortSignal` and the SDK's own types, never +T3's contracts, which change between releases. `plugins.invoke` and `plugins.subscribe` need only the read scope, so the host checks a method's `access` itself: a `write` method needs the caller's operate scope, as T3's own changes do. A call @@ -52,12 +55,22 @@ Manifests ignore keys they don't know, so a newer manifest key is lost silently outside it. `api.version` is how a plugin detects newer API members and still runs on older releases, so a change that adds a member raises it and names that version in the member's doc. +Discovery and install compile the manifest's `settingsSchema`, so a schema T3 can't validate fails +the plugin with the reason instead of refusing every save later. + ## State lives beside T3's `/plugins.json` holds enabled flags, dev paths and the plugins that stopped the server. -They stay out of `settings.json`, whose writer drops keys it doesn't know, and out of -`statev2.sqlite`, whose numbered migrations refuse to start when a fork's id collides with an -upstream one. +Settings go in `plugin-data//` and storage in `plugins.sqlite`. They stay out of +`settings.json`, whose writer drops keys it doesn't know, and out of `statev2.sqlite`, whose +numbered migrations refuse to start when a fork's id collides with an upstream one. Settings stay +out of `plugins//`, so replacing a plugin's files keeps them. Removing a plugin deletes its +settings, secrets and storage, so nothing is left that no plugin can reach. + +All of a plugin's secrets share one secret store entry, because a keychain can't list names and +removal must also find the secrets of fields a plugin has since renamed. The settings store keeps +each plugin's files in memory after the first read and changes them only through its own writes, +so a hand edit to `settings.json` shows after a restart. ## Installing @@ -72,12 +85,13 @@ the signature is the credential, so the upload works the same on every kind of c installs once. The server keeps its unused URLs in memory, so a restart refuses those it signed before. -Discovery takes the id from the manifest, because people copy plugin folders in by hand. The enabled -flag belongs to the id, so a later folder with an id already listed fails as a duplicate. Installed -folders come first, in name order, then dev paths. Removing or unlinking a duplicate deletes only -its folder or link. Discovery also forgets the enabled flag and crash record of every id no folder -has, so a folder copied in later with that id starts off. Nothing watches `plugins/`: the server -reads it at startup, on reload and when Settings asks. +Discovery takes the id from the manifest, because people copy plugin folders in by hand. Settings, +storage and the enabled flag belong to the id, so a later folder with an id already listed fails as +a duplicate. Installed folders come first, in name order, then dev paths. Removing or unlinking a +duplicate deletes only its folder or link, and the copy in use keeps the data. Discovery also +forgets the enabled flag and crash record of every id no folder has, so a folder copied in later +with that id starts off. Nothing watches `plugins/`: the server reads it at startup, on reload and +when Settings asks. ## Clients diff --git a/docs/user/plugins.md b/docs/user/plugins.md index d8f7ff6a1dcbe..cebd36cdf03dd 100644 --- a/docs/user/plugins.md +++ b/docs/user/plugins.md @@ -15,7 +15,7 @@ copy do not affect the installed plugin. To update it, add the new version's fol A new plugin is installed turned off, so none of it runs until you have read its row and turned it on. A new version of a plugin stays on or off as the old one was. If a new version fails to install or start, you keep the version you had, and T3 Code says why. A plugin that needs a newer version -of T3 Code isn't installed, and T3 Code says why. +of T3 Code, or whose settings T3 Code can't check, isn't installed, and T3 Code says why. Plugin folders you copy into the `plugins` folder yourself show up, turned off, the next time you open **Settings → Plugins**. That folder is `~/.t3/userdata/plugins` on the environment's machine @@ -38,13 +38,14 @@ off and its row says it stopped the server. To try it again, turn it back on. ## Remove a plugin -Choose **Remove** on the plugin's row and confirm in the popup. +Choose **Remove** on the plugin's row and confirm in the popup. Removing a plugin also deletes its +settings, secrets and stored data, and this can't be undone. Reinstalling it later starts fresh. -A plugin loaded from its own folder shows **Unlink** instead. Unlinking stops loading it and leaves -the folder where it is. +A plugin loaded from its own folder shows **Unlink** instead. Unlinking stops loading it and deletes +its settings, secrets and stored data the same way, but leaves the folder where it is. On a duplicate's row, **Remove** deletes only that folder and **Unlink** only stops loading it. The -plugin in use keeps running. +plugin in use keeps running with its settings, secrets and stored data. ## Themes and styles @@ -57,7 +58,9 @@ plugins live](#where-plugins-live)). ## Where plugins live Installed plugins are in the `plugins` folder of the environment's T3 Code data directory, one -folder per plugin, and `plugins.json` records which ones are enabled. +folder per plugin. Their settings are in `plugin-data`, and `plugins.json` records which ones are +enabled. T3 Code reads a plugin's settings file once, so an edit you make to it by hand shows after +T3 Code restarts. Plugin styles and themes load in the web and desktop apps. They load from the app's main environment, which is the desktop app's own server or the server the web app was opened from, and diff --git a/knip.jsonc b/knip.jsonc index 025cbd6609e77..a7fcfe5e6aa3e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -143,6 +143,8 @@ "packages/plugin-sdk": { // Plugin authors outside this repository import these exports. "includeEntryExports": false, + // The plugin host loads fixture entries from their manifests. + "entry": ["fixtures/*/*.cjs"], }, "packages/effect-acp": { "includeEntryExports": true, diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 370e1c1591e88..bc59994f102a9 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -98,12 +98,13 @@ describe("command permissions", () => { it.effect.each([ WS_METHODS.serverRunStorageCleanup, - // Installing, enabling or reloading a plugin runs its code in the server. + // Installing, enabling, reloading or configuring a plugin runs its code in the server. WS_METHODS.pluginsSetEnabled, WS_METHODS.pluginsReload, WS_METHODS.pluginsCreateUploadUrl, WS_METHODS.pluginsRescan, WS_METHODS.pluginsUninstall, + WS_METHODS.pluginsSettingsUpdate, ])("requires the destination settings grant for %s", (method) => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 5dc148424693f..e94e8a528bd2b 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -63,6 +63,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pluginsCreateUploadUrl]: AuthSettingsWriteScope, [WS_METHODS.pluginsRescan]: AuthSettingsWriteScope, [WS_METHODS.pluginsUninstall]: AuthSettingsWriteScope, + [WS_METHODS.pluginsSettingsUpdate]: AuthSettingsWriteScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/plugins.test.ts b/packages/contracts/src/plugins.test.ts index 41570968f0111..468584c214060 100644 --- a/packages/contracts/src/plugins.test.ts +++ b/packages/contracts/src/plugins.test.ts @@ -11,6 +11,7 @@ const manifest = { version: "0.1.0", engines: { t3: ">=0.1.6" }, server: "./dist/server.cjs", + settingsSchema: { type: "object", properties: { greeting: { type: "string" } } }, permissions: ["Reads thread titles"], }; diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index 460da634a7a96..a1a2abe4cb26a 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -57,6 +57,11 @@ export const PluginManifest = Schema.Struct({ description: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(500))), engines: Schema.Struct({ t3: PluginEngineRange }), server: Schema.optionalKey(PluginServerEntryPath), + /** + * JSON Schema for the plugin's settings. Top-level string fields with `format: "t3-secret"` are + * write-only and kept in the secret store; `t3-secret` anywhere else is saved as plain text. + */ + settingsSchema: Schema.optionalKey(Schema.JsonObject), /** Shown to the user before enabling. Not enforced: plugins are trusted code. */ permissions: Schema.optionalKey( Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(200))).check( @@ -221,7 +226,10 @@ export type PluginRescanInput = typeof PluginRescanInput.Type; export const PluginUninstallInput = Schema.Struct({ pluginId: PluginId, - /** A row's `folder`, to remove or unlink that copy. A `duplicate` copy goes alone. */ + /** + * A row's `folder`, to remove or unlink that copy. A `duplicate` copy goes alone, and the copy + * in use keeps the id's data. + */ folder: Schema.optionalKey(Schema.String), }); export type PluginUninstallInput = typeof PluginUninstallInput.Type; @@ -235,6 +243,24 @@ export const PluginMutationResult = Schema.Struct({ }); export type PluginMutationResult = typeof PluginMutationResult.Type; +export const PluginSettingsGetInput = Schema.Struct({ + pluginId: PluginId, +}); +export type PluginSettingsGetInput = typeof PluginSettingsGetInput.Type; + +export const PluginSettingsUpdateInput = Schema.Struct({ + pluginId: PluginId, + patch: Schema.JsonObject, +}); +export type PluginSettingsUpdateInput = typeof PluginSettingsUpdateInput.Type; + +/** Secret fields read back as the server's redaction marker, never their value. */ +export const PluginSettingsResult = Schema.Struct({ + pluginId: PluginId, + settings: Schema.JsonObject, +}); +export type PluginSettingsResult = typeof PluginSettingsResult.Type; + export const PluginInvokeInput = Schema.Struct({ pluginId: PluginId, method: PluginRpcName, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c2fd78b0ceafc..bf03be37c647d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -389,6 +389,9 @@ import { PluginMutationResult, PluginReloadInput, PluginSetEnabledInput, + PluginSettingsGetInput, + PluginSettingsResult, + PluginSettingsUpdateInput, PluginSubscribeInput, PluginSubscriptionEvent, PluginStatesEvent, @@ -573,6 +576,8 @@ export const WS_METHODS = { pluginsCreateUploadUrl: "plugins.createUploadUrl", pluginsRescan: "plugins.rescan", pluginsUninstall: "plugins.uninstall", + pluginsSettingsGet: "plugins.settings.get", + pluginsSettingsUpdate: "plugins.settings.update", pluginsInvoke: "plugins.invoke", pluginsSubscribe: "plugins.subscribe", @@ -2026,6 +2031,18 @@ const WsPluginsUninstallRpc = Rpc.make(WS_METHODS.pluginsUninstall, { error: PluginRpcError, }); +const WsPluginsSettingsGetRpc = Rpc.make(WS_METHODS.pluginsSettingsGet, { + payload: PluginSettingsGetInput, + success: PluginSettingsResult, + error: PluginRpcError, +}); + +const WsPluginsSettingsUpdateRpc = Rpc.make(WS_METHODS.pluginsSettingsUpdate, { + payload: PluginSettingsUpdateInput, + success: PluginSettingsResult, + error: PluginRpcError, +}); + const WsPluginsInvokeRpc = Rpc.make(WS_METHODS.pluginsInvoke, { payload: PluginInvokeInput, success: PluginInvokeResult, @@ -2131,6 +2148,8 @@ export const WsRpcGroup = RpcGroup.make( WsPluginsCreateUploadUrlRpc, WsPluginsRescanRpc, WsPluginsUninstallRpc, + WsPluginsSettingsGetRpc, + WsPluginsSettingsUpdateRpc, WsPluginsInvokeRpc, WsPluginsSubscribeRpc, WsServerReportClientActivityRpc, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 85e03d2915a4f..ca4eec67e76d6 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -16,7 +16,11 @@ written by hand and need no build. "version": "0.1.0", "description": "Shown on the Plugins page.", "engines": { "t3": ">=0.1.6" }, - "server": "./server.cjs" + "server": "./server.cjs", + "settingsSchema": { + "type": "object", + "properties": { "token": { "type": "string", "format": "t3-secret" } } + } } ``` @@ -29,6 +33,11 @@ written by hand and need no build. - `server` must be CommonJS ending in `.cjs`. A plugin with a `server` runs only on a T3 Code server running Node 24 or later, such as the desktop app's. On an older Node, such as `npx t3` on Node 22, it fails to start and says why. +- `settingsSchema` is JSON Schema. Top-level string fields with `"format": "t3-secret"` are stored + in the server's secret store and never shown again. `t3-secret` anywhere else is saved as plain + text. Every save is checked against the schema with the top-level `default`s filled in, so a + default its own field rejects refuses every save. A schema T3 Code can't validate against fails + the plugin when T3 Code finds or installs it, and says why. - A `themes/` folder of theme JSON files is published while the plugin is active. T3 Code remembers the published themes, so the boot screen and the desktop window start in the active theme's colors. A theme that sets `"sidebarArtwork": true` keeps the nightly and dev artwork in @@ -53,7 +62,9 @@ module.exports = { }; ``` -`activate` receives the [`PluginApi`](./src/server.ts): RPC methods and channels for clients. +`activate` receives the [`PluginApi`](./src/server.ts): settings, storage, and RPC methods and +channels for clients. `api.settings.get()` reads from memory after its first call, so a handler can +call it on every request. Everything a plugin registers is released when it is disabled, reloaded or uninstalled; add your own cleanup with `api.onDeactivate`. diff --git a/packages/plugin-sdk/fixtures/broken-plugin/server.cjs b/packages/plugin-sdk/fixtures/broken-plugin/server.cjs new file mode 100644 index 0000000000000..3c152c0154f1b --- /dev/null +++ b/packages/plugin-sdk/fixtures/broken-plugin/server.cjs @@ -0,0 +1,6 @@ +/** @type {import("@t3tools/plugin-sdk/server").PluginDefinition} */ +module.exports = { + activate() { + throw new Error("broken-plugin refuses to start"); + }, +}; diff --git a/packages/plugin-sdk/fixtures/broken-plugin/t3-plugin.json b/packages/plugin-sdk/fixtures/broken-plugin/t3-plugin.json new file mode 100644 index 0000000000000..2ad4ed7d34299 --- /dev/null +++ b/packages/plugin-sdk/fixtures/broken-plugin/t3-plugin.json @@ -0,0 +1,8 @@ +{ + "id": "broken-plugin", + "name": "Broken", + "version": "0.1.0", + "description": "Test fixture whose activate throws.", + "engines": { "t3": ">=0.1.0" }, + "server": "./server.cjs" +} diff --git a/packages/plugin-sdk/fixtures/hello-plugin/server.cjs b/packages/plugin-sdk/fixtures/hello-plugin/server.cjs new file mode 100644 index 0000000000000..affe4238e29a8 --- /dev/null +++ b/packages/plugin-sdk/fixtures/hello-plugin/server.cjs @@ -0,0 +1,36 @@ +// Plain CommonJS with no SDK import, so the plugin host loads it as is. The type comes from the SDK. + +/** @type {import("@t3tools/plugin-sdk/server").PluginDefinition} */ +module.exports = { + activate(api) { + api.log.info("hello-plugin activated"); + api.rpc.register( + "ping", + async (input, { access }) => { + const settings = await api.settings.get(); + const greeting = typeof settings.greeting === "string" ? settings.greeting : "hello"; + return { pong: input ?? null, greeting, access }; + }, + { access: "read" }, + ); + api.rpc.register( + "count", + async () => { + const current = await api.storage.get("count"); + const next = (typeof current === "number" ? current : 0) + 1; + await api.storage.set("count", next); + api.rpc.publish("counted", next); + return next; + }, + { access: "write" }, + ); + api.onDeactivate(() => api.storage.set("deactivated", true)); + void (async () => { + // Only the greeting: settings include secret values, and any reader can subscribe. + for await (const settings of api.settings.changes()) { + const greeting = typeof settings.greeting === "string" ? settings.greeting : null; + api.rpc.publish("settings", { greeting }); + } + })(); + }, +}; diff --git a/packages/plugin-sdk/fixtures/hello-plugin/t3-plugin.json b/packages/plugin-sdk/fixtures/hello-plugin/t3-plugin.json new file mode 100644 index 0000000000000..dc8391fc6d822 --- /dev/null +++ b/packages/plugin-sdk/fixtures/hello-plugin/t3-plugin.json @@ -0,0 +1,16 @@ +{ + "id": "hello-plugin", + "name": "Hello", + "version": "0.1.0", + "description": "Test fixture that answers ping with its greeting setting and counts calls in storage.", + "engines": { "t3": ">=0.1.0" }, + "server": "./server.cjs", + "settingsSchema": { + "type": "object", + "properties": { + "greeting": { "type": "string", "maxLength": 40 }, + "token": { "type": "string", "format": "t3-secret" } + }, + "additionalProperties": false + } +} diff --git a/packages/plugin-sdk/src/server.ts b/packages/plugin-sdk/src/server.ts index 6170b6ac46431..b6e86e11b8e52 100644 --- a/packages/plugin-sdk/src/server.ts +++ b/packages/plugin-sdk/src/server.ts @@ -1,4 +1,4 @@ -import type { JsonObject, PluginRpcMap } from "./shared.ts"; +import type { JsonObject, JsonValue, PluginRpcMap } from "./shared.ts"; /** `read` methods need the caller's `orchestration:read` scope, `write` methods `orchestration:operate`. */ export type PluginRpcAccess = "read" | "write"; @@ -24,6 +24,11 @@ export type PluginRpcHandler< context: PluginRpcContext, ) => Method["output"] | Promise; +/** How every rejected API call fails. `code` names the server's error; treat it as opaque. */ +export interface PluginApiError extends Error { + readonly code: string; +} + export interface PluginLogger { readonly debug: (message: string, fields?: JsonObject) => void; readonly info: (message: string, fields?: JsonObject) => void; @@ -45,6 +50,21 @@ export interface PluginApi { readonly id: string; readonly rootDir: string; readonly log: PluginLogger; + readonly settings: { + /** + * The saved settings, secret values included. Fields nobody saved read as the `default` in + * the manifest's `settingsSchema`, at the top level and for fields that are not secrets. + */ + readonly get: () => Promise; + /** Yields the settings as `get` reads them after each change, until deactivation. */ + readonly changes: () => AsyncIterable; + }; + readonly storage: { + readonly get: (key: string) => Promise; + readonly set: (key: string, value: JsonValue) => Promise; + readonly delete: (key: string) => Promise; + readonly list: (prefix?: string) => Promise>; + }; readonly rpc: { /** * Answers `plugins.invoke` calls for `method` from clients. `input` has the type `Rpc` gives From 77eb03f0caa914f69e6f8f2e62508dcdaf157d9d Mon Sep 17 00:00:00 2001 From: ashx-j Date: Mon, 9 Nov 2026 03:16:51 +0000 Subject: [PATCH 07/11] feat(plugins): Settings draws a plugin's settings form from its JSON Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins could declare a `settingsSchema`, but nothing in the app could show or change their settings. A plugin row with settings now has a settings button that opens a form drawn from the schema. Each field saves on its own, secret fields go to the secret store and never show again, and a schema the form doesn't cover gets a JSON box. Rows say only whether a plugin has settings, and `plugins.settings.get` sends the schema with the settings, so a large schema doesn't ride every row update to every client. The form code sits in apps/web beside the page, its only user. Surfaces: Settings → Plugins in web and desktop; mobile shows no plugin settings. No providers or agents. Contracts: `hasSettings` on rows and the schema in `plugins.settings.get`. Reverse: a secret's clear button removes it, and an emptied field goes back to the default. Connections: WebSocket only. Docs: internals, the user guide and the SDK README's `settingsSchema` bullet. Built with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/plugins/PluginHost.test.ts | 7 +- apps/server/src/plugins/PluginHost.ts | 9 +- apps/server/src/plugins/pluginRpcHandlers.ts | 2 +- apps/server/src/plugins/pluginStates.ts | 1 + .../settings/JsonSchemaSettingsForm.test.ts | 43 ++ .../settings/JsonSchemaSettingsForm.tsx | 394 ++++++++++++++++++ .../components/settings/plugins/PluginRow.tsx | 36 +- .../settings/plugins/PluginSettingsRows.tsx | 68 +++ .../plugins/pluginSettingsForm.test.ts | 99 +++++ .../settings/plugins/pluginSettingsForm.ts | 129 ++++++ .../src/plugins/pluginStyles.browser.test.ts | 1 + docs/internals/plugins.md | 4 + docs/user/plugins.md | 7 + packages/client-runtime/src/state/plugins.ts | 11 + packages/contracts/src/plugins.ts | 9 + packages/contracts/src/rpc.ts | 3 +- packages/plugin-sdk/README.md | 13 +- 17 files changed, 823 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts create mode 100644 apps/web/src/components/settings/JsonSchemaSettingsForm.tsx create mode 100644 apps/web/src/components/settings/plugins/PluginSettingsRows.tsx create mode 100644 apps/web/src/components/settings/plugins/pluginSettingsForm.test.ts create mode 100644 apps/web/src/components/settings/plugins/pluginSettingsForm.ts diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index d3e62cfb50ad7..425ebe9b0844d 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -143,7 +143,8 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { yield* enableHello; yield* Effect.gen(function* () { const host = yield* PluginHost.PluginHost; - yield* waitForStatuses(host, { [hello]: "active" }); + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { hasSettings: true }); const changed = yield* host .subscribe({ pluginId: hello, channel: "settings" }) @@ -154,6 +155,10 @@ it.layer(NodeServices.layer)("PluginHost", (it) => { yield* host.invoke({ pluginId: hello, method: "ping", input: "b", scopes: operate }), { greeting: "hey" }, ); + // The settings form's schema comes with the settings, not on every row. + const { settings, settingsSchema } = yield* host.getSettings(hello); + assert.deepStrictEqual(settings, { greeting: "hey" }); + assert.include(settingsSchema, { additionalProperties: false }); }).pipe(Effect.provide(layerHost())); }).pipe(Effect.provide(layerConfig())), ); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 36a395dd36444..da9864ade9def 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -8,6 +8,7 @@ import { AuthOrchestrationReadScope, PluginId, type PluginListResult, + type PluginSettingsGetResult, type PluginState, type PluginStatesEvent, } from "@t3tools/contracts"; @@ -164,10 +165,11 @@ export class PluginHost extends Context.Service< { readonly plugins: ReadonlyArray }, PluginConfigError >; + /** The settings clients see, with the schema the settings form is drawn from. */ readonly getSettings: ( pluginId: PluginId, ) => Effect.Effect< - Schema.JsonObject, + Omit, PluginNotFoundError | PluginNotActiveError | PluginSettingsError >; readonly updateSettings: ( @@ -699,8 +701,9 @@ const make = Effect.gen(function* () { reload, rescan: rescan(), getSettings: Effect.fn("PluginHost.getSettings")(function* (pluginId) { - const manifest = yield* manifestFor(pluginId); - return yield* settingsStore.getForClient(pluginId, manifest.settingsSchema); + const { settingsSchema } = yield* manifestFor(pluginId); + const settings = yield* settingsStore.getForClient(pluginId, settingsSchema); + return settingsSchema === undefined ? { settings } : { settings, settingsSchema }; }), updateSettings: Effect.fn("PluginHost.updateSettings")(function* (pluginId, patch) { const manifest = yield* manifestFor(pluginId); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts index 85979f167abac..4288e7a866f3c 100644 --- a/apps/server/src/plugins/pluginRpcHandlers.ts +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -57,7 +57,7 @@ export const makePluginRpcHandlers = ( .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), [WS_METHODS.pluginsSettingsGet]: (input: PluginSettingsGetInput) => pluginHost.getSettings(input.pluginId).pipe( - Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.map((result) => ({ pluginId: input.pluginId, ...result })), Effect.mapError(toRpcError(input.pluginId)), ), [WS_METHODS.pluginsSettingsUpdate]: (input: PluginSettingsUpdateInput) => diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts index 4e175a670e392..a6245b6d4ad39 100644 --- a/apps/server/src/plugins/pluginStates.ts +++ b/apps/server/src/plugins/pluginStates.ts @@ -87,6 +87,7 @@ const toState = ( ...(record.revision === undefined ? {} : { revision: record.revision }), ...(record.styles === undefined || record.styles.length === 0 ? {} : { styles: record.styles }), hasServer: manifest?.server !== undefined, + hasSettings: manifest?.settingsSchema !== undefined, ...(serverFailure === undefined ? {} : { serverFailure }), }; }; diff --git a/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts b/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts new file mode 100644 index 0000000000000..d98c0cb09192b --- /dev/null +++ b/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { toJsonPatch, toPatch } from "./JsonSchemaSettingsForm"; +import { pluginSettingsForm } from "./plugins/pluginSettingsForm"; + +const form = pluginSettingsForm({ + type: "object", + properties: { + token: { type: "string", format: "t3-secret" }, + name: { type: "string" }, + count: { type: "integer", minimum: 0 }, + }, +}); +const fields = form.kind === "fields" ? form.fields : []; + +describe("toPatch", () => { + it("sends only changed fields, removes a cleared secret and keeps an untouched one", () => { + const saved = { token: "", name: "Ash", count: "2" }; + expect(toPatch(fields, saved, { ...saved, token: null })).toEqual({ + patch: { token: null }, + }); + expect(toPatch(fields, saved, { ...saved, name: " " })).toEqual({ patch: { name: null } }); + expect(toPatch(fields, saved, { ...saved, token: "new" })).toEqual({ + patch: { token: "new" }, + }); + // A half-typed or out-of-range number is a problem to fix, not a cleared field. + expect(toPatch(fields, saved, { ...saved, count: "1e" })).toEqual({ + problem: "count must be a whole number.", + }); + expect(toPatch(fields, saved, { ...saved, count: "-1" })).toEqual({ + problem: "count must be at least 0.", + }); + }); +}); + +describe("toJsonPatch", () => { + it("removes a key deleted from the JSON", () => { + expect(toJsonPatch({ a: 1, b: { c: 2 } }, '{ "b": { "c": 3 } }')).toEqual({ + patch: { a: null, b: { c: 3 } }, + }); + expect(toJsonPatch({ a: 1 }, "[]")).toEqual({ problem: "Settings must be a JSON object." }); + }); +}); diff --git a/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx b/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx new file mode 100644 index 0000000000000..c0f37a59a291b --- /dev/null +++ b/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx @@ -0,0 +1,394 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { XIcon } from "lucide-react"; +import { type ComponentProps, useState } from "react"; + +import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { SecretInput } from "./SecretInput"; +import { type PluginSettingsField, pluginSettingsForm } from "./plugins/pluginSettingsForm"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; + +// `null` clears a saved value, so the plugin gets its default again. +type DraftValue = string | boolean | null; +export type Draft = Readonly>; + +const draftValue = (field: PluginSettingsField, value: Schema.Json | undefined) => { + switch (field.kind) { + case "boolean": + return typeof value === "boolean" ? value : (field.defaultValue ?? false); + case "secret": + return ""; + case "list": + return Array.isArray(value) ? value.join("\n") : ""; + default: + return typeof value === "string" || typeof value === "number" ? String(value) : ""; + } +}; + +/** The changed fields as a settings patch, or the first problem a user can fix before saving. */ +export const toPatch = ( + fields: ReadonlyArray, + saved: Draft, + draft: Draft, +): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { + const patch: Record = {}; + for (const field of fields) { + const value = draft[field.key]; + if (value === saved[field.key] || value === undefined) continue; + if (value === null) { + patch[field.key] = null; + } else if (typeof value === "boolean") { + patch[field.key] = value; + } else if (field.kind === "secret") { + if (value !== "") patch[field.key] = value; + } else if (value.trim() === "") { + patch[field.key] = null; + } else if (field.kind === "number") { + const parsed = Number(value); + if (!Number.isFinite(parsed) || (field.integer && !Number.isInteger(parsed))) { + return { + problem: `${field.label} must be ${field.integer ? "a whole number" : "a number"}.`, + }; + } + if (field.minimum !== undefined && parsed < field.minimum) { + return { problem: `${field.label} must be at least ${field.minimum}.` }; + } + if (field.maximum !== undefined && parsed > field.maximum) { + return { problem: `${field.label} must be at most ${field.maximum}.` }; + } + patch[field.key] = parsed; + } else if (field.kind === "select") { + patch[field.key] = field.options.find((option) => String(option) === value) ?? value; + } else if (field.kind === "list") { + patch[field.key] = value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } else { + patch[field.key] = value; + } + } + return { patch }; +}; + +const parseJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.JsonObject)); + +/** The edited JSON as a settings patch, with `null` for each saved key it no longer has. */ +export const toJsonPatch = ( + saved: Schema.JsonObject, + json: string, +): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { + const parsed = parseJsonObject(json); + if (Option.isNone(parsed)) return { problem: "Settings must be a JSON object." }; + const removed = Object.keys(saved).filter((key) => !Object.hasOwn(parsed.value, key)); + return { patch: { ...Object.fromEntries(removed.map((key) => [key, null])), ...parsed.value } }; +}; + +const without = (record: Readonly>, key: string) => + Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key)); + +/** + * Settings rows drawn from a plugin's JSON Schema. Each field saves on its own, as T3's own + * settings rows do. `onSave` gets that field as a patch, `null` when cleared, and resolves with the + * settings as saved or the reason they were not. + */ +export function JsonSchemaSettingsForm({ + idPrefix, + label, + schema, + settings, + canSave, + onSave, +}: { + readonly idPrefix: string; + readonly label: string; + readonly schema: Schema.JsonObject | undefined; + readonly settings: Schema.JsonObject; + readonly canSave: boolean; + readonly onSave: ( + patch: Schema.JsonObject, + ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; +}) { + const form = pluginSettingsForm(schema); + const [saved, setSaved] = useState(settings); + // What the user just set, shown until it saves; it stays, with its problem, when it can't. + const [pending, setPending] = useState>>({}); + const [problems, setProblems] = useState>>({}); + const settle = (key: string, problem: string | null) => { + setProblems((all) => (problem === null ? without(all, key) : { ...all, [key]: problem })); + if (problem === null) setPending((all) => without(all, key)); + }; + + if (form.kind === "json") { + return ( + + ); + } + if (form.kind !== "fields") return null; + + const commit = async (field: PluginSettingsField, value: DraftValue) => { + const current = draftValue(field, saved[field.key]); + const next = toPatch([field], { [field.key]: current }, { [field.key]: value }); + setPending((all) => ({ ...all, [field.key]: value })); + if ("problem" in next) return settle(field.key, next.problem); + if (Object.keys(next.patch).length === 0) return settle(field.key, null); + const result = await onSave(next.patch); + if ("problem" in result) return settle(field.key, result.problem); + setSaved(result.settings); + settle(field.key, null); + }; + + return ( + <> + {form.fields.map((field) => ( + void commit(field, value)} + /> + ))} + + ); +} + +/** For settings whose schema doesn't fit a form: the settings as JSON, saved together. */ +function JsonSettingsRow({ + label, + settings, + canSave, + onSave, + onSaved, +}: { + readonly label: string; + readonly settings: Schema.JsonObject; + readonly canSave: boolean; + readonly onSave: ( + patch: Schema.JsonObject, + ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; + readonly onSaved: (settings: Schema.JsonObject) => void; +}) { + const [json, setJson] = useState(() => JSON.stringify(settings, null, 2)); + const [problem, setProblem] = useState(null); + const [busy, setBusy] = useState(false); + const save = async () => { + const next = toJsonPatch(settings, json); + if ("problem" in next) return setProblem(next.problem); + setBusy(true); + const result = await onSave(next.patch); + setBusy(false); + if ("problem" in result) return setProblem(result.problem); + setProblem(null); + onSaved(result.settings); + setJson(JSON.stringify(result.settings, null, 2)); + }; + return ( + {problem} : undefined} + > +
+