mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat(plugins): unify source installation and simplify plugin settings
This commit is contained in:
@@ -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<void> {
|
||||
await expectSettingsHeader(page, "Plugins");
|
||||
}
|
||||
|
||||
async function openCompactPluginSettings(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await openPluginActions(page, pluginId);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
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<voi
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginInstallForm(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: "wide" | "wide-menu" | "compact-error" | "compact-success" | "compact-menu",
|
||||
): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`plugin-source-install-${name}.png`),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
async function installPlugin(page: Page, source: string): Promise<void> {
|
||||
await page.getByLabel("Plugin source").fill(source);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
}
|
||||
|
||||
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 <Text>${title}</Text>; }
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "installPluginSource"
|
||||
| "disablePlugin"
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<View style={settingsStyles.card} testID={testID}>
|
||||
@@ -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 (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
<View style={styles.label}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
)}
|
||||
{hint ? <Text style={settingsStyles.rowHint}>{hint}</Text> : null}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container} testID={testID}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{trailing}
|
||||
</View>
|
||||
{children}
|
||||
{subtext}
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "重试",
|
||||
|
||||
@@ -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 (
|
||||
<SettingsAction label={title} actionLabel={t("settings.plugins.screens.open")} onPress={open} />
|
||||
<DropdownMenuItem onSelect={open} disabled={disabled}>
|
||||
{title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginSettingsLinks({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
|
||||
export function PluginSettingsMenuItems({
|
||||
serverId,
|
||||
pluginId,
|
||||
disabled,
|
||||
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const supported = useHostFeature(serverId, "pluginSettings");
|
||||
if (!supported || !plugin) return null;
|
||||
return (
|
||||
<>
|
||||
{plugin.settingsScreens.map((screen) => (
|
||||
<SettingsLink
|
||||
<PluginSettingsMenuItem
|
||||
key={screen.id}
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
screenId={screen.id}
|
||||
title={screen.title}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<PluginInstallFormState, "source" | "resetKey">) => {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<T>(): Promise<T> {
|
||||
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<void> {
|
||||
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<PluginListItem[]> => []),
|
||||
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<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<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<never>());
|
||||
client.installPluginSource.mockImplementation(() => never<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();
|
||||
|
||||
|
||||
@@ -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(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
|
||||
const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id });
|
||||
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={menuLabel}
|
||||
disabled={pending}
|
||||
hitSlop={8}
|
||||
style={styles.menuButton}
|
||||
>
|
||||
<MoreHorizontal size={18} color={styles.menuIcon.color} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
|
||||
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
|
||||
{supportsLogs ? (
|
||||
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={reload}
|
||||
disabled={pending || !plugin.enabled}
|
||||
status={pendingAction === "reload" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.reloading")}
|
||||
>
|
||||
{t("settings.plugins.actions.reload")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={removeIcon}
|
||||
onSelect={remove}
|
||||
disabled={pending}
|
||||
status={pendingAction === "remove" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.removing")}
|
||||
>
|
||||
{t("settings.plugins.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
|
||||
[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 (
|
||||
<View>
|
||||
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.pluginTitle}>
|
||||
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
|
||||
<StatusBadge label={statusLabel} variant={badgeVariant} />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
|
||||
{clientError || plugin.error ? (
|
||||
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
{supportsLogs ? (
|
||||
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={reload}
|
||||
disabled={pending || !plugin.enabled}
|
||||
>
|
||||
{pendingAction === "reload"
|
||||
? t("settings.plugins.actions.reloading")
|
||||
: t("settings.plugins.actions.reload")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
|
||||
{toggleLabel}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
|
||||
{pendingAction === "remove"
|
||||
? t("settings.plugins.actions.removing")
|
||||
: t("settings.plugins.actions.remove")}
|
||||
</Button>
|
||||
</View>
|
||||
<SettingsRow
|
||||
label={plugin.id}
|
||||
labelAccessory={statusBadge}
|
||||
hint={plugin.description}
|
||||
error={clientError ?? plugin.error}
|
||||
testID={`plugin-row-${plugin.id}`}
|
||||
>
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
disabled={pending}
|
||||
accessibilityLabel={toggleLabel}
|
||||
/>
|
||||
<PluginActionsMenu
|
||||
plugin={plugin}
|
||||
serverId={serverId}
|
||||
pending={pending}
|
||||
pendingAction={pendingAction}
|
||||
onAction={onAction}
|
||||
onOpenLogs={onOpenLogs}
|
||||
supportsLogs={supportsLogs}
|
||||
/>
|
||||
</View>
|
||||
<PluginSettingsLinks serverId={serverId} pluginId={plugin.id} />
|
||||
</View>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
() => (
|
||||
<ExternalLink
|
||||
href={PLUGIN_SOURCE_DOCS_URL}
|
||||
label={t("settings.plugins.docs")}
|
||||
testID="plugin-source-docs-link"
|
||||
/>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const [logsPluginId, setLogsPluginId] = useState<string | null>(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 = (
|
||||
<View style={settingsStyles.card}>
|
||||
<SettingsCard>
|
||||
{plugins.data?.length ? (
|
||||
plugins.data.map((plugin) => {
|
||||
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
|
||||
@@ -384,7 +446,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,38 +472,29 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.card, styles.install]}>
|
||||
<Field label={t("settings.plugins.directoryLabel")}>
|
||||
<FormTextInput
|
||||
initialValue=""
|
||||
resetKey={directoryResetKey}
|
||||
onChangeText={setDirectory}
|
||||
onBlur={prefillManifestId}
|
||||
placeholder={t("settings.plugins.directoryPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.directoryLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("settings.plugins.idLabel")} hint={t("settings.plugins.idHint")}>
|
||||
<FormTextInput
|
||||
initialValue={pluginId}
|
||||
resetKey={pluginIdResetKey}
|
||||
onChangeText={setPluginId}
|
||||
placeholder={t("settings.plugins.idPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.idLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Button onPress={install} disabled={!directory.trim() || mutation.isPending}>
|
||||
{mutation.isPending && mutation.variables?.action === "install"
|
||||
? t("settings.plugins.installing")
|
||||
: t("settings.plugins.install")}
|
||||
</Button>
|
||||
</View>
|
||||
{sourceInstallSupported ? (
|
||||
<View style={[settingsStyles.card, styles.install]}>
|
||||
<Field label={t("settings.plugins.sourceLabel")} trailing={sourceDocsLink}>
|
||||
<FormTextInput
|
||||
initialValue=""
|
||||
resetKey={installState.resetKey}
|
||||
onChangeText={installForm.setSource}
|
||||
placeholder={t("settings.plugins.sourcePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.sourceLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Button onPress={install} disabled={!installState.canSubmit || mutation.isPending}>
|
||||
{mutation.isPending && mutation.variables?.action === "install"
|
||||
? t("settings.plugins.installing")
|
||||
: t("settings.plugins.install")}
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
|
||||
)}
|
||||
{feedback ? (
|
||||
<Alert
|
||||
variant={feedback.kind}
|
||||
@@ -465,15 +518,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
.option("--path <path>", "Legacy form of the :plugin/path source suffix"),
|
||||
).action(withOutput(install));
|
||||
).action(withOutput(runPluginInstallCommand));
|
||||
addJsonAndDaemonHostOptions(
|
||||
plugin
|
||||
.command("update")
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function withPluginSourceClient<T>(
|
||||
return withPluginClient(
|
||||
target,
|
||||
"pluginGitManagement",
|
||||
"Update the host to install and update Git plugins.",
|
||||
"Update the host to install or update plugins.",
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6529,6 +6529,7 @@ export type PluginStatus = z.infer<typeof PluginStatusSchema>;
|
||||
|
||||
export const PluginListItemSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
description: z.string().optional(),
|
||||
path: z.string(),
|
||||
enabled: z.boolean(),
|
||||
status: PluginStatusSchema,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -145,11 +145,11 @@ export class PluginService {
|
||||
});
|
||||
}
|
||||
|
||||
listPlugins(): PluginListItem[] {
|
||||
async listPlugins(): Promise<PluginListItem[]> {
|
||||
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<PluginListItem> {
|
||||
const item = (await this.listPlugins()).find((plugin) => plugin.id === pluginId);
|
||||
if (!item) throw new Error(`Plugin is not configured: ${pluginId}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<import("@getpaseo/protocol/messages").PluginListItem[]>;
|
||||
getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[];
|
||||
installDirectory(input: {
|
||||
path: string;
|
||||
@@ -2359,11 +2359,13 @@ export class Session {
|
||||
|
||||
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | 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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <source>` and `paseo plugin install <source>` 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 <runtime-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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user