import { Logger, field } from "@coder/logger" import * as express from "express" import * as fs from "fs" import * as path from "path" import * as semver from "semver" import * as pluginapi from "../../typings/pluginapi" import { version } from "./constants" import * as util from "./util" const fsp = fs.promises interface Plugin extends pluginapi.Plugin { /** * These fields are populated from the plugin's package.json. */ name: string version: string description: string /** * path to the node module on the disk. */ modulePath: string } interface Application extends pluginapi.Application { /* * Clone of the above without functions. */ plugin: Omit } /** * PluginAPI implements the plugin API described in typings/pluginapi.d.ts * Please see that file for details. */ export class PluginAPI { private readonly plugins = new Array() private readonly logger: Logger public constructor( logger: Logger, /** * These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively. */ private readonly csPlugin = "", private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`, ) { this.logger = logger.named("pluginapi") } /** * applications grabs the full list of applications from * all loaded plugins. */ public async applications(): Promise { const apps = new Array() for (const p of this.plugins) { const pluginApps = await p.applications() // Add plugin key to each app. apps.push( ...pluginApps.map((app) => { return { ...app, plugin: { name: p.name, version: p.version, description: p.description, modulePath: p.modulePath, }, } }), ) } return apps } /** * mount mounts all plugin routers onto r. */ public mount(r: express.Router): void { for (const p of this.plugins) { r.use(`/${p.name}`, p.router()) } } /** * loadPlugins loads all plugins based on this.csPlugin, * this.csPluginPath and the built in plugins. */ public async loadPlugins(): Promise { for (const dir of this.csPlugin.split(":")) { if (!dir) { continue } await this.loadPlugin(dir) } for (const dir of this.csPluginPath.split(":")) { if (!dir) { continue } await this._loadPlugins(dir) } // Built-in plugins. await this._loadPlugins(path.join(__dirname, "../../plugins")) } private async _loadPlugins(dir: string): Promise { try { const entries = await fsp.readdir(dir, { withFileTypes: true }) for (const ent of entries) { if (!ent.isDirectory()) { continue } await this.loadPlugin(path.join(dir, ent.name)) } } catch (err) { if (err.code !== "ENOENT") { this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`) } } } private async loadPlugin(dir: string): Promise { try { const str = await fsp.readFile(path.join(dir, "package.json"), { encoding: "utf8", }) const packageJSON: PackageJSON = JSON.parse(str) for (const p of this.plugins) { if (p.name === packageJSON.name) { this.logger.warn( `ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`, ) return } } const p = this._loadPlugin(dir, packageJSON) this.plugins.push(p) } catch (err) { if (err.code !== "ENOENT") { this.logger.warn(`failed to load plugin: ${err.message}`) } } } private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin { const logger = this.logger.named(packageJSON.name) logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON)) if (!semver.satisfies(version, packageJSON.engines["code-server"])) { throw new Error( `plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`, ) } if (!packageJSON.name) { throw new Error("plugin missing name") } if (!packageJSON.version) { throw new Error("plugin missing version") } const p = { name: packageJSON.name, version: packageJSON.version, description: packageJSON.description, modulePath: dir, ...require(dir), } as Plugin p.init({ logger: logger, }) logger.debug("loaded") return p } } interface PackageJSON { name: string version: string description: string engines: { "code-server": string } } function q(s: string | undefined): string { if (s === undefined) { s = "undefined" } return JSON.stringify(s) }