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:
@@ -137,6 +137,8 @@ const INBOUND_PERMISSION = {
|
||||
"plugin.source.install.request": "daemon.manage",
|
||||
"plugin.source.status.request": "daemon.manage",
|
||||
"plugin.source.update.request": "daemon.manage",
|
||||
"plugin.source.update.preview.request": "daemon.manage",
|
||||
"plugin.source.update.apply.request": "daemon.manage",
|
||||
"project.add.request": "workspace.manage",
|
||||
"project.create_directory.request": "workspace.manage",
|
||||
"project.github.clone.request": "workspace.manage",
|
||||
@@ -357,6 +359,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"plugin.source.install.response": "daemon.manage",
|
||||
"plugin.source.status.response": "daemon.manage",
|
||||
"plugin.source.update.response": "daemon.manage",
|
||||
"plugin.source.update.preview.response": "daemon.manage",
|
||||
"plugin.source.update.apply.response": "daemon.manage",
|
||||
pong: "daemon.read",
|
||||
"project.add.response": "workspace.manage",
|
||||
"project.create_directory.response": "workspace.manage",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rename, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -7,6 +8,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { PluginService } from "./index.js";
|
||||
import { ManagedPluginSources } from "./managed-source.js";
|
||||
import {
|
||||
startNpmRegistry,
|
||||
npmPluginPackages,
|
||||
} from "../../../../../scripts/test-support/npm-registry.mjs";
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -309,7 +314,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(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }),
|
||||
]);
|
||||
|
||||
@@ -378,6 +383,150 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it.each([true, false])(
|
||||
"updates the shipped legacy Git layout with enabled=%s",
|
||||
async (enabled) => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
|
||||
roots.push(repository);
|
||||
const id = "legacy-review";
|
||||
const pluginPath = "plugins/review";
|
||||
await mkdir(path.join(repository, pluginPath), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repository, pluginPath, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: { paseo: ">=0.8.0" } }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repository, pluginPath, "index.server.ts"),
|
||||
`import { defineSettings } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerSettings(defineSettings({ id: "preferences", scope: "host", version: 1, schema: z.object({ message: z.string().default("default") }) }));
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
for (const args of [
|
||||
["init", "-b", "main"],
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
|
||||
["tag", "v1"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const initial = (
|
||||
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
|
||||
).stdout.trim();
|
||||
const remote = pathToFileURL(repository).href;
|
||||
const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`);
|
||||
const checkoutRoot = path.join(legacyRoot, "checkout");
|
||||
const directory = path.join(checkoutRoot, pluginPath);
|
||||
await mkdir(legacyRoot, { recursive: true });
|
||||
await runGitCommand(["clone", remote, checkoutRoot], { cwd: home });
|
||||
await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot });
|
||||
await writeFile(
|
||||
path.join(home, "plugins", "sources.json"),
|
||||
JSON.stringify({
|
||||
[id]: {
|
||||
remote,
|
||||
requestedRef: "v1",
|
||||
trackingBranch: null,
|
||||
commit: "f".repeat(40),
|
||||
pluginPath,
|
||||
checkoutRoot,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const settingsDirectory = path.join(home, "plugin-settings");
|
||||
await mkdir(path.join(settingsDirectory, id), { recursive: true });
|
||||
const settingsFile = path.join(settingsDirectory, id, "preferences.json");
|
||||
const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
|
||||
await writeFile(settingsFile, savedSettings);
|
||||
const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } });
|
||||
const open = () =>
|
||||
bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
|
||||
managedSources: new ManagedPluginSources(home),
|
||||
settingsDirectory,
|
||||
}),
|
||||
);
|
||||
let service = open();
|
||||
try {
|
||||
await service.start();
|
||||
const expected = {
|
||||
identity: { kind: "git", remote, pluginPath },
|
||||
currentRevision: initial,
|
||||
};
|
||||
await rename(repository, `${repository}-offline`);
|
||||
try {
|
||||
expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{ id, enabled, status: enabled ? "running" : "disabled", installation: expected },
|
||||
]);
|
||||
} finally {
|
||||
await rename(`${repository}-offline`, repository);
|
||||
}
|
||||
await service.stopAllPlugins();
|
||||
service = open();
|
||||
await service.start();
|
||||
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
|
||||
await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default");
|
||||
for (const args of [
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const latest = (
|
||||
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
|
||||
).stdout.trim();
|
||||
const [preview] = await service.previewUpdates({ pluginId: id });
|
||||
expect(preview).toMatchObject({
|
||||
outcome: "update",
|
||||
current: expected,
|
||||
target: { kind: "git", commit: latest },
|
||||
});
|
||||
const [updated] = await service.applyUpdates([preview!.proposal!]);
|
||||
expect(updated).toMatchObject({
|
||||
outcome: "updated",
|
||||
plugin: {
|
||||
id,
|
||||
enabled,
|
||||
status: enabled ? "running" : "disabled",
|
||||
installation: { identity: expected.identity, currentRevision: latest },
|
||||
},
|
||||
});
|
||||
await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(settingsFile, "utf8")).toBe(savedSettings);
|
||||
await service.disablePlugin(id);
|
||||
await service.enablePlugin(id);
|
||||
expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" });
|
||||
expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({
|
||||
status: "ready",
|
||||
values: { message: "keep my settings" },
|
||||
});
|
||||
await service.removePlugin(id);
|
||||
await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(await stat(repository)).toBeDefined();
|
||||
const sources = new ManagedPluginSources(home);
|
||||
// A record cannot confer ownership on another plugin's tree or a malformed version name.
|
||||
sources.commit(id, { kind: "git", remote });
|
||||
for (const outside of [
|
||||
path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"),
|
||||
path.join(home, "plugins", id, "f".repeat(36), "checkout"),
|
||||
path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"),
|
||||
])
|
||||
await expect(sources.describe(id, outside)).rejects.toThrow(
|
||||
"outside its managed installation",
|
||||
);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("keeps the running commit when a Git update build command fails", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
@@ -424,12 +573,14 @@ describe("PluginService", () => {
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "broken update"], { cwd: repository });
|
||||
|
||||
await expect(service.updateSources("git-update")).rejects.toThrow();
|
||||
expect(await applyReviewedUpdate(service, "git-update")).toMatchObject([
|
||||
{ outcome: "error", error: expect.stringContaining("build exploded") },
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
expect(service.catalog()).toEqual([
|
||||
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
|
||||
]);
|
||||
expect(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
expect.objectContaining({
|
||||
id: "git-update",
|
||||
path: installedPath,
|
||||
@@ -518,8 +669,8 @@ describe("PluginService", () => {
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository });
|
||||
|
||||
await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([
|
||||
expect.objectContaining({ id: "prepared-git-plugin", updated: true }),
|
||||
await expect(applyReviewedUpdate(service, "prepared-git-plugin")).resolves.toEqual([
|
||||
expect.objectContaining({ id: "prepared-git-plugin", outcome: "updated" }),
|
||||
]);
|
||||
expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]);
|
||||
await service.stopAllPlugins();
|
||||
@@ -578,7 +729,7 @@ describe("PluginService", () => {
|
||||
managedSources,
|
||||
});
|
||||
await service.start();
|
||||
expect(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
expect.objectContaining({ id: "failed-update", status: "failed" }),
|
||||
]);
|
||||
|
||||
@@ -589,17 +740,87 @@ describe("PluginService", () => {
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "fixed"], { cwd: repository });
|
||||
|
||||
await expect(service.updateSources("failed-update")).resolves.toEqual([
|
||||
expect.objectContaining({ id: "failed-update", updated: true }),
|
||||
await expect(applyReviewedUpdate(service, "failed-update")).resolves.toEqual([
|
||||
expect.objectContaining({ id: "failed-update", outcome: "updated" }),
|
||||
]);
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(starts[1]).not.toBe(initial.directory);
|
||||
expect(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }),
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it.each([false, true])(
|
||||
"cleans a failed update when restoration also fails=%s",
|
||||
async (failRestore) => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-"));
|
||||
roots.push(home);
|
||||
const repository = await createPlugin("recovery", "export default () => () => {};\n");
|
||||
for (const args of [
|
||||
["init", "-b", "main"],
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const running = new Set<string>();
|
||||
let starts = 0;
|
||||
const runtime: TestPluginRuntime = {
|
||||
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||
invoke: async () => undefined,
|
||||
getLogs: () => [],
|
||||
clearLogs: () => undefined,
|
||||
validatePlugin: async () => undefined,
|
||||
startPlugin: async (id) => {
|
||||
starts++;
|
||||
if (starts === 2) throw new Error("candidate activation failed");
|
||||
if (starts === 3 && failRestore) throw new Error("prior activation failed");
|
||||
running.add(id);
|
||||
},
|
||||
stopPluginById: async (id) => running.delete(id),
|
||||
stopAll: async () => running.clear(),
|
||||
subscribe: () => () => undefined,
|
||||
bindPaseoSessionHost: () => undefined,
|
||||
};
|
||||
const service = createService(
|
||||
home,
|
||||
{},
|
||||
{ runtime, managedSources: new ManagedPluginSources(home) },
|
||||
);
|
||||
await service.start();
|
||||
const installed = await service.installSource({ source: pathToFileURL(repository).href });
|
||||
await writeFile(path.join(repository, "revision.txt"), "new");
|
||||
for (const args of [
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const result = await applyReviewedUpdate(service, "recovery");
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
outcome: "error",
|
||||
error: expect.stringContaining(
|
||||
failRestore ? "restoring previous plugin also failed" : "candidate activation failed",
|
||||
),
|
||||
},
|
||||
]);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{
|
||||
path: installed.path,
|
||||
enabled: true,
|
||||
status: failRestore ? "failed" : "running",
|
||||
installation: installed.installation,
|
||||
},
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([
|
||||
path.basename(path.dirname(installed.path)),
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
await service.stopAllPlugins();
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("disables and removes a plugin without touching its source directory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
@@ -625,7 +846,7 @@ export default function contribute(plugin: unknown) {
|
||||
expect(await readFile(cleanupFile, "utf8")).toBe("cleaned");
|
||||
await service.removePlugin("cleanup-plugin");
|
||||
|
||||
expect(await service.listPlugins()).toEqual([]);
|
||||
expect(await service.listPlugins()).toMatchObject([]);
|
||||
await expect(stat(directory)).resolves.toMatchObject({});
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
@@ -684,7 +905,7 @@ export default function contribute(plugin: unknown) {
|
||||
await service.stopAllPlugins();
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
expect(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{ id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" },
|
||||
]);
|
||||
});
|
||||
@@ -799,7 +1020,7 @@ export default function contribute(plugin: unknown) {
|
||||
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
|
||||
"startup exploded",
|
||||
);
|
||||
expect(await service.listPlugins()).toEqual([
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
expect.objectContaining({
|
||||
id: "legacy-plugin",
|
||||
status: "failed",
|
||||
@@ -848,3 +1069,282 @@ export default function contribute(plugin: unknown) {
|
||||
expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npm plugin installation", () => {
|
||||
it("preserves dependencies and metadata, and reloads without npm", async () => {
|
||||
const packages = npmPluginPackages();
|
||||
const fixture = packages[1];
|
||||
packages.push({ ...fixture, version: "2.0.0", tags: ["future"] });
|
||||
const failures: Array<[string, Record<string, string>]> = [
|
||||
["bad-manifest", { "paseo-plugin.json": "{}" }],
|
||||
[
|
||||
"bad-build",
|
||||
{
|
||||
"paseo-plugin.json": JSON.stringify({
|
||||
id: "bad-build",
|
||||
requirements: { paseo: ">=0.4.0" },
|
||||
build: [[process.execPath, "-e", "throw new Error('preparation failed')"]],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
"bad-requirements",
|
||||
{
|
||||
"paseo-plugin.json": JSON.stringify({
|
||||
id: "bad-requirements",
|
||||
requirements: { paseo: ">=999.0.0" },
|
||||
}),
|
||||
},
|
||||
],
|
||||
["bad-code", { "index.server.ts": "invalid syntax !!!" }],
|
||||
[
|
||||
"bad-activation",
|
||||
{ "index.server.ts": "export default () => { throw new Error('activation failed'); };" },
|
||||
],
|
||||
];
|
||||
const failedPackages = [];
|
||||
for (const [name, overrides] of failures) {
|
||||
failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } });
|
||||
}
|
||||
const registry = await startNpmRegistry([
|
||||
...packages,
|
||||
{
|
||||
name: "paseo-prepared-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { "paseo-fixture-dependency": "1.0.0" },
|
||||
scripts: { postinstall: "node -e \"throw new Error('npm lifecycle must not run')\"" },
|
||||
files: {
|
||||
"nested/paseo-plugin.json": JSON.stringify({
|
||||
id: "prepared",
|
||||
requirements: { paseo: ">=0.4.0" },
|
||||
build: [
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"require('node:fs').mkdirSync('server'); require('node:fs').writeFileSync('server/generated.js', 'export default 42;');",
|
||||
],
|
||||
],
|
||||
}),
|
||||
"nested/index.server.ts": `import generated from "./server/generated.js";
|
||||
import dependency from "paseo-fixture-dependency";
|
||||
import { z } from "zod";
|
||||
export default function contribute() {
|
||||
if (generated !== 42 || z.string().parse(dependency) !== "dependency loaded") throw new Error("Preparation failed");
|
||||
return () => {};
|
||||
}`,
|
||||
},
|
||||
},
|
||||
...failedPackages,
|
||||
]);
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-"));
|
||||
roots.push(home);
|
||||
const previousConfig = process.env.npm_config_userconfig;
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.npm_config_userconfig = registry.userconfig;
|
||||
const store = createStore(home);
|
||||
const open = () =>
|
||||
bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
|
||||
managedSources: new ManagedPluginSources(home),
|
||||
}),
|
||||
);
|
||||
let service = open();
|
||||
try {
|
||||
await service.start();
|
||||
const installed = await service.installSource({ source: "npm:paseo-fixture-plugin@^1.0.0" });
|
||||
expect(installed).toMatchObject({
|
||||
id: "npm-review",
|
||||
status: "running",
|
||||
installation: {
|
||||
identity: { kind: "npm", packageName: "paseo-fixture-plugin", pluginPath: "." },
|
||||
currentRevision: "1.1.0",
|
||||
},
|
||||
});
|
||||
expect(installed.commit).toBeUndefined();
|
||||
expect(installed.remote).toBeUndefined();
|
||||
expect(
|
||||
service
|
||||
.getLogs("npm-review")
|
||||
.map((entry) => entry.message)
|
||||
.join("\n"),
|
||||
).toContain("dependency loaded");
|
||||
const installRoot = path.dirname(path.dirname(installed.path));
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
|
||||
"npm-review"
|
||||
],
|
||||
).toEqual({ kind: "npm" });
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")).packages[
|
||||
"node_modules/paseo-fixture-dependency"
|
||||
].version,
|
||||
).toBe("1.0.0");
|
||||
for (const [source, id, version] of [
|
||||
["npm:paseo-fixture-plugin@1.0.0", "exact", "1.0.0"],
|
||||
["paseo-fixture-plugin@stable", "tag", "1.0.0"],
|
||||
["npm:@paseo-fixture/review", "scoped", "2.0.0"],
|
||||
]) {
|
||||
expect(await service.installSource({ source, id })).toMatchObject({
|
||||
id,
|
||||
status: "running",
|
||||
installation: { currentRevision: version },
|
||||
});
|
||||
}
|
||||
const [preview] = await service.previewUpdates({ pluginId: "exact" });
|
||||
expect(preview).toMatchObject({
|
||||
outcome: "update",
|
||||
current: { currentRevision: "1.0.0" },
|
||||
target: { version: "1.1.0" },
|
||||
});
|
||||
const before = (await service.listPlugins()).find((item) => item.id === "exact")!;
|
||||
await service.disablePlugin("exact");
|
||||
registry.setTag("paseo-fixture-plugin", "latest", "2.0.0");
|
||||
const results = await service.applyUpdates([
|
||||
{ ...preview!.proposal!, id: "missing-plugin" },
|
||||
preview!.proposal!,
|
||||
]);
|
||||
expect(results).toMatchObject([
|
||||
{ outcome: "error" },
|
||||
{
|
||||
outcome: "updated",
|
||||
plugin: { enabled: false, installation: { currentRevision: "1.1.0" } },
|
||||
},
|
||||
]);
|
||||
expect(results[1]?.plugin?.installation?.identity).toEqual(before.installation?.identity);
|
||||
registry.setTag("paseo-fixture-plugin", "latest", "1.1.0");
|
||||
expect(await service.applyUpdates([preview!.proposal!])).toMatchObject([
|
||||
{ outcome: "error", error: expect.stringContaining("changed since review") },
|
||||
]);
|
||||
await service.enablePlugin("exact");
|
||||
const [future] = await service.previewUpdates({
|
||||
pluginId: "exact",
|
||||
target: { kind: "npm", version: "future" },
|
||||
});
|
||||
expect(await service.applyUpdates([future!.proposal!])).toMatchObject([
|
||||
{ outcome: "updated", plugin: { installation: { currentRevision: "2.0.0" } } },
|
||||
]);
|
||||
const [newer] = await service.previewUpdates({ pluginId: "exact" });
|
||||
expect(newer).toMatchObject({
|
||||
outcome: "installed-newer",
|
||||
current: { currentRevision: "2.0.0" },
|
||||
target: { version: "1.1.0" },
|
||||
});
|
||||
expect(newer?.proposal).toBeUndefined();
|
||||
const [downgrade] = await service.previewUpdates({
|
||||
pluginId: "exact",
|
||||
target: { kind: "npm", version: "1.0.0" },
|
||||
});
|
||||
const wrongArtifact = {
|
||||
...downgrade!.proposal!,
|
||||
target: { ...downgrade!.proposal!.target, integrity: "sha512-invalid" },
|
||||
};
|
||||
expect(await service.applyUpdates([wrongArtifact])).toMatchObject([{ outcome: "error" }]);
|
||||
expect(await service.applyUpdates([downgrade!.proposal!])).toMatchObject([
|
||||
{ outcome: "updated", plugin: { installation: { currentRevision: "1.0.0" } } },
|
||||
]);
|
||||
expect((await service.previewUpdates({ pluginId: "exact" }))[0]?.outcome).toBe("update");
|
||||
await expect(service.updateSources("exact")).rejects.toThrow("Update the client");
|
||||
const prepared = await service.installSource({
|
||||
source: "npm:paseo-prepared-plugin@1.0.0:nested",
|
||||
});
|
||||
expect(prepared).toMatchObject({ id: "prepared", status: "running" });
|
||||
expect(await readFile(path.join(prepared.path, "server/generated.js"), "utf8")).toBe(
|
||||
"export default 42;",
|
||||
);
|
||||
expect(prepared.installation).toMatchObject({ identity: { pluginPath: "nested" } });
|
||||
await service.removePlugin("prepared");
|
||||
expect(registry.requests).not.toContain("/@getpaseo/plugin");
|
||||
await expect(service.installSource({ source: "npm:missing-plugin" })).rejects.toThrow();
|
||||
await expect(
|
||||
service.installSource({ source: "npm:paseo-fixture-plugin", ref: "main" }),
|
||||
).rejects.toThrow("--ref is only valid for Git");
|
||||
await expect(service.installSource({ source: "npm:paseo-fixture-plugin" })).rejects.toThrow(
|
||||
"already configured",
|
||||
);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
for (const name of [
|
||||
"bad-manifest",
|
||||
"bad-build",
|
||||
"bad-requirements",
|
||||
"bad-code",
|
||||
"bad-activation",
|
||||
]) {
|
||||
await expect(service.installSource({ source: `npm:${name}`, id: name })).rejects.toThrow();
|
||||
expect((await service.listPlugins()).map((plugin) => plugin.id)).toEqual([
|
||||
"exact",
|
||||
"npm-review",
|
||||
"scoped",
|
||||
"tag",
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
}
|
||||
await service.disablePlugin("scoped");
|
||||
await service.stopAllPlugins();
|
||||
const recordsPath = path.join(home, "plugins", "sources.json");
|
||||
const oldRecords = JSON.parse(await readFile(recordsPath, "utf8"));
|
||||
Object.assign(oldRecords["npm-review"], {
|
||||
requestedSpec: "0.0.1",
|
||||
version: "0.0.1",
|
||||
packageName: "obsolete",
|
||||
installRoot: "/obsolete",
|
||||
integrity: "obsolete",
|
||||
});
|
||||
await writeFile(recordsPath, JSON.stringify(oldRecords));
|
||||
process.env.PATH = path.join(home, "no-executables");
|
||||
service = open();
|
||||
await service.start();
|
||||
expect(await service.listPlugins()).toMatchObject(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "npm-review",
|
||||
status: "running",
|
||||
installation: installed.installation,
|
||||
}),
|
||||
expect.objectContaining({ id: "scoped", enabled: false, status: "disabled" }),
|
||||
]),
|
||||
);
|
||||
expect(await service.reloadPlugin("npm-review")).toMatchObject({
|
||||
status: "running",
|
||||
installation: installed.installation,
|
||||
});
|
||||
await expect(
|
||||
service.installSource({ source: "npm:paseo-fixture-plugin", id: "missing-npm" }),
|
||||
).rejects.toThrow("npm is required on the daemon host");
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
const packagePath = path.join(installed.path, "package.json");
|
||||
const packageJson = await readFile(packagePath, "utf8");
|
||||
await writeFile(
|
||||
packagePath,
|
||||
JSON.stringify({ ...JSON.parse(packageJson), version: "99.0.0" }),
|
||||
);
|
||||
const corrupt = (await service.listPlugins()).find((plugin) => plugin.id === "npm-review")!;
|
||||
expect(corrupt.status).toBe("running");
|
||||
expect(corrupt.installation).toEqual({ identity: installed.installation!.identity });
|
||||
expect((await service.previewUpdates({ pluginId: "npm-review" }))[0]).toMatchObject({
|
||||
outcome: "error",
|
||||
error: expect.stringContaining("Installed revision is unavailable"),
|
||||
});
|
||||
expect(await service.reloadPlugin("npm-review")).toMatchObject({ status: "running" });
|
||||
await writeFile(packagePath, packageJson);
|
||||
await service.removePlugin("npm-review");
|
||||
await expect(stat(installRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
|
||||
"npm-review"
|
||||
],
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
process.env.PATH = previousPath;
|
||||
if (previousConfig === undefined) delete process.env.npm_config_userconfig;
|
||||
else process.env.npm_config_userconfig = previousConfig;
|
||||
await service.stopAllPlugins();
|
||||
await registry.close();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
async function applyReviewedUpdate(service: PluginService, pluginId: string) {
|
||||
const [preview] = await service.previewUpdates({ pluginId });
|
||||
expect(preview?.outcome).toBe("update");
|
||||
return service.applyUpdates([preview!.proposal!]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
type PluginSource,
|
||||
type PluginSourceStatusItem,
|
||||
type PluginSourceUpdateItem,
|
||||
type PluginUpdateSelection,
|
||||
type PluginUpdateProposal,
|
||||
type PluginUpdatePreview,
|
||||
type PluginUpdateResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
@@ -163,13 +167,15 @@ export class PluginService {
|
||||
};
|
||||
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.installation = await this.managedSources
|
||||
?.describe(id, source.path)
|
||||
.catch(() => undefined);
|
||||
if (!this.managedSources)
|
||||
item.installation = { identity: { kind: "directory", path: path.resolve(source.path) } };
|
||||
if (item.installation?.identity.kind === "git") {
|
||||
item.source = "git";
|
||||
const remote = this.managedSources?.displayRemote(id);
|
||||
if (remote) item.remote = remote;
|
||||
item.ref = managed.requestedRef ?? managed.trackingBranch ?? managed.commit;
|
||||
item.commit = managed.commit;
|
||||
item.remote = item.installation.identity.remote;
|
||||
item.commit = item.installation.currentRevision;
|
||||
}
|
||||
const error = this.errors.get(id);
|
||||
if (error && item.status === "failed") item.error = error;
|
||||
@@ -224,12 +230,14 @@ export class PluginService {
|
||||
ref?: string;
|
||||
}): Promise<PluginListItem> {
|
||||
const directDirectory = path.resolve(input.source);
|
||||
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
|
||||
const directInfo = await stat(directDirectory).catch(() => null);
|
||||
const reference = directInfo?.isDirectory()
|
||||
? { source: input.source, pluginPath: undefined }
|
||||
: parsePluginSourceReference(input.source);
|
||||
const directory = path.resolve(reference.source);
|
||||
const info = directInfo?.isDirectory() ? directInfo : await stat(directory).catch(() => null);
|
||||
let info = directInfo;
|
||||
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
|
||||
if (info?.isDirectory()) {
|
||||
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
|
||||
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
|
||||
@@ -245,32 +253,36 @@ export class PluginService {
|
||||
let pluginId: string;
|
||||
try {
|
||||
await this.checkRequirements(candidate.directory);
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
|
||||
if (this.configStore.get().plugins?.[pluginId]) {
|
||||
throw new Error(
|
||||
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
||||
);
|
||||
}
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
await this.validateCandidate(candidate);
|
||||
await managedSources.verifyCandidate(pluginId, candidate);
|
||||
} catch (error) {
|
||||
await managedSources.discard(candidate);
|
||||
throw error;
|
||||
}
|
||||
const sources = {
|
||||
...this.configStore.get().plugins,
|
||||
[pluginId]: { source: "directory" as const, path: candidate.directory, enabled: true },
|
||||
};
|
||||
managedSources.commit(pluginId, candidate.record);
|
||||
this.configStore.patch({ plugins: sources });
|
||||
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
|
||||
this.notify(pluginId);
|
||||
const installed = await this.requireItem(pluginId);
|
||||
if (installed.status === "failed") {
|
||||
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
|
||||
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
|
||||
try {
|
||||
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
|
||||
managedSources.commit(pluginId, candidate.record);
|
||||
} catch (error) {
|
||||
await this.stopPlugin(pluginId);
|
||||
const sources = { ...this.configStore.get().plugins };
|
||||
delete sources[pluginId];
|
||||
this.configStore.patch({ plugins: sources });
|
||||
await managedSources.discard(candidate);
|
||||
this.errors.delete(pluginId);
|
||||
this.notify(pluginId);
|
||||
throw error;
|
||||
}
|
||||
return installed;
|
||||
this.notify(pluginId);
|
||||
return this.requireItem(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,15 +301,54 @@ export class PluginService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
|
||||
// COMPAT(plugin-immediate-update): added in v0.8.0; remove after 2027-03-16 when clients use reviewed targets.
|
||||
async updateSources(_pluginId?: string): Promise<PluginSourceUpdateItem[]> {
|
||||
throw new Error("Update the client to review plugin updates before applying them.");
|
||||
}
|
||||
|
||||
async previewUpdates(input: {
|
||||
pluginId?: string;
|
||||
target?: PluginUpdateSelection;
|
||||
}): Promise<PluginUpdatePreview[]> {
|
||||
if (input.target && !input.pluginId)
|
||||
throw new Error("An explicit update target requires one plugin ID");
|
||||
const sources = this.configStore.get().plugins ?? {};
|
||||
const ids = input.pluginId ? [input.pluginId] : Object.keys(sources).sort();
|
||||
return Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
return await this.requireManagedSources().preview(
|
||||
id,
|
||||
this.requireSource(id).path,
|
||||
input.target,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
outcome: "error" as const,
|
||||
links: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async applyUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]> {
|
||||
return this.enqueue(async () => {
|
||||
const sources = this.configStore.get().plugins ?? {};
|
||||
const ids = pluginId
|
||||
? [pluginId]
|
||||
: Object.keys(sources).filter((id) => this.managedSources?.get(id));
|
||||
const updates: PluginSourceUpdateItem[] = [];
|
||||
for (const id of ids.sort()) updates.push(await this.updateSource(id));
|
||||
return updates;
|
||||
const results: PluginUpdateResult[] = [];
|
||||
for (const proposal of proposals) {
|
||||
try {
|
||||
results.push(await this.updateSource(proposal));
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id: proposal.id,
|
||||
outcome: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -533,59 +584,51 @@ export class PluginService {
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
}
|
||||
|
||||
private async updateSource(pluginId: string): Promise<PluginSourceUpdateItem> {
|
||||
private async updateSource(proposal: PluginUpdateProposal): Promise<PluginUpdateResult> {
|
||||
const pluginId = proposal.id;
|
||||
const managedSources = this.requireManagedSources();
|
||||
const source = this.requireSource(pluginId);
|
||||
const previous = managedSources.get(pluginId);
|
||||
if (!previous) throw new Error(`Plugin is not managed by Git: ${pluginId}`);
|
||||
const prepared = await managedSources.prepareUpdate(pluginId, source.path);
|
||||
if (!prepared.candidate) {
|
||||
return {
|
||||
id: pluginId,
|
||||
previousCommit: previous.commit,
|
||||
currentCommit: previous.commit,
|
||||
commits: 0,
|
||||
updated: false,
|
||||
};
|
||||
}
|
||||
let candidate = prepared.candidate;
|
||||
let candidate = await managedSources.prepareUpdate(proposal, source.path);
|
||||
try {
|
||||
await this.checkRequirements(candidate.directory);
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
await this.validateCandidate(candidate);
|
||||
await managedSources.verifyCandidate(pluginId, candidate, proposal.target);
|
||||
await managedSources.assertCurrent(proposal, this.requireSource(pluginId).path);
|
||||
} catch (error) {
|
||||
await managedSources.discard(candidate);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const current = this.configStore.get().plugins?.[pluginId];
|
||||
if (!current) {
|
||||
await managedSources.discard(candidate);
|
||||
throw new Error(`Plugin is no longer configured: ${pluginId}`);
|
||||
}
|
||||
const current = this.requireSource(pluginId);
|
||||
const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId);
|
||||
const isEnabled = current.enabled !== false;
|
||||
const isGloballyEnabled = this.configStore.get().pluginsEnabled === true;
|
||||
const shouldActivate = isEnabled && isGloballyEnabled;
|
||||
const shouldActivate =
|
||||
current.enabled !== false && this.configStore.get().pluginsEnabled === true;
|
||||
if (shouldActivate) {
|
||||
if (isRunning) await this.stopPlugin(pluginId);
|
||||
try {
|
||||
await this.startExplicit(pluginId, candidate.directory);
|
||||
} catch (error) {
|
||||
this.errors.delete(pluginId);
|
||||
const latest = this.configStore.get().plugins?.[pluginId];
|
||||
const isStillConfigured = latest !== undefined;
|
||||
const isStillEnabled = isStillConfigured && latest.enabled !== false;
|
||||
const isStillGloballyEnabled = this.configStore.get().pluginsEnabled === true;
|
||||
const canRestore = isRunning && isStillEnabled && isStillGloballyEnabled;
|
||||
if (canRestore) await this.startExplicit(pluginId, source.path);
|
||||
await managedSources.discard(candidate);
|
||||
this.notify(pluginId);
|
||||
let recoveryError: unknown;
|
||||
try {
|
||||
this.errors.delete(pluginId);
|
||||
if (isRunning && this.canPublish(pluginId))
|
||||
await this.startExplicit(pluginId, source.path);
|
||||
} catch (restoreError) {
|
||||
recoveryError = restoreError;
|
||||
} finally {
|
||||
await managedSources.discard(candidate);
|
||||
this.notify(pluginId);
|
||||
}
|
||||
if (recoveryError)
|
||||
throw new Error(
|
||||
`Update failed: ${error instanceof Error ? error.message : String(error)}; restoring previous plugin also failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const activatedSource = this.configStore.get().plugins?.[pluginId];
|
||||
if (!activatedSource) {
|
||||
await this.stopPlugin(pluginId);
|
||||
@@ -593,28 +636,31 @@ export class PluginService {
|
||||
throw new Error(`Plugin is no longer configured: ${pluginId}`);
|
||||
}
|
||||
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
|
||||
managedSources.commit(pluginId, candidate.record);
|
||||
await managedSources.removeVersion(previous);
|
||||
this.errors.delete(pluginId);
|
||||
this.notify(pluginId);
|
||||
let warning: string | undefined;
|
||||
try {
|
||||
await managedSources.removeVersion(pluginId, source.path);
|
||||
} catch (error) {
|
||||
warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
return {
|
||||
id: pluginId,
|
||||
previousCommit: previous.commit,
|
||||
currentCommit: candidate.record.commit,
|
||||
commits: prepared.commits,
|
||||
updated: true,
|
||||
outcome: "updated",
|
||||
plugin: await this.requireItem(pluginId),
|
||||
...(warning ? { warning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> {
|
||||
if (!this.runtime.validatePlugin) {
|
||||
throw new Error("Plugin runtime cannot validate managed Git sources");
|
||||
throw new Error("Plugin runtime cannot validate managed sources");
|
||||
}
|
||||
return this.runtime.validatePlugin(candidate.directory);
|
||||
}
|
||||
|
||||
private requireManagedSources(): ManagedPluginSources {
|
||||
if (!this.managedSources) throw new Error("Git plugin management is unavailable");
|
||||
if (!this.managedSources) throw new Error("Plugin source management is unavailable");
|
||||
return this.managedSources;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rename, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -41,24 +43,72 @@ describe("managed Git plugin sources", () => {
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
const failure = sources.prepareInstall({
|
||||
source: "https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||
});
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
});
|
||||
|
||||
it("tracks branches, prepares updates, persists commits, and keeps tags pinned", async () => {
|
||||
it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => {
|
||||
const repository = await createRepository();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-"));
|
||||
roots.push(home);
|
||||
const overlay = {
|
||||
GIT_CONFIG_COUNT: "1",
|
||||
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
|
||||
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
|
||||
};
|
||||
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
|
||||
Object.assign(process.env, overlay);
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home);
|
||||
for (const source of [
|
||||
"github:fixture/repository",
|
||||
"fixture/repository",
|
||||
"git:fixture/repository",
|
||||
]) {
|
||||
const candidate = await sources.prepareInstall({ source });
|
||||
expect(candidate.record).toMatchObject({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
});
|
||||
await sources.discard(candidate);
|
||||
}
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("offers current default HEAD after installing a tag", async () => {
|
||||
const repository = await createRepository();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
|
||||
roots.push(home);
|
||||
const remote = pathToFileURL(repository).href;
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
let candidate = await sources.prepareInstall({ source: remote });
|
||||
let candidate = await sources.prepareInstall({ source: `git:${remote}` });
|
||||
expect(candidate.defaultId).toBe("managed-example");
|
||||
candidate = await sources.place("managed-example", candidate);
|
||||
sources.commit("managed-example", candidate.record);
|
||||
const metadataPath = path.join(home, "plugins", "sources.json");
|
||||
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
||||
delete metadata["managed-example"].kind;
|
||||
Object.assign(metadata["managed-example"], {
|
||||
commit: "obsolete",
|
||||
requestedRef: "v0",
|
||||
trackingBranch: "missing",
|
||||
checkoutRoot: "/obsolete",
|
||||
});
|
||||
await writeFile(metadataPath, JSON.stringify(metadata));
|
||||
expect(
|
||||
await new ManagedPluginSources(home).describe("managed-example", candidate.directory),
|
||||
).toMatchObject({ identity: { kind: "git", remote } });
|
||||
|
||||
const initial = candidate.record.commit;
|
||||
if (candidate.record.kind !== "git") throw new Error("Expected Git source");
|
||||
const initial = (await sources.describe("managed-example", candidate.directory))
|
||||
.currentRevision!;
|
||||
await writeFile(
|
||||
path.join(repository, "index.server.ts"),
|
||||
"export default () => () => { new Date(); };\n",
|
||||
@@ -68,29 +118,160 @@ describe("managed Git plugin sources", () => {
|
||||
expect(status).toMatchObject({
|
||||
currentCommit: initial,
|
||||
latestCommit: latest,
|
||||
commitsBehind: 1,
|
||||
updateAvailable: true,
|
||||
});
|
||||
|
||||
const prepared = await sources.prepareUpdate("managed-example", candidate.directory);
|
||||
expect(prepared.commits).toBe(1);
|
||||
if (!prepared.candidate) throw new Error("Expected an update candidate");
|
||||
const updated = await sources.place("managed-example", prepared.candidate);
|
||||
const preview = await new ManagedPluginSources(home).preview(
|
||||
"managed-example",
|
||||
candidate.directory,
|
||||
);
|
||||
expect(preview.outcome).toBe("update");
|
||||
const prepared = await sources.prepareUpdate(preview.proposal!, candidate.directory);
|
||||
const updated = await sources.place("managed-example", prepared);
|
||||
sources.commit("managed-example", updated.record);
|
||||
expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain(
|
||||
"new Date",
|
||||
);
|
||||
expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest);
|
||||
expect(
|
||||
await new ManagedPluginSources(home).describe("managed-example", updated.directory),
|
||||
).toMatchObject({ currentRevision: latest });
|
||||
expect(JSON.parse(await readFile(metadataPath, "utf8"))["managed-example"]).toEqual({
|
||||
kind: "git",
|
||||
remote,
|
||||
});
|
||||
|
||||
await runGitCommand(["tag", "v1", initial], { cwd: repository });
|
||||
let pinned = await sources.prepareInstall({ source: remote, ref: "v1" });
|
||||
pinned = await sources.place("pinned-example", pinned);
|
||||
sources.commit("pinned-example", pinned.record);
|
||||
expect(await sources.status("pinned-example", pinned.directory)).toMatchObject({
|
||||
let tagged = await sources.prepareInstall({ source: remote, ref: "v1" });
|
||||
tagged = await sources.place("tagged-example", tagged);
|
||||
sources.commit("tagged-example", tagged.record);
|
||||
expect(await sources.status("tagged-example", tagged.directory)).toMatchObject({
|
||||
currentCommit: initial,
|
||||
latestCommit: initial,
|
||||
commitsBehind: 0,
|
||||
updateAvailable: false,
|
||||
latestCommit: latest,
|
||||
updateAvailable: true,
|
||||
});
|
||||
}, 30_000);
|
||||
it("resolves branch, tag and commit installs against changed default HEAD and preserves the reviewed commit", async () => {
|
||||
const repository = await createRepository();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-review-"));
|
||||
roots.push(home);
|
||||
const remote = pathToFileURL(repository).href;
|
||||
const initial = (await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })).stdout.trim();
|
||||
await runGitCommand(["tag", "v1"], { cwd: repository });
|
||||
const sources = new ManagedPluginSources(home);
|
||||
const installations = [];
|
||||
for (const [index, ref] of [undefined, "main", "v1", initial].entries()) {
|
||||
const id = `review-${index}`;
|
||||
const candidate = await sources.place(
|
||||
id,
|
||||
await sources.prepareInstall({ source: remote, ref }),
|
||||
);
|
||||
sources.commit(id, candidate.record);
|
||||
installations.push({ id, candidate });
|
||||
}
|
||||
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
|
||||
await writeFile(path.join(repository, "revision.txt"), "reviewed");
|
||||
const reviewed = await commitAll(repository, "new default");
|
||||
const previews = await Promise.all(
|
||||
installations.map(({ id, candidate }) => sources.preview(id, candidate.directory)),
|
||||
);
|
||||
for (const preview of previews)
|
||||
expect(preview).toMatchObject({
|
||||
outcome: "update",
|
||||
current: { currentRevision: initial },
|
||||
target: { commit: reviewed },
|
||||
});
|
||||
await writeFile(path.join(repository, "revision.txt"), "moved after review");
|
||||
const moved = await commitAll(repository, "move again");
|
||||
const restarted = new ManagedPluginSources(home);
|
||||
const { id, candidate } = installations[0]!;
|
||||
const updated = await restarted.place(
|
||||
id,
|
||||
await restarted.prepareUpdate(previews[0]!.proposal!, candidate.directory),
|
||||
);
|
||||
expect(await restarted.describe(id, updated.directory)).toEqual({
|
||||
identity: previews[0]!.current!.identity,
|
||||
currentRevision: reviewed,
|
||||
});
|
||||
expect(await readFile(path.join(updated.directory, "revision.txt"), "utf8")).toBe("reviewed");
|
||||
expect(await restarted.preview(id, updated.directory)).toMatchObject({
|
||||
target: { commit: moved },
|
||||
});
|
||||
await expect(
|
||||
restarted.assertCurrent(previews[0]!.proposal!, updated.directory),
|
||||
).rejects.toThrow("changed since review");
|
||||
const sameRevision = await restarted.place(
|
||||
id,
|
||||
await restarted.prepareInstall({ source: remote, ref: initial }),
|
||||
);
|
||||
await expect(
|
||||
restarted.assertCurrent(previews[0]!.proposal!, sameRevision.directory),
|
||||
).rejects.toThrow("changed since review");
|
||||
expect(
|
||||
await restarted.preview(id, updated.directory, { kind: "git", ref: initial.slice(0, 8) }),
|
||||
).toMatchObject({ target: { commit: initial } });
|
||||
}, 30_000);
|
||||
it("uses retained acquisition remote when credential rewriting changes origin, preserving nested identity", async () => {
|
||||
const repository = await createRepository();
|
||||
const nested = path.join(repository, "plugins", "review");
|
||||
await mkdir(nested, { recursive: true });
|
||||
for (const name of ["paseo-plugin.json", "index.server.ts"])
|
||||
await rename(path.join(repository, name), path.join(nested, name));
|
||||
await commitAll(repository, "nested plugin");
|
||||
await runGitCommand(["update-server-info"], { cwd: repository });
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
const file = path.resolve(repository, ".git", `.${pathname}`);
|
||||
if (!file.startsWith(path.join(repository, ".git") + path.sep)) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
response.end(readFileSync(file));
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected HTTP port");
|
||||
const remote = `http://fixture:fake-secret@127.0.0.1:${address.port}/`;
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-credential-"));
|
||||
roots.push(home);
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home);
|
||||
const candidate = await sources.place(
|
||||
"nested",
|
||||
await sources.prepareInstall({ source: remote, pluginPath: "plugins/review" }),
|
||||
);
|
||||
sources.commit("nested", candidate.record);
|
||||
expect(
|
||||
(
|
||||
await runGitCommand(["remote", "get-url", "origin"], { cwd: candidate.directory })
|
||||
).stdout.trim(),
|
||||
).toBe("https://paseo.invalid/plugin.git");
|
||||
const before = await sources.describe("nested", candidate.directory);
|
||||
expect(before.identity).toEqual({
|
||||
kind: "git",
|
||||
remote: `http://127.0.0.1:${address.port}/`,
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
await writeFile(path.join(nested, "revision.txt"), "new");
|
||||
const latest = await commitAll(repository, "advance");
|
||||
await runGitCommand(["update-server-info"], { cwd: repository });
|
||||
const restarted = new ManagedPluginSources(home);
|
||||
const preview = await restarted.preview("nested", candidate.directory);
|
||||
expect(preview.target).toEqual({ kind: "git", commit: latest });
|
||||
const updated = await restarted.place(
|
||||
"nested",
|
||||
await restarted.prepareUpdate(preview.proposal!, candidate.directory),
|
||||
);
|
||||
expect(await restarted.describe("nested", updated.directory)).toEqual({
|
||||
identity: before.identity,
|
||||
currentRevision: latest,
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import { mkdir, realpath, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import compareVersions from "semver/functions/compare.js";
|
||||
import { z } from "zod";
|
||||
import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
PluginIdSchema,
|
||||
type PluginInstallation,
|
||||
type PluginSourceStatusItem,
|
||||
type PluginUpdatePreview,
|
||||
type PluginUpdateProposal,
|
||||
type PluginUpdateSelection,
|
||||
type PluginUpdateTarget,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import { readPluginManifest, type PluginManifest } from "./manifest.js";
|
||||
import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-source/npm.js";
|
||||
|
||||
const GIT_TIMEOUT_MS = 120_000;
|
||||
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
|
||||
|
||||
const ManagedPluginRecordSchema = z
|
||||
.object({
|
||||
remote: z.string().min(1),
|
||||
requestedRef: z.string().min(1).nullable(),
|
||||
trackingBranch: z.string().min(1).nullable(),
|
||||
commit: z.string().regex(/^[0-9a-f]{40,64}$/),
|
||||
pluginPath: z.string(),
|
||||
checkoutRoot: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordSchema);
|
||||
|
||||
export interface ManagedPluginRecord extends z.infer<typeof ManagedPluginRecordSchema> {}
|
||||
|
||||
// COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata.
|
||||
const ManagedPluginRecordSchema = z.union([
|
||||
z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }),
|
||||
z.object({ kind: z.literal("npm") }),
|
||||
]);
|
||||
export type ManagedPluginRecord = z.infer<typeof ManagedPluginRecordSchema>;
|
||||
export interface ManagedPluginCandidate {
|
||||
build: PluginManifest["build"];
|
||||
defaultId: string;
|
||||
@@ -34,67 +34,138 @@ export interface ManagedPluginCandidate {
|
||||
record: ManagedPluginRecord;
|
||||
versionRoot: string;
|
||||
}
|
||||
|
||||
interface InstallInput {
|
||||
source: string;
|
||||
ref?: string;
|
||||
pluginPath?: string;
|
||||
}
|
||||
|
||||
interface RefResolution {
|
||||
commit: string;
|
||||
requestedRef: string | null;
|
||||
trackingBranch: string | null;
|
||||
interface OwnedLocation {
|
||||
versionRoot: string;
|
||||
sourceRoot: string;
|
||||
identity: PluginInstallation["identity"];
|
||||
}
|
||||
|
||||
export class ManagedPluginSources {
|
||||
private readonly root: string;
|
||||
private readonly metadataPath: string;
|
||||
private readonly records: Record<string, ManagedPluginRecord>;
|
||||
|
||||
constructor(paseoHome: string) {
|
||||
this.root = path.join(paseoHome, "plugins");
|
||||
this.root = path.resolve(paseoHome, "plugins");
|
||||
this.metadataPath = path.join(this.root, "sources.json");
|
||||
this.records = this.readRecords();
|
||||
this.records = existsSync(this.metadataPath)
|
||||
? z
|
||||
.record(PluginIdSchema, ManagedPluginRecordSchema)
|
||||
.parse(JSON.parse(readFileSync(this.metadataPath, "utf8")))
|
||||
: {};
|
||||
}
|
||||
|
||||
get(pluginId: string): ManagedPluginRecord | null {
|
||||
return this.records[pluginId] ?? null;
|
||||
}
|
||||
|
||||
displayRemote(pluginId: string): string | null {
|
||||
const remote = this.records[pluginId]?.remote;
|
||||
return remote ? redactRemoteCredentials(remote) : null;
|
||||
}
|
||||
|
||||
async prepareInstall(input: InstallInput): Promise<ManagedPluginCandidate> {
|
||||
const remote = normalizeGitSource(input.source);
|
||||
const pluginPath = normalizePluginPath(input.pluginPath);
|
||||
const stagingRoot = await this.createStagingRoot();
|
||||
try {
|
||||
const checkoutRoot = path.join(stagingRoot, "checkout");
|
||||
await clone(remote, checkoutRoot);
|
||||
const resolution = await resolveRequestedRef(checkoutRoot, input.ref);
|
||||
await checkout(checkoutRoot, resolution.commit);
|
||||
const directory = path.resolve(checkoutRoot, pluginPath);
|
||||
assertPluginPath(checkoutRoot, directory);
|
||||
const { id: defaultId, build } = await readPluginManifest(directory);
|
||||
private locate(
|
||||
pluginId: string,
|
||||
configuredPath: string,
|
||||
record = this.records[pluginId],
|
||||
): OwnedLocation {
|
||||
if (!record) throw new Error(`Plugin is a local directory: ${pluginId}`);
|
||||
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
|
||||
const parts = path.relative(pluginRoot, path.resolve(configuredPath)).split(path.sep);
|
||||
// COMPAT(plugin-git-layout): added in v0.8.0; retain while pre-correction installations exist.
|
||||
// Git originally used <12-hex-commit>-<uuid>; the prefix is a directory name, never revision truth.
|
||||
const versionId =
|
||||
record.kind === "git" ? (parts[0] ?? "").replace(/^[0-9a-f]{12}-/, "") : (parts[0] ?? "");
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(versionId) ||
|
||||
parts.some((part) => part === "..")
|
||||
)
|
||||
throw new Error(`Plugin directory is outside its managed installation: ${pluginId}`);
|
||||
const versionRoot = path.join(pluginRoot, parts[0]!);
|
||||
if (record.kind === "git") {
|
||||
if (parts[1] !== "checkout")
|
||||
throw new Error(`Invalid Git installation directory: ${pluginId}`);
|
||||
return {
|
||||
build,
|
||||
defaultId,
|
||||
directory,
|
||||
record: {
|
||||
remote,
|
||||
requestedRef: resolution.requestedRef,
|
||||
trackingBranch: resolution.trackingBranch,
|
||||
commit: resolution.commit,
|
||||
pluginPath,
|
||||
checkoutRoot,
|
||||
versionRoot,
|
||||
sourceRoot: path.join(versionRoot, "checkout"),
|
||||
identity: {
|
||||
kind: "git",
|
||||
remote: redactRemoteCredentials(record.remote),
|
||||
pluginPath: parts.slice(2).join("/") || ".",
|
||||
},
|
||||
versionRoot: stagingRoot,
|
||||
};
|
||||
}
|
||||
if (parts[1] !== "node_modules" || !parts[2])
|
||||
throw new Error(`Invalid npm installation directory: ${pluginId}`);
|
||||
const packageParts = parts[2].startsWith("@") ? 2 : 1;
|
||||
const packageName = parts.slice(2, 2 + packageParts).join("/");
|
||||
if (!isNpmSource(packageName)) throw new Error(`Invalid npm package directory: ${pluginId}`);
|
||||
return {
|
||||
versionRoot,
|
||||
sourceRoot: path.join(versionRoot, "node_modules", packageName),
|
||||
identity: {
|
||||
kind: "npm",
|
||||
packageName,
|
||||
pluginPath: parts.slice(2 + packageParts).join("/") || ".",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async describe(pluginId: string, configuredPath: string): Promise<PluginInstallation> {
|
||||
if (!this.records[pluginId])
|
||||
return { identity: { kind: "directory", path: path.resolve(configuredPath) } };
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
try {
|
||||
return {
|
||||
identity: location.identity,
|
||||
currentRevision: await this.revision(location, configuredPath),
|
||||
};
|
||||
} catch {
|
||||
return { identity: location.identity };
|
||||
}
|
||||
}
|
||||
|
||||
private async revision(location: OwnedLocation, configuredPath: string): Promise<string> {
|
||||
await assertRealContainment(this.root, location.versionRoot);
|
||||
await assertRealContainment(location.versionRoot, configuredPath);
|
||||
return location.identity.kind === "npm"
|
||||
? (await readNpmArtifact(location.versionRoot, location.identity.packageName)).version
|
||||
: revParse(location.sourceRoot, "HEAD");
|
||||
}
|
||||
|
||||
async prepareInstall(
|
||||
input: InstallInput,
|
||||
target?: PluginUpdateTarget,
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
const pluginPath = normalizePluginPath(input.pluginPath);
|
||||
const versionRoot = await this.createStagingRoot();
|
||||
try {
|
||||
let record: ManagedPluginRecord;
|
||||
let sourceRoot: string;
|
||||
if (isNpmSource(input.source)) {
|
||||
if (input.ref)
|
||||
throw new Error(
|
||||
"Plugin --ref is only valid for Git sources; use npm:package@version for npm",
|
||||
);
|
||||
const artifact = await acquireNpm(
|
||||
input.source,
|
||||
versionRoot,
|
||||
target?.kind === "npm" ? target : undefined,
|
||||
);
|
||||
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
|
||||
record = { kind: "npm" };
|
||||
} else {
|
||||
const remote = normalizeGitSource(input.source);
|
||||
sourceRoot = path.join(versionRoot, "checkout");
|
||||
await clone(remote, sourceRoot);
|
||||
const commit = await resolveRequestedRef(sourceRoot, input.ref);
|
||||
if (target?.kind === "git" && commit !== target.commit)
|
||||
throw new Error("Git target changed since review");
|
||||
await checkout(sourceRoot, commit);
|
||||
record = { kind: "git", remote };
|
||||
}
|
||||
const directory = path.resolve(sourceRoot, pluginPath);
|
||||
assertPluginPath(sourceRoot, directory);
|
||||
await assertRealContainment(sourceRoot, directory);
|
||||
const { id: defaultId, build } = await readPluginManifest(directory);
|
||||
return { build, defaultId, directory, record, versionRoot };
|
||||
} catch (error) {
|
||||
await rm(stagingRoot, { recursive: true, force: true });
|
||||
await rm(versionRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -103,144 +174,236 @@ export class ManagedPluginSources {
|
||||
pluginId: string,
|
||||
candidate: ManagedPluginCandidate,
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
const pluginRoot = path.join(this.root, pluginId);
|
||||
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
|
||||
ensurePrivateDirectory(pluginRoot);
|
||||
const versionRoot = path.join(
|
||||
pluginRoot,
|
||||
`${candidate.record.commit.slice(0, 12)}-${randomUUID()}`,
|
||||
);
|
||||
await assertRealContainment(this.root, pluginRoot);
|
||||
const versionRoot = path.join(pluginRoot, randomUUID());
|
||||
const relativeDirectory = path.relative(candidate.versionRoot, candidate.directory);
|
||||
await rename(candidate.versionRoot, versionRoot);
|
||||
const checkoutRoot = path.join(versionRoot, "checkout");
|
||||
const directory = path.resolve(checkoutRoot, candidate.record.pluginPath);
|
||||
return { ...candidate, directory: path.join(versionRoot, relativeDirectory), versionRoot };
|
||||
}
|
||||
|
||||
async preview(
|
||||
pluginId: string,
|
||||
configuredPath: string,
|
||||
selection?: PluginUpdateSelection,
|
||||
): Promise<PluginUpdatePreview> {
|
||||
const current = await this.describe(pluginId, configuredPath);
|
||||
if (current.identity.kind === "directory") {
|
||||
if (selection) throw new Error(`Plugin is a local directory: ${pluginId}`);
|
||||
return { id: pluginId, outcome: "local", current, links: [] };
|
||||
}
|
||||
if (!current.currentRevision)
|
||||
throw new Error(
|
||||
`Installed revision is unavailable for ${pluginId}; restore its acquisition artifacts before updating`,
|
||||
);
|
||||
if (selection && selection.kind !== current.identity.kind)
|
||||
throw new Error(`Update target does not match ${current.identity.kind} source: ${pluginId}`);
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
let target: PluginUpdateTarget;
|
||||
let outcome: PluginUpdatePreview["outcome"];
|
||||
if (current.identity.kind === "npm") {
|
||||
target = {
|
||||
kind: "npm",
|
||||
...(await resolveNpm(
|
||||
current.identity.packageName,
|
||||
selection?.kind === "npm" ? selection.version : "latest",
|
||||
this.root,
|
||||
)),
|
||||
};
|
||||
const comparison = compareVersions(target.version, current.currentRevision);
|
||||
if (comparison === 0) outcome = "current";
|
||||
else if (comparison < 0 && !selection) outcome = "installed-newer";
|
||||
else outcome = "update";
|
||||
} else {
|
||||
const record = this.records[pluginId];
|
||||
if (record?.kind !== "git") throw new Error("Git source is no longer configured");
|
||||
target = {
|
||||
kind: "git",
|
||||
commit: await resolveRemoteCommit(
|
||||
record.remote,
|
||||
this.root,
|
||||
selection?.kind === "git" ? selection.ref : undefined,
|
||||
),
|
||||
};
|
||||
outcome = target.commit === current.currentRevision ? "current" : "update";
|
||||
}
|
||||
const links = reviewLinks(current, target);
|
||||
return {
|
||||
...candidate,
|
||||
directory,
|
||||
versionRoot,
|
||||
record: { ...candidate.record, checkoutRoot },
|
||||
id: pluginId,
|
||||
outcome,
|
||||
current,
|
||||
target,
|
||||
links,
|
||||
...(outcome === "update"
|
||||
? {
|
||||
proposal: {
|
||||
id: pluginId,
|
||||
expected: {
|
||||
identity: current.identity,
|
||||
installationRoot: location.versionRoot,
|
||||
revision: current.currentRevision,
|
||||
},
|
||||
target,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async status(pluginId: string, configuredPath: string): Promise<PluginSourceStatusItem> {
|
||||
const record = this.records[pluginId];
|
||||
if (!record) return { id: pluginId, source: "directory", path: configuredPath };
|
||||
if (!record.trackingBranch) {
|
||||
return {
|
||||
id: pluginId,
|
||||
source: "git",
|
||||
path: configuredPath,
|
||||
remote: redactRemoteCredentials(record.remote),
|
||||
ref: record.requestedRef ?? record.commit,
|
||||
currentCommit: record.commit,
|
||||
latestCommit: record.commit,
|
||||
commitsBehind: 0,
|
||||
updateAvailable: false,
|
||||
};
|
||||
}
|
||||
await fetchOrigin(record.checkoutRoot);
|
||||
const latestCommit = await revParse(
|
||||
record.checkoutRoot,
|
||||
`refs/remotes/origin/${record.trackingBranch}^{commit}`,
|
||||
);
|
||||
const commitsBehind = await countCommits(record.checkoutRoot, record.commit, latestCommit);
|
||||
return {
|
||||
id: pluginId,
|
||||
source: "git",
|
||||
path: configuredPath,
|
||||
remote: redactRemoteCredentials(record.remote),
|
||||
ref: record.requestedRef ?? record.trackingBranch,
|
||||
currentCommit: record.commit,
|
||||
latestCommit,
|
||||
commitsBehind,
|
||||
updateAvailable: latestCommit !== record.commit,
|
||||
};
|
||||
async assertCurrent(proposal: PluginUpdateProposal, configuredPath: string): Promise<void> {
|
||||
const current = await this.describe(proposal.id, configuredPath);
|
||||
const location = this.locate(proposal.id, configuredPath);
|
||||
if (
|
||||
!isDeepStrictEqual(current.identity, proposal.expected.identity) ||
|
||||
current.currentRevision !== proposal.expected.revision ||
|
||||
location.versionRoot !== proposal.expected.installationRoot
|
||||
)
|
||||
throw new Error(`Plugin ${proposal.id} changed since review; check again`);
|
||||
if (proposal.target.kind !== current.identity.kind)
|
||||
throw new Error("Update target does not match the installed source");
|
||||
}
|
||||
|
||||
async prepareUpdate(
|
||||
pluginId: string,
|
||||
proposal: PluginUpdateProposal,
|
||||
configuredPath: string,
|
||||
): Promise<{ candidate: ManagedPluginCandidate | null; commits: number }> {
|
||||
const record = this.records[pluginId];
|
||||
if (!record) throw new Error(`Plugin is not managed by Git: ${pluginId}`);
|
||||
const status = await this.status(pluginId, configuredPath);
|
||||
const latestCommit = status.latestCommit;
|
||||
if (!latestCommit || latestCommit === record.commit) return { candidate: null, commits: 0 };
|
||||
const candidate = await this.prepareCommit(record, latestCommit);
|
||||
return { candidate, commits: status.commitsBehind ?? 0 };
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
await this.assertCurrent(proposal, configuredPath);
|
||||
const identity = this.locate(proposal.id, configuredPath).identity;
|
||||
const record = this.records[proposal.id];
|
||||
if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git")
|
||||
return this.prepareInstall(
|
||||
{
|
||||
source: `git:${record.remote}`,
|
||||
ref: proposal.target.commit,
|
||||
pluginPath: identity.pluginPath,
|
||||
},
|
||||
proposal.target,
|
||||
);
|
||||
if (identity.kind === "npm" && proposal.target.kind === "npm")
|
||||
return this.prepareInstall(
|
||||
{
|
||||
source: `npm:${identity.packageName}@${proposal.target.version}`,
|
||||
pluginPath: identity.pluginPath,
|
||||
},
|
||||
proposal.target,
|
||||
);
|
||||
throw new Error("Update target does not match the installed source");
|
||||
}
|
||||
|
||||
async verifyCandidate(
|
||||
pluginId: string,
|
||||
candidate: ManagedPluginCandidate,
|
||||
target?: PluginUpdateTarget,
|
||||
): Promise<void> {
|
||||
const location = this.locate(pluginId, candidate.directory, candidate.record);
|
||||
const revision = await this.revision(location, candidate.directory);
|
||||
if (target?.kind === "git" && revision !== target.commit)
|
||||
throw new Error("Preparation changed the reviewed Git commit");
|
||||
if (target?.kind === "npm" && location.identity.kind === "npm") {
|
||||
const artifact = await readNpmArtifact(location.versionRoot, location.identity.packageName);
|
||||
if (
|
||||
artifact.version !== target.version ||
|
||||
artifact.integrity !== target.integrity ||
|
||||
artifact.resolved !== target.resolved
|
||||
)
|
||||
throw new Error("Preparation changed the reviewed npm artifact");
|
||||
}
|
||||
}
|
||||
|
||||
async status(pluginId: string, configuredPath: string): Promise<PluginSourceStatusItem> {
|
||||
const preview = await this.preview(pluginId, configuredPath);
|
||||
const installation = preview.current!;
|
||||
return {
|
||||
id: pluginId,
|
||||
source: installation.identity.kind === "git" ? "git" : "directory",
|
||||
path: configuredPath,
|
||||
installation,
|
||||
...(installation.identity.kind === "git"
|
||||
? {
|
||||
remote: installation.identity.remote,
|
||||
currentCommit: installation.currentRevision,
|
||||
latestCommit: preview.target?.kind === "git" ? preview.target.commit : undefined,
|
||||
updateAvailable: preview.outcome === "update",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
commit(pluginId: string, record: ManagedPluginRecord): void {
|
||||
this.records[pluginId] = record;
|
||||
this.records[pluginId] = ManagedPluginRecordSchema.parse(record);
|
||||
this.writeRecords();
|
||||
}
|
||||
|
||||
async discard(candidate: ManagedPluginCandidate): Promise<void> {
|
||||
await rm(candidate.versionRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async removeVersion(record: ManagedPluginRecord): Promise<void> {
|
||||
const versionRoot = path.dirname(record.checkoutRoot);
|
||||
const pluginRoot = path.dirname(versionRoot);
|
||||
const expectedRoot = path.resolve(this.root);
|
||||
if (path.dirname(pluginRoot) !== expectedRoot) {
|
||||
throw new Error(
|
||||
`Managed plugin checkout is outside the plugin store: ${record.checkoutRoot}`,
|
||||
);
|
||||
}
|
||||
await rm(versionRoot, { recursive: true, force: true });
|
||||
async removeVersion(pluginId: string, configuredPath: string): Promise<void> {
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
await assertRealContainment(this.root, path.dirname(location.versionRoot));
|
||||
await rm(location.versionRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async remove(pluginId: string): Promise<void> {
|
||||
if (!this.records[pluginId]) return;
|
||||
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
|
||||
if (existsSync(pluginRoot)) {
|
||||
await assertRealContainment(this.root, pluginRoot);
|
||||
await rm(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
delete this.records[pluginId];
|
||||
this.writeRecords();
|
||||
await rm(path.join(this.root, pluginId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
private async prepareCommit(
|
||||
record: ManagedPluginRecord,
|
||||
commit: string,
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
const stagingRoot = await this.createStagingRoot();
|
||||
try {
|
||||
const checkoutRoot = path.join(stagingRoot, "checkout");
|
||||
await clone(record.remote, checkoutRoot);
|
||||
const resolvedCommit = await revParse(checkoutRoot, `${commit}^{commit}`);
|
||||
if (resolvedCommit !== commit)
|
||||
throw new Error(`Git source no longer contains commit ${commit}`);
|
||||
await checkout(checkoutRoot, commit);
|
||||
const directory = path.resolve(checkoutRoot, record.pluginPath);
|
||||
assertPluginPath(checkoutRoot, directory);
|
||||
const { id: defaultId, build } = await readPluginManifest(directory);
|
||||
return {
|
||||
build,
|
||||
defaultId,
|
||||
directory,
|
||||
versionRoot: stagingRoot,
|
||||
record: { ...record, commit, checkoutRoot },
|
||||
};
|
||||
} catch (error) {
|
||||
await rm(stagingRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createStagingRoot(): Promise<string> {
|
||||
const stagingRoot = path.join(this.root, ".staging", randomUUID());
|
||||
await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
await assertRealContainment(this.root, stagingRoot);
|
||||
return stagingRoot;
|
||||
}
|
||||
|
||||
private readRecords(): Record<string, ManagedPluginRecord> {
|
||||
if (!existsSync(this.metadataPath)) return {};
|
||||
return ManagedPluginRecordsSchema.parse(JSON.parse(readFileSync(this.metadataPath, "utf8")));
|
||||
}
|
||||
|
||||
private writeRecords(): void {
|
||||
writePrivateFileAtomicSync(this.metadataPath, `${JSON.stringify(this.records, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRealContainment(root: string, directory: string): Promise<void> {
|
||||
assertPluginPath(await realpath(root), await realpath(directory));
|
||||
}
|
||||
function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): string[] {
|
||||
if (
|
||||
current.identity.kind === "npm" &&
|
||||
target.kind === "npm" &&
|
||||
new URL(target.resolved).hostname === "registry.npmjs.org"
|
||||
)
|
||||
return [current.currentRevision, target.version].map(
|
||||
(version) =>
|
||||
`https://www.npmjs.com/package/${current.identity.kind === "npm" ? current.identity.packageName : ""}/v/${encodeURIComponent(version!)}`,
|
||||
);
|
||||
if (current.identity.kind !== "git" || target.kind !== "git") return [];
|
||||
const remote = current.identity.remote
|
||||
.replace(/^git@([^:]+):/, "https://$1/")
|
||||
.replace(/^ssh:\/\/(?:git@)?/, "https://")
|
||||
.replace(/\.git$/, "");
|
||||
try {
|
||||
const url = new URL(remote);
|
||||
if (url.hostname === "github.com")
|
||||
return [`${url.origin}${url.pathname}/compare/${current.currentRevision}...${target.commit}`];
|
||||
if (url.hostname === "gitlab.com")
|
||||
return [
|
||||
`${url.origin}${url.pathname}/-/compare/${current.currentRevision}...${target.commit}`,
|
||||
];
|
||||
} catch {
|
||||
/* Local remotes have no review website. */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function normalizeGitSource(source: string): string {
|
||||
if (source.startsWith("github:")) {
|
||||
const shorthand = source.slice(7);
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
|
||||
throw new Error("Use github:owner/repository for a GitHub plugin source");
|
||||
}
|
||||
source = shorthand;
|
||||
} else if (source.startsWith("git:") && !source.startsWith("git://")) {
|
||||
source = source.slice(4);
|
||||
}
|
||||
const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
|
||||
if (github) {
|
||||
const repository = github[2].replace(/\.git$/, "");
|
||||
@@ -337,29 +500,70 @@ async function checkout(checkoutRoot: string, commit: string): Promise<void> {
|
||||
async function resolveRequestedRef(
|
||||
checkoutRoot: string,
|
||||
requestedRef: string | undefined,
|
||||
): Promise<RefResolution> {
|
||||
if (!requestedRef) {
|
||||
const { stdout } = await runGitCommand(
|
||||
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
||||
{ cwd: checkoutRoot },
|
||||
);
|
||||
const trackingBranch = stdout.trim().replace(/^origin\//, "");
|
||||
const commit = await revParse(checkoutRoot, `refs/remotes/origin/${trackingBranch}^{commit}`);
|
||||
return { commit, requestedRef: null, trackingBranch };
|
||||
}
|
||||
): Promise<string> {
|
||||
if (!requestedRef) return revParse(checkoutRoot, "refs/remotes/origin/HEAD^{commit}");
|
||||
if (requestedRef.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'");
|
||||
const branchRef = `refs/remotes/origin/${requestedRef}`;
|
||||
if (await refExists(checkoutRoot, branchRef)) {
|
||||
const commit = await revParse(checkoutRoot, `${branchRef}^{commit}`);
|
||||
return { commit, requestedRef, trackingBranch: requestedRef };
|
||||
for (const ref of [
|
||||
`refs/remotes/origin/${requestedRef}`,
|
||||
`refs/tags/${requestedRef}`,
|
||||
requestedRef,
|
||||
]) {
|
||||
if (await refExists(checkoutRoot, ref)) return revParse(checkoutRoot, `${ref}^{commit}`);
|
||||
}
|
||||
const tagRef = `refs/tags/${requestedRef}`;
|
||||
if (await refExists(checkoutRoot, tagRef)) {
|
||||
const commit = await revParse(checkoutRoot, `${tagRef}^{commit}`);
|
||||
return { commit, requestedRef, trackingBranch: null };
|
||||
return revParse(checkoutRoot, `${requestedRef}^{commit}`);
|
||||
}
|
||||
async function resolveRemoteCommit(remote: string, cwd: string, ref?: string): Promise<string> {
|
||||
if (ref?.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'");
|
||||
if (ref && /^[0-9a-f]{40,64}$/.test(ref)) return ref;
|
||||
const publicRemote = redactRemoteCredentials(remote);
|
||||
const cloneRemote = publicRemote === remote ? remote : "https://paseo.invalid/plugin.git";
|
||||
const envOverlay =
|
||||
cloneRemote === remote
|
||||
? GIT_ENV
|
||||
: {
|
||||
...GIT_ENV,
|
||||
GIT_CONFIG_COUNT: "1",
|
||||
GIT_CONFIG_KEY_0: `url.${remote}.insteadOf`,
|
||||
GIT_CONFIG_VALUE_0: cloneRemote,
|
||||
};
|
||||
try {
|
||||
const names = ref
|
||||
? [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]
|
||||
: ["HEAD"];
|
||||
const { stdout } = await runGitCommand(["ls-remote", "--symref", "--", cloneRemote, ...names], {
|
||||
cwd,
|
||||
envOverlay,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
const entries = new Map(
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [commit, name] = line.split("\t");
|
||||
return [name, commit];
|
||||
}),
|
||||
);
|
||||
for (const name of names) {
|
||||
const commit = entries.get(name);
|
||||
if (commit && /^[0-9a-f]{40,64}$/.test(commit)) return commit;
|
||||
}
|
||||
if (ref) {
|
||||
// Abbreviated commits and revision expressions are not advertised by ls-remote.
|
||||
// Resolve them in a temporary object database without preparing or touching the installed plugin.
|
||||
const staging = path.join(cwd, ".staging", randomUUID());
|
||||
await mkdir(staging, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await clone(remote, path.join(staging, "checkout"));
|
||||
return await resolveRequestedRef(path.join(staging, "checkout"), ref);
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
throw new Error("Cannot resolve Git default HEAD");
|
||||
} catch (error) {
|
||||
throw redactRemoteError(error, remote);
|
||||
}
|
||||
const commit = await revParse(checkoutRoot, `${requestedRef}^{commit}`);
|
||||
return { commit, requestedRef, trackingBranch: null };
|
||||
}
|
||||
|
||||
async function refExists(cwd: string, ref: string): Promise<boolean> {
|
||||
@@ -374,17 +578,3 @@ async function revParse(cwd: string, ref: string): Promise<string> {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function fetchOrigin(cwd: string): Promise<void> {
|
||||
await runGitCommand(["fetch", "--prune", "--tags", "origin"], {
|
||||
cwd,
|
||||
envOverlay: GIT_ENV,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function countCommits(cwd: string, current: string, latest: string): Promise<number> {
|
||||
if (current === latest) return 0;
|
||||
const { stdout } = await runGitCommand(["rev-list", "--count", `${current}..${latest}`], { cwd });
|
||||
return Number.parseInt(stdout.trim(), 10);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import compareVersions from "semver/functions/compare.js";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { findExecutable } from "../../../executable-resolution/executable-resolution.js";
|
||||
|
||||
const LockfileSchema = z.object({
|
||||
lockfileVersion: z.number().min(2),
|
||||
packages: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
version: z.string().optional(),
|
||||
resolved: z.string().optional(),
|
||||
integrity: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const NPM_SOURCE_PATTERN =
|
||||
/^(?<name>(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@(?<spec>[^:@/\\]+))?$/;
|
||||
|
||||
export function isNpmSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || NPM_SOURCE_PATTERN.test(source);
|
||||
}
|
||||
|
||||
function parseNpmSource(source: string): { packageName: string; requestedSpec: string } {
|
||||
const spec = source.replace(/^npm:/, "");
|
||||
const match = NPM_SOURCE_PATTERN.exec(spec);
|
||||
if (!match?.groups) {
|
||||
throw new Error(
|
||||
`Invalid npm plugin source: ${source}. Use npm:package or npm:@scope/package@version, tag, or range.`,
|
||||
);
|
||||
}
|
||||
const requestedSpec = match.groups.spec ?? "latest";
|
||||
if (!requestedSpec.trim() || requestedSpec !== requestedSpec.trim()) {
|
||||
throw new Error("An npm version, tag, or range must be non-empty without surrounding spaces");
|
||||
}
|
||||
return { packageName: match.groups.name, requestedSpec };
|
||||
}
|
||||
|
||||
export async function acquireNpm(source: string, installRoot: string, target?: NpmArtifact) {
|
||||
const { packageName, requestedSpec } = parseNpmSource(source);
|
||||
await writeFile(
|
||||
path.join(installRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "paseo-plugin-installation",
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
dependencies: { [packageName]: target?.resolved ?? requestedSpec },
|
||||
}),
|
||||
);
|
||||
const npm = await requireNpm();
|
||||
await execCommand(
|
||||
npm,
|
||||
[
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--legacy-peer-deps",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--package-lock=true",
|
||||
"--lockfile-version=3",
|
||||
"--include=prod",
|
||||
"--omit=dev",
|
||||
"--global=false",
|
||||
"--workspaces=false",
|
||||
],
|
||||
{ cwd: installRoot, timeout: 120_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const installed = await readNpmArtifact(installRoot, packageName);
|
||||
if (
|
||||
target &&
|
||||
(installed.version !== target.version ||
|
||||
installed.integrity !== target.integrity ||
|
||||
installed.resolved !== target.resolved)
|
||||
) {
|
||||
throw new Error(`npm artifact changed since review for ${packageName}`);
|
||||
}
|
||||
return { packageName, ...installed };
|
||||
}
|
||||
|
||||
export interface NpmArtifact {
|
||||
version: string;
|
||||
resolved: string;
|
||||
integrity: string;
|
||||
}
|
||||
|
||||
export async function readNpmArtifact(
|
||||
installRoot: string,
|
||||
packageName: string,
|
||||
): Promise<NpmArtifact> {
|
||||
const lock = LockfileSchema.parse(
|
||||
JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")),
|
||||
);
|
||||
const artifact = lock.packages[`node_modules/${packageName}`];
|
||||
if (!artifact?.version || !artifact.resolved || !artifact.integrity)
|
||||
throw new Error(`Missing npm artifact for ${packageName}`);
|
||||
const installed = z
|
||||
.object({ name: z.string(), version: z.string() })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(installRoot, "node_modules", packageName, "package.json"), "utf8"),
|
||||
),
|
||||
);
|
||||
const root = z
|
||||
.object({ dependencies: z.record(z.string(), z.string()) })
|
||||
.parse(JSON.parse(await readFile(path.join(installRoot, "package.json"), "utf8")));
|
||||
if (
|
||||
Object.keys(root.dependencies).length !== 1 ||
|
||||
!(packageName in root.dependencies) ||
|
||||
installed.name !== packageName ||
|
||||
installed.version !== artifact.version
|
||||
) {
|
||||
throw new Error(
|
||||
`Installed npm package does not match its acquisition artifacts: ${packageName}`,
|
||||
);
|
||||
}
|
||||
return { version: artifact.version, resolved: artifact.resolved, integrity: artifact.integrity };
|
||||
}
|
||||
|
||||
export async function resolveNpm(
|
||||
packageName: string,
|
||||
selector: string,
|
||||
cwd: string,
|
||||
): Promise<NpmArtifact> {
|
||||
// Validate selectors using the same grammar as installation; never interpret an update as another source.
|
||||
parseNpmSource(`npm:${packageName}@${selector}`);
|
||||
const { stdout } = await execCommand(
|
||||
await requireNpm(),
|
||||
["view", `${packageName}@${selector}`, "version", "dist", "--json"],
|
||||
{ cwd, timeout: 120_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const schema = z.object({
|
||||
version: z.string(),
|
||||
dist: z.object({ tarball: z.string().url(), integrity: z.string().min(1) }),
|
||||
});
|
||||
const raw: unknown = JSON.parse(stdout);
|
||||
const entries = z.array(schema).parse(Array.isArray(raw) ? raw : [raw]);
|
||||
const latest = entries.sort((a, b) => compareVersions(b.version, a.version))[0];
|
||||
if (!latest) throw new Error(`No npm version matches ${packageName}@${selector}`);
|
||||
return {
|
||||
version: latest.version,
|
||||
resolved: latest.dist.tarball,
|
||||
integrity: latest.dist.integrity,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireNpm(): Promise<string> {
|
||||
const npm = await findExecutable("npm");
|
||||
if (!npm)
|
||||
throw new Error(
|
||||
"npm is required on the daemon host to acquire or check npm plugins. Install Node.js with npm and make npm available on the daemon's PATH.",
|
||||
);
|
||||
return npm;
|
||||
}
|
||||
@@ -491,6 +491,13 @@ export interface SessionOptions {
|
||||
statusSources(
|
||||
pluginId?: string,
|
||||
): Promise<import("@getpaseo/protocol/messages").PluginSourceStatusItem[]>;
|
||||
previewUpdates(input: {
|
||||
pluginId?: string;
|
||||
target?: import("@getpaseo/protocol/messages").PluginUpdateSelection;
|
||||
}): Promise<import("@getpaseo/protocol/messages").PluginUpdatePreview[]>;
|
||||
applyUpdates(
|
||||
proposals: import("@getpaseo/protocol/messages").PluginUpdateProposal[],
|
||||
): Promise<import("@getpaseo/protocol/messages").PluginUpdateResult[]>;
|
||||
updateSources(
|
||||
pluginId?: string,
|
||||
): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>;
|
||||
@@ -2469,6 +2476,28 @@ export class Session {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.source.update.preview.request") {
|
||||
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
||||
return this.pluginRuntime
|
||||
.previewUpdates({ pluginId: msg.pluginId, target: msg.target })
|
||||
.then((plugins) => {
|
||||
this.emit({
|
||||
type: "plugin.source.update.preview.response",
|
||||
payload: { requestId: msg.requestId, plugins },
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.source.update.apply.request") {
|
||||
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
||||
return this.pluginRuntime.applyUpdates(msg.proposals).then((plugins) => {
|
||||
this.emit({
|
||||
type: "plugin.source.update.apply.response",
|
||||
payload: { requestId: msg.requestId, plugins },
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.source.update.request") {
|
||||
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
||||
return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => {
|
||||
|
||||
@@ -1721,6 +1721,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
plugins: true,
|
||||
pluginManagement: true,
|
||||
pluginGitManagement: true,
|
||||
pluginSourceInstallation: true,
|
||||
pluginSourceUpdates: true,
|
||||
pluginLogs: true,
|
||||
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
|
||||
pluginThemes: true,
|
||||
|
||||
Reference in New Issue
Block a user