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
@@ -1,3 +1,4 @@
import { z } from "zod";
import { describe, expect, it } from "vitest";
import {
MutableDaemonConfigSchema,
@@ -293,3 +294,37 @@ describe("plugin protocol compatibility", () => {
).toThrow();
});
});
it("npm metadata leaves legacy plugin rows parseable without extending their closed source enum", () => {
const legacyPlugin = z.object({
id: z.string(),
path: z.string(),
enabled: z.boolean(),
status: z.enum(["running", "disabled", "failed"]),
source: z.enum(["directory", "git"]).optional(),
});
const plugin = {
id: "review",
path: "/plugins/review",
enabled: true,
status: "running",
installation: {
identity: { kind: "npm", packageName: "@acme/review", pluginPath: "." },
currentRevision: "1.2.0",
},
};
const message = SessionOutboundMessageSchema.parse({
type: "plugin.list.response",
payload: { requestId: "npm", plugins: [plugin] },
});
expect(legacyPlugin.parse(plugin)).toEqual({
id: "review",
path: "/plugins/review",
enabled: true,
status: "running",
});
expect(message).toEqual({
type: "plugin.list.response",
payload: { requestId: "npm", plugins: [plugin] },
});
});
+91
View File
@@ -1494,6 +1494,53 @@ export const PluginSourceInstallRequestSchema = z.object({
pluginPath: z.string().min(1).optional(),
});
export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("directory"), path: z.string() }),
z.object({ kind: z.literal("git"), remote: z.string(), pluginPath: z.string() }),
z.object({ kind: z.literal("npm"), packageName: z.string(), pluginPath: z.string() }),
]);
export const PluginInstallationSchema = z.object({
identity: PluginSourceIdentitySchema,
currentRevision: z.string().optional(),
});
export type PluginInstallation = z.infer<typeof PluginInstallationSchema>;
export const PluginUpdateTargetSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("git"), commit: z.string().regex(/^[0-9a-f]{40,64}$/) }),
z.object({
kind: z.literal("npm"),
version: z.string().min(1),
resolved: z.string().url(),
integrity: z.string().min(1),
}),
]);
export type PluginUpdateTarget = z.infer<typeof PluginUpdateTargetSchema>;
export const PluginUpdateProposalSchema = z.object({
id: PluginIdSchema,
expected: z.object({
identity: PluginSourceIdentitySchema,
installationRoot: z.string(),
revision: z.string(),
}),
target: PluginUpdateTargetSchema,
});
export type PluginUpdateProposal = z.infer<typeof PluginUpdateProposalSchema>;
export const PluginUpdateSelectionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("git"), ref: z.string().min(1) }),
z.object({ kind: z.literal("npm"), version: z.string().min(1) }),
]);
export type PluginUpdateSelection = z.infer<typeof PluginUpdateSelectionSchema>;
export const PluginUpdatePreviewRequestSchema = z.object({
type: z.literal("plugin.source.update.preview.request"),
requestId: z.string(),
pluginId: PluginIdSchema.optional(),
target: PluginUpdateSelectionSchema.optional(),
});
export const PluginUpdateApplyRequestSchema = z.object({
type: z.literal("plugin.source.update.apply.request"),
requestId: z.string(),
proposals: z.array(PluginUpdateProposalSchema).min(1),
});
export const PluginSourceStatusRequestSchema = z.object({
type: z.literal("plugin.source.status.request"),
requestId: z.string(),
@@ -3201,6 +3248,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallRequestSchema,
PluginSourceStatusRequestSchema,
PluginSourceUpdateRequestSchema,
PluginUpdatePreviewRequestSchema,
PluginUpdateApplyRequestSchema,
PluginReloadRequestSchema,
PluginEnableRequestSchema,
PluginDisableRequestSchema,
@@ -3576,6 +3625,8 @@ export const ServerInfoStatusPayloadSchema = z
pluginLogs: z.boolean().optional(),
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
pluginGitManagement: z.boolean().optional(),
pluginSourceInstallation: z.boolean().optional(),
pluginSourceUpdates: z.boolean().optional(),
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all.
@@ -6527,6 +6578,13 @@ export const PluginCatalogGetResponseSchema = z.object({
export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]);
export type PluginStatus = z.infer<typeof PluginStatusSchema>;
export const PluginNpmInstallationSchema = z.object({
packageName: z.string(),
requestedSpec: z.string(),
version: z.string(),
integrity: z.string(),
});
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
description: z.string().optional(),
@@ -6534,6 +6592,8 @@ export const PluginListItemSchema = z.object({
enabled: z.boolean(),
status: PluginStatusSchema,
source: z.enum(["directory", "git"]).optional(),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
remote: z.string().optional(),
ref: z.string().optional(),
commit: z.string().optional(),
@@ -6581,6 +6641,8 @@ export const PluginSourceInstallResponseSchema = z.object({
export const PluginSourceStatusItemSchema = z.object({
id: PluginIdSchema,
source: z.enum(["directory", "git"]),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
path: z.string(),
remote: z.string().optional(),
ref: z.string().optional(),
@@ -6610,6 +6672,33 @@ export const PluginSourceUpdateResponseSchema = z.object({
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }),
});
export const PluginUpdatePreviewSchema = z.object({
id: PluginIdSchema,
outcome: z.enum(["update", "current", "installed-newer", "local", "error"]),
current: PluginInstallationSchema.optional(),
target: PluginUpdateTargetSchema.optional(),
links: z.array(z.string()),
proposal: PluginUpdateProposalSchema.optional(),
error: z.string().optional(),
});
export type PluginUpdatePreview = z.infer<typeof PluginUpdatePreviewSchema>;
export const PluginUpdateResultSchema = z.object({
id: PluginIdSchema,
outcome: z.enum(["updated", "error"]),
plugin: PluginListItemSchema.optional(),
error: z.string().optional(),
warning: z.string().optional(),
});
export type PluginUpdateResult = z.infer<typeof PluginUpdateResultSchema>;
export const PluginUpdatePreviewResponseSchema = z.object({
type: z.literal("plugin.source.update.preview.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdatePreviewSchema) }),
});
export const PluginUpdateApplyResponseSchema = z.object({
type: z.literal("plugin.source.update.apply.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdateResultSchema) }),
});
function pluginActionResponse<const Type extends string>(type: Type) {
return z.object({
type: z.literal(type),
@@ -6689,6 +6778,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallResponseSchema,
PluginSourceStatusResponseSchema,
PluginSourceUpdateResponseSchema,
PluginUpdatePreviewResponseSchema,
PluginUpdateApplyResponseSchema,
PluginReloadResponseSchema,
PluginEnableResponseSchema,
PluginDisableResponseSchema,
@@ -3,6 +3,16 @@ import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
["npm:@team/review@next:plugins/main", "npm:@team/review@next", "plugins/main"],
["github:owner/repository", "github:owner/repository", undefined],
[
"git:git@example.test:owner/repository.git",
"git:git@example.test:owner/repository.git",
undefined,
],
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["owner/repository", "owner/repository", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
@@ -1,9 +1,17 @@
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -18,7 +26,7 @@ export function parsePluginSourceReference(reference: string): PluginSourceRefer
if (pathStart === -1 || separator < pathStart) {
return { source: reference, pluginPath: undefined };
}
} else {
} else if (options.scp) {
const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length;
if (scpSeparator !== undefined && separator === scpSeparator - 1) {
return { source: reference, pluginPath: undefined };
@@ -45,3 +53,17 @@ function isPortableRelativePluginPath(pluginPath: string): boolean {
if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false;
return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== "..");
}
export function formatPluginIdentity(identity: PluginInstallation["identity"]): string {
if (identity.kind === "directory") return identity.path;
const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`;
return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`;
}
export function formatPluginInstallation(installation: PluginInstallation): string {
const identity = formatPluginIdentity(installation.identity);
const revision =
installation.identity.kind === "git"
? installation.currentRevision?.slice(0, 12)
: installation.currentRevision;
return revision ? `${identity} · ${revision}` : identity;
}