mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
be7a1cf734
* Harden external plugin validation: semver, SPDX, email, unknown fields Extend the canonical external-plugin validator with Open-Plugins-aligned rules, reusing the shared validation functions rather than duplicating checks: - version: enforce Semantic Versioning (allows prerelease/build metadata) - license: validate SPDX identifiers/expressions; warn (not error) on well-formed-but-unrecognized ids so existing entries like SSAL-1.0 pass - author.email: validate format when present - unknown-field detection: warn on typo'd top-level/author/source keys - immutable locator: marketplace warns when source lacks ref/sha; publicSubmission keeps the existing hard error Add eng/external-plugin-validation.test.mjs (node:test) covering each rule plus a regression that committed external.json passes marketplace policy with zero errors. Update CONTRIBUTING.md accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Make license non-SPDX a warning and share license check with local plugins The agent-plugins-spec schema does not enforce SPDX, and plugins may use proprietary/non-OSS licenses. Relax license validation so any non-empty license string that isn't a recognized SPDX identifier/expression produces a warning rather than an error. Extract the license check into a reusable validateLicenseField() and apply it to both external plugins and local plugin.json manifests via eng/validate-plugins.mjs, so licenses are validated consistently in one place. Update tests and CONTRIBUTING.md accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Extract shared license validation into eng/lib/license.mjs Move the SPDX constants and license validation (validateLicenseField, isRecognizedSpdxExpression) out of external-plugin-validation.mjs into a dedicated eng/lib/license.mjs module. Both the external plugin catalog validator and the local plugin.json validator now import license logic from this neutral shared module instead of one validator importing from the other. Behavior is unchanged; tests import from the new home. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Fix SPDX license parsing correctness and warning-message injection Rewrite isRecognizedSpdxExpression as a recursive-descent SPDX license expression parser to address PR review feedback: - Validate LicenseRef-/DocumentRef- grammar (non-empty idstring; DocumentRef requires the ":LicenseRef-<id>" suffix) instead of accepting any token with that prefix. - Parse parentheses with balance and placement checks rather than stripping them, so malformed groupings like "(MIT", "MIT)", and "MIT OR (Apache-2.0))" are no longer silently treated as valid. - Treat WITH as a distinct operator whose right operand must be a known SPDX license exception, so "MIT WITH Apache-2.0" and "GPL-2.0-only WITH MIT" are rejected while "GPL-2.0-only WITH Classpath-exception-2.0" is recognized. - Sanitize the untrusted license value in the non-SPDX warning (collapse whitespace, truncate, escape backticks, wrap as inline code) since the warning is rendered verbatim into a Markdown bot comment during intake. Malformed SPDX stays a warning (never an error) so proprietary and non-OSS license strings remain allowed. Adds targeted grammar and sanitization tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Complete backslash escaping and run source unknown-field check for all sources Address two PR review findings: - eng/lib/license.mjs: sanitizeForMessage now escapes backslashes before backticks so the inline-code warning value is fully escaped, resolving a CodeQL "incomplete string escaping" alert. A raw backslash in a license value is now doubled rather than left to combine with a following escaped backtick. - eng/external-plugin-validation.mjs: move the source unknown-field check out of validateGitHubSource into the outer source-object branch so it runs for every object source and then dispatch by source.source. A typo in the source discriminator (e.g. "soruce") or an unsupported source type now still surfaces the unknown-field warning instead of being silently skipped. Adds regression tests for both behaviors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Harden Markdown safety of validation warnings via inlineCode helper External-plugin validation warnings are rendered verbatim as Markdown list items in the intake bot comment, so untrusted values interpolated into them are an injection vector. Add eng/lib/markdown.mjs exporting inlineCode(), which wraps a value in a code span fenced with more backticks than any run in the content (and pads leading/trailing backticks). This cannot be broken out of, unlike the previous backslash escaping, which does not work inside code spans. - lib/license.mjs: replace sanitizeForMessage with inlineCode for the non-SPDX license warning. - external-plugin-validation.mjs: wrap unknown-field keys (attacker controlled JSON object keys) with inlineCode so newlines/Markdown in a key can no longer inject a new list item. - Add inlineCode unit tests and injection-neutralization tests for both the license value and unknown-field key paths. Addresses PR review comments 3679379606 and 3679434134. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728 * Fixing codespell --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eaa5eed6-5b65-4b28-9904-24f380d26728
502 lines
15 KiB
JavaScript
502 lines
15 KiB
JavaScript
import fs from "fs";
|
|
import path from "path";
|
|
import { ROOT_FOLDER } from "./constants.mjs";
|
|
import { validateLicenseField } from "./lib/license.mjs";
|
|
import { inlineCode } from "./lib/markdown.mjs";
|
|
|
|
export const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
|
|
|
|
export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({
|
|
marketplace: Object.freeze({
|
|
allowedSourceTypes: ["github"],
|
|
requireAuthor: true,
|
|
requireRepository: true,
|
|
requireKeywords: true,
|
|
requireLicense: false,
|
|
requireImmutableLocator: false,
|
|
warnMissingImmutableLocator: true,
|
|
}),
|
|
publicSubmission: Object.freeze({
|
|
allowedSourceTypes: ["github"],
|
|
requireAuthor: true,
|
|
requireRepository: true,
|
|
requireKeywords: true,
|
|
requireLicense: true,
|
|
requireImmutableLocator: true,
|
|
warnMissingImmutableLocator: false,
|
|
}),
|
|
});
|
|
|
|
// Allowed keys for typo detection. Kept intentionally permissive: unknown keys
|
|
// produce warnings (not errors) so the schema stays forward-compatible.
|
|
const ALLOWED_PLUGIN_KEYS = Object.freeze([
|
|
"name",
|
|
"description",
|
|
"version",
|
|
"author",
|
|
"repository",
|
|
"homepage",
|
|
"license",
|
|
"keywords",
|
|
"tags",
|
|
"source",
|
|
]);
|
|
|
|
const ALLOWED_AUTHOR_KEYS = Object.freeze(["name", "url", "email"]);
|
|
|
|
const ALLOWED_SOURCE_KEYS = Object.freeze(["source", "repo", "path", "ref", "sha"]);
|
|
|
|
// Semantic Versioning 2.0.0 (https://semver.org). Anchored: major.minor.patch
|
|
// with optional -prerelease and +build metadata.
|
|
const SEMVER_PATTERN =
|
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
|
|
// NOTE: Keep in sync with PLUGIN_JSON_CANDIDATES in external-plugin-quality-gates.mjs
|
|
const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([
|
|
"plugin.json",
|
|
".github/plugin/plugin.json",
|
|
".plugin/plugin.json",
|
|
]);
|
|
|
|
function resolvePolicy(policy) {
|
|
if (!policy) {
|
|
return EXTERNAL_PLUGIN_POLICIES.marketplace;
|
|
}
|
|
|
|
if (typeof policy === "string") {
|
|
const resolved = EXTERNAL_PLUGIN_POLICIES[policy];
|
|
if (!resolved) {
|
|
throw new Error(`Unknown external plugin validation policy "${policy}"`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
return {
|
|
...EXTERNAL_PLUGIN_POLICIES.marketplace,
|
|
...policy,
|
|
};
|
|
}
|
|
|
|
function isNonEmptyString(value) {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function validatePluginName(name, prefix, errors) {
|
|
if (!isNonEmptyString(name)) {
|
|
errors.push(`${prefix}: "name" is required and must be a non-empty string`);
|
|
return;
|
|
}
|
|
|
|
if (name.length > 64) {
|
|
errors.push(`${prefix}: "name" must be 64 characters or fewer`);
|
|
}
|
|
|
|
if (!/^[a-z0-9.-]+$/.test(name)) {
|
|
errors.push(`${prefix}: "name" must contain only lowercase letters, numbers, hyphens, and periods`);
|
|
}
|
|
|
|
if (!/^[a-z0-9].*[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
|
|
errors.push(`${prefix}: "name" must start and end with a lowercase letter or number`);
|
|
}
|
|
|
|
if (name.includes("--") || name.includes("..")) {
|
|
errors.push(`${prefix}: "name" must not contain consecutive hyphens or periods`);
|
|
}
|
|
}
|
|
|
|
function validateDescription(description, prefix, errors) {
|
|
if (!isNonEmptyString(description)) {
|
|
errors.push(`${prefix}: "description" is required and must be a non-empty string`);
|
|
return;
|
|
}
|
|
|
|
if (description.length > 500) {
|
|
errors.push(`${prefix}: "description" must be 500 characters or fewer`);
|
|
}
|
|
}
|
|
|
|
function validateVersion(version, prefix, errors) {
|
|
if (!isNonEmptyString(version)) {
|
|
errors.push(`${prefix}: "version" is required and must be a non-empty string`);
|
|
return;
|
|
}
|
|
|
|
if (version.length > 100) {
|
|
errors.push(`${prefix}: "version" must be 100 characters or fewer`);
|
|
}
|
|
|
|
if (!SEMVER_PATTERN.test(version)) {
|
|
errors.push(
|
|
`${prefix}: "version" must be a valid semantic version (e.g. "1.2.3" or "1.2.3-beta.1"); see https://semver.org`
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateKeywords(keywords, prefix, errors, warnings, required) {
|
|
if (keywords === undefined) {
|
|
if (required) {
|
|
errors.push(`${prefix}: "keywords" is required and must be an array of lowercase tags`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(keywords)) {
|
|
errors.push(`${prefix}: "keywords" must be an array`);
|
|
return;
|
|
}
|
|
|
|
if (keywords.length > 10) {
|
|
errors.push(`${prefix}: "keywords" must contain no more than 10 entries`);
|
|
}
|
|
|
|
for (let i = 0; i < keywords.length; i++) {
|
|
const keyword = keywords[i];
|
|
if (!isNonEmptyString(keyword)) {
|
|
errors.push(`${prefix}: "keywords[${i}]" must be a non-empty string`);
|
|
continue;
|
|
}
|
|
|
|
if (!/^[a-z0-9-]+$/.test(keyword)) {
|
|
errors.push(`${prefix}: "keywords[${i}]" must contain only lowercase letters, numbers, and hyphens`);
|
|
}
|
|
|
|
if (keyword.length > 30) {
|
|
errors.push(`${prefix}: "keywords[${i}]" must be 30 characters or fewer`);
|
|
}
|
|
}
|
|
|
|
if (keywords.length === 0) {
|
|
if (required) {
|
|
errors.push(`${prefix}: "keywords" must contain at least one entry`);
|
|
} else {
|
|
warnings.push(`${prefix}: "keywords" is empty; at least one keyword is recommended for discovery`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateHttpsUrl(value, fieldName, prefix, errors, options = {}) {
|
|
if (!isNonEmptyString(value)) {
|
|
errors.push(`${prefix}: "${fieldName}" must be a non-empty string`);
|
|
return;
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
errors.push(`${prefix}: "${fieldName}" must be a valid URL`);
|
|
return;
|
|
}
|
|
|
|
if (parsed.protocol !== "https:") {
|
|
errors.push(`${prefix}: "${fieldName}" must use https`);
|
|
}
|
|
|
|
if (options.githubOnly && parsed.hostname !== "github.com") {
|
|
errors.push(`${prefix}: "${fieldName}" must point to https://github.com/...`);
|
|
}
|
|
}
|
|
|
|
function validateAuthor(author, prefix, errors, warnings, required) {
|
|
if (author === undefined) {
|
|
if (required) {
|
|
errors.push(`${prefix}: "author" is required`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!author || typeof author !== "object" || Array.isArray(author)) {
|
|
errors.push(`${prefix}: "author" must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (!isNonEmptyString(author.name)) {
|
|
errors.push(`${prefix}: "author.name" is required and must be a non-empty string`);
|
|
}
|
|
|
|
if (author.url !== undefined) {
|
|
validateHttpsUrl(author.url, "author.url", prefix, errors);
|
|
}
|
|
|
|
if (author.email !== undefined) {
|
|
validateEmail(author.email, "author.email", prefix, errors);
|
|
}
|
|
|
|
validateKnownFields(author, ALLOWED_AUTHOR_KEYS, "author", prefix, warnings);
|
|
}
|
|
|
|
function validateEmail(value, fieldName, prefix, errors) {
|
|
if (!isNonEmptyString(value)) {
|
|
errors.push(`${prefix}: "${fieldName}" must be a non-empty string`);
|
|
return;
|
|
}
|
|
|
|
// Pragmatic email check: single "@", non-empty local part, and a dotted domain.
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
errors.push(`${prefix}: "${fieldName}" must be a valid email address`);
|
|
}
|
|
}
|
|
|
|
function validateLicense(license, prefix, errors, warnings, required) {
|
|
const result = validateLicenseField(license, { prefix, required });
|
|
errors.push(...result.errors);
|
|
warnings.push(...result.warnings);
|
|
}
|
|
|
|
function validateKnownFields(value, allowedKeys, scope, prefix, warnings) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return;
|
|
}
|
|
|
|
const allowed = new Set(allowedKeys);
|
|
const label = scope ? `${scope}.` : "";
|
|
for (const key of Object.keys(value)) {
|
|
if (!allowed.has(key)) {
|
|
warnings.push(`${prefix}: unknown ${scope || "top-level"} field ${inlineCode(`${label}${key}`)} (possible typo)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateRepository(repository, prefix, errors, required) {
|
|
if (repository === undefined) {
|
|
if (required) {
|
|
errors.push(`${prefix}: "repository" is required`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
validateHttpsUrl(repository, "repository", prefix, errors, { githubOnly: true });
|
|
}
|
|
|
|
function validateHomepage(homepage, prefix, errors) {
|
|
if (homepage === undefined) {
|
|
return;
|
|
}
|
|
|
|
validateHttpsUrl(homepage, "homepage", prefix, errors);
|
|
}
|
|
|
|
function formatExpectedPluginRootMessage() {
|
|
return EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS.map((manifestPath) => `"${manifestPath}"`).join(", ");
|
|
}
|
|
|
|
function validateRelativePath(pathValue, prefix, errors) {
|
|
if (!isNonEmptyString(pathValue)) {
|
|
errors.push(`${prefix}: "source.path" must be a non-empty string when provided`);
|
|
return;
|
|
}
|
|
|
|
if (pathValue === "/") {
|
|
return;
|
|
}
|
|
|
|
const normalized = path.posix.normalize(pathValue);
|
|
const segments = pathValue.split("/");
|
|
|
|
if (pathValue.startsWith("/") || pathValue.startsWith("../") || normalized !== pathValue || segments.includes("..")) {
|
|
errors.push(`${prefix}: "source.path" must be a safe relative path inside the repository`);
|
|
}
|
|
|
|
if (pathValue.includes("\\")) {
|
|
errors.push(`${prefix}: "source.path" must use forward slashes`);
|
|
}
|
|
|
|
if (normalized === ".") {
|
|
errors.push(`${prefix}: "source.path" must be "/" for the repository root or a plugin root directory relative to the repository root`);
|
|
}
|
|
|
|
if (path.posix.basename(normalized) === "plugin.json") {
|
|
errors.push(
|
|
`${prefix}: "source.path" must point to the plugin root directory, not the manifest file; relative to "source.path", expected one of ${formatExpectedPluginRootMessage()}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateImmutableRef(ref, prefix, errors) {
|
|
if (!isNonEmptyString(ref)) {
|
|
errors.push(`${prefix}: "source.ref" must be a non-empty string when provided`);
|
|
return;
|
|
}
|
|
|
|
if (ref.startsWith("refs/heads/")) {
|
|
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch ref`);
|
|
return;
|
|
}
|
|
|
|
if (["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) {
|
|
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch name`);
|
|
}
|
|
|
|
if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) {
|
|
errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`);
|
|
}
|
|
|
|
if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) {
|
|
errors.push(`${prefix}: "source.ref" must be a full 40-character commit SHA when referencing a commit`);
|
|
}
|
|
}
|
|
|
|
function validateCommitSha(sha, prefix, errors) {
|
|
if (!isNonEmptyString(sha)) {
|
|
errors.push(`${prefix}: "source.sha" must be a non-empty string when provided`);
|
|
return;
|
|
}
|
|
|
|
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
errors.push(`${prefix}: "source.sha" must be a full 40-character commit SHA`);
|
|
}
|
|
}
|
|
|
|
function validateGitHubSource(source, prefix, errors, warnings, policy) {
|
|
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
errors.push(`${prefix}: "source" must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (source.source !== "github") {
|
|
errors.push(`${prefix}: "source.source" must be "github"`);
|
|
}
|
|
|
|
if (!isNonEmptyString(source.repo)) {
|
|
errors.push(`${prefix}: "source.repo" is required and must be a non-empty string`);
|
|
} else if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) {
|
|
errors.push(`${prefix}: "source.repo" must be in "owner/repo" format`);
|
|
}
|
|
|
|
if (source.path !== undefined) {
|
|
validateRelativePath(source.path, prefix, errors);
|
|
}
|
|
|
|
if (source.ref !== undefined) {
|
|
validateImmutableRef(source.ref, prefix, errors);
|
|
}
|
|
|
|
if (source.sha !== undefined) {
|
|
validateCommitSha(source.sha, prefix, errors);
|
|
}
|
|
|
|
const missingLocator = source.ref === undefined && source.sha === undefined;
|
|
if (missingLocator) {
|
|
if (policy.requireImmutableLocator) {
|
|
errors.push(`${prefix}: one of "source.ref" or "source.sha" is required for public external plugin submissions`);
|
|
} else if (policy.warnMissingImmutableLocator) {
|
|
warnings.push(
|
|
`${prefix}: "source" has no "source.ref" or "source.sha"; an immutable tag ref or commit SHA is recommended for reproducible installs`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function validateExternalPlugin(plugin, index, options = {}) {
|
|
const policy = resolvePolicy(options.policy ?? options);
|
|
const errors = [];
|
|
const warnings = [];
|
|
const prefix = `external.json[${index}]`;
|
|
|
|
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
|
|
return {
|
|
errors: [`${prefix}: entry must be an object`],
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
validatePluginName(plugin.name, prefix, errors);
|
|
validateDescription(plugin.description, prefix, errors);
|
|
validateVersion(plugin.version, prefix, errors);
|
|
validateAuthor(plugin.author, prefix, errors, warnings, policy.requireAuthor);
|
|
validateRepository(plugin.repository, prefix, errors, policy.requireRepository);
|
|
validateHomepage(plugin.homepage, prefix, errors);
|
|
validateLicense(plugin.license, prefix, errors, warnings, policy.requireLicense);
|
|
validateKeywords(plugin.keywords ?? plugin.tags, prefix, errors, warnings, policy.requireKeywords);
|
|
validateKnownFields(plugin, ALLOWED_PLUGIN_KEYS, "", prefix, warnings);
|
|
|
|
if (plugin.tags !== undefined && plugin.keywords === undefined) {
|
|
warnings.push(`${prefix}: prefer "keywords" over legacy "tags"`);
|
|
}
|
|
|
|
if (!plugin.source) {
|
|
errors.push(`${prefix}: "source" is required`);
|
|
} else if (typeof plugin.source !== "object" || Array.isArray(plugin.source)) {
|
|
errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`);
|
|
} else {
|
|
validateKnownFields(plugin.source, ALLOWED_SOURCE_KEYS, "source", prefix, warnings);
|
|
if (!policy.allowedSourceTypes.includes(plugin.source.source)) {
|
|
errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`);
|
|
} else if (plugin.source.source === "github") {
|
|
validateGitHubSource(plugin.source, prefix, errors, warnings, policy);
|
|
}
|
|
}
|
|
|
|
return { errors, warnings };
|
|
}
|
|
|
|
export function validateExternalPlugins(plugins, options = {}) {
|
|
const policy = resolvePolicy(options.policy ?? options);
|
|
const errors = [];
|
|
const warnings = [];
|
|
const localNames = new Map(
|
|
(options.localPluginNames ?? []).map((name) => [String(name).toLowerCase(), String(name)])
|
|
);
|
|
const seenExternalNames = new Map();
|
|
|
|
if (!Array.isArray(plugins)) {
|
|
return {
|
|
errors: ["external.json must contain an array"],
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
plugins.forEach((plugin, index) => {
|
|
const result = validateExternalPlugin(plugin, index, { policy });
|
|
errors.push(...result.errors);
|
|
warnings.push(...result.warnings);
|
|
|
|
if (!isNonEmptyString(plugin?.name)) {
|
|
return;
|
|
}
|
|
|
|
const normalizedName = plugin.name.toLowerCase();
|
|
const duplicateIndex = seenExternalNames.get(normalizedName);
|
|
if (duplicateIndex !== undefined) {
|
|
errors.push(`external.json[${index}]: duplicate plugin name "${plugin.name}" already used by external.json[${duplicateIndex}]`);
|
|
} else {
|
|
seenExternalNames.set(normalizedName, index);
|
|
}
|
|
|
|
const localDuplicate = localNames.get(normalizedName);
|
|
if (localDuplicate) {
|
|
errors.push(`external.json[${index}]: plugin name "${plugin.name}" conflicts with local plugin "${localDuplicate}"`);
|
|
}
|
|
});
|
|
|
|
return { errors, warnings };
|
|
}
|
|
|
|
export function readExternalPlugins(options = {}) {
|
|
const filePath = options.filePath ?? EXTERNAL_PLUGINS_FILE;
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return {
|
|
plugins: [],
|
|
errors: [],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
let plugins;
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
plugins = JSON.parse(content);
|
|
} catch (error) {
|
|
return {
|
|
plugins: [],
|
|
errors: [`Error reading ${path.basename(filePath)}: ${error.message}`],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const { errors, warnings } = validateExternalPlugins(plugins, options);
|
|
return { plugins, errors, warnings };
|
|
}
|