2020-10-30 07:26:30 +00:00
|
|
|
import { Logger, field } from "@coder/logger"
|
|
|
|
import * as express from "express"
|
2020-10-30 07:18:45 +00:00
|
|
|
import * as fs from "fs"
|
2020-10-30 07:26:30 +00:00
|
|
|
import * as path from "path"
|
2020-10-30 07:18:45 +00:00
|
|
|
import * as semver from "semver"
|
2020-11-03 22:13:21 +00:00
|
|
|
import * as pluginapi from "../../typings/pluginapi"
|
2020-10-30 07:18:45 +00:00
|
|
|
import { version } from "./constants"
|
2020-10-30 07:26:30 +00:00
|
|
|
import * as util from "./util"
|
2020-10-30 07:18:45 +00:00
|
|
|
const fsp = fs.promises
|
2020-07-28 20:06:15 +00:00
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
interface Plugin extends pluginapi.Plugin {
|
2020-10-30 07:37:42 +00:00
|
|
|
/**
|
2020-11-04 02:11:14 +00:00
|
|
|
* These fields are populated from the plugin's package.json
|
|
|
|
* and now guaranteed to exist.
|
2020-10-30 07:37:42 +00:00
|
|
|
*/
|
2020-10-30 07:18:45 +00:00
|
|
|
name: string
|
|
|
|
version: string
|
2020-10-30 07:37:42 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* path to the node module on the disk.
|
|
|
|
*/
|
|
|
|
modulePath: string
|
2020-07-28 20:06:15 +00:00
|
|
|
}
|
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
interface Application extends pluginapi.Application {
|
2020-11-03 21:24:06 +00:00
|
|
|
/*
|
|
|
|
* Clone of the above without functions.
|
|
|
|
*/
|
|
|
|
plugin: Omit<Plugin, "init" | "router" | "applications">
|
2020-07-28 20:06:15 +00:00
|
|
|
}
|
|
|
|
|
2020-09-30 20:22:54 +00:00
|
|
|
/**
|
2020-11-03 22:13:21 +00:00
|
|
|
* PluginAPI implements the plugin API described in typings/pluginapi.d.ts
|
2020-10-30 07:18:45 +00:00
|
|
|
* Please see that file for details.
|
2020-09-30 20:22:54 +00:00
|
|
|
*/
|
2020-10-30 07:18:45 +00:00
|
|
|
export class PluginAPI {
|
2020-11-04 02:11:14 +00:00
|
|
|
private readonly plugins = new Map<string, Plugin>()
|
2020-10-30 07:18:45 +00:00
|
|
|
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`,
|
2020-10-30 07:26:30 +00:00
|
|
|
) {
|
2020-10-30 07:18:45 +00:00
|
|
|
this.logger = logger.named("pluginapi")
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* applications grabs the full list of applications from
|
|
|
|
* all loaded plugins.
|
|
|
|
*/
|
|
|
|
public async applications(): Promise<Application[]> {
|
|
|
|
const apps = new Array<Application>()
|
2020-11-04 02:53:16 +00:00
|
|
|
for (const [, p] of this.plugins) {
|
2020-11-06 19:44:19 +00:00
|
|
|
if (!p.applications) {
|
|
|
|
continue
|
|
|
|
}
|
2020-10-30 07:18:45 +00:00
|
|
|
const pluginApps = await p.applications()
|
|
|
|
|
|
|
|
// Add plugin key to each app.
|
|
|
|
apps.push(
|
|
|
|
...pluginApps.map((app) => {
|
2020-11-04 02:53:16 +00:00
|
|
|
app = { ...app, path: path.join(p.routerPath, app.path || "") }
|
|
|
|
app = { ...app, iconPath: path.join(app.path || "", app.iconPath) }
|
2020-11-03 21:24:06 +00:00
|
|
|
return {
|
|
|
|
...app,
|
|
|
|
plugin: {
|
|
|
|
name: p.name,
|
|
|
|
version: p.version,
|
|
|
|
modulePath: p.modulePath,
|
2020-11-04 02:11:14 +00:00
|
|
|
|
|
|
|
displayName: p.displayName,
|
|
|
|
description: p.description,
|
2020-11-04 02:42:21 +00:00
|
|
|
routerPath: p.routerPath,
|
2020-11-04 02:45:25 +00:00
|
|
|
homepageURL: p.homepageURL,
|
2020-11-03 21:24:06 +00:00
|
|
|
},
|
|
|
|
}
|
2020-10-30 07:18:45 +00:00
|
|
|
}),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return apps
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* mount mounts all plugin routers onto r.
|
|
|
|
*/
|
|
|
|
public mount(r: express.Router): void {
|
2020-11-04 02:53:16 +00:00
|
|
|
for (const [, p] of this.plugins) {
|
2020-11-06 19:44:19 +00:00
|
|
|
if (!p.router) {
|
|
|
|
continue
|
|
|
|
}
|
2020-11-05 19:17:13 +00:00
|
|
|
r.use(`${p.routerPath}`, p.router())
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2020-11-03 21:24:06 +00:00
|
|
|
* loadPlugins loads all plugins based on this.csPlugin,
|
|
|
|
* this.csPluginPath and the built in plugins.
|
2020-10-30 07:18:45 +00:00
|
|
|
*/
|
|
|
|
public async loadPlugins(): Promise<void> {
|
2020-11-03 21:42:18 +00:00
|
|
|
for (const dir of this.csPlugin.split(":")) {
|
2020-10-30 07:18:45 +00:00
|
|
|
if (!dir) {
|
|
|
|
continue
|
|
|
|
}
|
2020-11-03 21:42:18 +00:00
|
|
|
await this.loadPlugin(dir)
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
|
|
|
|
2020-11-03 21:42:18 +00:00
|
|
|
for (const dir of this.csPluginPath.split(":")) {
|
2020-10-30 07:18:45 +00:00
|
|
|
if (!dir) {
|
|
|
|
continue
|
|
|
|
}
|
2020-11-03 21:42:18 +00:00
|
|
|
await this._loadPlugins(dir)
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
2020-11-03 21:42:18 +00:00
|
|
|
|
|
|
|
// Built-in plugins.
|
|
|
|
await this._loadPlugins(path.join(__dirname, "../../plugins"))
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
|
|
|
|
2020-11-04 02:14:19 +00:00
|
|
|
/**
|
|
|
|
* _loadPlugins is the counterpart to loadPlugins.
|
|
|
|
*
|
|
|
|
* It differs in that it loads all plugins in a single
|
|
|
|
* directory whereas loadPlugins uses all available directories
|
|
|
|
* as documented.
|
|
|
|
*/
|
2020-10-30 07:18:45 +00:00
|
|
|
private async _loadPlugins(dir: string): Promise<void> {
|
|
|
|
try {
|
|
|
|
const entries = await fsp.readdir(dir, { withFileTypes: true })
|
2020-10-30 07:26:30 +00:00
|
|
|
for (const ent of entries) {
|
2020-10-30 07:18:45 +00:00
|
|
|
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<void> {
|
|
|
|
try {
|
|
|
|
const str = await fsp.readFile(path.join(dir, "package.json"), {
|
|
|
|
encoding: "utf8",
|
|
|
|
})
|
|
|
|
const packageJSON: PackageJSON = JSON.parse(str)
|
2020-11-04 02:53:16 +00:00
|
|
|
for (const [, p] of this.plugins) {
|
2020-10-30 07:37:42 +00:00
|
|
|
if (p.name === packageJSON.name) {
|
|
|
|
this.logger.warn(
|
|
|
|
`ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`,
|
|
|
|
)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
2020-10-30 07:18:45 +00:00
|
|
|
const p = this._loadPlugin(dir, packageJSON)
|
2020-11-04 02:11:14 +00:00
|
|
|
this.plugins.set(p.name, p)
|
2020-10-30 07:18:45 +00:00
|
|
|
} catch (err) {
|
|
|
|
if (err.code !== "ENOENT") {
|
2020-11-05 03:59:43 +00:00
|
|
|
this.logger.warn(`failed to load plugin: ${err.stack}`)
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-04 02:14:19 +00:00
|
|
|
/**
|
|
|
|
* _loadPlugin is the counterpart to loadPlugin and actually
|
|
|
|
* loads the plugin now that we know there is no duplicate
|
|
|
|
* and that the package.json has been read.
|
|
|
|
*/
|
2020-10-30 07:18:45 +00:00
|
|
|
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
|
2020-11-04 02:11:14 +00:00
|
|
|
dir = path.resolve(dir)
|
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
const logger = this.logger.named(packageJSON.name)
|
2020-10-30 07:26:30 +00:00
|
|
|
logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON))
|
2020-10-30 07:18:45 +00:00
|
|
|
|
2020-11-05 03:59:43 +00:00
|
|
|
if (!packageJSON.name) {
|
|
|
|
throw new Error("plugin package.json missing name")
|
|
|
|
}
|
|
|
|
if (!packageJSON.version) {
|
|
|
|
throw new Error("plugin package.json missing version")
|
|
|
|
}
|
|
|
|
if (!packageJSON.engines || !packageJSON.engines["code-server"]) {
|
|
|
|
throw new Error(`plugin package.json missing code-server range like:
|
|
|
|
"engines": {
|
2020-11-16 15:50:05 +00:00
|
|
|
"code-server": "^3.7.0"
|
2020-11-05 03:59:43 +00:00
|
|
|
}
|
|
|
|
`)
|
|
|
|
}
|
2020-10-30 07:18:45 +00:00
|
|
|
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
|
2020-10-30 07:26:30 +00:00
|
|
|
throw new Error(
|
|
|
|
`plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`,
|
|
|
|
)
|
2020-10-30 07:18:45 +00:00
|
|
|
}
|
|
|
|
|
2020-11-05 04:10:41 +00:00
|
|
|
const pluginModule = require(dir)
|
|
|
|
if (!pluginModule.plugin) {
|
|
|
|
throw new Error("plugin module does not export a plugin")
|
|
|
|
}
|
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
const p = {
|
|
|
|
name: packageJSON.name,
|
|
|
|
version: packageJSON.version,
|
2020-10-30 07:37:42 +00:00
|
|
|
modulePath: dir,
|
2020-11-05 04:10:41 +00:00
|
|
|
...pluginModule.plugin,
|
2020-10-30 07:18:45 +00:00
|
|
|
} as Plugin
|
|
|
|
|
2020-11-04 02:11:14 +00:00
|
|
|
if (!p.displayName) {
|
|
|
|
throw new Error("plugin missing displayName")
|
|
|
|
}
|
|
|
|
if (!p.description) {
|
|
|
|
throw new Error("plugin missing description")
|
|
|
|
}
|
2020-11-04 02:42:21 +00:00
|
|
|
if (!p.routerPath) {
|
|
|
|
throw new Error("plugin missing router path")
|
2020-11-04 02:11:14 +00:00
|
|
|
}
|
2020-11-05 03:59:43 +00:00
|
|
|
if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) {
|
|
|
|
throw new Error(`plugin router path ${q(p.routerPath)}: invalid`)
|
|
|
|
}
|
2020-11-04 02:45:25 +00:00
|
|
|
if (!p.homepageURL) {
|
|
|
|
throw new Error("plugin missing homepage")
|
|
|
|
}
|
2020-11-04 02:11:14 +00:00
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
p.init({
|
|
|
|
logger: logger,
|
|
|
|
})
|
|
|
|
|
|
|
|
logger.debug("loaded")
|
|
|
|
|
|
|
|
return p
|
2020-07-28 20:06:15 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-10-30 07:18:45 +00:00
|
|
|
interface PackageJSON {
|
|
|
|
name: string
|
|
|
|
version: string
|
|
|
|
engines: {
|
|
|
|
"code-server": string
|
2020-07-28 20:06:15 +00:00
|
|
|
}
|
2020-09-30 20:22:54 +00:00
|
|
|
}
|
2020-07-29 20:02:14 +00:00
|
|
|
|
2020-11-03 21:21:18 +00:00
|
|
|
function q(s: string | undefined): string {
|
2020-10-30 07:18:45 +00:00
|
|
|
if (s === undefined) {
|
|
|
|
s = "undefined"
|
|
|
|
}
|
|
|
|
return JSON.stringify(s)
|
2020-07-28 20:06:15 +00:00
|
|
|
}
|