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
@@ -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!]);
}
+114 -68
View File
@@ -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;
}
+29
View File
@@ -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,