94 lines
3.3 KiB
JavaScript
94 lines
3.3 KiB
JavaScript
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { isRecord } from "./utils.mjs";
|
|
|
|
const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
|
|
|
|
export async function readMarketplace(repoRoot) {
|
|
const marketplacePath = join(repoRoot, MARKETPLACE_PATH);
|
|
const raw = await readFile(marketplacePath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object");
|
|
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
|
throw new Error("marketplace.json name must be a non-empty string");
|
|
}
|
|
validatePathSegment(parsed.name, "marketplace name");
|
|
if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array");
|
|
|
|
return {
|
|
name: parsed.name,
|
|
plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)),
|
|
};
|
|
}
|
|
|
|
export function resolvePluginSource(repoRoot, plugin) {
|
|
const sourcePath = localSourcePath(plugin.source);
|
|
const relativePath = sourcePath.slice(2);
|
|
return join(repoRoot, ...relativePath.split(/[\\/]/));
|
|
}
|
|
|
|
export async function readPluginManifest(pluginRoot) {
|
|
const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`);
|
|
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
|
throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`);
|
|
}
|
|
const manifest = { name: parsed.name };
|
|
if (parsed.version !== undefined) {
|
|
if (typeof parsed.version !== "string" || parsed.version.trim() === "") {
|
|
throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`);
|
|
}
|
|
manifest.version = parsed.version.trim();
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
export function validatePathSegment(value, label) {
|
|
if (!/^[A-Za-z0-9._+-]+$/.test(value)) {
|
|
throw new Error(`${label} contains unsupported characters: ${value}`);
|
|
}
|
|
if (value === "." || value === "..") {
|
|
throw new Error(`${label} must not be a path traversal segment`);
|
|
}
|
|
}
|
|
|
|
function normalizeMarketplacePlugin(plugin, index) {
|
|
if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`);
|
|
if (typeof plugin.name !== "string" || plugin.name.trim() === "") {
|
|
throw new Error(`marketplace plugin ${index} name must be a non-empty string`);
|
|
}
|
|
validatePathSegment(plugin.name, "plugin name");
|
|
return {
|
|
name: plugin.name,
|
|
source: plugin.source,
|
|
};
|
|
}
|
|
|
|
function localSourcePath(source) {
|
|
if (typeof source === "string") return validateLocalSourcePath(source);
|
|
if (
|
|
isRecord(source) &&
|
|
source.source === "local" &&
|
|
typeof source.path === "string"
|
|
) {
|
|
return validateLocalSourcePath(source.path);
|
|
}
|
|
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
|
|
}
|
|
|
|
function validateLocalSourcePath(path) {
|
|
if (!path.startsWith("./")) {
|
|
throw new Error("local plugin source path must start with ./");
|
|
}
|
|
const relative = path.slice(2);
|
|
if (relative.length === 0) throw new Error("local plugin source path must not be empty");
|
|
for (const part of relative.split(/[\\/]/)) {
|
|
if (part === "" || part === "." || part === "..") {
|
|
throw new Error("local plugin source path must stay within the marketplace root");
|
|
}
|
|
}
|
|
return path;
|
|
}
|