diff options
-rw-r--r-- | package.json | 2 | ||||
-rwxr-xr-x | scripts/plugin/install.ts | 39 | ||||
-rw-r--r-- | server.ts | 4 | ||||
-rw-r--r-- | server/helpers/core-utils.ts | 13 | ||||
-rw-r--r-- | server/helpers/custom-validators/misc.ts | 2 | ||||
-rw-r--r-- | server/helpers/custom-validators/plugins.ts | 12 | ||||
-rw-r--r-- | server/initializers/database.ts | 4 | ||||
-rw-r--r-- | server/lib/plugins/plugin-manager.ts | 76 | ||||
-rw-r--r-- | server/lib/plugins/yarn.ts | 61 | ||||
-rw-r--r-- | server/models/server/plugin.ts | 11 | ||||
-rw-r--r-- | shared/models/plugins/plugin-package-json.model.ts | 1 |
11 files changed, 216 insertions, 9 deletions
diff --git a/package.json b/package.json index ee12718c7..fde913574 100644 --- a/package.json +++ b/package.json | |||
@@ -32,6 +32,8 @@ | |||
32 | "clean:server:test": "scripty", | 32 | "clean:server:test": "scripty", |
33 | "watch:client": "scripty", | 33 | "watch:client": "scripty", |
34 | "watch:server": "scripty", | 34 | "watch:server": "scripty", |
35 | "plugin:install": "node ./dist/scripts/plugin/install.js", | ||
36 | "plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js", | ||
35 | "danger:clean:dev": "scripty", | 37 | "danger:clean:dev": "scripty", |
36 | "danger:clean:prod": "scripty", | 38 | "danger:clean:prod": "scripty", |
37 | "danger:clean:modules": "scripty", | 39 | "danger:clean:modules": "scripty", |
diff --git a/scripts/plugin/install.ts b/scripts/plugin/install.ts new file mode 100755 index 000000000..8e9c9897f --- /dev/null +++ b/scripts/plugin/install.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import { initDatabaseModels } from '../../server/initializers/database' | ||
2 | import * as program from 'commander' | ||
3 | import { PluginManager } from '../../server/lib/plugins/plugin-manager' | ||
4 | import { isAbsolute } from 'path' | ||
5 | |||
6 | program | ||
7 | .option('-n, --pluginName [pluginName]', 'Plugin name to install') | ||
8 | .option('-v, --pluginVersion [pluginVersion]', 'Plugin version to install') | ||
9 | .option('-p, --pluginPath [pluginPath]', 'Path of the plugin you want to install') | ||
10 | .parse(process.argv) | ||
11 | |||
12 | if (!program['pluginName'] && !program['pluginPath']) { | ||
13 | console.error('You need to specify a plugin name with the desired version, or a plugin path.') | ||
14 | process.exit(-1) | ||
15 | } | ||
16 | |||
17 | if (program['pluginName'] && !program['pluginVersion']) { | ||
18 | console.error('You need to specify a the version of the plugin you want to install.') | ||
19 | process.exit(-1) | ||
20 | } | ||
21 | |||
22 | if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) { | ||
23 | console.error('Plugin path should be absolute.') | ||
24 | process.exit(-1) | ||
25 | } | ||
26 | |||
27 | run() | ||
28 | .then(() => process.exit(0)) | ||
29 | .catch(err => { | ||
30 | console.error(err) | ||
31 | process.exit(-1) | ||
32 | }) | ||
33 | |||
34 | async function run () { | ||
35 | await initDatabaseModels(true) | ||
36 | |||
37 | const toInstall = program['pluginName'] || program['pluginPath'] | ||
38 | await PluginManager.Instance.install(toInstall, program['pluginVersion'], !!program['pluginPath']) | ||
39 | } | ||
@@ -1,4 +1,6 @@ | |||
1 | // FIXME: https://github.com/nodejs/node/pull/16853 | 1 | // FIXME: https://github.com/nodejs/node/pull/16853 |
2 | import { PluginManager } from './server/lib/plugins/plugin-manager' | ||
3 | |||
2 | require('tls').DEFAULT_ECDH_CURVE = 'auto' | 4 | require('tls').DEFAULT_ECDH_CURVE = 'auto' |
3 | 5 | ||
4 | import { isTestInstance } from './server/helpers/core-utils' | 6 | import { isTestInstance } from './server/helpers/core-utils' |
@@ -259,6 +261,8 @@ async function startApplication () { | |||
259 | updateStreamingPlaylistsInfohashesIfNeeded() | 261 | updateStreamingPlaylistsInfohashesIfNeeded() |
260 | .catch(err => logger.error('Cannot update streaming playlist infohashes.', { err })) | 262 | .catch(err => logger.error('Cannot update streaming playlist infohashes.', { err })) |
261 | 263 | ||
264 | await PluginManager.Instance.registerPlugins() | ||
265 | |||
262 | // Make server listening | 266 | // Make server listening |
263 | server.listen(port, hostname, () => { | 267 | server.listen(port, hostname, () => { |
264 | logger.info('Server listening on %s:%d', hostname, port) | 268 | logger.info('Server listening on %s:%d', hostname, port) |
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index b1e9af0a1..c5b139378 100644 --- a/server/helpers/core-utils.ts +++ b/server/helpers/core-utils.ts | |||
@@ -10,7 +10,7 @@ import { isAbsolute, join } from 'path' | |||
10 | import * as pem from 'pem' | 10 | import * as pem from 'pem' |
11 | import { URL } from 'url' | 11 | import { URL } from 'url' |
12 | import { truncate } from 'lodash' | 12 | import { truncate } from 'lodash' |
13 | import { exec } from 'child_process' | 13 | import { exec, ExecOptions } from 'child_process' |
14 | 14 | ||
15 | const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => { | 15 | const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => { |
16 | if (!oldObject || typeof oldObject !== 'object') { | 16 | if (!oldObject || typeof oldObject !== 'object') { |
@@ -204,6 +204,16 @@ function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') | |||
204 | return createHash('sha1').update(str).digest(encoding) | 204 | return createHash('sha1').update(str).digest(encoding) |
205 | } | 205 | } |
206 | 206 | ||
207 | function execShell (command: string, options?: ExecOptions) { | ||
208 | return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => { | ||
209 | exec(command, options, (err, stdout, stderr) => { | ||
210 | if (err) return rej({ err, stdout, stderr }) | ||
211 | |||
212 | return res({ stdout, stderr }) | ||
213 | }) | ||
214 | }) | ||
215 | } | ||
216 | |||
207 | function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { | 217 | function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { |
208 | return function promisified (): Promise<A> { | 218 | return function promisified (): Promise<A> { |
209 | return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => { | 219 | return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => { |
@@ -269,6 +279,7 @@ export { | |||
269 | sanitizeUrl, | 279 | sanitizeUrl, |
270 | sanitizeHost, | 280 | sanitizeHost, |
271 | buildPath, | 281 | buildPath, |
282 | execShell, | ||
272 | peertubeTruncate, | 283 | peertubeTruncate, |
273 | 284 | ||
274 | sha256, | 285 | sha256, |
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index f72513c1c..3ef38fce1 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts | |||
@@ -9,7 +9,7 @@ function exists (value: any) { | |||
9 | function isSafePath (p: string) { | 9 | function isSafePath (p: string) { |
10 | return exists(p) && | 10 | return exists(p) && |
11 | (p + '').split(sep).every(part => { | 11 | (p + '').split(sep).every(part => { |
12 | return [ '', '.', '..' ].includes(part) === false | 12 | return [ '..' ].includes(part) === false |
13 | }) | 13 | }) |
14 | } | 14 | } |
15 | 15 | ||
diff --git a/server/helpers/custom-validators/plugins.ts b/server/helpers/custom-validators/plugins.ts index ff687dc3f..2fcdc581f 100644 --- a/server/helpers/custom-validators/plugins.ts +++ b/server/helpers/custom-validators/plugins.ts | |||
@@ -17,6 +17,13 @@ function isPluginNameValid (value: string) { | |||
17 | validator.matches(value, /^[a-z\-]+$/) | 17 | validator.matches(value, /^[a-z\-]+$/) |
18 | } | 18 | } |
19 | 19 | ||
20 | function isNpmPluginNameValid (value: string) { | ||
21 | return exists(value) && | ||
22 | validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.NAME) && | ||
23 | validator.matches(value, /^[a-z\-]+$/) && | ||
24 | (value.startsWith('peertube-plugin-') || value.startsWith('peertube-theme-')) | ||
25 | } | ||
26 | |||
20 | function isPluginDescriptionValid (value: string) { | 27 | function isPluginDescriptionValid (value: string) { |
21 | return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION) | 28 | return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION) |
22 | } | 29 | } |
@@ -55,7 +62,7 @@ function isCSSPathsValid (css: any[]) { | |||
55 | } | 62 | } |
56 | 63 | ||
57 | function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) { | 64 | function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) { |
58 | return isPluginNameValid(packageJSON.name) && | 65 | return isNpmPluginNameValid(packageJSON.name) && |
59 | isPluginDescriptionValid(packageJSON.description) && | 66 | isPluginDescriptionValid(packageJSON.description) && |
60 | isPluginEngineValid(packageJSON.engine) && | 67 | isPluginEngineValid(packageJSON.engine) && |
61 | isUrlValid(packageJSON.homepage) && | 68 | isUrlValid(packageJSON.homepage) && |
@@ -78,5 +85,6 @@ export { | |||
78 | isPluginVersionValid, | 85 | isPluginVersionValid, |
79 | isPluginNameValid, | 86 | isPluginNameValid, |
80 | isPluginDescriptionValid, | 87 | isPluginDescriptionValid, |
81 | isLibraryCodeValid | 88 | isLibraryCodeValid, |
89 | isNpmPluginNameValid | ||
82 | } | 90 | } |
diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 142063a99..a7988d75b 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts | |||
@@ -37,6 +37,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla | |||
37 | import { VideoPlaylistModel } from '../models/video/video-playlist' | 37 | import { VideoPlaylistModel } from '../models/video/video-playlist' |
38 | import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' | 38 | import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' |
39 | import { ThumbnailModel } from '../models/video/thumbnail' | 39 | import { ThumbnailModel } from '../models/video/thumbnail' |
40 | import { PluginModel } from '../models/server/plugin' | ||
40 | import { QueryTypes, Transaction } from 'sequelize' | 41 | import { QueryTypes, Transaction } from 'sequelize' |
41 | 42 | ||
42 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 43 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string |
@@ -107,7 +108,8 @@ async function initDatabaseModels (silent: boolean) { | |||
107 | VideoStreamingPlaylistModel, | 108 | VideoStreamingPlaylistModel, |
108 | VideoPlaylistModel, | 109 | VideoPlaylistModel, |
109 | VideoPlaylistElementModel, | 110 | VideoPlaylistElementModel, |
110 | ThumbnailModel | 111 | ThumbnailModel, |
112 | PluginModel | ||
111 | ]) | 113 | ]) |
112 | 114 | ||
113 | // Check extensions exist in the database | 115 | // Check extensions exist in the database |
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index b48ecc991..533ed4391 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { PluginModel } from '../../models/server/plugin' | 1 | import { PluginModel } from '../../models/server/plugin' |
2 | import { logger } from '../../helpers/logger' | 2 | import { logger } from '../../helpers/logger' |
3 | import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' | 3 | import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' |
4 | import { join } from 'path' | 4 | import { basename, join } from 'path' |
5 | import { CONFIG } from '../../initializers/config' | 5 | import { CONFIG } from '../../initializers/config' |
6 | import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' | 6 | import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' |
7 | import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model' | 7 | import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model' |
@@ -9,6 +9,7 @@ import { PluginLibrary } from '../../../shared/models/plugins/plugin-library.mod | |||
9 | import { createReadStream, createWriteStream } from 'fs' | 9 | import { createReadStream, createWriteStream } from 'fs' |
10 | import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' | 10 | import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' |
11 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | 11 | import { PluginType } from '../../../shared/models/plugins/plugin.type' |
12 | import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn' | ||
12 | 13 | ||
13 | export interface RegisteredPlugin { | 14 | export interface RegisteredPlugin { |
14 | name: string | 15 | name: string |
@@ -84,11 +85,63 @@ export class PluginManager { | |||
84 | await plugin.unregister() | 85 | await plugin.unregister() |
85 | } | 86 | } |
86 | 87 | ||
88 | async install (toInstall: string, version: string, fromDisk = false) { | ||
89 | let plugin: PluginModel | ||
90 | let name: string | ||
91 | |||
92 | logger.info('Installing plugin %s.', toInstall) | ||
93 | |||
94 | try { | ||
95 | fromDisk | ||
96 | ? await installNpmPluginFromDisk(toInstall) | ||
97 | : await installNpmPlugin(toInstall, version) | ||
98 | |||
99 | name = fromDisk ? basename(toInstall) : toInstall | ||
100 | const pluginType = name.startsWith('peertube-theme-') ? PluginType.THEME : PluginType.PLUGIN | ||
101 | const pluginName = this.normalizePluginName(name) | ||
102 | |||
103 | const packageJSON = this.getPackageJSON(pluginName, pluginType) | ||
104 | if (!isPackageJSONValid(packageJSON, pluginType)) { | ||
105 | throw new Error('PackageJSON is invalid.') | ||
106 | } | ||
107 | |||
108 | [ plugin ] = await PluginModel.upsert({ | ||
109 | name: pluginName, | ||
110 | description: packageJSON.description, | ||
111 | type: pluginType, | ||
112 | version: packageJSON.version, | ||
113 | enabled: true, | ||
114 | uninstalled: false, | ||
115 | peertubeEngine: packageJSON.engine.peertube | ||
116 | }, { returning: true }) | ||
117 | } catch (err) { | ||
118 | logger.error('Cannot install plugin %s, removing it...', toInstall, { err }) | ||
119 | |||
120 | try { | ||
121 | await removeNpmPlugin(name) | ||
122 | } catch (err) { | ||
123 | logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err }) | ||
124 | } | ||
125 | |||
126 | throw err | ||
127 | } | ||
128 | |||
129 | logger.info('Successful installation of plugin %s.', toInstall) | ||
130 | |||
131 | await this.registerPluginOrTheme(plugin) | ||
132 | } | ||
133 | |||
134 | async uninstall (packageName: string) { | ||
135 | await PluginModel.uninstall(this.normalizePluginName(packageName)) | ||
136 | |||
137 | await removeNpmPlugin(packageName) | ||
138 | } | ||
139 | |||
87 | private async registerPluginOrTheme (plugin: PluginModel) { | 140 | private async registerPluginOrTheme (plugin: PluginModel) { |
88 | logger.info('Registering plugin or theme %s.', plugin.name) | 141 | logger.info('Registering plugin or theme %s.', plugin.name) |
89 | 142 | ||
90 | const pluginPath = join(CONFIG.STORAGE.PLUGINS_DIR, plugin.name, plugin.version) | 143 | const packageJSON = this.getPackageJSON(plugin.name, plugin.type) |
91 | const packageJSON: PluginPackageJson = require(join(pluginPath, 'package.json')) | 144 | const pluginPath = this.getPluginPath(plugin.name, plugin.type) |
92 | 145 | ||
93 | if (!isPackageJSONValid(packageJSON, plugin.type)) { | 146 | if (!isPackageJSONValid(packageJSON, plugin.type)) { |
94 | throw new Error('Package.JSON is invalid.') | 147 | throw new Error('Package.JSON is invalid.') |
@@ -124,6 +177,7 @@ export class PluginManager { | |||
124 | } | 177 | } |
125 | 178 | ||
126 | const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) | 179 | const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) |
180 | |||
127 | if (!isLibraryCodeValid(library)) { | 181 | if (!isLibraryCodeValid(library)) { |
128 | throw new Error('Library code is not valid (miss register or unregister function)') | 182 | throw new Error('Library code is not valid (miss register or unregister function)') |
129 | } | 183 | } |
@@ -163,6 +217,22 @@ export class PluginManager { | |||
163 | }) | 217 | }) |
164 | } | 218 | } |
165 | 219 | ||
220 | private getPackageJSON (pluginName: string, pluginType: PluginType) { | ||
221 | const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json') | ||
222 | |||
223 | return require(pluginPath) as PluginPackageJson | ||
224 | } | ||
225 | |||
226 | private getPluginPath (pluginName: string, pluginType: PluginType) { | ||
227 | const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-' | ||
228 | |||
229 | return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName) | ||
230 | } | ||
231 | |||
232 | private normalizePluginName (name: string) { | ||
233 | return name.replace(/^peertube-((theme)|(plugin))-/, '') | ||
234 | } | ||
235 | |||
166 | static get Instance () { | 236 | static get Instance () { |
167 | return this.instance || (this.instance = new this()) | 237 | return this.instance || (this.instance = new this()) |
168 | } | 238 | } |
diff --git a/server/lib/plugins/yarn.ts b/server/lib/plugins/yarn.ts new file mode 100644 index 000000000..35fe1625f --- /dev/null +++ b/server/lib/plugins/yarn.ts | |||
@@ -0,0 +1,61 @@ | |||
1 | import { execShell } from '../../helpers/core-utils' | ||
2 | import { logger } from '../../helpers/logger' | ||
3 | import { isNpmPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' | ||
4 | import { CONFIG } from '../../initializers/config' | ||
5 | import { outputJSON, pathExists } from 'fs-extra' | ||
6 | import { join } from 'path' | ||
7 | |||
8 | async function installNpmPlugin (name: string, version: string) { | ||
9 | // Security check | ||
10 | checkNpmPluginNameOrThrow(name) | ||
11 | checkPluginVersionOrThrow(version) | ||
12 | |||
13 | const toInstall = `${name}@${version}` | ||
14 | await execYarn('add ' + toInstall) | ||
15 | } | ||
16 | |||
17 | async function installNpmPluginFromDisk (path: string) { | ||
18 | await execYarn('add file:' + path) | ||
19 | } | ||
20 | |||
21 | async function removeNpmPlugin (name: string) { | ||
22 | checkNpmPluginNameOrThrow(name) | ||
23 | |||
24 | await execYarn('remove ' + name) | ||
25 | } | ||
26 | |||
27 | // ############################################################################ | ||
28 | |||
29 | export { | ||
30 | installNpmPlugin, | ||
31 | installNpmPluginFromDisk, | ||
32 | removeNpmPlugin | ||
33 | } | ||
34 | |||
35 | // ############################################################################ | ||
36 | |||
37 | async function execYarn (command: string) { | ||
38 | try { | ||
39 | const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR | ||
40 | const pluginPackageJSON = join(pluginDirectory, 'package.json') | ||
41 | |||
42 | // Create empty package.json file if needed | ||
43 | if (!await pathExists(pluginPackageJSON)) { | ||
44 | await outputJSON(pluginPackageJSON, {}) | ||
45 | } | ||
46 | |||
47 | await execShell(`yarn ${command}`, { cwd: pluginDirectory }) | ||
48 | } catch (result) { | ||
49 | logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr }) | ||
50 | |||
51 | throw result.err | ||
52 | } | ||
53 | } | ||
54 | |||
55 | function checkNpmPluginNameOrThrow (name: string) { | ||
56 | if (!isNpmPluginNameValid(name)) throw new Error('Invalid NPM plugin name to install') | ||
57 | } | ||
58 | |||
59 | function checkPluginVersionOrThrow (name: string) { | ||
60 | if (!isPluginVersionValid(name)) throw new Error('Invalid NPM plugin version to install') | ||
61 | } | ||
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts index 7ce376d13..1fbfd208f 100644 --- a/server/models/server/plugin.ts +++ b/server/models/server/plugin.ts | |||
@@ -42,7 +42,6 @@ export class PluginModel extends Model<PluginModel> { | |||
42 | uninstalled: boolean | 42 | uninstalled: boolean |
43 | 43 | ||
44 | @AllowNull(false) | 44 | @AllowNull(false) |
45 | @Is('PluginPeertubeEngine', value => throwIfNotValid(value, isPluginVersionValid, 'peertubeEngine')) | ||
46 | @Column | 45 | @Column |
47 | peertubeEngine: string | 46 | peertubeEngine: string |
48 | 47 | ||
@@ -76,4 +75,14 @@ export class PluginModel extends Model<PluginModel> { | |||
76 | return PluginModel.findAll(query) | 75 | return PluginModel.findAll(query) |
77 | } | 76 | } |
78 | 77 | ||
78 | static uninstall (pluginName: string) { | ||
79 | const query = { | ||
80 | where: { | ||
81 | name: pluginName | ||
82 | } | ||
83 | } | ||
84 | |||
85 | return PluginModel.update({ enabled: false, uninstalled: true }, query) | ||
86 | } | ||
87 | |||
79 | } | 88 | } |
diff --git a/shared/models/plugins/plugin-package-json.model.ts b/shared/models/plugins/plugin-package-json.model.ts index 4520ee181..d5aa90179 100644 --- a/shared/models/plugins/plugin-package-json.model.ts +++ b/shared/models/plugins/plugin-package-json.model.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | export type PluginPackageJson = { | 1 | export type PluginPackageJson = { |
2 | name: string | 2 | name: string |
3 | version: string | ||
3 | description: string | 4 | description: string |
4 | engine: { peertube: string }, | 5 | engine: { peertube: string }, |
5 | 6 | ||