Files
openwork/apps/server/src/cloud-plugins.ts
T

684 lines
26 KiB
TypeScript

import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { ServerConfig } from "./types.js";
import { ApiError } from "./errors.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { addMcp, removeMcp } from "./mcp.js";
import { createWorkspaceKvStore, isRecord } from "./workspace-kv-store.js";
const OPENCODE_SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const OPENCODE_MCP_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
const OPENCODE_MCP_IMPORT_PATH_PREFIX = "opencode.jsonc#mcp.";
type CloudPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom";
type CloudPluginConfigObjectVersion = {
id: string;
rawSourceText: string | null;
normalizedPayloadJson: Record<string, unknown> | null;
};
type CloudPluginConfigObject = {
id: string;
objectType: CloudPluginConfigObjectType;
title: string;
description: string | null;
currentRelativePath: string | null;
status: string;
updatedAt: string | null;
latestVersion: CloudPluginConfigObjectVersion | null;
};
type CloudPluginMembership = {
configObjectId: string;
configObject?: CloudPluginConfigObject;
};
export type CloudPluginResolved = {
plugin: {
id: string;
name: string;
description: string | null;
updatedAt: string | null;
};
memberships: CloudPluginMembership[];
};
export type CloudImportedPluginFile = {
configObjectId: string;
versionId: string | null;
objectType: string;
title: string;
path: string;
updatedAt: string | null;
};
export type CloudImportedPlugin = {
pluginId: string;
marketplaceId: string | null;
name: string;
description: string | null;
updatedAt: string | null;
files: CloudImportedPluginFile[];
importedAt: number | null;
};
export type CloudPluginInstallResult = {
item: CloudImportedPlugin;
warnings: string[];
};
type WorkspaceCloudImports = {
skills: Record<string, unknown>;
providers: Record<string, unknown>;
marketplaces: Record<string, { marketplaceId: string; name: string; updatedAt: string | null; pluginIds: string[]; importedAt: number | null }>;
plugins: Record<string, CloudImportedPlugin>;
};
function readString(value: unknown): string | null {
return typeof value === "string" ? value.trim() || null : null;
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.flatMap((entry) => {
const text = readString(entry);
return text ? [text] : [];
}) : [];
}
function readStringRecord(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null;
const output: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
const text = readString(entry);
if (text) output[key] = text;
}
return Object.keys(output).length ? output : null;
}
function parseJsonRecord(text: string | null): Record<string, unknown> | null {
if (!text) return null;
try {
const parsed: unknown = JSON.parse(text);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function normalizeConfigObjectType(value: unknown): CloudPluginConfigObjectType | null {
switch (value) {
case "skill":
case "agent":
case "command":
case "tool":
case "mcp":
case "hook":
case "context":
case "custom":
return value;
default:
return null;
}
}
function normalizeConfigObjectVersion(value: unknown): CloudPluginConfigObjectVersion | null {
if (!isRecord(value) || typeof value.id !== "string") return null;
return {
id: value.id,
rawSourceText: typeof value.rawSourceText === "string" ? value.rawSourceText : null,
normalizedPayloadJson: isRecord(value.normalizedPayloadJson) ? value.normalizedPayloadJson : null,
};
}
function normalizeConfigObject(value: unknown): CloudPluginConfigObject | null {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string") return null;
const objectType = normalizeConfigObjectType(value.objectType);
if (!objectType) return null;
return {
id: value.id,
objectType,
title: value.title,
description: typeof value.description === "string" ? value.description : null,
currentRelativePath: typeof value.currentRelativePath === "string" ? value.currentRelativePath : null,
status: typeof value.status === "string" ? value.status : "active",
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null,
latestVersion: normalizeConfigObjectVersion(value.latestVersion),
};
}
function normalizeCloudPluginResolved(value: unknown): CloudPluginResolved | null {
if (!isRecord(value) || !isRecord(value.plugin) || !Array.isArray(value.memberships)) return null;
if (typeof value.plugin.id !== "string" || typeof value.plugin.name !== "string") return null;
const memberships = value.memberships.flatMap((entry) => {
if (!isRecord(entry) || typeof entry.configObjectId !== "string") return [];
const configObject = normalizeConfigObject(entry.configObject);
return [{ configObjectId: entry.configObjectId, ...(configObject ? { configObject } : {}) }];
});
return {
plugin: {
id: value.plugin.id,
name: value.plugin.name,
description: typeof value.plugin.description === "string" ? value.plugin.description : null,
updatedAt: typeof value.plugin.updatedAt === "string" ? value.plugin.updatedAt : null,
},
memberships,
};
}
export function readCloudPluginResolved(value: unknown): CloudPluginResolved {
const resolved = normalizeCloudPluginResolved(value);
if (!resolved) throw new ApiError(400, "invalid_cloud_plugin", "resolved cloud plugin is required");
return resolved;
}
function extractSkillBodyMarkdown(skillText: string): string {
const trimmed = skillText.trim();
if (!trimmed.startsWith("---")) return trimmed;
const rest = trimmed.slice(3);
const end = rest.indexOf("\n---");
if (end === -1) return trimmed;
return rest.slice(end + 4).replace(/^\s*\n?/, "");
}
function slugifyConfigObjectName(title: string, fallback: string): string {
let base = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!base) base = "skill";
if (base.length > 64) base = base.slice(0, 64).replace(/-+$/g, "");
if (!OPENCODE_SKILL_NAME_RE.test(base)) base = "skill";
if (base === "skill" && fallback) return slugifyConfigObjectName(fallback, "");
return base;
}
function pluginNamespace(pluginName: string, pluginId: string): string {
const base = slugifyConfigObjectName(pluginName, pluginId);
return `${base.replace(/-plugin$/, "")}-plugin`;
}
function normalizePluginSourcePath(path: string, objectType: string, namespace: string): string {
const parts = path.trim().replace(/^\/+/, "").split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === ".." || part === ".")) return "";
const folderByType: Record<string, string> = {
agent: "agents",
command: "commands",
context: "context",
hook: "hooks",
mcp: "mcps",
skill: "skills",
tool: "tools",
};
const folder = folderByType[objectType];
if (!folder) return "";
const opencodeIndex = parts.findIndex((part) => part === ".opencode");
const searchParts = opencodeIndex >= 0 ? parts.slice(opencodeIndex + 1) : parts;
const folderIndex = searchParts.findIndex((part) => part === folder);
if (folderIndex < 0 || folderIndex === searchParts.length - 1) return "";
const rest = searchParts.slice(folderIndex + 1);
if (rest[0] === namespace) return [".opencode", folder, ...rest].join("/");
return [".opencode", folder, namespace, ...rest].join("/");
}
function getPluginObjectInstallPath(object: CloudPluginConfigObject, namespace: string): string {
const existing = normalizePluginSourcePath(object.currentRelativePath ?? "", object.objectType, namespace);
if (existing) {
if (object.objectType === "skill") {
const parts = existing.split("/").filter(Boolean);
const lastPart = parts.at(-1) ?? "";
const skillName = /^SKILL\.md$/i.test(lastPart)
? parts.at(-2) ?? slugifyConfigObjectName(object.title, object.id)
: lastPart || slugifyConfigObjectName(object.title, object.id);
return `.opencode/skills/${namespace}/${skillName}/SKILL.md`;
}
return existing;
}
const name = slugifyConfigObjectName(object.title, object.id);
switch (object.objectType) {
case "skill":
return `.opencode/skills/${namespace}/${name}/SKILL.md`;
case "agent":
return `.opencode/agents/${namespace}/${name}.md`;
case "command":
return `.opencode/commands/${namespace}/${name}.md`;
case "mcp":
return `.opencode/mcps/${namespace}/${name}.json`;
case "hook":
return `.opencode/hooks/${namespace}/${name}.json`;
case "tool":
return `.opencode/tools/${namespace}/${name}.ts`;
case "context":
return `.opencode/context/${namespace}/${name}.md`;
default:
return `.opencode/plugins/${namespace}/${name}.txt`;
}
}
function buildCloudSkillContent(name: string, description: string, body: string): string {
const safeDescription = description.replace(/\s+/g, " ").trim();
const normalizedBody = body.replace(/^\s*\n?/, "");
return [
"---",
`name: ${JSON.stringify(name)}`,
`description: ${JSON.stringify(safeDescription)}`,
"---",
"",
normalizedBody,
].join("\n");
}
const OPENCODE_MODEL_ID_RE = /^[^\s/]+\/[^\s]+$/;
function translateClaudeTools(value: unknown): Record<string, boolean> | null {
const names = typeof value === "string"
? value.split(",")
: Array.isArray(value)
? value.flatMap((entry) => (typeof entry === "string" ? [entry] : []))
: null;
if (names) {
const tools: Record<string, boolean> = {};
for (const raw of names) {
const name = raw.trim().toLowerCase();
if (name) tools[name] = true;
}
return Object.keys(tools).length ? tools : null;
}
if (isRecord(value)) {
const tools: Record<string, boolean> = {};
for (const [key, entry] of Object.entries(value)) {
const name = key.trim().toLowerCase();
if (name && typeof entry === "boolean") tools[name] = entry;
}
return Object.keys(tools).length ? tools : null;
}
return null;
}
function translateClaudeModel(value: unknown): string | null {
const model = readString(value);
return model && OPENCODE_MODEL_ID_RE.test(model) ? model : null;
}
function cloudConfigObjectDescription(object: CloudPluginConfigObject): string {
const rawDesc = (object.description?.trim() || object.title).trim();
return rawDesc.slice(0, 1024) || object.title.slice(0, 1024);
}
function buildCloudAgentContent(description: string, rawSourceText: string): string {
const { data, body } = parseFrontmatter(rawSourceText.trim());
const safeDescription = (readString(data.description) ?? description).replace(/\s+/g, " ").trim();
const model = translateClaudeModel(data.model);
const tools = translateClaudeTools(data.tools);
const frontmatter = buildFrontmatter({
description: safeDescription,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
});
return frontmatter + "\n" + body.replace(/^\s*\n?/, "");
}
function buildCloudCommandContent(name: string, description: string, rawSourceText: string): string {
const { data, body } = parseFrontmatter(rawSourceText.trim());
const safeDescription = (readString(data.description) ?? description).replace(/\s+/g, " ").trim();
const model = translateClaudeModel(data.model);
const agent = readString(data.agent);
const frontmatter = buildFrontmatter({
name,
description: safeDescription,
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(typeof data.subtask === "boolean" ? { subtask: data.subtask } : {}),
});
return frontmatter + "\n" + body.replace(/^\s*\n?/, "");
}
function pluginMcpName(rawName: string, namespace: string, fallback: string, namespaceName: boolean): string {
const trimmed = rawName.trim();
const base = OPENCODE_MCP_NAME_RE.test(trimmed) ? trimmed : slugifyConfigObjectName(trimmed || fallback, fallback);
if (!namespaceName) return base;
const namespaced = base.startsWith(`${namespace}-`) ? base : `${namespace}-${base}`;
return OPENCODE_MCP_NAME_RE.test(namespaced) ? namespaced : slugifyConfigObjectName(namespaced, fallback);
}
function mcpCommandFromConfig(config: Record<string, unknown>): string[] {
if (Array.isArray(config.command)) return readStringArray(config.command);
const command = readString(config.command);
if (!command) return [];
return [command, ...readStringArray(config.args)];
}
function normalizePluginMcpConfig(input: unknown): Record<string, unknown> | null {
if (!isRecord(input)) return null;
const enabled = typeof input.enabled === "boolean"
? input.enabled
: typeof input.disabled === "boolean"
? !input.disabled
: true;
const url = readString(input.url);
if (url) {
const config: Record<string, unknown> = { type: "remote", url, enabled };
const headers = readStringRecord(input.headers);
if (headers) config.headers = headers;
if (isRecord(input.oauth)) config.oauth = input.oauth;
if (input.oauth === true) config.oauth = {};
return config;
}
const command = mcpCommandFromConfig(input);
if (command.length > 0) {
const config: Record<string, unknown> = { type: "local", command, enabled };
const environment = readStringRecord(input.environment) ?? readStringRecord(input.env);
if (environment) config.environment = environment;
return config;
}
return null;
}
function pluginMcpConfigsFromPayload(object: CloudPluginConfigObject, namespace: string) {
const version = object.latestVersion;
const payload = version?.normalizedPayloadJson ?? parseJsonRecord(version?.rawSourceText ?? null);
if (!payload) return [];
const configs = new Map<string, { name: string; config: Record<string, unknown>; path: string }>();
const addConfig = (rawName: string, rawConfig: unknown, namespaceName: boolean) => {
const config = normalizePluginMcpConfig(rawConfig);
if (!config) return;
const name = pluginMcpName(rawName, namespace, object.id, namespaceName);
configs.set(name, {
name,
config,
path: `${OPENCODE_MCP_IMPORT_PATH_PREFIX}${name}`,
});
};
if (isRecord(payload.mcp)) {
for (const [name, config] of Object.entries(payload.mcp)) addConfig(name, config, false);
}
if (isRecord(payload.mcpServers)) {
for (const [name, config] of Object.entries(payload.mcpServers)) addConfig(name, config, false);
}
if (configs.size === 0) addConfig(object.title, payload, true);
return [...configs.values()];
}
function mcpNoConfigWarning(title: string): string {
return `MCP component "${title}" could not be installed: no server config with a "url" or "command" was found.`;
}
function mcpInactiveWarning(title: string): string {
return `MCP component "${title}" could not be installed because it is not active.`;
}
function readCloudImports(config: Record<string, unknown>): WorkspaceCloudImports {
const root = isRecord(config.cloudImports) ? config.cloudImports : {};
const marketplaces = isRecord(root.marketplaces) ? Object.fromEntries(Object.entries(root.marketplaces).flatMap(([key, value]) => {
if (!isRecord(value)) return [];
const marketplaceId = readString(value.marketplaceId) ?? key.trim();
const name = readString(value.name) ?? marketplaceId;
if (!marketplaceId || !name) return [];
return [[marketplaceId, {
marketplaceId,
name,
updatedAt: readString(value.updatedAt),
pluginIds: readStringArray(value.pluginIds),
importedAt: typeof value.importedAt === "number" && Number.isFinite(value.importedAt) ? value.importedAt : null,
}]];
})) : {};
const plugins = isRecord(root.plugins) ? Object.fromEntries(Object.entries(root.plugins).flatMap(([key, value]) => {
if (!isRecord(value)) return [];
const pluginId = readString(value.pluginId) ?? key.trim();
const name = readString(value.name) ?? pluginId;
if (!pluginId || !name) return [];
const files = Array.isArray(value.files) ? value.files.flatMap((file) => {
if (!isRecord(file)) return [];
const configObjectId = readString(file.configObjectId);
const objectType = readString(file.objectType);
const title = readString(file.title) ?? configObjectId;
const path = readString(file.path);
if (!configObjectId || !objectType || !title || !path) return [];
return [{
configObjectId,
versionId: readString(file.versionId),
objectType,
title,
path,
updatedAt: readString(file.updatedAt),
}];
}) : [];
return [[pluginId, {
pluginId,
marketplaceId: readString(value.marketplaceId),
name,
description: readString(value.description),
updatedAt: readString(value.updatedAt),
files,
importedAt: typeof value.importedAt === "number" && Number.isFinite(value.importedAt) ? value.importedAt : null,
}]];
})) : {};
return {
skills: isRecord(root.skills) ? root.skills : {},
providers: isRecord(root.providers) ? root.providers : {},
marketplaces,
plugins,
};
}
function parseInstalledCloudPlugins(configJson: string): WorkspaceCloudImports {
try {
return readCloudImports({ cloudImports: JSON.parse(configJson) });
} catch {
return readCloudImports({});
}
}
const cloudPluginInstallStore = createWorkspaceKvStore<WorkspaceCloudImports>({
tableName: "cloud_plugin_install_configs",
valueColumn: "config_json",
parse: parseInstalledCloudPlugins,
serialize: (value) => JSON.stringify(value),
});
export async function readInstalledCloudPlugins(config: ServerConfig, workspaceId: string): Promise<WorkspaceCloudImports> {
return await cloudPluginInstallStore.get(config, workspaceId) ?? readCloudImports({});
}
async function writeInstalledCloudPlugins(
config: ServerConfig,
workspaceId: string,
updater: (current: WorkspaceCloudImports) => WorkspaceCloudImports,
): Promise<WorkspaceCloudImports> {
const next = updater(await readInstalledCloudPlugins(config, workspaceId));
await cloudPluginInstallStore.set(config, workspaceId, next);
return next;
}
function resolveWorkspaceInstallPath(workspaceRoot: string, relativePath: string): string {
const normalized = relativePath.trim().replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (!normalized.startsWith(".opencode/") || parts.some((part) => part === "." || part === "..")) {
throw new ApiError(400, "invalid_cloud_plugin_path", `Invalid cloud plugin path: ${relativePath}`);
}
const root = resolve(workspaceRoot);
const candidate = resolve(root, normalized);
if (candidate !== root && !candidate.startsWith(`${root}/`)) {
throw new ApiError(400, "invalid_cloud_plugin_path", `Invalid cloud plugin path: ${relativePath}`);
}
return candidate;
}
async function writePluginWorkspaceFile(workspaceRoot: string, path: string, content: string): Promise<void> {
const absolutePath = resolveWorkspaceInstallPath(workspaceRoot, path);
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
}
async function removePluginWorkspaceFile(workspaceRoot: string, path: string): Promise<void> {
if (!path.startsWith(".opencode/")) return;
const absolutePath = resolveWorkspaceInstallPath(workspaceRoot, path);
if (/^\.opencode\/skills\/[^/]+\/[^/]+\/SKILL\.md$/.test(path)) {
await rm(dirname(absolutePath), { recursive: true, force: true });
return;
}
await rm(absolutePath, { force: true });
}
function cloudPluginMcpNameFromPath(path: string): string | null {
if (!path.startsWith(OPENCODE_MCP_IMPORT_PATH_PREFIX)) return null;
const name = path.slice(OPENCODE_MCP_IMPORT_PATH_PREFIX.length).trim();
return OPENCODE_MCP_NAME_RE.test(name) ? name : null;
}
export async function installCloudPlugin(input: {
serverConfig: ServerConfig;
workspaceId: string;
workspaceRoot: string;
marketplaceId: string | null;
marketplace?: { id: string; name: string; updatedAt: string | null } | null;
resolved: CloudPluginResolved;
}): Promise<CloudPluginInstallResult> {
const namespace = pluginNamespace(input.resolved.plugin.name, input.resolved.plugin.id);
const cloudImports = await readInstalledCloudPlugins(input.serverConfig, input.workspaceId);
const existing = cloudImports.plugins[input.resolved.plugin.id];
const files: CloudImportedPluginFile[] = [];
const warnings: string[] = [];
for (const membership of input.resolved.memberships) {
const object = membership.configObject;
const version = object?.latestVersion ?? null;
if (!object) continue;
if (object.status !== "active") {
if (object.objectType === "mcp") warnings.push(mcpInactiveWarning(object.title));
continue;
}
if (object.objectType === "mcp") {
const configs = pluginMcpConfigsFromPayload(object, namespace);
if (configs.length === 0) warnings.push(mcpNoConfigWarning(object.title));
for (const config of configs) {
await addMcp(input.serverConfig, input.workspaceId, config.name, config.config);
files.push({
configObjectId: object.id,
versionId: version?.id ?? null,
objectType: object.objectType,
title: object.title,
path: config.path,
updatedAt: object.updatedAt,
});
}
continue;
}
if (version?.rawSourceText == null) continue;
const path = getPluginObjectInstallPath(object, namespace);
let content = version.rawSourceText;
if (object.objectType === "skill") {
const description = cloudConfigObjectDescription(object) || "Skill";
const installName = path.match(/^\.opencode\/skills\/[^/]+\/([^/]+)\/SKILL\.md$/)?.[1] ?? slugifyConfigObjectName(object.title, object.id);
content = buildCloudSkillContent(installName, description, extractSkillBodyMarkdown(content));
} else if (object.objectType === "agent") {
content = buildCloudAgentContent(cloudConfigObjectDescription(object), content);
} else if (object.objectType === "command") {
const fileName = path.match(/\/([^/]+)\.md$/)?.[1] ?? object.title;
content = buildCloudCommandContent(slugifyConfigObjectName(fileName, object.id), cloudConfigObjectDescription(object), content);
}
await writePluginWorkspaceFile(input.workspaceRoot, path, content);
files.push({
configObjectId: object.id,
versionId: version.id,
objectType: object.objectType,
title: object.title,
path,
updatedAt: object.updatedAt,
});
}
const nextPaths = new Set(files.map((file) => file.path));
const removedMcpNames = (existing?.files ?? []).flatMap((file) => {
const name = file.objectType === "mcp" && !nextPaths.has(file.path) ? cloudPluginMcpNameFromPath(file.path) : null;
return name ? [name] : [];
});
await Promise.all(removedMcpNames.map((name) => removeMcp(input.serverConfig, input.workspaceId, name)));
const imported: CloudImportedPlugin = {
pluginId: input.resolved.plugin.id,
marketplaceId: input.marketplaceId,
name: input.resolved.plugin.name,
description: input.resolved.plugin.description,
updatedAt: input.resolved.plugin.updatedAt,
files,
importedAt: existing?.importedAt ?? Date.now(),
};
const nextPlugins = {
...cloudImports.plugins,
[input.resolved.plugin.id]: imported,
};
let nextMarketplaces = cloudImports.marketplaces;
if (input.marketplaceId) {
const existingMarketplace = cloudImports.marketplaces[input.marketplaceId];
const pluginIds = new Set(existingMarketplace?.pluginIds ?? []);
pluginIds.add(input.resolved.plugin.id);
nextMarketplaces = {
...cloudImports.marketplaces,
[input.marketplaceId]: {
marketplaceId: input.marketplaceId,
name: input.marketplace?.name ?? existingMarketplace?.name ?? input.marketplaceId,
updatedAt: input.marketplace?.updatedAt ?? existingMarketplace?.updatedAt ?? null,
pluginIds: [...pluginIds].sort(),
importedAt: existingMarketplace?.importedAt ?? Date.now(),
},
};
}
await writeInstalledCloudPlugins(input.serverConfig, input.workspaceId, (current) => ({
...current,
marketplaces: nextMarketplaces,
plugins: nextPlugins,
}));
return { item: imported, warnings };
}
export async function removeCloudPlugin(input: {
serverConfig: ServerConfig;
workspaceId: string;
workspaceRoot: string;
pluginId: string;
}): Promise<CloudImportedPlugin> {
const cloudImports = await readInstalledCloudPlugins(input.serverConfig, input.workspaceId);
const imported = cloudImports.plugins[input.pluginId];
if (!imported) throw new ApiError(404, "cloud_plugin_not_installed", "Marketplace package is not installed in this workspace.");
await Promise.all(imported.files.map(async (file) => {
const mcpName = file.objectType === "mcp" ? cloudPluginMcpNameFromPath(file.path) : null;
if (mcpName) {
await removeMcp(input.serverConfig, input.workspaceId, mcpName);
return;
}
await removePluginWorkspaceFile(input.workspaceRoot, file.path);
}));
const nextPlugins = { ...cloudImports.plugins };
delete nextPlugins[input.pluginId];
const nextMarketplaces = Object.fromEntries(Object.entries(cloudImports.marketplaces).flatMap(([marketplaceId, marketplace]) => {
const pluginIds = marketplace.pluginIds.filter((id) => id !== input.pluginId);
if (pluginIds.length === 0) return [];
return [[marketplaceId, { ...marketplace, pluginIds }]];
}));
await writeInstalledCloudPlugins(input.serverConfig, input.workspaceId, (current) => ({
...current,
marketplaces: nextMarketplaces,
plugins: nextPlugins,
}));
return imported;
}