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:
@@ -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] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user