diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index aaa3b2c0da..bb4af300dd 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,13 +1,19 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { TestInfo } from "@playwright/test"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { waitForSettledPosition } from "../support/helpers/sheet-layout"; import { expectSettingsHeader, + openCompactSettings, openHostSection, openSettingsHost, } from "../support/helpers/settings"; @@ -72,11 +78,28 @@ async function openPluginSettings(page: Page): Promise { await expectSettingsHeader(page, "Plugins"); } +async function openCompactPluginSettings(page: Page): Promise { + const serverId = getServerId(); + await openCompactSettings(page, buildOpenProjectRoute()); + await openHostSection(page, serverId, "plugins"); + await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible(); +} + async function reloadPlugin(page: Page): Promise { - await page.getByRole("button", { name: "Reload", exact: true }).click(); + await selectPluginAction(page, "e2e-plugin", "Reload"); await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible(); } +async function openPluginActions(page: Page, pluginId: string): Promise { + await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click(); + await expect(page.getByRole("menuitem").first()).toBeVisible(); +} + +async function selectPluginAction(page: Page, pluginId: string, action: string): Promise { + await openPluginActions(page, pluginId); + await page.getByRole("menuitem", { name: action, exact: true }).click(); +} + async function expectContributionBody(page: Page, expectedBody: string): Promise { const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true }); await expect(body).toHaveCount(1); @@ -115,15 +138,109 @@ async function expectContributionRemoved(page: Page, title: string): Promise { + await page.screenshot({ + path: testInfo.outputPath(`plugin-source-install-${name}.png`), + fullPage: true, + animations: "disabled", + }); +} + +async function installPlugin(page: Page, source: string): Promise { + await page.getByLabel("Plugin source").fill(source); + await page.getByRole("button", { name: "Install plugin" }).click(); +} + +async function expectPluginSourceDocsOpen(page: Page): Promise { + const docsPagePromise = page.context().waitForEvent("page"); + await page.getByRole("link", { name: "Docs", exact: true }).click(); + const docsPage = await docsPagePromise; + try { + await docsPage.waitForURL("https://paseo.sh/docs/plugins/v0.8/reference#plugin-sources", { + waitUntil: "commit", + }); + } finally { + await docsPage.close(); + } +} + +async function createGitPluginRepository(root: string): Promise { + const repository = path.join(root, "repository"); + await mkdir(repository); + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "git-e2e-plugin", + description: "Adds review tools from a Git repository", + requirements: pluginRequirements, + }), + ); + await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Git plugin")); + execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Paseo Tests"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.email", "paseo@example.test"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" }); + return repository; +} + +async function createDirectoryPlugin( + id: string, + description: string, + title: string, +): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-row-e2e-")); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, description, requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `import React from "react"; +import { Text } from "react-native"; +export default function contribute(plugin) { + function Surface() { return ${title}; } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" }); + return () => undefined; +}`, + ); + return directory; +} + test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({ page, -}) => { +}, testInfo) => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-")); + const disabledDirectory = await createDirectoryPlugin( + "disabled-preview-plugin-with-a-long-name", + "Keeps optional previews off until this plugin is enabled", + "Preview plugin", + ); + const failedDirectory = await createDirectoryPlugin( + "failed-preview-plugin", + "Demonstrates a plugin that needs attention", + "Failed preview plugin", + ); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }), + JSON.stringify({ + id: "e2e-plugin", + description: "Exercises the complete local plugin lifecycle", + requirements: pluginRequirements, + }), ); await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1")); @@ -132,15 +249,35 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await gotoAppShell(page); await openPluginSettings(page); await catalog.waitForInitialFetch(); + await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0); + await expectPluginSourceDocsOpen(page); await client.patchDaemonConfig({ pluginsEnabled: false }); await page.getByRole("switch", { name: "Enable plugins" }).click(); await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible(); - await page.getByLabel("Plugin directory").fill(directory); - await page.getByLabel("Plugin installation ID").focus(); - await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin"); - await page.getByRole("button", { name: "Install directory" }).click(); + await client.installPluginSource({ source: disabledDirectory }); + await client.disablePlugin("disabled-preview-plugin-with-a-long-name"); + await client.installPluginSource({ source: failedDirectory }); + await writeFile(path.join(failedDirectory, "index.client.tsx"), "export default broken !!!"); + await client.reloadPlugin("failed-preview-plugin").catch(() => undefined); + await page.getByLabel("Plugin source").fill(directory); + await page.getByRole("button", { name: "Install plugin" }).click(); await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible(); await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible(); + await expect( + page.getByText("Keeps optional previews off until this plugin is enabled"), + ).toBeVisible(); + await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible(); + await expect( + page.getByLabel("disabled-preview-plugin-with-a-long-name disabled"), + ).toBeVisible(); + await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible(); + await capturePluginInstallForm(page, testInfo, "wide"); + await openPluginActions(page, "e2e-plugin"); + await capturePluginInstallForm(page, testInfo, "wide-menu"); + await page.getByRole("menuitem", { name: "Logs", exact: true }).click(); + await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin"); + await page.getByRole("button", { name: "Close", exact: true }).click(); await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0"); await openPluginSettings(page); @@ -171,7 +308,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!"); await openPluginSettings(page); - await page.getByRole("button", { name: "Reload", exact: true }).click(); + await selectPluginAction(page, "e2e-plugin", "Reload"); await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed"); await expect(page.getByLabel("e2e-plugin failed")).toBeVisible(); await leavePluginSettings(page); @@ -179,35 +316,103 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3")); await openPluginSettings(page); - await page.getByRole("button", { name: "Reload", exact: true }).click(); + await selectPluginAction(page, "e2e-plugin", "Reload"); await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5"); await openPluginSettings(page); - await page.getByRole("button", { name: "Disable", exact: true }).click(); + await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click(); await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible(); await leavePluginSettings(page); await expectContributionRemoved(page, "Plugin v3"); await openPluginSettings(page); - await page.getByRole("button", { name: "Enable", exact: true }).click(); + await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click(); await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6"); await openPluginSettings(page); page.once("dialog", (dialog) => dialog.accept()); - await page.getByRole("button", { name: "Remove", exact: true }).click(); - await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible(); + await selectPluginAction(page, "e2e-plugin", "Remove"); + await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0); - await page.getByLabel("Plugin directory").fill(directory); - await page.getByRole("button", { name: "Install directory" }).click(); + await installPlugin(page, directory); await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await gotoAppShell(page); await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7"); } finally { await client.removePlugin("e2e-plugin").catch(() => undefined); + await client.removePlugin("disabled-preview-plugin-with-a-long-name").catch(() => undefined); + await client.removePlugin("failed-preview-plugin").catch(() => undefined); await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); await client.close().catch(() => undefined); await rm(directory, { recursive: true, force: true }); + await rm(disabledDirectory, { recursive: true, force: true }); + await rm(failedDirectory, { recursive: true, force: true }); + } +}); + +test("installs a Git source after a failed source remains editable", async ({ page }, testInfo) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-e2e-")); + const repository = await createGitPluginRepository(root); + const longDirectory = await createDirectoryPlugin( + "compact-plugin-with-a-realistically-long-name", + "Shows how a realistically long plugin description wraps on a compact screen", + "Compact long plugin", + ); + const disabledDirectory = await createDirectoryPlugin( + "compact-disabled-plugin", + "Remains installed and ready to enable later", + "Compact disabled plugin", + ); + const missingSource = path.join(root, "missing-plugin"); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await client.installPluginSource({ source: longDirectory }); + await client.installPluginSource({ source: disabledDirectory }); + await client.disablePlugin("compact-disabled-plugin"); + await gotoAppShell(page); + await openCompactPluginSettings(page); + await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0); + await client.patchDaemonConfig({ pluginsEnabled: false }); + await page.getByRole("switch", { name: "Enable plugins" }).click(); + await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible(); + + await installPlugin(page, missingSource); + await expect(page.getByTestId("plugin-management-feedback")).toContainText( + "Plugin source is neither an existing directory nor a Git URL", + ); + await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource); + await capturePluginInstallForm(page, testInfo, "compact-error"); + + await installPlugin(page, pathToFileURL(repository).href); + await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible(); + await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible(); + await expect( + page.getByText("Shows how a realistically long plugin description wraps on a compact screen"), + ).toBeVisible(); + await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible(); + await expect(page.getByLabel("Plugin source")).toHaveValue(""); + await capturePluginInstallForm(page, testInfo, "compact-success"); + await openPluginActions(page, "git-e2e-plugin"); + const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true }); + await expect(removeAction).toBeInViewport({ ratio: 1 }); + await waitForSettledPosition(removeAction); + await capturePluginInstallForm(page, testInfo, "compact-menu"); + await page.keyboard.press("Escape"); + } finally { + await client.removePlugin("git-e2e-plugin").catch(() => undefined); + await client + .removePlugin("compact-plugin-with-a-realistically-long-name") + .catch(() => undefined); + await client.removePlugin("compact-disabled-plugin").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await client.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + await rm(longDirectory, { recursive: true, force: true }); + await rm(disabledDirectory, { recursive: true, force: true }); } }); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index c6695a268d..dace580491 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -22,6 +22,7 @@ type NewWorkspaceDaemonClient = Pick< | "getPaseoWorktreeList" | "getDaemonConfig" | "installDirectoryPlugin" + | "installPluginSource" | "disablePlugin" | "enablePlugin" | "inspectWorkspaceRecovery" diff --git a/packages/app/e2e/support/helpers/plugin-requirements.ts b/packages/app/e2e/support/helpers/plugin-requirements.ts index 18631c319a..cf680c11b4 100644 --- a/packages/app/e2e/support/helpers/plugin-requirements.ts +++ b/packages/app/e2e/support/helpers/plugin-requirements.ts @@ -101,14 +101,19 @@ export async function expectAppMismatch(page: Page) { export async function correctRequirementAndReload(page: Page, directory: string) { await writeRequirements(directory, pluginRequirements.paseo); - await page.getByRole("button", { name: "Reload", exact: true }).click(); + await reloadRequirementsPlugin(page); await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible(); await expect(page.getByText(/Your app is/)).toHaveCount(0); } export async function rejectDaemonMismatch(page: Page, directory: string) { await writeRequirements(directory, ">=100.0.0"); - await page.getByRole("button", { name: "Reload", exact: true }).click(); + await reloadRequirementsPlugin(page); await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible(); await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible(); } + +async function reloadRequirementsPlugin(page: Page) { + await page.getByRole("button", { name: "Actions for requirements-example", exact: true }).click(); + await page.getByRole("menuitem", { name: "Reload", exact: true }).click(); +} diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index e391f648f2..507cd8ea35 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -42,7 +42,8 @@ export async function openCompactDisplaySettings(page: Page) { } async function openPluginScreen(page: Page) { await openHostSection(page, getServerId(), "plugins"); - await page.getByRole("button", { name: "Open", exact: true }).click(); + await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click(); + await page.getByRole("menuitem", { name: "Display", exact: true }).click(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); } export async function groupByWorkspace(page: Page) { @@ -98,8 +99,10 @@ export async function discardTitle(page: Page) { } export async function returnAndReopenSettings(page: Page) { await goBackInSettings(page); - await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Open", exact: true }).click(); + const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true }); + await expect(actions).toBeVisible(); + await actions.click(); + await page.getByRole("menuitem", { name: "Display", exact: true }).click(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); } export async function expectSettingsUnavailable(page: Page) { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 7e8c0ea5e6..4852657f0f 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -27,6 +27,10 @@ import { settingsStyles } from "@/styles/settings"; export { SettingsGroup } from "./headings/settings-group"; export { SettingsSection } from "./headings/settings-section"; +interface AppSettingsRowProps extends SettingsRowProps { + labelAccessory?: ReactNode; +} + export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { return ( @@ -42,13 +46,27 @@ export function SettingsCard({ children, testID }: { children: ReactNode; testID ); } -export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) { +export function SettingsRow({ + label, + labelAccessory, + hint, + error, + children, + testID, +}: AppSettingsRowProps) { const compact = useIsCompactFormFactor(); const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); return ( - {label} + {labelAccessory ? ( + + {label} + {labelAccessory} + + ) : ( + {label} + )} {hint ? {hint} : null} {error ? ( @@ -176,6 +194,13 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti const styles = StyleSheet.create((theme) => ({ compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, + labelRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + }, + accessoryLabel: { flexShrink: 1, minWidth: 0 }, control: { flexShrink: 1, maxWidth: "100%" }, value: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, input: { minWidth: 180 }, diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index d7c5356f03..f55b10bb10 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -27,12 +27,13 @@ import { interface FieldProps { label: string; children: ReactNode; + trailing?: ReactNode; hint?: string; error?: string | null; testID?: string; } -export function Field({ label, children, hint, error, testID }: FieldProps) { +export function Field({ label, children, trailing, hint, error, testID }: FieldProps) { const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]); const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]); const subtext = useMemo(() => { @@ -55,7 +56,10 @@ export function Field({ label, children, hint, error, testID }: FieldProps) { return ( - {label} + + {label} + {trailing} + {children} {subtext} @@ -226,7 +230,15 @@ const styles = StyleSheet.create((theme) => ({ container: { gap: theme.spacing[2], }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, label: { + flexShrink: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e1efdc613d..7cd1984a5d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2064,12 +2064,10 @@ export const en = { "They run on this host and inside the app without sandboxing. Install only code you trust.", globalTitle: "Enable plugins", globalHint: "Global switch for every configured plugin", - directoryLabel: "Plugin directory", - directoryPlaceholder: "/absolute/path/on/host", - idLabel: "Plugin installation ID", - idHint: "Leave blank to use paseo-plugin.json", - idPlaceholder: "Manifest default", - install: "Install directory", + sourceLabel: "Plugin source", + sourcePlaceholder: "Directory, Git URL, or npm package", + docs: "Docs", + install: "Install plugin", installing: "Installing…", logs: { action: "Logs", @@ -2082,6 +2080,7 @@ export const en = { }, status: { running: "running", disabled: "disabled", failed: "failed" }, actions: { + menu: "Actions for {{id}}", reload: "Reload", reloading: "Reloading…", enable: "Enable", @@ -2107,6 +2106,7 @@ export const en = { offlineTitle: "Plugin host is offline", offlineDescription: "Reconnect to this host to manage its plugins.", updateTitle: "Update this host to manage plugins", + sourceUpdateTitle: "Update this host to install plugins", loading: "Loading plugins…", errorTitle: "Unable to load plugins", retry: "Retry", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 26c26fb733..15373a822d 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -1,5 +1,4 @@ const shared = { - directoryPlaceholder: "/absolute/path/on/host", logs: { action: "Logs", title: "Logs: {{id}}", @@ -26,14 +25,14 @@ export const pluginSettings = { "تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.", globalTitle: "تفعيل الإضافات", globalHint: "مفتاح عام لكل الإضافات المضبوطة", - directoryLabel: "مجلد الإضافة", - idLabel: "معرّف تثبيت الإضافة", - idHint: "اتركه فارغًا لاستخدام paseo-plugin.json", - idPlaceholder: "القيمة الافتراضية للبيان", - install: "تثبيت المجلد", + sourceLabel: "مصدر الإضافة", + sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm", + docs: "الوثائق", + install: "تثبيت الإضافة", installing: "جارٍ التثبيت…", status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, actions: { + menu: "إجراءات {{id}}", reload: "إعادة تحميل", reloading: "جارٍ إعادة التحميل…", enable: "تفعيل", @@ -59,6 +58,7 @@ export const pluginSettings = { offlineTitle: "مضيف الإضافات غير متصل", offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.", updateTitle: "حدّث هذا المضيف لإدارة الإضافات", + sourceUpdateTitle: "حدّث هذا المضيف لتثبيت الإضافات", loading: "جارٍ تحميل الإضافات…", errorTitle: "تعذّر تحميل الإضافات", retry: "إعادة المحاولة", @@ -79,14 +79,14 @@ export const pluginSettings = { "Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.", globalTitle: "Activar plugins", globalHint: "Interruptor global para todos los plugins configurados", - directoryLabel: "Directorio del plugin", - idLabel: "ID de instalación del plugin", - idHint: "Déjalo vacío para usar paseo-plugin.json", - idPlaceholder: "Valor del manifiesto", - install: "Instalar directorio", + sourceLabel: "Fuente del plugin", + sourcePlaceholder: "Directorio, URL de Git o paquete npm", + docs: "Documentación", + install: "Instalar plugin", installing: "Instalando…", status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, actions: { + menu: "Acciones para {{id}}", reload: "Recargar", reloading: "Recargando…", enable: "Activar", @@ -112,6 +112,7 @@ export const pluginSettings = { offlineTitle: "El host de plugins está sin conexión", offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.", updateTitle: "Actualiza este host para gestionar plugins", + sourceUpdateTitle: "Actualiza este host para instalar plugins", loading: "Cargando plugins…", errorTitle: "No se pudieron cargar los plugins", retry: "Reintentar", @@ -132,14 +133,14 @@ export const pluginSettings = { "Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.", globalTitle: "Activer les plugins", globalHint: "Interrupteur global pour tous les plugins configurés", - directoryLabel: "Dossier du plugin", - idLabel: "ID d’installation du plugin", - idHint: "Laissez vide pour utiliser paseo-plugin.json", - idPlaceholder: "Valeur du manifeste", - install: "Installer le dossier", + sourceLabel: "Source du plugin", + sourcePlaceholder: "Dossier, URL Git ou paquet npm", + docs: "Documentation", + install: "Installer le plugin", installing: "Installation…", status: { running: "actif", disabled: "désactivé", failed: "échec" }, actions: { + menu: "Actions pour {{id}}", reload: "Recharger", reloading: "Rechargement…", enable: "Activer", @@ -165,6 +166,7 @@ export const pluginSettings = { offlineTitle: "L’hôte des plugins est hors ligne", offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.", updateTitle: "Mettez cet hôte à jour pour gérer les plugins", + sourceUpdateTitle: "Mettez cet hôte à jour pour installer des plugins", loading: "Chargement des plugins…", errorTitle: "Impossible de charger les plugins", retry: "Réessayer", @@ -185,14 +187,14 @@ export const pluginSettings = { "ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。", globalTitle: "プラグインを有効化", globalHint: "設定済みプラグインすべての共通スイッチ", - directoryLabel: "プラグインディレクトリ", - idLabel: "プラグインのインストール ID", - idHint: "paseo-plugin.json を使う場合は空欄にします", - idPlaceholder: "マニフェストの既定値", - install: "ディレクトリをインストール", + sourceLabel: "プラグインソース", + sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ", + docs: "ドキュメント", + install: "プラグインをインストール", installing: "インストール中…", status: { running: "実行中", disabled: "無効", failed: "失敗" }, actions: { + menu: "{{id}} のアクション", reload: "再読み込み", reloading: "再読み込み中…", enable: "有効化", @@ -218,6 +220,7 @@ export const pluginSettings = { offlineTitle: "プラグインホストはオフラインです", offlineDescription: "プラグインを管理するにはホストに再接続してください。", updateTitle: "プラグインを管理するにはホストを更新してください", + sourceUpdateTitle: "プラグインをインストールするにはこのホストを更新してください", loading: "プラグインを読み込み中…", errorTitle: "プラグインを読み込めません", retry: "再試行", @@ -237,14 +240,14 @@ export const pluginSettings = { trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", globalTitle: "플러그인 활성화", globalHint: "설정된 모든 플러그인의 전역 스위치", - directoryLabel: "플러그인 디렉터리", - idLabel: "플러그인 설치 ID", - idHint: "paseo-plugin.json을 사용하려면 비워 두세요", - idPlaceholder: "매니페스트 기본값", - install: "디렉터리 설치", + sourceLabel: "플러그인 소스", + sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지", + docs: "문서", + install: "플러그인 설치", installing: "설치 중…", status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, actions: { + menu: "{{id}} 작업", reload: "다시 로드", reloading: "다시 로드 중…", enable: "활성화", @@ -270,6 +273,7 @@ export const pluginSettings = { offlineTitle: "플러그인 호스트가 오프라인입니다", offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.", updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요", + sourceUpdateTitle: "플러그인을 설치하려면 이 호스트를 업데이트하세요", loading: "플러그인 로드 중…", errorTitle: "플러그인을 로드할 수 없습니다", retry: "다시 시도", @@ -290,14 +294,14 @@ export const pluginSettings = { "Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.", globalTitle: "Ativar plugins", globalHint: "Controle global para todos os plugins configurados", - directoryLabel: "Diretório do plugin", - idLabel: "ID de instalação do plugin", - idHint: "Deixe vazio para usar paseo-plugin.json", - idPlaceholder: "Padrão do manifesto", - install: "Instalar diretório", + sourceLabel: "Fonte do plugin", + sourcePlaceholder: "Diretório, URL Git ou pacote npm", + docs: "Documentação", + install: "Instalar plugin", installing: "Instalando…", status: { running: "em execução", disabled: "desativado", failed: "falhou" }, actions: { + menu: "Ações para {{id}}", reload: "Recarregar", reloading: "Recarregando…", enable: "Ativar", @@ -323,6 +327,7 @@ export const pluginSettings = { offlineTitle: "O host de plugins está offline", offlineDescription: "Reconecte este host para gerenciar seus plugins.", updateTitle: "Atualize este host para gerenciar plugins", + sourceUpdateTitle: "Atualize este host para instalar plugins", loading: "Carregando plugins…", errorTitle: "Não foi possível carregar os plugins", retry: "Tentar novamente", @@ -343,14 +348,14 @@ export const pluginSettings = { "Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.", globalTitle: "Включить плагины", globalHint: "Общий переключатель для всех настроенных плагинов", - directoryLabel: "Каталог плагина", - idLabel: "ID установки плагина", - idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json", - idPlaceholder: "Значение из манифеста", - install: "Установить каталог", + sourceLabel: "Источник плагина", + sourcePlaceholder: "Каталог, URL Git или пакет npm", + docs: "Документация", + install: "Установить плагин", installing: "Установка…", status: { running: "работает", disabled: "отключён", failed: "ошибка" }, actions: { + menu: "Действия для {{id}}", reload: "Перезагрузить", reloading: "Перезагрузка…", enable: "Включить", @@ -376,6 +381,7 @@ export const pluginSettings = { offlineTitle: "Хост плагинов не в сети", offlineDescription: "Подключите хост снова, чтобы управлять плагинами.", updateTitle: "Обновите хост для управления плагинами", + sourceUpdateTitle: "Обновите этот хост для установки плагинов", loading: "Загрузка плагинов…", errorTitle: "Не удалось загрузить плагины", retry: "Повторить", @@ -395,14 +401,14 @@ export const pluginSettings = { trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", globalTitle: "启用插件", globalHint: "所有已配置插件的全局开关", - directoryLabel: "插件目录", - idLabel: "插件安装 ID", - idHint: "留空以使用 paseo-plugin.json", - idPlaceholder: "清单默认值", - install: "安装目录", + sourceLabel: "插件来源", + sourcePlaceholder: "目录、Git URL 或 npm 包", + docs: "文档", + install: "安装插件", installing: "正在安装…", status: { running: "运行中", disabled: "已禁用", failed: "失败" }, actions: { + menu: "{{id}} 的操作", reload: "重新加载", reloading: "正在重新加载…", enable: "启用", @@ -428,6 +434,7 @@ export const pluginSettings = { offlineTitle: "插件主机离线", offlineDescription: "重新连接此主机以管理其插件。", updateTitle: "更新此主机以管理插件", + sourceUpdateTitle: "更新此主机以安装插件", loading: "正在加载插件…", errorTitle: "无法加载插件", retry: "重试", diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index f4c66c3b32..191ecc20f2 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -4,8 +4,8 @@ import { Platform, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { router } from "expo-router"; import type { PluginHostProps } from "@getpaseo/plugin/client"; -import { SettingsAction } from "@/components/settings"; import { Button } from "@/components/ui/button"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; @@ -22,35 +22,42 @@ interface SettingsIdentity { screenId: string; } -function SettingsLink({ +function PluginSettingsMenuItem({ serverId, pluginId, screenId, title, -}: SettingsIdentity & { title: string }) { - const { t } = useTranslation(); + disabled, +}: SettingsIdentity & { title: string; disabled?: boolean }) { const open = useCallback( () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), [serverId, pluginId, screenId], ); return ( - + + {title} + ); } -export function PluginSettingsLinks({ serverId, pluginId }: Omit) { +export function PluginSettingsMenuItems({ + serverId, + pluginId, + disabled, +}: Omit & { disabled?: boolean }) { const plugin = useInstalledPlugin(serverId, pluginId); const supported = useHostFeature(serverId, "pluginSettings"); if (!supported || !plugin) return null; return ( <> {plugin.settingsScreens.map((screen) => ( - ))} diff --git a/packages/app/src/screens/settings/plugin-install-form-model.test.ts b/packages/app/src/screens/settings/plugin-install-form-model.test.ts new file mode 100644 index 0000000000..3ee48f43d7 --- /dev/null +++ b/packages/app/src/screens/settings/plugin-install-form-model.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { openPluginInstallForm } from "./plugin-install-form-model"; + +describe("plugin install form", () => { + it("keeps the edited source in the submission until explicitly reset", () => { + const model = openPluginInstallForm(); + + model.setSource("owner/review"); + + expect(model.getSubmission()).toEqual({ source: "owner/review" }); + + model.reset(); + expect(model.getState()).toEqual({ + source: "", + canSubmit: false, + resetKey: 1, + }); + }); + + it("trims the submitted source", () => { + const model = openPluginInstallForm(); + model.setSource(" /plugins/review "); + expect(model.getSubmission()).toEqual({ source: "/plugins/review" }); + }); +}); diff --git a/packages/app/src/screens/settings/plugin-install-form-model.ts b/packages/app/src/screens/settings/plugin-install-form-model.ts new file mode 100644 index 0000000000..82fbfa12e2 --- /dev/null +++ b/packages/app/src/screens/settings/plugin-install-form-model.ts @@ -0,0 +1,53 @@ +export interface PluginInstallFormState { + source: string; + canSubmit: boolean; + resetKey: number; +} + +export interface PluginInstallSubmission { + source: string; +} + +export interface PluginInstallFormModel { + getState(): PluginInstallFormState; + subscribe(listener: () => void): () => void; + close(): void; + setSource(value: string): void; + getSubmission(): PluginInstallSubmission; + reset(): void; +} + +export function openPluginInstallForm(): PluginInstallFormModel { + const listeners = new Set<() => void>(); + let state: PluginInstallFormState = { + source: "", + canSubmit: false, + resetKey: 0, + }; + + const publish = (next: Pick) => { + state = { ...next, canSubmit: next.source.trim().length > 0 }; + for (const listener of listeners) listener(); + }; + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close() { + listeners.clear(); + }, + setSource(source) { + publish({ ...state, source }); + }, + getSubmission() { + const source = state.source.trim(); + return { source }; + }, + reset() { + publish({ source: "", resetKey: state.resetKey + 1 }); + }, + }; +} diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index f02cfa80a8..4c3abf6482 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -16,6 +16,7 @@ void testI18n; const runtime = vi.hoisted(() => ({ connected: true, supported: true, + sourceSupported: true, logsSupported: true, client: null as DaemonClient | null, })); @@ -26,8 +27,11 @@ vi.mock("@/runtime/host-runtime", () => ({ })); vi.mock("@/runtime/host-features", () => ({ - useHostFeature: (_serverId: string, feature: string) => - feature === "pluginLogs" ? runtime.logsSupported : runtime.supported, + useHostFeature: (_serverId: string, feature: string) => { + if (feature === "pluginLogs") return runtime.logsSupported; + if (feature === "pluginGitManagement") return runtime.sourceSupported; + return runtime.supported; + }, })); vi.mock("@/components/adaptive-modal-sheet", async () => { @@ -96,12 +100,18 @@ function never(): Promise { function plugin(enabled = true): PluginListItem { return { id: "example", + description: "Reviews changes before merge", path: "/plugins/example", enabled, status: enabled ? "running" : "disabled", }; } +async function selectPluginAction(action: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: "Actions for example" })); + fireEvent.click(await screen.findByRole("menuitem", { name: action })); +} + function createClient() { return { observeEvents: () => @@ -112,8 +122,7 @@ function createClient() { getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), listPlugins: vi.fn(async (): Promise => []), - inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })), - installDirectoryPlugin: vi.fn(async () => plugin()), + installPluginSource: vi.fn(async () => plugin()), reloadPlugin: vi.fn(async () => plugin()), enablePlugin: vi.fn(async () => plugin()), disablePlugin: vi.fn(async () => plugin(false)), @@ -145,6 +154,7 @@ describe("HostPluginsPage", () => { vi.stubGlobal("React", React); runtime.connected = true; runtime.supported = true; + runtime.sourceSupported = true; runtime.logsSupported = true; runtime.client = null; vi.stubGlobal( @@ -180,36 +190,112 @@ describe("HostPluginsPage", () => { }); it.each([ - ["reloadPlugin", "Reload", "Reloading…", true], - ["disablePlugin", "Disable", "Disabling…", true], - ["enablePlugin", "Enable", "Enabling…", false], - ["removePlugin", "Remove", "Removing…", true], - ] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => { + ["reloadPlugin", "Reload"], + ["removePlugin", "Remove"], + ] as const)("runs %s from the plugin actions menu", async (method, action) => { const client = createClient(); - client.listPlugins.mockResolvedValue([plugin(enabled)]); - client[method].mockImplementation(() => never()); + client.listPlugins.mockResolvedValue([plugin()]); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: trigger })); + await selectPluginAction(action); - const pendingControl = await screen.findByRole("button", { name: pending }); - expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); - expect(client[method]).toHaveBeenCalledTimes(1); + await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1)); + }); + + it("disables the plugin switch and menu while an action is pending", async () => { + const client = createClient(); + client.listPlugins.mockResolvedValue([plugin()]); + client.reloadPlugin.mockImplementation(() => never()); + renderPage(client); + + await selectPluginAction("Reload"); + + await waitFor(() => { + expect( + screen.getByRole("switch", { name: "example: Disable" }).getAttribute("aria-disabled"), + ).toBe("true"); + expect( + screen.getByRole("button", { name: "Actions for example" }).getAttribute("aria-disabled"), + ).toBe("true"); + }); + }); + + it.each([ + [true, "disablePlugin"], + [false, "enablePlugin"], + ] as const)("toggles an enabled=%s plugin with its switch", async (enabled, method) => { + const client = createClient(); + client.listPlugins.mockResolvedValue([plugin(enabled)]); + renderPage(client); + + fireEvent.click( + await screen.findByRole("switch", { name: `example: ${enabled ? "Disable" : "Enable"}` }), + ); + + await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1)); + }); + + it("shows the manifest description and status without exposing the source path", async () => { + const client = createClient(); + client.listPlugins.mockResolvedValue([ + plugin(), + { + id: "disabled-example", + description: "Available when needed", + path: "/plugins/disabled-example", + enabled: false, + status: "disabled", + }, + { + id: "failed-example", + description: "Needs attention", + path: "/plugins/failed-example", + enabled: true, + status: "failed", + error: "Plugin failed to start", + }, + ]); + renderPage(client); + + expect(await screen.findByText("Reviews changes before merge")).toBeDefined(); + expect(screen.getByText("Available when needed")).toBeDefined(); + expect(screen.getByText("Needs attention")).toBeDefined(); + expect(screen.getByText("running")).toBeDefined(); + expect(screen.getByText("disabled")).toBeDefined(); + expect(screen.getByText("failed")).toBeDefined(); + expect(screen.getByText("Plugin failed to start")).toBeDefined(); + expect(screen.queryByText("/plugins/example")).toBeNull(); + expect(screen.queryByText("/plugins/disabled-example")).toBeNull(); + expect(screen.queryByText("/plugins/failed-example")).toBeNull(); }); it("renders install pending through the real form and mutation", async () => { const client = createClient(); - client.installDirectoryPlugin.mockImplementation(() => never()); + client.installPluginSource.mockImplementation(() => never()); renderPage(client); - fireEvent.change(screen.getByLabelText("Plugin directory"), { + expect(screen.getByPlaceholderText("Directory, Git URL, or npm package")).toBeDefined(); + fireEvent.change(screen.getByLabelText("Plugin source"), { target: { value: "/plugins/example" }, }); - fireEvent.click(screen.getByRole("button", { name: "Install directory" })); + fireEvent.click(screen.getByRole("button", { name: "Install plugin" })); const pendingControl = await screen.findByRole("button", { name: "Installing…" }); expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); - expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined); + expect(client.installPluginSource).toHaveBeenCalledWith({ source: "/plugins/example" }); + expect(screen.queryByLabelText("Plugin installation ID")).toBeNull(); + expect(screen.getByRole("link", { name: "Docs" })).toBeDefined(); + }); + + it("keeps management available while source installation requires a host update", async () => { + runtime.sourceSupported = false; + const client = createClient(); + client.listPlugins.mockResolvedValue([plugin()]); + renderPage(client); + + expect(await screen.findByRole("button", { name: "Actions for example" })).toBeDefined(); + expect(screen.getByText("Update this host to install plugins")).toBeDefined(); + expect(screen.queryByLabelText("Plugin source")).toBeNull(); }); it("hides the logs action when the host does not advertise support", async () => { @@ -219,7 +305,8 @@ describe("HostPluginsPage", () => { renderPage(client); await screen.findByText("example"); - expect(screen.queryByRole("button", { name: "Logs" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Actions for example" })); + expect(screen.queryByRole("menuitem", { name: "Logs" })).toBeNull(); }); it("opens readable stdout and stderr logs and refreshes them", async () => { @@ -241,7 +328,7 @@ describe("HostPluginsPage", () => { ]); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: "Logs" })); + await selectPluginAction("Logs"); expect(await screen.findByRole("dialog")).toBeDefined(); expect(screen.getByText("Logs: example")).toBeDefined(); @@ -260,7 +347,7 @@ describe("HostPluginsPage", () => { client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded")); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: "Logs" })); + await selectPluginAction("Logs"); expect(await screen.findByText("Unable to load plugin logs")).toBeDefined(); expect(screen.getByText("logs exploded")).toBeDefined(); diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 60a797b951..bd4e76158e 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -1,27 +1,38 @@ -import { PluginSettingsLinks } from "@/plugins/settings"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PluginSettingsMenuItems } from "@/plugins/settings"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; +import { MoreHorizontal, Trash2 } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsCard, SettingsRow } from "@/components/settings"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { ExternalLink } from "@/components/ui/external-link"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { StatusBadge } from "@/components/ui/status-badge"; import { Switch } from "@/components/ui/switch"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useFetchQuery } from "@/data/query"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; +import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; +const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/v0.8/reference#plugin-sources"; type PluginRowAction = "reload" | "enable" | "disable" | "remove"; function errorMessage(error: unknown): string { @@ -35,6 +46,71 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine return undefined; } +function PluginActionsMenu({ + plugin, + serverId, + pending, + pendingAction, + onAction, + onOpenLogs, + supportsLogs, +}: { + plugin: PluginListItem; + serverId: string; + pending: boolean; + pendingAction?: PluginRowAction; + onAction(action: PluginRowAction, plugin: PluginListItem): void; + onOpenLogs(pluginId: string): void; + supportsLogs: boolean; +}) { + const { t } = useTranslation(); + const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); + const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); + const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]); + const removeIcon = useMemo(() => , []); + const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id }); + + return ( + + + + + + + {supportsLogs ? ( + + {t("settings.plugins.logs.action")} + + ) : null} + + {t("settings.plugins.actions.reload")} + + + {t("settings.plugins.actions.remove")} + + + + ); +} + function PluginRow({ plugin, serverId, @@ -55,64 +131,48 @@ function PluginRow({ supportsLogs: boolean; }) { const { t } = useTranslation(); - const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); const toggle = useCallback( - () => onAction(plugin.enabled ? "disable" : "enable", plugin), + (enabled: boolean) => onAction(enabled ? "enable" : "disable", plugin), [onAction, plugin], ); - const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); - const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]); const status = clientError ? "failed" : plugin.status; let badgeVariant: "success" | "error" | "muted" = "muted"; if (status === "running") badgeVariant = "success"; else if (status === "failed") badgeVariant = "error"; const statusLabel = t(`settings.plugins.status.${status}`); - let toggleLabel = t( - plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + const statusBadge = useMemo( + () => , + [badgeVariant, statusLabel], ); - if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); - else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); + const toggleLabel = `${plugin.id}: ${t( + plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + )}`; return ( - - - - - {plugin.id} - - - {plugin.path} - {clientError || plugin.error ? ( - {clientError ?? plugin.error} - ) : null} - - - {supportsLogs ? ( - - ) : null} - - - - + + + + - - + ); } @@ -193,16 +253,30 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { const client = useHostRuntimeClient(serverId); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "pluginManagement"); + // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. + const sourceInstallSupported = useHostFeature(serverId, "pluginGitManagement"); // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16. const logsSupported = useHostFeature(serverId, "pluginLogs"); const { config, patchConfig } = useDaemonConfig(serverId); const refreshQueue = useRef(Promise.resolve()); useInstalledPlugins(); const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); - const [directory, setDirectory] = useState(""); - const [pluginId, setPluginId] = useState(""); - const [directoryResetKey, setDirectoryResetKey] = useState(0); - const [pluginIdResetKey, setPluginIdResetKey] = useState(0); + const [installForm] = useState(openPluginInstallForm); + const installState = useSyncExternalStore( + installForm.subscribe, + installForm.getState, + installForm.getState, + ); + const sourceDocsLink = useMemo( + () => ( + + ), + [t], + ); const [logsPluginId, setLogsPluginId] = useState(null); const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( null, @@ -226,6 +300,9 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { refreshQueue.current = refreshQueue.current.then(refetch, refetch); await refreshQueue.current; }, [client, refetchPlugins]); + useEffect(() => { + return () => installForm.close(); + }, [installForm]); useEffect(() => { if (!client || !connected || !supported) return; const observation = client.observeEvents(["status.plugin_catalog_changed"]); @@ -255,33 +332,18 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }), }); const install = useCallback(() => { - const path = directory.trim(); - if (!client || !path) return; + if (!client || !sourceInstallSupported || !installState.canSubmit) return; + const input = installForm.getSubmission(); setFeedback(null); mutation.mutate({ action: "install", run: async () => { - const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined); - setDirectory(""); - setPluginId(""); - setDirectoryResetKey((key) => key + 1); - setPluginIdResetKey((key) => key + 1); + const installed = await client.installPluginSource(input); + installForm.reset(); return t("settings.plugins.feedback.installed", { id: installed.id }); }, }); - }, [client, directory, mutation, pluginId, t]); - const prefillManifestId = useCallback(() => { - const path = directory.trim(); - if (!client || !path || pluginId.trim()) return; - void client - .inspectDirectoryPlugin(path) - .then(({ id }) => { - setPluginId(id); - setPluginIdResetKey((key) => key + 1); - return undefined; - }) - .catch(() => undefined); - }, [client, directory, pluginId]); + }, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]); const action = useCallback( (name: PluginRowAction, plugin: PluginListItem) => { if (!client) return; @@ -360,7 +422,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { ); } else if (pageState !== "loading") { catalogContent = ( - + {plugins.data?.length ? ( plugins.data.map((plugin) => { const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id); @@ -384,7 +446,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { {t("settings.plugins.states.empty")} )} - + ); } @@ -410,38 +472,29 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { /> - - - - - - - - - + {sourceInstallSupported ? ( + + + + + + + ) : ( + + )} {feedback ? ( ({ install: { padding: theme.spacing[4], gap: theme.spacing[3] }, - pluginRow: { - padding: theme.spacing[4], - gap: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, - actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, - error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm }, + pluginControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + menuButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.sm }, + menuIcon: { color: theme.colors.foregroundMuted }, + dangerIcon: { color: theme.colors.statusDanger }, empty: { padding: theme.spacing[4], alignItems: "center" }, logsState: { color: theme.colors.foregroundMuted, diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 957a0072ae..733143384d 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -28,15 +28,15 @@ const getPluginLogs = vi.fn(async () => [ message: "ready", }, ]); -const installDirectoryPlugin = vi.fn(async () => ({ +const installPluginSource = vi.fn(async () => ({ id: "trusted-plugin", path: "/plugins/trusted-plugin", enabled: true, status: "running" as const, })); -const installPluginSource = vi.fn(async () => ({ - id: "trusted-plugin", - path: "/plugins/trusted-plugin", +const installDirectoryPlugin = vi.fn(async () => ({ + id: "legacy-plugin", + path: "/plugins/legacy-plugin", enabled: true, status: "running" as const, })); @@ -59,7 +59,12 @@ vi.mock("../../utils/client.js", () => ({ })); import { render } from "../../output/index.js"; -import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js"; +import { + createPluginCommand, + runPluginInstallCommand, + runPluginListCommand, + runPluginLogsCommand, +} from "./index.js"; describe("plugin management commands", () => { beforeEach(() => { @@ -146,7 +151,7 @@ describe("plugin management commands", () => { }); it("prints the trust acknowledgement before installing", async () => { - features.pluginManagement = true; + features.pluginGitManagement = true; const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); @@ -155,7 +160,22 @@ describe("plugin management commands", () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining("Git build commands run unsandboxed on the daemon host"), ); - expect(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined); + expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" }); + stderr.mockRestore(); + }); + + it("requires source support for directory installs without using the legacy RPC", async () => { + features.pluginManagement = true; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await expect( + runPluginInstallCommand("/plugins/trusted-plugin", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to install or update plugins.", + }); + expect(installPluginSource).not.toHaveBeenCalled(); + expect(installDirectoryPlugin).not.toHaveBeenCalled(); stderr.mockRestore(); }); @@ -184,7 +204,6 @@ describe("plugin management commands", () => { expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/monorepo:plugins/review", }); - expect(installDirectoryPlugin).not.toHaveBeenCalled(); stderr.mockRestore(); }); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index e067d4aa39..fa73f5caf1 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,14 +1,10 @@ import { Command } from "commander"; -import path from "node:path"; import type { PluginListItem, PluginLogEntry, PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; -import { - formatPluginSourceReference, - parsePluginSourceReference, -} from "@getpaseo/protocol/plugin-source-reference"; +import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; @@ -109,7 +105,7 @@ export async function runPluginLogsCommand( return { type: "list", data, schema: pluginLogsSchema }; } -async function install( +export async function runPluginInstallCommand( source: string, options: PluginOptions, _command: Command, @@ -117,29 +113,14 @@ async function install( process.stderr.write( "Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); - const isExplicitPath = - path.isAbsolute(source) || - source === "." || - source === ".." || - source.startsWith("./") || - source.startsWith("../") || - source.startsWith(".\\") || - source.startsWith("..\\"); - const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined; - const canUseLegacyDirectoryInstall = - isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; const sourceReference = formatPluginSourceReference(source, options.path); - const data = canUseLegacyDirectoryInstall - ? await withPluginManagementClient(options.daemonTarget, (client) => - client.installDirectoryPlugin(source, options.id), - ) - : await withPluginSourceClient(options.daemonTarget, (client) => - client.installPluginSource({ - source: sourceReference, - ...(options.id ? { id: options.id } : {}), - ...(options.ref ? { ref: options.ref } : {}), - }), - ); + const data = await withPluginSourceClient(options.daemonTarget, (client) => + client.installPluginSource({ + source: sourceReference, + ...(options.id ? { id: options.id } : {}), + ...(options.ref ? { ref: options.ref } : {}), + }), + ); return { type: "single", data, schema: pluginSchema }; } @@ -209,7 +190,7 @@ export function createPluginCommand(): Command { .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") .option("--path ", "Legacy form of the :plugin/path source suffix"), - ).action(withOutput(install)); + ).action(withOutput(runPluginInstallCommand)); addJsonAndDaemonHostOptions( plugin .command("update") diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index 80c3dcfafa..f08defc536 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -55,7 +55,7 @@ export async function withPluginSourceClient( return withPluginClient( target, "pluginGitManagement", - "Update the host to install and update Git plugins.", + "Update the host to install or update plugins.", run, ); } diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index e2be026cb0..f8679aaec2 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -96,6 +96,22 @@ describe("plugin protocol compatibility", () => { type: "plugin.directory.install.response", payload: { requestId: "request-1", + plugin: { + id: "example-work", + description: "Reviews changes before merge", + path: "/plugins/example", + enabled: true, + status: "running", + }, + }, + }).type, + ).toBe("plugin.directory.install.response"); + + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.directory.install.response", + payload: { + requestId: "request-old-shape", plugin: { id: "example-work", path: "/plugins/example", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d98a35682d..ef8b3a3dde 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6529,6 +6529,7 @@ export type PluginStatus = z.infer; export const PluginListItemSchema = z.object({ id: PluginIdSchema, + description: z.string().optional(), path: z.string(), enabled: z.boolean(), status: PluginStatusSchema, diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 52477b3b83..7d90f516e4 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -309,7 +309,7 @@ describe("PluginService", () => { await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!"); await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); expect(service.catalog()).toEqual([]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }), ]); @@ -321,6 +321,39 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); + it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const running = await createPlugin("running", "export default () => () => {};"); + const disabled = await createPlugin("disabled", "export default () => () => {};"); + const malformed = await createPlugin("malformed", "export default () => () => {};"); + await writeFile( + path.join(running, "paseo-plugin.json"), + JSON.stringify({ id: "running", description: "Runs checks" }), + ); + await writeFile( + path.join(disabled, "paseo-plugin.json"), + JSON.stringify({ id: "disabled", description: "Waits until enabled" }), + ); + await writeFile(path.join(malformed, "paseo-plugin.json"), "{"); + const service = createService(home, { + running: { source: "directory", path: running }, + disabled: { source: "directory", path: disabled, enabled: false }, + malformed: { source: "directory", path: malformed }, + }); + + await service.start(); + + expect( + (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + ).toEqual([ + { id: "disabled", description: "Waits until enabled" }, + { id: "malformed", description: undefined }, + { id: "running", description: "Runs checks" }, + ]); + await service.stopAllPlugins(); + }); + it("prefers an existing directory and installs its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -396,7 +429,7 @@ describe("PluginService", () => { expect(service.catalog()).toEqual([ expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }), ]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ id: "git-update", path: installedPath, @@ -545,7 +578,7 @@ describe("PluginService", () => { managedSources, }); await service.start(); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ id: "failed-update", status: "failed" }), ]); @@ -561,7 +594,7 @@ describe("PluginService", () => { ]); expect(starts).toHaveLength(2); expect(starts[1]).not.toBe(initial.directory); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }), ]); await service.stopAllPlugins(); @@ -592,7 +625,7 @@ export default function contribute(plugin: unknown) { expect(await readFile(cleanupFile, "utf8")).toBe("cleaned"); await service.removePlugin("cleanup-plugin"); - expect(service.listPlugins()).toEqual([]); + expect(await service.listPlugins()).toEqual([]); await expect(stat(directory)).resolves.toMatchObject({}); await service.stopAllPlugins(); }, 20_000); @@ -624,7 +657,7 @@ export default function contribute(plugin: unknown) { ); store.patch({ pluginsEnabled: true }); await service.reloadPlugin("first"); - expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([ + expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ { id: "first", status: "running" }, { id: "second", status: "running" }, ]); @@ -651,7 +684,7 @@ export default function contribute(plugin: unknown) { await service.stopAllPlugins(); expect(service.catalog()).toEqual([]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ { id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" }, ]); }); @@ -766,7 +799,7 @@ export default function contribute(plugin: unknown) { await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( "startup exploded", ); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ id: "legacy-plugin", status: "failed", diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 5a449763f3..f8b6cff7cb 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -145,11 +145,11 @@ export class PluginService { }); } - listPlugins(): PluginListItem[] { + async listPlugins(): Promise { const config = this.configStore.get(); const running = new Set(this.runtime.catalog().map((plugin) => plugin.id)); - return Object.entries(config.plugins ?? {}) - .map(([id, source]) => { + const plugins = await Promise.all( + Object.entries(config.plugins ?? {}).map(async ([id, source]) => { const enabled = source.enabled !== false; const item: PluginListItem = { id, @@ -161,6 +161,8 @@ export class PluginService { running: running.has(id), }), }; + const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); + if (manifest?.description) item.description = manifest.description; const managed = this.managedSources?.get(id); if (managed) { item.source = "git"; @@ -172,8 +174,9 @@ export class PluginService { const error = this.errors.get(id); if (error && item.status === "failed") item.error = error; return item; - }) - .sort((left, right) => left.id.localeCompare(right.id)); + }), + ); + return plugins.sort((left, right) => left.id.localeCompare(right.id)); } getLogs(pluginId: string): PluginLogEntry[] { @@ -203,7 +206,7 @@ export class PluginService { this.configStore.patch({ plugins: sources }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); - const installed = this.requireItem(pluginId); + const installed = await this.requireItem(pluginId); if (installed.status === "failed") { throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); } @@ -263,7 +266,7 @@ export class PluginService { this.configStore.patch({ plugins: sources }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); - const installed = this.requireItem(pluginId); + const installed = await this.requireItem(pluginId); if (installed.status === "failed") { throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); } @@ -634,8 +637,8 @@ export class PluginService { }); } - private requireItem(pluginId: string): PluginListItem { - const item = this.listPlugins().find((plugin) => plugin.id === pluginId); + private async requireItem(pluginId: string): Promise { + const item = (await this.listPlugins()).find((plugin) => plugin.id === pluginId); if (!item) throw new Error(`Plugin is not configured: ${pluginId}`); return item; } diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 12637d8540..3ff06e2c3e 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -45,6 +45,26 @@ describe("plugin manifest", () => { } }); + it("reads an optional description", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "described", description: "Reviews changes before merge" }), + ); + + await expect(readPluginManifest(directory)).resolves.toEqual({ + id: "described", + description: "Reviews changes before merge", + }); + + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "described", description: " " }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + it("accepts only non-empty argv arrays for build commands", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index b1021e21c0..d2e13d614b 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -11,6 +11,7 @@ const PluginBuildCommandSchema = z const PluginManifestSchema = z .object({ id: PluginIdSchema, + description: z.string().trim().min(1).optional(), requirements: PluginRequirementsSchema.strict().optional(), build: z.array(PluginBuildCommandSchema).min(1).optional(), }) diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts index c0c6792bc5..a2b10188c8 100644 --- a/packages/server/src/server/plugins/requirements.posix.test.ts +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -77,7 +77,7 @@ it("marks pre-0.8 plugins failed on startup and recovers after migration and rel const root = await directory(); await writePlugin(root); const { service } = await host("0.8.0", root); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toEqual([ expect.objectContaining({ status: "failed", error: expect.stringContaining("https://paseo.sh/docs/plugins/v0.8/migration"), @@ -121,7 +121,7 @@ it("rejects Git install and update before build commands, preserving the running ]); await commit(); await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0"); - expect(service.listPlugins()).toEqual([installed]); + expect(await service.listPlugins()).toEqual([installed]); expect(service.catalog()).toHaveLength(1); expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); @@ -129,5 +129,5 @@ it("rejects Git install and update before build commands, preserving the running "requires Paseo >=0.9.0", ); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); - expect(service.listPlugins()).toEqual([installed]); + expect(await service.listPlugins()).toEqual([installed]); }); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index fd4d650e0b..ab43c023cc 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -500,7 +500,7 @@ test("routes plugin requests and releases its owned catalog subscription on clea return request; }, emit: () => {}, - listPlugins: () => [plugin], + listPlugins: async () => [plugin], getLogs: () => [ { sequence: 1, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4d72c3fd57..7878e61383 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -476,7 +476,7 @@ export interface SessionOptions { pluginRuntime?: { before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; - listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; + listPlugins(): Promise; getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[]; installDirectory(input: { path: string; @@ -2359,11 +2359,13 @@ export class Session { private dispatchPluginMessage(msg: SessionInboundMessage): Promise | undefined { if (msg.type === "plugin.list.request") { - this.emit({ - type: "plugin.list.response", - payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] }, + return (this.pluginRuntime?.listPlugins() ?? Promise.resolve([])).then((plugins) => { + this.emit({ + type: "plugin.list.response", + payload: { requestId: msg.requestId, plugins }, + }); + return undefined; }); - return undefined; } if (msg.type === "plugin.logs.get.request") { if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 96e6fbb35a..98a1570508 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -122,6 +122,20 @@ paseo plugin install /absolute/path/to/workspace-plugin paseo plugin ls ``` +Or install the directory from the app: + +1. Open **Settings → Plugins** on the target host. +2. Paste `/absolute/path/to/workspace-plugin` into **Plugin source**. +3. Select **Install plugin**. The app uses the plugin ID from `paseo-plugin.json`. + +The source field also accepts Git repositories and plugin subdirectories. See +[Plugin sources](/docs/plugins/v0.8/reference#plugin-sources) for the accepted syntax and resolution +rules. If the form asks you to update the host, update that host before installing; the existing +management actions remain available. + +Each installed plugin row shows its status and description. Use its switch to enable or disable it, +and open its three-dot menu for settings, logs, reload, and removal. + `paseo plugin ls` should report the plugin as `running`. Open Paseo, choose **Greeting** in the sidebar, and press **Create greeting**. The message comes back from the daemon subprocess through the RPC. @@ -252,7 +266,8 @@ console.log("Refreshing issues"); console.error("Issue refresh failed", error); ``` -Read the recent output from **Settings → Plugins → Logs** or the CLI: +Read the recent output by opening the plugin's three-dot menu under **Settings → Plugins** and +selecting **Logs**, or use the CLI: ```bash paseo plugin logs workspace-plugin diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index bf50804260..77b5d12f30 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -42,12 +42,23 @@ my-plugin/ tsconfig.json ``` -The required root manifest is `paseo-plugin.json`. It contains the default plugin ID and supported Paseo versions: +The required root manifest is `paseo-plugin.json`: ```json -{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } } +{ + "id": "my-plugin", + "description": "Reviews changes before merge", + "requirements": { "paseo": ">=0.8.0" } +} ``` +| Field | Required | Behavior | +| -------------- | -------- | ---------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + ### Requirements `requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver @@ -1873,6 +1884,52 @@ remove Command Center items and clear the installation's query state. An already remains as unavailable until its matching contribution returns or the user closes it. Panel render failures stay inside the plugin error boundary. +## Plugin sources + +Paste one of these source identifiers into **Settings → Plugins**, or pass it to +`paseo plugin install`. `paseo plugin add ` and `paseo plugin install ` are aliases. +Absolute host paths are recommended because relative paths resolve against the daemon's working +directory. The app does not expand `~`; your shell may expand it before the CLI runs. + +| Source | Accepted form | Example | +| -------------------------- | --------------------------------------------------------------- | ----------------------------------------- | +| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | +| GitHub repository | `owner/repository` | `acme/paseo-review` | +| Git URL | `https://`, `http://`, `ssh://`, `git://`, or `file://` URL | `https://git.example.com/acme/review.git` | +| SCP-style Git repository | `user@host:path` | `git@git.example.com:acme/review.git` | +| Plugin below a source root | Add `:relative/plugin/path` to a directory or repository source | `acme/monorepo:plugins/review` | + +Paseo resolves an identifier in this order: + +1. If the complete identifier names an existing daemon-host directory, Paseo uses that directory. + This check comes first, so an existing literal directory containing `:` wins. +2. Otherwise, Paseo recognizes a final `:relative/plugin/path` only when the suffix is relative and + contains no empty, `.` or `..` segments. A lone `.` selects the source root. Both `/` and `\` + separate suffix segments; use `/` for paths that must be portable across hosts because `\` also + follows the daemon host's path semantics. A suffix that does not match these rules remains part + of the source. URL ports and the separator in an SCP-style repository also remain part of the Git + source. A recognized suffix selects that subdirectory after the source is acquired. +3. If the remaining source names an existing daemon-host directory, Paseo installs from it. +4. Exact `owner/repository` syntax is expanded to a GitHub HTTPS URL. +5. The listed Git URL and SCP-style forms are cloned. Any other source is rejected. + +Directory lookup happens on the daemon host; neither the app nor CLI checks the caller's filesystem. +The app always uses the ID from `paseo-plugin.json`. The CLI also accepts `--id ` to +override it: + +```bash +paseo plugin install /srv/paseo/plugins/review +paseo plugin install acme/paseo-review +paseo plugin install https://git.example.com:8443/acme/monorepo.git:plugins/review --ref main +paseo plugin install git@git.example.com:acme/review.git +paseo plugin install file:///srv/repos/monorepo:plugins/review +paseo plugin install acme/paseo-review --id review-staging +``` + +`--ref` applies only to Git sources and accepts a branch, tag, or commit. Without it, Paseo tracks +the remote's default branch. An explicit branch remains tracked; tags and commits are pinned. The +legacy `--path relative/plugin/path` option is equivalent to the `:relative/plugin/path` suffix. + ## CLI reference ```bash @@ -1901,10 +1958,6 @@ never deletes a directory source; it deletes the managed checkout for a Git sour > **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With the global `--host` option, commands run on the remote daemon host. -An existing directory wins over `owner/repository` GitHub shorthand. Append `:relative/path` when -the plugin lives below the repository root. Omit `--ref` to track the default branch. Explicit -branches track updates; tags and commits stay pinned. - Most plugins should omit `build`. Use it only when the staged checkout must install a dependency that Paseo does not provide, generate source or assets, or perform another required preparation step: @@ -1927,7 +1980,8 @@ compilation, activation, or replacement. A failing command reports its output, d candidate, and leaves the installed/running version intact. The daemon log records each command and output; with the global `--host` option, execution is on that daemon host. -Run `npm run typecheck` before install or reload. Manage plugin source entries with the CLI or Settings. +Run `npm run typecheck` before install or reload. Manage plugin source entries with the CLI or +Settings; see [Plugin sources](#plugin-sources) for install syntax. The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on.