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:
Mohamed Boudra
2026-09-16 23:27:45 +02:00
parent 3f0a74d3fb
commit c3a1e0027d
36 changed files with 2965 additions and 425 deletions
+17
View File
@@ -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;
+184 -6
View File
@@ -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([]);
},
);
});
+69 -32
View File
@@ -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>"),
+21 -4
View File
@@ -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,
);
}
+102
View File
@@ -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");
}
+20
View File
@@ -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({
@@ -2,6 +2,10 @@
import { resolveCliVersion } from "../../src/version.js";
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -26,7 +30,8 @@ async function git(cwd: string, args: string[]): Promise<void> {
async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
const context = await createE2ETestContext({ timeout: 45_000 });
const registry = await startNpmRegistry(npmPluginPackages());
const context = await createE2ETestContext({ timeout: 45_000, env: registry.env });
try {
const scaffold = path.join(context.workDir, "authored-plugin");
const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
@@ -82,7 +87,7 @@ async function main(): Promise<void> {
const gitInstall = await context.paseo([
"plugin",
"add",
pathToFileURL(gitDirectory).href,
`git:${pathToFileURL(gitDirectory).href}`,
"--json",
]);
assert.equal(gitInstall.exitCode, 0, gitInstall.stderr);
@@ -99,11 +104,11 @@ async function main(): Promise<void> {
assert.equal(JSON.parse(status.stdout)[0].status, "running");
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--yes", "--json"]);
assert.equal(update.exitCode, 0, update.stderr);
assert.equal(JSON.parse(update.stdout)[0].updated, true);
assert.equal(JSON.parse(update.stdout)[0].outcome, "updated");
const installedCommit = JSON.parse(update.stdout)[0].currentCommit;
const installedCommit = JSON.parse(update.stdout)[0].plugin.installation.currentRevision;
const buildMarker = path.join(context.workDir, "incompatible-build-ran");
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
@@ -122,9 +127,15 @@ async function main(): Promise<void> {
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]);
const incompatibleUpdate = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
const incompatibleUpdate = await context.paseo([
"plugin",
"update",
"git-cli-e2e",
"--yes",
"--json",
]);
assert.equal(incompatibleUpdate.exitCode, 1);
assert.match(incompatibleUpdate.stderr, /requires Paseo >=999.0.0/);
assert.match(JSON.parse(incompatibleUpdate.stdout)[0].error, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
assert.equal(retained.exitCode, 0, retained.stderr);
@@ -160,11 +171,54 @@ async function main(): Promise<void> {
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr);
for (const source of ["npm:paseo-fixture-plugin@^1.0.0", "npm:@paseo-fixture/review@2.0.0"]) {
const npmInstall = await context.paseo([
"plugin",
"install",
source,
"--path",
".",
"--json",
]);
assert.equal(npmInstall.exitCode, 0, npmInstall.stderr);
assert.equal(JSON.parse(npmInstall.stdout).id, "npm-review");
assert.equal(JSON.parse(npmInstall.stdout).status, "running");
assert.equal(
JSON.parse(npmInstall.stdout).installation.currentRevision,
source.includes("@paseo-fixture") ? "2.0.0" : "1.1.0",
);
const npmDisabled = await context.paseo(["plugin", "disable", "npm-review", "--json"]);
assert.equal(npmDisabled.exitCode, 0, npmDisabled.stderr);
const restart = await context.paseo(["daemon", "restart", "--timeout", "45", "--json"], {
timeout: 60_000,
});
assert.equal(restart.exitCode, 0, restart.stderr);
assert.notEqual(
JSON.parse(restart.stdout).workerPid,
JSON.parse(restart.stdout).previousWorkerPid,
);
const persisted = await context.paseo(["plugin", "ls", "npm-review", "--json"]);
assert.equal(persisted.exitCode, 0, persisted.stderr);
assert.equal(JSON.parse(persisted.stdout)[0].status, "disabled");
assert.equal(JSON.parse(persisted.stdout)[0].path, JSON.parse(npmInstall.stdout).path);
assert.deepEqual(
JSON.parse(persisted.stdout)[0].installation,
JSON.parse(npmInstall.stdout).installation,
);
const npmEnabled = await context.paseo(["plugin", "enable", "npm-review", "--json"]);
assert.equal(npmEnabled.exitCode, 0, npmEnabled.stderr);
assert.equal(JSON.parse(npmEnabled.stdout).status, "running");
const npmReload = await context.paseo(["plugin", "reload", "npm-review", "--json"]);
assert.equal(npmReload.exitCode, 0, npmReload.stderr);
const npmRemove = await context.paseo(["plugin", "remove", "npm-review", "--json"]);
assert.equal(npmRemove.exitCode, 0, npmRemove.stderr);
}
const list = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(list.exitCode, 0, list.stderr);
assert.deepEqual(JSON.parse(list.stdout), []);
} finally {
await context.stop();
await registry.close();
await rm(directory, { recursive: true, force: true });
await rm(gitDirectory, { recursive: true, force: true });
}