|
1 | | -import { field, logger } from "@coder/logger" |
2 | | -import { Express } from "express" |
| 1 | +import { Logger, field } from "@coder/logger" |
| 2 | +import * as express from "express" |
3 | 3 | import * as fs from "fs" |
4 | 4 | import * as path from "path" |
5 | | -import * as util from "util" |
6 | | -import { Args } from "./cli" |
7 | | -import { paths } from "./util" |
| 5 | +import * as semver from "semver" |
| 6 | +import * as pluginapi from "../../typings/pluginapi" |
| 7 | +import { version } from "./constants" |
| 8 | +import * as util from "./util" |
| 9 | +const fsp = fs.promises |
8 | 10 |
|
9 | | -/* eslint-disable @typescript-eslint/no-var-requires */ |
| 11 | +interface Plugin extends pluginapi.Plugin { |
| 12 | + /** |
| 13 | + * These fields are populated from the plugin's package.json |
| 14 | + * and now guaranteed to exist. |
| 15 | + */ |
| 16 | + name: string |
| 17 | + version: string |
10 | 18 |
|
11 | | -export type Activate = (app: Express, args: Args) => void |
12 | | - |
13 | | -/** |
14 | | - * Plugins must implement this interface. |
15 | | - */ |
16 | | -export interface Plugin { |
17 | | - activate: Activate |
| 19 | + /** |
| 20 | + * path to the node module on the disk. |
| 21 | + */ |
| 22 | + modulePath: string |
18 | 23 | } |
19 | 24 |
|
20 | | -/** |
21 | | - * Intercept imports so we can inject code-server when the plugin tries to |
22 | | - * import it. |
23 | | - */ |
24 | | -const originalLoad = require("module")._load |
25 | | -// eslint-disable-next-line @typescript-eslint/no-explicit-any |
26 | | -require("module")._load = function (request: string, parent: object, isMain: boolean): any { |
27 | | - return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain]) |
| 25 | +interface Application extends pluginapi.Application { |
| 26 | + /* |
| 27 | + * Clone of the above without functions. |
| 28 | + */ |
| 29 | + plugin: Omit<Plugin, "init" | "router" | "applications"> |
28 | 30 | } |
29 | 31 |
|
30 | 32 | /** |
31 | | - * Load a plugin and run its activation function. |
| 33 | + * PluginAPI implements the plugin API described in typings/pluginapi.d.ts |
| 34 | + * Please see that file for details. |
32 | 35 | */ |
33 | | -const loadPlugin = async (pluginPath: string, app: Express, args: Args): Promise<void> => { |
34 | | - try { |
35 | | - const plugin: Plugin = require(pluginPath) |
36 | | - plugin.activate(app, args) |
37 | | - |
38 | | - const packageJson = require(path.join(pluginPath, "package.json")) |
39 | | - logger.debug( |
40 | | - "Loaded plugin", |
41 | | - field("name", packageJson.name || path.basename(pluginPath)), |
42 | | - field("path", pluginPath), |
43 | | - field("version", packageJson.version || "n/a"), |
44 | | - ) |
45 | | - } catch (error) { |
46 | | - logger.error(error.message) |
| 36 | +export class PluginAPI { |
| 37 | + private readonly plugins = new Map<string, Plugin>() |
| 38 | + private readonly logger: Logger |
| 39 | + |
| 40 | + public constructor( |
| 41 | + logger: Logger, |
| 42 | + /** |
| 43 | + * These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively. |
| 44 | + */ |
| 45 | + private readonly csPlugin = "", |
| 46 | + private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`, |
| 47 | + ) { |
| 48 | + this.logger = logger.named("pluginapi") |
47 | 49 | } |
48 | | -} |
49 | 50 |
|
50 | | -/** |
51 | | - * Load all plugins in the specified directory. |
52 | | - */ |
53 | | -const _loadPlugins = async (pluginDir: string, app: Express, args: Args): Promise<void> => { |
54 | | - try { |
55 | | - const files = await util.promisify(fs.readdir)(pluginDir, { |
56 | | - withFileTypes: true, |
57 | | - }) |
58 | | - await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), app, args))) |
59 | | - } catch (error) { |
60 | | - if (error.code !== "ENOENT") { |
61 | | - logger.warn(error.message) |
| 51 | + /** |
| 52 | + * applications grabs the full list of applications from |
| 53 | + * all loaded plugins. |
| 54 | + */ |
| 55 | + public async applications(): Promise<Application[]> { |
| 56 | + const apps = new Array<Application>() |
| 57 | + for (const [, p] of this.plugins) { |
| 58 | + if (!p.applications) { |
| 59 | + continue |
| 60 | + } |
| 61 | + const pluginApps = await p.applications() |
| 62 | + |
| 63 | + // Add plugin key to each app. |
| 64 | + apps.push( |
| 65 | + ...pluginApps.map((app) => { |
| 66 | + app = { ...app, path: path.join(p.routerPath, app.path || "") } |
| 67 | + app = { ...app, iconPath: path.join(app.path || "", app.iconPath) } |
| 68 | + return { |
| 69 | + ...app, |
| 70 | + plugin: { |
| 71 | + name: p.name, |
| 72 | + version: p.version, |
| 73 | + modulePath: p.modulePath, |
| 74 | + |
| 75 | + displayName: p.displayName, |
| 76 | + description: p.description, |
| 77 | + routerPath: p.routerPath, |
| 78 | + homepageURL: p.homepageURL, |
| 79 | + }, |
| 80 | + } |
| 81 | + }), |
| 82 | + ) |
62 | 83 | } |
| 84 | + return apps |
63 | 85 | } |
64 | | -} |
65 | 86 |
|
66 | | -/** |
67 | | - * Load all plugins from the `plugins` directory, directories specified by |
68 | | - * `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by |
69 | | - * `CS_PLUGIN` (also colon-separated). |
70 | | - */ |
71 | | -export const loadPlugins = async (app: Express, args: Args): Promise<void> => { |
72 | | - const pluginPath = process.env.CS_PLUGIN_PATH || `${path.join(paths.data, "plugins")}:/usr/share/code-server/plugins` |
73 | | - const plugin = process.env.CS_PLUGIN || "" |
74 | | - await Promise.all([ |
| 87 | + /** |
| 88 | + * mount mounts all plugin routers onto r. |
| 89 | + */ |
| 90 | + public mount(r: express.Router): void { |
| 91 | + for (const [, p] of this.plugins) { |
| 92 | + if (!p.router) { |
| 93 | + continue |
| 94 | + } |
| 95 | + r.use(`${p.routerPath}`, p.router()) |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + /** |
| 100 | + * loadPlugins loads all plugins based on this.csPlugin, |
| 101 | + * this.csPluginPath and the built in plugins. |
| 102 | + */ |
| 103 | + public async loadPlugins(): Promise<void> { |
| 104 | + for (const dir of this.csPlugin.split(":")) { |
| 105 | + if (!dir) { |
| 106 | + continue |
| 107 | + } |
| 108 | + await this.loadPlugin(dir) |
| 109 | + } |
| 110 | + |
| 111 | + for (const dir of this.csPluginPath.split(":")) { |
| 112 | + if (!dir) { |
| 113 | + continue |
| 114 | + } |
| 115 | + await this._loadPlugins(dir) |
| 116 | + } |
| 117 | + |
75 | 118 | // Built-in plugins. |
76 | | - _loadPlugins(path.resolve(__dirname, "../../plugins"), app, args), |
77 | | - // User-added plugins. |
78 | | - ...pluginPath |
79 | | - .split(":") |
80 | | - .filter((p) => !!p) |
81 | | - .map((dir) => _loadPlugins(path.resolve(dir), app, args)), |
82 | | - // Individual plugins so you don't have to symlink or move them into a |
83 | | - // directory specifically for plugins. This lets you load plugins that are |
84 | | - // on the same level as other directories that are not plugins (if you tried |
85 | | - // to use CS_PLUGIN_PATH code-server would try to load those other |
86 | | - // directories as plugins). Intended for development. |
87 | | - ...plugin |
88 | | - .split(":") |
89 | | - .filter((p) => !!p) |
90 | | - .map((dir) => loadPlugin(path.resolve(dir), app, args)), |
91 | | - ]) |
| 119 | + await this._loadPlugins(path.join(__dirname, "../../plugins")) |
| 120 | + } |
| 121 | + |
| 122 | + /** |
| 123 | + * _loadPlugins is the counterpart to loadPlugins. |
| 124 | + * |
| 125 | + * It differs in that it loads all plugins in a single |
| 126 | + * directory whereas loadPlugins uses all available directories |
| 127 | + * as documented. |
| 128 | + */ |
| 129 | + private async _loadPlugins(dir: string): Promise<void> { |
| 130 | + try { |
| 131 | + const entries = await fsp.readdir(dir, { withFileTypes: true }) |
| 132 | + for (const ent of entries) { |
| 133 | + if (!ent.isDirectory()) { |
| 134 | + continue |
| 135 | + } |
| 136 | + await this.loadPlugin(path.join(dir, ent.name)) |
| 137 | + } |
| 138 | + } catch (err) { |
| 139 | + if (err.code !== "ENOENT") { |
| 140 | + this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`) |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + private async loadPlugin(dir: string): Promise<void> { |
| 146 | + try { |
| 147 | + const str = await fsp.readFile(path.join(dir, "package.json"), { |
| 148 | + encoding: "utf8", |
| 149 | + }) |
| 150 | + const packageJSON: PackageJSON = JSON.parse(str) |
| 151 | + for (const [, p] of this.plugins) { |
| 152 | + if (p.name === packageJSON.name) { |
| 153 | + this.logger.warn( |
| 154 | + `ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`, |
| 155 | + ) |
| 156 | + return |
| 157 | + } |
| 158 | + } |
| 159 | + const p = this._loadPlugin(dir, packageJSON) |
| 160 | + this.plugins.set(p.name, p) |
| 161 | + } catch (err) { |
| 162 | + if (err.code !== "ENOENT") { |
| 163 | + this.logger.warn(`failed to load plugin: ${err.stack}`) |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * _loadPlugin is the counterpart to loadPlugin and actually |
| 170 | + * loads the plugin now that we know there is no duplicate |
| 171 | + * and that the package.json has been read. |
| 172 | + */ |
| 173 | + private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin { |
| 174 | + dir = path.resolve(dir) |
| 175 | + |
| 176 | + const logger = this.logger.named(packageJSON.name) |
| 177 | + logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON)) |
| 178 | + |
| 179 | + if (!packageJSON.name) { |
| 180 | + throw new Error("plugin package.json missing name") |
| 181 | + } |
| 182 | + if (!packageJSON.version) { |
| 183 | + throw new Error("plugin package.json missing version") |
| 184 | + } |
| 185 | + if (!packageJSON.engines || !packageJSON.engines["code-server"]) { |
| 186 | + throw new Error(`plugin package.json missing code-server range like: |
| 187 | + "engines": { |
| 188 | + "code-server": "^3.6.0" |
| 189 | + } |
| 190 | +`) |
| 191 | + } |
| 192 | + if (!semver.satisfies(version, packageJSON.engines["code-server"])) { |
| 193 | + throw new Error( |
| 194 | + `plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`, |
| 195 | + ) |
| 196 | + } |
| 197 | + |
| 198 | + const pluginModule = require(dir) |
| 199 | + if (!pluginModule.plugin) { |
| 200 | + throw new Error("plugin module does not export a plugin") |
| 201 | + } |
| 202 | + |
| 203 | + const p = { |
| 204 | + name: packageJSON.name, |
| 205 | + version: packageJSON.version, |
| 206 | + modulePath: dir, |
| 207 | + ...pluginModule.plugin, |
| 208 | + } as Plugin |
| 209 | + |
| 210 | + if (!p.displayName) { |
| 211 | + throw new Error("plugin missing displayName") |
| 212 | + } |
| 213 | + if (!p.description) { |
| 214 | + throw new Error("plugin missing description") |
| 215 | + } |
| 216 | + if (!p.routerPath) { |
| 217 | + throw new Error("plugin missing router path") |
| 218 | + } |
| 219 | + if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) { |
| 220 | + throw new Error(`plugin router path ${q(p.routerPath)}: invalid`) |
| 221 | + } |
| 222 | + if (!p.homepageURL) { |
| 223 | + throw new Error("plugin missing homepage") |
| 224 | + } |
| 225 | + |
| 226 | + p.init({ |
| 227 | + logger: logger, |
| 228 | + }) |
| 229 | + |
| 230 | + logger.debug("loaded") |
| 231 | + |
| 232 | + return p |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +interface PackageJSON { |
| 237 | + name: string |
| 238 | + version: string |
| 239 | + engines: { |
| 240 | + "code-server": string |
| 241 | + } |
| 242 | +} |
| 243 | + |
| 244 | +function q(s: string | undefined): string { |
| 245 | + if (s === undefined) { |
| 246 | + s = "undefined" |
| 247 | + } |
| 248 | + return JSON.stringify(s) |
92 | 249 | } |
0 commit comments