|
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