Files
factorio-clusterio/packages/lib/src/load_plugin_list.ts
T
Hornwitser 5dcaca4d0b Migrate to ECMAScript Modules
Migrate the usage of CommonJS specific code and imports to ESM.  Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
  equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.

Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
2026-09-11 22:43:57 +02:00

233 lines
7.2 KiB
TypeScript

import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "path";
import { logger } from "./logging.js";
import * as libFileOps from "./file_ops.js";
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;
}
const require = createRequire(import.meta.url);
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");
}