Files
factorio-clusterio/packages/lib/src/load_plugin_list.ts
T

232 lines
7.2 KiB
TypeScript

import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "path";
import { logger } from "./logging";
import * as libFileOps from "./file_ops";
import vm from "vm";
/**
* Searches for local plugins in the plugins/ and external_plugins/ directories
* and adds them to the plugin list if they contain a package.json file.
*
* @param pluginList - Map of plugin names to their file paths
* @returns Promise that resolves to true if any plugins were added to the list, false otherwise
*/
async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolean> {
// Check folders for plugins
const pluginFolders = [
"plugins",
"external_plugins",
];
let hasChanged = 0;
const ignoredFolders = new Set(["node_modules", ".git", "dist", "build", "disabled"]);
const maxDepth = 3;
for (const pluginFolder of pluginFolders) {
let pluginNames;
try {
pluginNames = await fs.readdir(pluginFolder);
} catch (err: any) {
if (err.code === "ENOENT") {
// Skip if folder doesn't exist
continue;
}
throw err;
}
for (const pluginName of pluginNames) {
const pluginPath = path.join(pluginFolder, pluginName);
if (pluginList.has(pluginName)) {
// If the one in the list is not the same as this one, warn
if (pluginList.get(pluginName) !== path.resolve(pluginPath)) {
logger.warn(
`${pluginName} is already in the plugin list, but with a different path - ` +
`using ${pluginList.get(pluginName)}`
);
}
continue;
}
const stats = await fs.stat(pluginPath);
if (stats.isDirectory()) {
// Check for package.json and plugin keyword
if (await checkPackageJson(pluginPath)) {
pluginList.set(pluginName, path.resolve(pluginPath));
logger.info(`Added ${pluginName} from ${pluginFolder}`);
hasChanged += 1;
continue;
}
if (pluginPath.split(path.sep).length < maxDepth && !ignoredFolders.has(pluginName)) {
logger.warn(`Possible monorepo detected, scanning ${pluginPath} for clusterio-plugins`);
pluginFolders.push(pluginPath);
}
}
}
}
return hasChanged > 0;
}
function getPluginName(requireSpec: string) {
const context = vm.createContext({ require: require, pluginInfo: null });
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
vm.runInContext(code, context);
return context.pluginInfo.name;
}
function resolvePackagePath(packageName: string, fromPath: string): string | undefined {
const packageRequire = createRequire(path.join(fromPath, "package.json"));
try {
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
} catch (err: any) {
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
return undefined;
}
throw err;
}
}
/**
* Find NPM packages in the root package.json that satisfies the requirements for plugins.
* Adds discovered plugins to the plugin list.
*
* @param pluginList - Map of plugin names to their file paths
* @returns Promise that resolves to true if any plugins were added to the list, false otherwise
*/
async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean> {
let dependencies;
try {
const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" }));
dependencies = rootPackageJson.dependencies;
} catch (err: any) {
if (err.code === "ENOENT") {
// Skip if package.json doesn't exist
return false;
}
throw err;
}
if (!dependencies) {
return false;
}
let hasChanged = 0;
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
packageName,
packageVersion,
parentPath: process.cwd(),
isRootDependency: true,
}));
const corePackages = new Set([
"@clusterio/controller",
"@clusterio/ctl",
"@clusterio/host",
"@clusterio/lib",
"@clusterio/web_ui",
]);
const visitedPackages = new Set<string>();
while (pendingPackages.length) {
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
if (visitedPackages.has(packageName)) {
continue;
}
const npmPackagePath = resolvePackagePath(packageName, parentPath);
if (!npmPackagePath) {
continue;
}
visitedPackages.add(packageName);
if (await checkPackageJson(npmPackagePath)) {
const pluginName = getPluginName(npmPackagePath);
if (pluginList.has(pluginName)) {
logger.warn(
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
`but with a different path - using ${pluginList.get(pluginName)}`
);
} else {
pluginList.set(pluginName, packageName);
logger.info(`Added ${pluginName} from node modules`);
hasChanged += 1;
}
const packageJson = JSON.parse(
await fs.readFile(path.join(npmPackagePath, "package.json"), { encoding: "utf8" })
);
for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) {
pendingPackages.push({
packageName: dependencyName,
packageVersion: dependencyVersion,
parentPath: npmPackagePath,
isRootDependency: false,
});
}
} else if (isRootDependency && !corePackages.has(packageName)) {
logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`);
}
}
return hasChanged > 0;
}
/**
* Loads the plugin list from the specified file and optionally discovers local plugins
*
* @param pluginListPath - Path to the plugin list JSON file
* @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories
* @returns Map of plugin name to plugin path
*/
export async function loadPluginList(pluginListPath: string, findLocal = true): Promise<Map<string, string>> {
let pluginList = new Map<string, string>();
// Try to load existing plugin list
try {
pluginList = new Map(JSON.parse(await fs.readFile(pluginListPath, { encoding: "utf8" })));
logger.info(`Loaded ${pluginList.size} plugins from ${pluginListPath}`);
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
logger.info(`No existing plugin list found at ${pluginListPath}`);
}
// Track if any changes were made
let hasChanges = false;
// Optionally discover local plugins
if (findLocal) {
hasChanges = await findLocalPlugins(pluginList) || hasChanges;
}
// Discover NPM plugins
hasChanges = await findNpmPlugins(pluginList) || hasChanges;
if (hasChanges) {
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
}
return pluginList;
}
/**
* Helper function to check if a package.json file exists and contains the clusterio-plugin keyword
* @param pluginPath - Path to the plugin directory
* @returns Promise that resolves to true if package.json exists and has clusterio-plugin keyword
*/
async function checkPackageJson(pluginPath: string): Promise<boolean> {
const packageJsonPath = path.join(pluginPath, "package.json");
// Read and parse package.json
let content;
try {
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
} catch (err: any) {
return false;
}
const packageJson = JSON.parse(content);
// Check if package has the clusterio-plugin keyword
return packageJson.keywords?.includes("clusterio-plugin");
}