mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat(plugins): install npm sources and review CLI updates
Treat install selectors as one-time choices. Keep source identity separate from the installed revision, and resolve ordinary updates from the current upstream default. npm only acquires directories; plugin loading remains independent of npm.
This commit is contained in:
@@ -160,6 +160,23 @@ export function createCli(): Command {
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
program.addCommand(createWorktreeCommand(), { hidden: true });
|
||||
|
||||
// Stop root parsing at the command so `plugin update --version` belongs to update.
|
||||
// Keep global options available after a command, as they were before positional parsing.
|
||||
program.enablePositionalOptions();
|
||||
for (const command of program.commands) {
|
||||
for (const option of program.options) {
|
||||
if (option.long === "--version") continue;
|
||||
if (!command.options.some((local) => local.long === option.long)) command.addOption(option);
|
||||
if (option.long === "--home" || option.long === "--host") continue;
|
||||
command.on(`option:${option.name()}`, () => {
|
||||
const key = option.attributeName();
|
||||
program.setOptionValueWithSource(key, command.opts()[key], "cli");
|
||||
});
|
||||
}
|
||||
if (command.name() !== "plugin")
|
||||
command.version(VERSION, "-v, --version", "output the version number");
|
||||
}
|
||||
|
||||
const enforceSelectorDuplicates = (command: Command) => {
|
||||
for (const option of command.options) {
|
||||
if (option.long !== "--home" && option.long !== "--host") continue;
|
||||
|
||||
@@ -11,6 +11,14 @@ const listPlugins = vi.fn(async () => [
|
||||
source: "git" as const,
|
||||
commit: "1557a34c91e2abcdef",
|
||||
ref: "main",
|
||||
installation: {
|
||||
identity: {
|
||||
kind: "git" as const,
|
||||
remote: "https://example.test/plugin.git",
|
||||
pluginPath: ".",
|
||||
},
|
||||
currentRevision: "1557a34c91e2abcdef",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "legacy-plugin",
|
||||
@@ -40,11 +48,14 @@ const installDirectoryPlugin = vi.fn(async () => ({
|
||||
enabled: true,
|
||||
status: "running" as const,
|
||||
}));
|
||||
const previewPluginUpdates = vi.fn(async () => []);
|
||||
const close = vi.fn(async () => undefined);
|
||||
const features: {
|
||||
pluginManagement?: boolean;
|
||||
pluginLogs?: boolean;
|
||||
pluginGitManagement?: boolean;
|
||||
pluginSourceInstallation?: boolean;
|
||||
pluginSourceUpdates?: boolean;
|
||||
} = {};
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
@@ -54,6 +65,7 @@ vi.mock("../../utils/client.js", () => ({
|
||||
getPluginLogs,
|
||||
installDirectoryPlugin,
|
||||
installPluginSource,
|
||||
previewPluginUpdates,
|
||||
close,
|
||||
})),
|
||||
}));
|
||||
@@ -62,6 +74,7 @@ import { render } from "../../output/index.js";
|
||||
import {
|
||||
createPluginCommand,
|
||||
runPluginInstallCommand,
|
||||
runPluginUpdateCommand,
|
||||
runPluginListCommand,
|
||||
runPluginLogsCommand,
|
||||
} from "./index.js";
|
||||
@@ -71,6 +84,8 @@ describe("plugin management commands", () => {
|
||||
features.pluginManagement = false;
|
||||
features.pluginLogs = false;
|
||||
features.pluginGitManagement = false;
|
||||
features.pluginSourceInstallation = false;
|
||||
features.pluginSourceUpdates = false;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -102,7 +117,7 @@ describe("plugin management commands", () => {
|
||||
const output = render(result, { noColor: true });
|
||||
|
||||
expect(output).toContain("SOURCE");
|
||||
expect(output).toContain("COMMIT");
|
||||
expect(output).toContain("REVISION");
|
||||
expect(output).not.toContain("LATEST");
|
||||
expect(output).toContain("1557a34c91e2");
|
||||
expect(output).toContain("This plugin was made for an older version of Paseo");
|
||||
@@ -151,14 +166,14 @@ describe("plugin management commands", () => {
|
||||
});
|
||||
|
||||
it("prints the trust acknowledgement before installing", async () => {
|
||||
features.pluginGitManagement = true;
|
||||
features.pluginSourceInstallation = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Git build commands run unsandboxed on the daemon host"),
|
||||
expect.stringContaining("preparation commands run unsandboxed on the daemon host"),
|
||||
);
|
||||
expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" });
|
||||
stderr.mockRestore();
|
||||
@@ -166,21 +181,77 @@ describe("plugin management commands", () => {
|
||||
|
||||
it("requires source support for directory installs without using the legacy RPC", async () => {
|
||||
features.pluginManagement = true;
|
||||
features.pluginGitManagement = 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.",
|
||||
message: "Update the host to install plugin sources.",
|
||||
});
|
||||
expect(installPluginSource).not.toHaveBeenCalled();
|
||||
expect(installDirectoryPlugin).not.toHaveBeenCalled();
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
"installs with source support and Git support=%s",
|
||||
async (gitSupport) => {
|
||||
features.pluginSourceInstallation = true;
|
||||
features.pluginGitManagement = gitSupport;
|
||||
await runPluginInstallCommand("npm:@acme/review@^1.0.0", { daemonTarget }, {} as never);
|
||||
expect(installPluginSource).toHaveBeenCalledWith({ source: "npm:@acme/review@^1.0.0" });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, false])(
|
||||
"rejects installs before an RPC when source support=%s despite Git support",
|
||||
async (sourceSupport) => {
|
||||
features.pluginGitManagement = true;
|
||||
features.pluginSourceInstallation = sourceSupport;
|
||||
await expect(
|
||||
runPluginInstallCommand("owner/review", { daemonTarget }, {} as never),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to install plugin sources.",
|
||||
});
|
||||
expect(installPluginSource).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
"reviews updates with source install support=%s",
|
||||
async (sourceSupport) => {
|
||||
features.pluginSourceUpdates = true;
|
||||
features.pluginSourceInstallation = sourceSupport;
|
||||
await runPluginUpdateCommand("review", { daemonTarget }, {} as never);
|
||||
expect(previewPluginUpdates).toHaveBeenCalledWith({ pluginId: "review", target: undefined });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, false])(
|
||||
"rejects reviewed updates when support=%s despite legacy Git support",
|
||||
async (gitSupport) => {
|
||||
features.pluginSourceInstallation = true;
|
||||
features.pluginGitManagement = true;
|
||||
features.pluginSourceUpdates = gitSupport;
|
||||
await expect(
|
||||
runPluginUpdateCommand("review", { daemonTarget }, {} as never),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to review plugin updates.",
|
||||
});
|
||||
expect(previewPluginUpdates).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("folds the legacy --path option into the plugin source reference", async () => {
|
||||
features.pluginGitManagement = true;
|
||||
features.pluginSourceInstallation = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
@@ -195,7 +266,7 @@ describe("plugin management commands", () => {
|
||||
});
|
||||
|
||||
it("keeps an absolute monorepo path as one plugin source reference", async () => {
|
||||
features.pluginGitManagement = true;
|
||||
features.pluginSourceInstallation = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
@@ -207,3 +278,110 @@ describe("plugin management commands", () => {
|
||||
stderr.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
import { reviewPluginUpdates, type UpdateInteraction } from "./update.js";
|
||||
import type {
|
||||
PluginUpdatePreview,
|
||||
PluginUpdateProposal,
|
||||
PluginUpdateResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
function reviewFixture(answer = true, outcome: "update" | "installed-newer" = "update") {
|
||||
const proposal: PluginUpdateProposal = {
|
||||
id: "review",
|
||||
expected: {
|
||||
identity: { kind: "npm", packageName: "review", pluginPath: "." },
|
||||
installationRoot: "/owned/one",
|
||||
revision: "1.0.0",
|
||||
},
|
||||
target: {
|
||||
kind: "npm",
|
||||
version: "1.1.0",
|
||||
resolved: "https://registry.npmjs.org/review.tgz",
|
||||
integrity: "sha512-test",
|
||||
},
|
||||
};
|
||||
const preview: PluginUpdatePreview = {
|
||||
id: "review",
|
||||
outcome,
|
||||
links: ["https://npmjs.com/package/review/v/1.1.0"],
|
||||
current: { identity: proposal.expected.identity, currentRevision: "1.0.0" },
|
||||
target: proposal.target,
|
||||
...(outcome === "update" ? { proposal } : {}),
|
||||
};
|
||||
const applied: PluginUpdateProposal[][] = [];
|
||||
const questions: string[] = [];
|
||||
const output: string[] = [];
|
||||
const client = {
|
||||
previewPluginUpdates: async () => [preview],
|
||||
applyPluginUpdates: async (
|
||||
proposals: PluginUpdateProposal[],
|
||||
): Promise<PluginUpdateResult[]> => {
|
||||
applied.push(proposals);
|
||||
return [{ id: "review", outcome: "updated" }];
|
||||
},
|
||||
};
|
||||
const interaction: UpdateInteraction = {
|
||||
interactive: true,
|
||||
structured: false,
|
||||
write: (text) => {
|
||||
output.push(text);
|
||||
},
|
||||
confirm: async (question) => {
|
||||
questions.push(question);
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
return { client, interaction, proposal, applied, questions, output };
|
||||
}
|
||||
describe("reviewed plugin update workflow", () => {
|
||||
it("prints target and links, then declines without applying", async () => {
|
||||
const f = reviewFixture(false);
|
||||
expect(await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction)).toEqual([
|
||||
{ id: "review", outcome: "declined" },
|
||||
]);
|
||||
expect(f.output.join("")).toContain("1.0.0 → 1.1.0");
|
||||
expect(f.output.join("")).toContain("Review: https://");
|
||||
expect(f.questions).toEqual(["Update review? [y/N] "]);
|
||||
expect(f.applied).toEqual([]);
|
||||
});
|
||||
it("applies exactly the displayed proposal after approval", async () => {
|
||||
const f = reviewFixture();
|
||||
await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction);
|
||||
expect(f.applied).toEqual([[f.proposal]]);
|
||||
});
|
||||
it.each([{ yes: true }, { version: "stable" }, { ref: "v1" }])(
|
||||
"skips questions for %j",
|
||||
async (options) => {
|
||||
const f = reviewFixture();
|
||||
await reviewPluginUpdates(f.client, { pluginId: "review", ...options }, f.interaction);
|
||||
expect(f.questions).toEqual([]);
|
||||
expect(f.applied).toEqual([[f.proposal]]);
|
||||
},
|
||||
);
|
||||
it("check takes precedence over yes and explicit targets", async () => {
|
||||
const f = reviewFixture();
|
||||
await reviewPluginUpdates(
|
||||
f.client,
|
||||
{ pluginId: "review", check: true, yes: true, version: "1.1.0" },
|
||||
f.interaction,
|
||||
);
|
||||
expect(f.applied).toEqual([]);
|
||||
expect(f.questions).toEqual([]);
|
||||
});
|
||||
it("yes never applies an ordinary downgrade", async () => {
|
||||
const f = reviewFixture(true, "installed-newer");
|
||||
await reviewPluginUpdates(f.client, { all: true, yes: true }, f.interaction);
|
||||
expect(f.applied).toEqual([]);
|
||||
expect(f.questions).toEqual([]);
|
||||
});
|
||||
it.each([{ interactive: false }, { structured: true }])(
|
||||
"requires explicit approval for %j",
|
||||
async (flags) => {
|
||||
const f = reviewFixture();
|
||||
await expect(
|
||||
reviewPluginUpdates(f.client, { pluginId: "review" }, { ...f.interaction, ...flags }),
|
||||
).rejects.toThrow("Confirmation required");
|
||||
expect(f.applied).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
|
||||
import { Command } from "commander";
|
||||
import type {
|
||||
PluginListItem,
|
||||
PluginLogEntry,
|
||||
PluginSourceUpdateItem,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
formatPluginSourceReference,
|
||||
formatPluginIdentity,
|
||||
} 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";
|
||||
import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js";
|
||||
import {
|
||||
withPluginLogsClient,
|
||||
withPluginUpdateClient,
|
||||
withPluginManagementClient,
|
||||
withPluginSourceClient,
|
||||
} from "./shared.js";
|
||||
@@ -21,6 +23,9 @@ interface PluginOptions extends CommandOptions {
|
||||
ref?: string;
|
||||
path?: string;
|
||||
all?: boolean;
|
||||
version?: string;
|
||||
check?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
const pluginSchema: OutputSchema<PluginListItem> = {
|
||||
@@ -29,9 +34,17 @@ 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: "SOURCE",
|
||||
field: (plugin) =>
|
||||
plugin.installation ? formatPluginIdentity(plugin.installation.identity) : "-",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
header: "REVISION",
|
||||
field: (plugin) => plugin.installation?.currentRevision ?? "-",
|
||||
width: 16,
|
||||
},
|
||||
{ header: "DIRECTORY", field: "path", width: 40 },
|
||||
{ header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 },
|
||||
],
|
||||
@@ -54,18 +67,17 @@ const pluginLogsSchema: OutputSchema<PluginLogEntry> = {
|
||||
],
|
||||
};
|
||||
|
||||
function shortCommit(commit: string | undefined): string {
|
||||
return commit?.slice(0, 12) ?? "-";
|
||||
}
|
||||
|
||||
const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = {
|
||||
const pluginUpdateSchema: OutputSchema<UpdateOutcome> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "PLUGIN", field: "id", width: 20 },
|
||||
{ header: "PREVIOUS", field: (plugin) => shortCommit(plugin.previousCommit), width: 14 },
|
||||
{ header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 },
|
||||
{ header: "COMMITS", field: (plugin) => String(plugin.commits), width: 8 },
|
||||
{ header: "UPDATED", field: (plugin) => (plugin.updated ? "yes" : "no"), width: 8 },
|
||||
{ header: "PLUGIN", field: "id", width: 24 },
|
||||
{ header: "RESULT", field: "outcome", width: 18 },
|
||||
{
|
||||
header: "DETAIL",
|
||||
field: (item) =>
|
||||
item.error ?? item.warning ?? item.plugin?.installation?.currentRevision ?? "",
|
||||
width: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -111,7 +123,7 @@ export async function runPluginInstallCommand(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<PluginListItem>> {
|
||||
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",
|
||||
"Trusting plugin code: server code and preparation 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 sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
@@ -124,17 +136,35 @@ export async function runPluginInstallCommand(
|
||||
return { type: "single", data, schema: pluginSchema };
|
||||
}
|
||||
|
||||
async function update(
|
||||
export async function runPluginUpdateCommand(
|
||||
pluginId: string | undefined,
|
||||
options: PluginOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<PluginSourceUpdateItem>> {
|
||||
if ((pluginId === undefined) === (options.all !== true)) {
|
||||
throw new Error("Choose one plugin ID or pass --all");
|
||||
}
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.updatePluginSources(pluginId),
|
||||
): Promise<ListResult<UpdateOutcome>> {
|
||||
const data = await withPluginUpdateClient(options.daemonTarget, (client) =>
|
||||
reviewPluginUpdates(
|
||||
client,
|
||||
{ ...options, pluginId },
|
||||
{
|
||||
interactive: process.stdin.isTTY === true,
|
||||
structured:
|
||||
options.json === true ||
|
||||
["json", "yaml"].includes(options.format?.trim().toLowerCase() ?? ""),
|
||||
write: (text) => process.stderr.write(text),
|
||||
confirm: async (message) => {
|
||||
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
return /^(y|yes)$/i.test((await prompt.question(message)).trim());
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
if (data.some((item) => item.outcome === "error")) process.exitCode = 1;
|
||||
return { type: "list", data, schema: pluginUpdateSchema };
|
||||
}
|
||||
|
||||
@@ -185,8 +215,11 @@ export function createPluginCommand(): Command {
|
||||
plugin
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Trust and install a plugin from a directory or Git repository")
|
||||
.argument("<source>", "Host directory, Git source, or Git source:plugin/path")
|
||||
.description("Trust and install a plugin from a directory, Git repository, or npm package")
|
||||
.argument(
|
||||
"<source>",
|
||||
"Host directory, Git or npm source, optionally followed by :plugin/path",
|
||||
)
|
||||
.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"),
|
||||
@@ -194,10 +227,14 @@ export function createPluginCommand(): Command {
|
||||
addJsonAndDaemonHostOptions(
|
||||
plugin
|
||||
.command("update")
|
||||
.description("Fetch and install Git-managed plugin updates")
|
||||
.description("Review and update installed plugins")
|
||||
.argument("[id]")
|
||||
.option("--all", "Update every Git-managed plugin"),
|
||||
).action(withOutput(update));
|
||||
.option("--all", "Review all configured plugins")
|
||||
.option("--check", "Show available updates without installing")
|
||||
.option("--yes", "Apply displayed updates without asking")
|
||||
.option("--ref <ref>", "Apply a Git branch, tag, or commit without asking")
|
||||
.option("--version <version>", "Apply an npm version, tag, or range without asking"),
|
||||
).action(withOutput(runPluginUpdateCommand));
|
||||
for (const action of ["reload", "enable", "disable"] as const) {
|
||||
addJsonAndDaemonHostOptions(
|
||||
plugin.command(action).description(`${action} a plugin`).argument("<id>"),
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CommandError } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement";
|
||||
type PluginFeature =
|
||||
| "pluginManagement"
|
||||
| "pluginLogs"
|
||||
| "pluginSourceUpdates"
|
||||
| "pluginSourceInstallation";
|
||||
|
||||
async function withPluginClient<T>(
|
||||
target: DaemonTarget,
|
||||
@@ -51,11 +55,24 @@ export async function withPluginSourceClient<T>(
|
||||
target: DaemonTarget,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
|
||||
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
|
||||
return withPluginClient(
|
||||
target,
|
||||
"pluginGitManagement",
|
||||
"Update the host to install or update plugins.",
|
||||
"pluginSourceInstallation",
|
||||
"Update the host to install plugin sources.",
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
export async function withPluginUpdateClient<T>(
|
||||
target: DaemonTarget,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates.
|
||||
return withPluginClient(
|
||||
target,
|
||||
"pluginSourceUpdates",
|
||||
"Update the host to review plugin updates.",
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
PluginUpdatePreview,
|
||||
PluginUpdateResult,
|
||||
PluginUpdateSelection,
|
||||
PluginUpdateProposal,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
interface UpdateClient {
|
||||
previewPluginUpdates(input: {
|
||||
pluginId?: string;
|
||||
target?: PluginUpdateSelection;
|
||||
}): Promise<PluginUpdatePreview[]>;
|
||||
applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]>;
|
||||
}
|
||||
export interface UpdateOptions {
|
||||
pluginId?: string;
|
||||
all?: boolean;
|
||||
ref?: string;
|
||||
version?: string;
|
||||
check?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
export interface UpdateInteraction {
|
||||
interactive: boolean;
|
||||
structured: boolean;
|
||||
write(text: string): void;
|
||||
confirm(message: string): Promise<boolean>;
|
||||
}
|
||||
export interface UpdateOutcome {
|
||||
id: string;
|
||||
outcome: PluginUpdatePreview["outcome"] | PluginUpdateResult["outcome"] | "declined";
|
||||
current?: PluginUpdatePreview["current"];
|
||||
target?: PluginUpdatePreview["target"];
|
||||
links?: string[];
|
||||
proposal?: PluginUpdateProposal;
|
||||
plugin?: PluginUpdateResult["plugin"];
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export async function reviewPluginUpdates(
|
||||
client: UpdateClient,
|
||||
options: UpdateOptions,
|
||||
interaction: UpdateInteraction,
|
||||
): Promise<UpdateOutcome[]> {
|
||||
if ((options.pluginId === undefined) === (options.all !== true))
|
||||
throw new Error("Choose one plugin ID or pass --all");
|
||||
if (options.ref && options.version) throw new Error("Choose --ref or --version, not both");
|
||||
let target: PluginUpdateSelection | undefined;
|
||||
if (options.ref) target = { kind: "git", ref: options.ref };
|
||||
if (options.version) target = { kind: "npm", version: options.version };
|
||||
if (target && options.all)
|
||||
throw new Error("An explicit target requires one plugin ID, without --all");
|
||||
const preview = await client.previewPluginUpdates({ pluginId: options.pluginId, target });
|
||||
if (!interaction.structured) interaction.write(formatUpdatePreview(preview));
|
||||
if (options.check) return preview;
|
||||
const proposals = preview.flatMap((item) => (item.proposal ? [item.proposal] : []));
|
||||
if (!proposals.length) return preview;
|
||||
if (!options.yes && !target) {
|
||||
if (interaction.structured || !interaction.interactive)
|
||||
throw new Error("Confirmation required; rerun with --yes.");
|
||||
if (
|
||||
!(await interaction.confirm(
|
||||
options.all ? "Update these plugins? [y/N] " : `Update ${options.pluginId}? [y/N] `,
|
||||
))
|
||||
) {
|
||||
return preview.map((item) => (item.proposal ? { id: item.id, outcome: "declined" } : item));
|
||||
}
|
||||
}
|
||||
const applied = new Map(
|
||||
(await client.applyPluginUpdates(proposals)).map((item) => [item.id, item]),
|
||||
);
|
||||
return preview.map((item) => applied.get(item.id) ?? item);
|
||||
}
|
||||
|
||||
export function formatUpdatePreview(items: PluginUpdatePreview[]): string {
|
||||
return items
|
||||
.map((item) => {
|
||||
const current = item.current?.currentRevision ?? "unknown";
|
||||
const target = item.target?.kind === "git" ? item.target.commit : item.target?.version;
|
||||
let text: string;
|
||||
switch (item.outcome) {
|
||||
case "update":
|
||||
text = `${current} → ${target}`;
|
||||
break;
|
||||
case "current":
|
||||
text = `up to date (${current})`;
|
||||
break;
|
||||
case "installed-newer":
|
||||
text = `installed ${current} is newer than latest ${target}; keeping installed version`;
|
||||
break;
|
||||
case "local":
|
||||
text = "local directory; use Reload after editing";
|
||||
break;
|
||||
case "error":
|
||||
text = item.error ?? "Check failed";
|
||||
break;
|
||||
}
|
||||
return `${item.id}: ${text}\n${item.links.map((link) => `Review: ${link}\n`).join("")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -2,9 +2,29 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli";
|
||||
import { createCliParseArgv } from "./run";
|
||||
|
||||
describe("runCli", () => {
|
||||
it("lets plugin update own --version while preserving global output options", async () => {
|
||||
const cli = createCli()
|
||||
.exitOverride()
|
||||
.configureOutput({ writeOut: () => {} });
|
||||
const update = cli.commands
|
||||
.find((command) => command.name() === "plugin")!
|
||||
.commands.find((command) => command.name() === "update")!;
|
||||
let received: Record<string, unknown> | undefined;
|
||||
update.action((_id, options) => {
|
||||
received = options;
|
||||
});
|
||||
await cli.parseAsync(
|
||||
["plugin", "update", "example", "--version", "1.2.0", "--format", "json", "--no-color"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(received).toMatchObject({ version: "1.2.0" });
|
||||
expect(update.optsWithGlobals()).toMatchObject({ format: "json", color: false });
|
||||
});
|
||||
|
||||
it("defaults an empty CLI invocation to onboard", () => {
|
||||
expect(
|
||||
createCliParseArgv({
|
||||
|
||||
Reference in New Issue
Block a user