|
1 |
| -import { field, logger } from "@coder/logger" |
2 |
| -import { Express } from "express" |
3 |
| -import * as fs from "fs" |
4 | 1 | import * as path from "path"
|
5 |
| -import * as util from "util" |
6 |
| -import { Args } from "./cli" |
7 |
| -import { paths } from "./util" |
8 |
| - |
9 |
| -/* eslint-disable @typescript-eslint/no-var-requires */ |
10 |
| - |
11 |
| -export type Activate = (app: Express, args: Args) => void |
| 2 | +import * as util from "./util" |
| 3 | +import * as pluginapi from "../../typings/plugin" |
| 4 | +import * as fs from "fs" |
| 5 | +import * as semver from "semver" |
| 6 | +import { version } from "./constants" |
| 7 | +const fsp = fs.promises |
| 8 | +import { Logger, field } from "@coder/logger" |
| 9 | +import * as express from "express" |
12 | 10 |
|
13 |
| -/** |
14 |
| - * Plugins must implement this interface. |
15 |
| - */ |
16 |
| -export interface Plugin { |
17 |
| - activate: Activate |
| 11 | +// These fields are populated from the plugin's package.json. |
| 12 | +interface Plugin extends pluginapi.Plugin { |
| 13 | + name: string |
| 14 | + version: string |
| 15 | + description: string |
18 | 16 | }
|
19 | 17 |
|
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]) |
| 18 | +interface Application extends pluginapi.Application { |
| 19 | + plugin: Plugin |
28 | 20 | }
|
29 | 21 |
|
30 | 22 | /**
|
31 |
| - * Load a plugin and run its activation function. |
| 23 | + * PluginAPI implements the plugin API described in typings/plugin.d.ts |
| 24 | + * Please see that file for details. |
32 | 25 | */
|
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"), |
| 26 | +export class PluginAPI { |
| 27 | + private readonly plugins = new Array<Plugin>() |
| 28 | + private readonly logger: Logger |
| 29 | + |
| 30 | + public constructor( |
| 31 | + logger: Logger, |
| 32 | + /** |
| 33 | + * These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively. |
| 34 | + */ |
| 35 | + private readonly csPlugin = "", |
| 36 | + private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`, |
| 37 | + ){ |
| 38 | + this.logger = logger.named("pluginapi") |
| 39 | + } |
| 40 | + |
| 41 | + /** |
| 42 | + * applications grabs the full list of applications from |
| 43 | + * all loaded plugins. |
| 44 | + */ |
| 45 | + public async applications(): Promise<Application[]> { |
| 46 | + const apps = new Array<Application>() |
| 47 | + for (let p of this.plugins) { |
| 48 | + const pluginApps = await p.applications() |
| 49 | + |
| 50 | + // TODO prevent duplicates |
| 51 | + // Add plugin key to each app. |
| 52 | + apps.push( |
| 53 | + ...pluginApps.map((app) => { |
| 54 | + return { ...app, plugin: p } |
| 55 | + }), |
| 56 | + ) |
| 57 | + } |
| 58 | + return apps |
| 59 | + } |
| 60 | + |
| 61 | + /** |
| 62 | + * mount mounts all plugin routers onto r. |
| 63 | + */ |
| 64 | + public mount(r: express.Router): void { |
| 65 | + for (let p of this.plugins) { |
| 66 | + r.use(`/${p.name}`, p.router()) |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * loadPlugins loads all plugins based on this.csPluginPath |
| 72 | + * and this.csPlugin. |
| 73 | + */ |
| 74 | + public async loadPlugins(): Promise<void> { |
| 75 | + // Built-in plugins. |
| 76 | + await this._loadPlugins(path.join(__dirname, "../../plugins")) |
| 77 | + |
| 78 | + for (let dir of this.csPluginPath.split(":")) { |
| 79 | + if (!dir) { |
| 80 | + continue |
| 81 | + } |
| 82 | + await this._loadPlugins(dir) |
| 83 | + } |
| 84 | + |
| 85 | + for (let dir of this.csPlugin.split(":")) { |
| 86 | + if (!dir) { |
| 87 | + continue |
| 88 | + } |
| 89 | + await this.loadPlugin(dir) |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + private async _loadPlugins(dir: string): Promise<void> { |
| 94 | + try { |
| 95 | + const entries = await fsp.readdir(dir, { withFileTypes: true }) |
| 96 | + for (let ent of entries) { |
| 97 | + if (!ent.isDirectory()) { |
| 98 | + continue |
| 99 | + } |
| 100 | + await this.loadPlugin(path.join(dir, ent.name)) |
| 101 | + } |
| 102 | + } catch (err) { |
| 103 | + if (err.code !== "ENOENT") { |
| 104 | + this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`) |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + private async loadPlugin(dir: string): Promise<void> { |
| 110 | + try { |
| 111 | + const str = await fsp.readFile(path.join(dir, "package.json"), { |
| 112 | + encoding: "utf8", |
| 113 | + }) |
| 114 | + const packageJSON: PackageJSON = JSON.parse(str) |
| 115 | + const p = this._loadPlugin(dir, packageJSON) |
| 116 | + // TODO prevent duplicates |
| 117 | + this.plugins.push(p) |
| 118 | + } catch (err) { |
| 119 | + if (err.code !== "ENOENT") { |
| 120 | + this.logger.warn(`failed to load plugin: ${err.message}`) |
| 121 | + } |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin { |
| 126 | + const logger = this.logger.named(packageJSON.name) |
| 127 | + logger.debug("loading plugin", |
| 128 | + field("plugin_dir", dir), |
| 129 | + field("package_json", packageJSON), |
44 | 130 | )
|
45 |
| - } catch (error) { |
46 |
| - logger.error(error.message) |
| 131 | + |
| 132 | + if (!semver.satisfies(version, packageJSON.engines["code-server"])) { |
| 133 | + throw new Error(`plugin range ${q(packageJSON.engines["code-server"])} incompatible` + |
| 134 | + ` with code-server version ${version}`) |
| 135 | + } |
| 136 | + if (!packageJSON.name) { |
| 137 | + throw new Error("plugin missing name") |
| 138 | + } |
| 139 | + if (!packageJSON.version) { |
| 140 | + throw new Error("plugin missing version") |
| 141 | + } |
| 142 | + if (!packageJSON.description) { |
| 143 | + throw new Error("plugin missing description") |
| 144 | + } |
| 145 | + |
| 146 | + const p = { |
| 147 | + name: packageJSON.name, |
| 148 | + version: packageJSON.version, |
| 149 | + description: packageJSON.description, |
| 150 | + ...require(dir), |
| 151 | + } as Plugin |
| 152 | + |
| 153 | + p.init({ |
| 154 | + logger: logger, |
| 155 | + }) |
| 156 | + |
| 157 | + logger.debug("loaded") |
| 158 | + |
| 159 | + return p |
47 | 160 | }
|
48 | 161 | }
|
49 | 162 |
|
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) |
62 |
| - } |
| 163 | +interface PackageJSON { |
| 164 | + name: string |
| 165 | + version: string |
| 166 | + description: string |
| 167 | + engines: { |
| 168 | + "code-server": string |
63 | 169 | }
|
64 | 170 | }
|
65 | 171 |
|
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([ |
75 |
| - // 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 |
| - ]) |
| 172 | +function q(s: string): string { |
| 173 | + if (s === undefined) { |
| 174 | + s = "undefined" |
| 175 | + } |
| 176 | + return JSON.stringify(s) |
92 | 177 | }
|
0 commit comments