Make plugin upgrade failures actionable (#4265)

* fix(plugins): clarify upgrade diagnostics

Keep plugin inspection network-free and consolidate runtime, source, and installed revision details under plugin ls. Legacy entry failures now explain the required v0.8 migration in user-facing language.

* test(cli): align hidden plugin status command
This commit is contained in:
Mohamed Boudra
2026-09-03 13:22:21 +02:00
committed by GitHub
parent eb753813a1
commit 25defbafb4
13 changed files with 95 additions and 54 deletions
+3 -3
View File
@@ -91,7 +91,7 @@ paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin status
paseo plugin ls
paseo plugin update review
paseo plugin update --all
```
@@ -99,8 +99,8 @@ paseo plugin update --all
Append `:relative/path` to the source when the plugin lives below the repository root.
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
commits. Removing a Git source deletes Paseo's managed checkout.
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
Removing a Git source deletes Paseo's managed checkout.
### Declare Git preparation
+1 -1
View File
@@ -79,9 +79,9 @@ describe("canonical CLI surface", () => {
expect(plugin?.commands.map((command) => command.name())).toEqual([
"init",
"ls",
"status",
"logs",
"install",
"status",
"update",
"reload",
"enable",
+50 -2
View File
@@ -1,6 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listPlugins = vi.fn(async () => []);
const listPlugins = vi.fn(async () => [
{
id: "git-plugin",
path: "/plugins/git-plugin",
enabled: true,
status: "running" as const,
source: "git" as const,
commit: "1557a34c91e2abcdef",
ref: "main",
},
{
id: "legacy-plugin",
path: "/plugins/legacy-plugin",
enabled: true,
status: "failed" as const,
error: "This plugin was made for an older version of Paseo",
},
]);
const getPluginLogs = vi.fn(async () => [
{
sequence: 1,
@@ -51,7 +68,7 @@ describe("plugin management commands", () => {
});
it("requires host support before attempting a management RPC", async () => {
await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({
await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
});
@@ -59,6 +76,37 @@ describe("plugin management commands", () => {
expect(close).toHaveBeenCalledTimes(1);
});
it("keeps status as a hidden alias for the network-free plugin list", async () => {
features.pluginManagement = true;
const command = createPluginCommand();
await command.parseAsync(["status"], { from: "user" });
expect(listPlugins).toHaveBeenCalledTimes(1);
expect(command.helpInformation()).not.toContain("status");
});
it("lists runtime state and the installed commit without an upstream commit", async () => {
features.pluginManagement = true;
const result = await runPluginListCommand(undefined, {}, {} as never);
const output = render(result, { noColor: true });
expect(output).toContain("SOURCE");
expect(output).toContain("COMMIT");
expect(output).not.toContain("LATEST");
expect(output).toContain("1557a34c91e2");
expect(output).toContain("This plugin was made for an older version of Paseo");
});
it("filters the shared ls and status command by plugin ID", async () => {
features.pluginManagement = true;
const result = await runPluginListCommand("legacy-plugin", {}, {} as never);
expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]);
});
it("requires plugin log support before attempting the RPC", async () => {
await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
+12 -30
View File
@@ -3,7 +3,6 @@ import path from "node:path";
import type {
PluginListItem,
PluginLogEntry,
PluginSourceStatusItem,
PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
import {
@@ -34,6 +33,9 @@ const pluginSchema: OutputSchema<PluginListItem> = {
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 },
{ header: "SOURCE", field: (plugin) => plugin.source ?? "directory", width: 10 },
{ header: "COMMIT", field: (plugin) => shortCommit(plugin.commit), width: 14 },
{ header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 },
{ header: "DIRECTORY", field: "path", width: 40 },
{ header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 },
],
@@ -60,18 +62,6 @@ function shortCommit(commit: string | undefined): string {
return commit?.slice(0, 12) ?? "-";
}
const pluginStatusSchema: OutputSchema<PluginSourceStatusItem> = {
idField: "id",
columns: [
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "SOURCE", field: "source", width: 10 },
{ header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 },
{ header: "LATEST", field: (plugin) => shortCommit(plugin.latestCommit), width: 14 },
{ header: "COMMITS", field: (plugin) => String(plugin.commitsBehind ?? 0), width: 8 },
{ header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 },
],
};
const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = {
idField: "id",
columns: [
@@ -96,10 +86,13 @@ export async function runPluginInitCommand(
}
export async function runPluginListCommand(
pluginId: string | undefined,
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) => client.listPlugins());
const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins());
const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins;
if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`);
return { type: "list", data, schema: pluginSchema };
}
@@ -146,17 +139,6 @@ async function install(
return { type: "single", data, schema: pluginSchema };
}
async function status(
pluginId: string | undefined,
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginSourceStatusItem>> {
const data = await withPluginSourceClient(options.host, (client) =>
client.getPluginSourceStatus(pluginId),
);
return { type: "list", data, schema: pluginStatusSchema };
}
async function update(
pluginId: string | undefined,
options: PluginOptions,
@@ -205,7 +187,10 @@ export function createPluginCommand(): Command {
.argument("<directory>")
.option("--id <id>", "Manifest plugin ID (defaults to the directory name)"),
).action(withOutput(runPluginInitCommand));
addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action(
addJsonAndDaemonHostOptions(
plugin.command("ls").description("List configured plugins").argument("[id]"),
).action(withOutput(runPluginListCommand));
addJsonAndDaemonHostOptions(plugin.command("status", { hidden: true }).argument("[id]")).action(
withOutput(runPluginListCommand),
);
addJsonAndDaemonHostOptions(
@@ -221,13 +206,10 @@ export function createPluginCommand(): Command {
.option("--ref <ref>", "Git branch, tag, or commit")
.option("--path <path>", "Legacy form of the :plugin/path source suffix"),
).action(withOutput(install));
addJsonAndDaemonHostOptions(
plugin.command("status").description("Check plugin source updates").argument("[id]"),
).action(withOutput(status));
addJsonAndDaemonHostOptions(
plugin
.command("update")
.description("Update a Git-managed plugin")
.description("Fetch and install Git-managed plugin updates")
.argument("[id]")
.option("--all", "Update every Git-managed plugin"),
).action(withOutput(update));
@@ -684,6 +684,9 @@ export default function contribute(plugin: unknown) {
await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({}));
const missingEntry = await createPlugin("missing-entry", "export default () => () => {};");
await rm(path.join(missingEntry, "index.server.ts"));
const legacy = await createPlugin("legacy-plugin", "export default () => () => {};");
await rm(path.join(legacy, "index.server.ts"));
await writeFile(path.join(legacy, "index.ts"), "export default () => () => {};");
const startupFailure = await createPlugin(
"startup-failure",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
@@ -695,10 +698,19 @@ export default function contribute(plugin: unknown) {
await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow(
"Plugin entry points are missing",
);
await expect(service.installDirectory({ path: legacy })).rejects.toThrow(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration",
);
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded",
);
expect(service.listPlugins()).toEqual([
expect.objectContaining({
id: "legacy-plugin",
status: "failed",
error:
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration",
}),
expect.objectContaining({
id: "missing-entry",
status: "failed",
@@ -494,7 +494,7 @@ export default function contribute(plugin: unknown) {
await runtime.stopAll();
});
it("rejects an index.ts plugin with the migration path", async () => {
it("explains that an index.ts plugin was made for an older Paseo version", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
@@ -504,10 +504,7 @@ export default function contribute(plugin: unknown) {
const runtime = createTestRuntime();
await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow(
"Plugin entry split is required",
);
await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow(
"https://paseo.sh/docs/plugins/v0.8/migration",
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration",
);
});
@@ -207,7 +207,7 @@ async function resolveEntryPaths(directory: string): Promise<{
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"Plugin entry split is required: migrate index.ts to index.client.tsx and/or index.server.ts. See https://paseo.sh/docs/plugins/v0.8/migration",
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration",
);
}
throw new Error(
+4 -4
View File
@@ -154,10 +154,9 @@ paseo plugin install /absolute/path/to/plugin
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin status
paseo plugin ls [id]
paseo plugin update my-plugin
paseo plugin update --all
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
@@ -166,8 +165,9 @@ paseo plugin remove my-plugin
```
GitHub shorthand checks an existing host directory first. Append `:<directory>` for a plugin in a
monorepo. `paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for
structured entries, or run `paseo --host <target> plugin logs <id>` for another daemon. See the
monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the
plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run
`paseo --host <target> plugin logs <id>` for another daemon. See the
[Plugin reference](/docs/plugins/v0.7/reference) for installation, trust, lifecycle, and log-retention
behavior.
+1 -2
View File
@@ -137,10 +137,9 @@ paseo plugin add owner/repository --ref main
Append `:relative/path` to the source when the plugin lives below the repository root.
An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are
pinned. Check and apply updates with:
pinned. Apply updates with:
```bash
paseo plugin status
paseo plugin update workspace-plugin
paseo plugin update --all
```
-1
View File
@@ -957,7 +957,6 @@ paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin status [id]
paseo plugin update <id>
paseo plugin update --all
paseo plugin ls
+3 -1
View File
@@ -231,11 +231,13 @@ Append `:relative/path` when the plugin lives below the repository root. Without
default branch is tracked; a branch tracks updates, while a tag or commit stays pinned.
```bash
paseo plugin status
paseo plugin ls
paseo plugin update workspace-plugin
paseo plugin update --all
```
`ls` reports runtime state, source details, and the installed commit without contacting the remote.
Paseo compiles TypeScript itself, so most plugins need no build step. A repository that must
install a dependency Paseo does not provide, or generate files, declares
[`build` commands](/docs/plugins/v0.8/reference#cli-reference) in its manifest.
+1 -1
View File
@@ -101,7 +101,7 @@ its registration; that registration belongs in the client entry.
| Compiler or load error | Meaning and fix |
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `Plugin entry split is required` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. |
| `This plugin was made for an older version of Paseo` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. |
| `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. |
| `server-only module cannot be imported into the plugin client bundle: <file>` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. |
| `client-only module cannot be imported into the plugin server bundle: <file>` | A server import reaches `client/`. Move that registration and import to the client entry. |
+5 -3
View File
@@ -1016,10 +1016,9 @@ paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin status [id]
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
@@ -1027,6 +1026,9 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
`ls` reports runtime state, source details, and the installed commit without contacting the remote.
Use `update` when you want Paseo to contact a tracked Git remote and install an available update.
Put `--host <url>` before a management command when the target is not the CLI's default daemon. `remove`
never deletes a directory source; it deletes the managed checkout for a Git source. The install-time
`--id` is the runtime ID and allows the same directory or repository to be installed more than once.
@@ -1070,7 +1072,7 @@ Use `paseo plugin ls` to read the current status and error.
| Symptom | Check |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin entry split is required` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). |
| `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). |
| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. |
| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/` or a `*.server.*` file. Move the work behind an RPC and import its contract from `shared/`. |
| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/` or a `*.client.*` file. Register that contribution from `index.client.tsx` instead. |