aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--package.json3
-rwxr-xr-xscripts/dev/cli.sh15
-rwxr-xr-xscripts/plugin/install.ts39
-rwxr-xr-xscripts/plugin/uninstall.ts27
-rw-r--r--server/controllers/api/plugins.ts16
-rw-r--r--server/controllers/themes.ts30
-rw-r--r--server/helpers/core-utils.ts8
-rw-r--r--server/helpers/custom-validators/video-channels.ts4
-rw-r--r--server/helpers/peertube-crypto.ts9
-rw-r--r--server/middlewares/validators/plugins.ts15
-rw-r--r--server/models/server/plugin.ts6
-rw-r--r--server/tests/api/search/search-activitypub-video-channels.ts2
-rw-r--r--server/tools/cli.ts18
-rw-r--r--server/tools/peertube-auth.ts12
-rw-r--r--server/tools/peertube-import-videos.ts34
-rw-r--r--server/tools/peertube-plugins.ts162
-rw-r--r--server/tools/peertube-upload.ts55
-rw-r--r--server/tools/peertube.ts5
-rw-r--r--shared/extra-utils/index.ts1
-rw-r--r--shared/extra-utils/miscs/miscs.ts5
-rw-r--r--shared/extra-utils/server/plugins.ts125
-rw-r--r--shared/extra-utils/server/servers.ts14
-rw-r--r--shared/extra-utils/users/login.ts19
-rw-r--r--shared/extra-utils/users/users.ts8
-rw-r--r--shared/extra-utils/videos/video-channels.ts8
-rw-r--r--shared/models/plugins/install-plugin.model.ts3
26 files changed, 452 insertions, 191 deletions
diff --git a/package.json b/package.json
index fde913574..306476c6a 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,6 @@
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",
37 "danger:clean:dev": "scripty", 35 "danger:clean:dev": "scripty",
38 "danger:clean:prod": "scripty", 36 "danger:clean:prod": "scripty",
39 "danger:clean:modules": "scripty", 37 "danger:clean:modules": "scripty",
@@ -45,6 +43,7 @@
45 "dev": "scripty", 43 "dev": "scripty",
46 "dev:server": "scripty", 44 "dev:server": "scripty",
47 "dev:client": "scripty", 45 "dev:client": "scripty",
46 "dev:cli": "scripty",
48 "start": "node dist/server", 47 "start": "node dist/server",
49 "start:server": "node dist/server --no-client", 48 "start:server": "node dist/server --no-client",
50 "update-host": "node ./dist/scripts/update-host.js", 49 "update-host": "node ./dist/scripts/update-host.js",
diff --git a/scripts/dev/cli.sh b/scripts/dev/cli.sh
new file mode 100755
index 000000000..4b6fe5508
--- /dev/null
+++ b/scripts/dev/cli.sh
@@ -0,0 +1,15 @@
1#!/bin/sh
2
3set -eu
4
5rm -rf ./dist/server/tools/
6
7(
8 cd ./server/tools
9 yarn install --pure-lockfile
10)
11
12mkdir -p "./dist/server/tools"
13cp -r "./server/tools/node_modules" "./dist/server/tools"
14
15npm run tsc -- --watch --project ./server/tools/tsconfig.json
diff --git a/scripts/plugin/install.ts b/scripts/plugin/install.ts
deleted file mode 100755
index 1725cbeb6..000000000
--- a/scripts/plugin/install.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import { initDatabaseModels } from '../../server/initializers/database'
2import * as program from 'commander'
3import { PluginManager } from '../../server/lib/plugins/plugin-manager'
4import { isAbsolute } from 'path'
5
6program
7 .option('-n, --plugin-name [pluginName]', 'Plugin name to install')
8 .option('-v, --plugin-version [pluginVersion]', 'Plugin version to install')
9 .option('-p, --plugin-path [pluginPath]', 'Path of the plugin you want to install')
10 .parse(process.argv)
11
12if (!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
17if (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
22if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) {
23 console.error('Plugin path should be absolute.')
24 process.exit(-1)
25}
26
27run()
28 .then(() => process.exit(0))
29 .catch(err => {
30 console.error(err)
31 process.exit(-1)
32 })
33
34async 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}
diff --git a/scripts/plugin/uninstall.ts b/scripts/plugin/uninstall.ts
deleted file mode 100755
index 7dcc234db..000000000
--- a/scripts/plugin/uninstall.ts
+++ /dev/null
@@ -1,27 +0,0 @@
1import { initDatabaseModels } from '../../server/initializers/database'
2import * as program from 'commander'
3import { PluginManager } from '../../server/lib/plugins/plugin-manager'
4import { isAbsolute } from 'path'
5
6program
7 .option('-n, --package-name [packageName]', 'Package name to install')
8 .parse(process.argv)
9
10if (!program['packageName']) {
11 console.error('You need to specify the plugin name.')
12 process.exit(-1)
13}
14
15run()
16 .then(() => process.exit(0))
17 .catch(err => {
18 console.error(err)
19 process.exit(-1)
20 })
21
22async function run () {
23 await initDatabaseModels(true)
24
25 const toUninstall = program['packageName']
26 await PluginManager.Instance.uninstall(toUninstall)
27}
diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts
index f17e8cab9..8e59f27cf 100644
--- a/server/controllers/api/plugins.ts
+++ b/server/controllers/api/plugins.ts
@@ -21,6 +21,7 @@ import {
21import { PluginManager } from '../../lib/plugins/plugin-manager' 21import { PluginManager } from '../../lib/plugins/plugin-manager'
22import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' 22import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model'
23import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' 23import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model'
24import { logger } from '../../helpers/logger'
24 25
25const pluginRouter = express.Router() 26const pluginRouter = express.Router()
26 27
@@ -46,7 +47,7 @@ pluginRouter.get('/:npmName/registered-settings',
46 authenticate, 47 authenticate,
47 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 48 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
48 asyncMiddleware(existingPluginValidator), 49 asyncMiddleware(existingPluginValidator),
49 asyncMiddleware(getPluginRegisteredSettings) 50 getPluginRegisteredSettings
50) 51)
51 52
52pluginRouter.put('/:npmName/settings', 53pluginRouter.put('/:npmName/settings',
@@ -101,7 +102,14 @@ function getPlugin (req: express.Request, res: express.Response) {
101async function installPlugin (req: express.Request, res: express.Response) { 102async function installPlugin (req: express.Request, res: express.Response) {
102 const body: InstallPlugin = req.body 103 const body: InstallPlugin = req.body
103 104
104 await PluginManager.Instance.install(body.npmName) 105 const fromDisk = !!body.path
106 const toInstall = body.npmName || body.path
107 try {
108 await PluginManager.Instance.install(toInstall, undefined, fromDisk)
109 } catch (err) {
110 logger.warn('Cannot install plugin %s.', toInstall, { err })
111 return res.sendStatus(400)
112 }
105 113
106 return res.sendStatus(204) 114 return res.sendStatus(204)
107} 115}
@@ -114,10 +122,10 @@ async function uninstallPlugin (req: express.Request, res: express.Response) {
114 return res.sendStatus(204) 122 return res.sendStatus(204)
115} 123}
116 124
117async function getPluginRegisteredSettings (req: express.Request, res: express.Response) { 125function getPluginRegisteredSettings (req: express.Request, res: express.Response) {
118 const plugin = res.locals.plugin 126 const plugin = res.locals.plugin
119 127
120 const settings = await PluginManager.Instance.getSettings(plugin.name) 128 const settings = PluginManager.Instance.getSettings(plugin.name)
121 129
122 return res.json({ 130 return res.json({
123 settings 131 settings
diff --git a/server/controllers/themes.ts b/server/controllers/themes.ts
deleted file mode 100644
index 104c285ad..000000000
--- a/server/controllers/themes.ts
+++ /dev/null
@@ -1,30 +0,0 @@
1import * as express from 'express'
2import { join } from 'path'
3import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
4import { serveThemeCSSValidator } from '../middlewares/validators/themes'
5
6const themesRouter = express.Router()
7
8themesRouter.get('/:themeName/:themeVersion/css/:staticEndpoint(*)',
9 serveThemeCSSValidator,
10 serveThemeCSSDirectory
11)
12
13// ---------------------------------------------------------------------------
14
15export {
16 themesRouter
17}
18
19// ---------------------------------------------------------------------------
20
21function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
22 const plugin: RegisteredPlugin = res.locals.registeredPlugin
23 const staticEndpoint = req.params.staticEndpoint
24
25 if (plugin.css.includes(staticEndpoint) === false) {
26 return res.sendStatus(404)
27 }
28
29 return res.sendFile(join(plugin.path, staticEndpoint))
30}
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index c5b139378..64818d036 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -3,7 +3,6 @@
3 Useful to avoid circular dependencies. 3 Useful to avoid circular dependencies.
4*/ 4*/
5 5
6import * as bcrypt from 'bcrypt'
7import * as createTorrent from 'create-torrent' 6import * as createTorrent from 'create-torrent'
8import { createHash, HexBase64Latin1Encoding, pseudoRandomBytes } from 'crypto' 7import { createHash, HexBase64Latin1Encoding, pseudoRandomBytes } from 'crypto'
9import { isAbsolute, join } from 'path' 8import { isAbsolute, join } from 'path'
@@ -258,9 +257,6 @@ function promisify2WithVoid<T, U> (func: (arg1: T, arg2: U, cb: (err: any) => vo
258const pseudoRandomBytesPromise = promisify1<number, Buffer>(pseudoRandomBytes) 257const pseudoRandomBytesPromise = promisify1<number, Buffer>(pseudoRandomBytes)
259const createPrivateKey = promisify1<number, { key: string }>(pem.createPrivateKey) 258const createPrivateKey = promisify1<number, { key: string }>(pem.createPrivateKey)
260const getPublicKey = promisify1<string, { publicKey: string }>(pem.getPublicKey) 259const getPublicKey = promisify1<string, { publicKey: string }>(pem.getPublicKey)
261const bcryptComparePromise = promisify2<any, string, boolean>(bcrypt.compare)
262const bcryptGenSaltPromise = promisify1<number, string>(bcrypt.genSalt)
263const bcryptHashPromise = promisify2<any, string | number, string>(bcrypt.hash)
264const createTorrentPromise = promisify2<string, any, any>(createTorrent) 260const createTorrentPromise = promisify2<string, any, any>(createTorrent)
265const execPromise2 = promisify2<string, any, string>(exec) 261const execPromise2 = promisify2<string, any, string>(exec)
266const execPromise = promisify1<string, string>(exec) 262const execPromise = promisify1<string, string>(exec)
@@ -287,13 +283,11 @@ export {
287 283
288 promisify0, 284 promisify0,
289 promisify1, 285 promisify1,
286 promisify2,
290 287
291 pseudoRandomBytesPromise, 288 pseudoRandomBytesPromise,
292 createPrivateKey, 289 createPrivateKey,
293 getPublicKey, 290 getPublicKey,
294 bcryptComparePromise,
295 bcryptGenSaltPromise,
296 bcryptHashPromise,
297 createTorrentPromise, 291 createTorrentPromise,
298 execPromise2, 292 execPromise2,
299 execPromise 293 execPromise
diff --git a/server/helpers/custom-validators/video-channels.ts b/server/helpers/custom-validators/video-channels.ts
index e1a2f9503..f818ce8f1 100644
--- a/server/helpers/custom-validators/video-channels.ts
+++ b/server/helpers/custom-validators/video-channels.ts
@@ -51,7 +51,9 @@ export {
51 51
52function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) { 52function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) {
53 if (!videoChannel) { 53 if (!videoChannel) {
54 `` 54 res.status(404)
55 .json({ error: 'Video channel not found' })
56 .end()
55 57
56 return false 58 return false
57 } 59 }
diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts
index 9148df2eb..1424949d0 100644
--- a/server/helpers/peertube-crypto.ts
+++ b/server/helpers/peertube-crypto.ts
@@ -1,12 +1,17 @@
1import { Request } from 'express' 1import { Request } from 'express'
2import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants' 2import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
3import { ActorModel } from '../models/activitypub/actor' 3import { ActorModel } from '../models/activitypub/actor'
4import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey, sha256 } from './core-utils' 4import { createPrivateKey, getPublicKey, promisify1, promisify2, sha256 } from './core-utils'
5import { jsig, jsonld } from './custom-jsonld-signature' 5import { jsig, jsonld } from './custom-jsonld-signature'
6import { logger } from './logger' 6import { logger } from './logger'
7import { cloneDeep } from 'lodash' 7import { cloneDeep } from 'lodash'
8import { createVerify } from 'crypto' 8import { createVerify } from 'crypto'
9import { buildDigest } from '../lib/job-queue/handlers/utils/activitypub-http-utils' 9import { buildDigest } from '../lib/job-queue/handlers/utils/activitypub-http-utils'
10import * as bcrypt from 'bcrypt'
11
12const bcryptComparePromise = promisify2<any, string, boolean>(bcrypt.compare)
13const bcryptGenSaltPromise = promisify1<number, string>(bcrypt.genSalt)
14const bcryptHashPromise = promisify2<any, string | number, string>(bcrypt.hash)
10 15
11const httpSignature = require('http-signature') 16const httpSignature = require('http-signature')
12 17
@@ -147,3 +152,5 @@ export {
147 cryptPassword, 152 cryptPassword,
148 signJsonLDObject 153 signJsonLDObject
149} 154}
155
156// ---------------------------------------------------------------------------
diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts
index a06add6b8..a1634ded4 100644
--- a/server/middlewares/validators/plugins.ts
+++ b/server/middlewares/validators/plugins.ts
@@ -6,6 +6,7 @@ import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPlugin
6import { PluginManager } from '../../lib/plugins/plugin-manager' 6import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' 7import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc'
8import { PluginModel } from '../../models/server/plugin' 8import { PluginModel } from '../../models/server/plugin'
9import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model'
9 10
10const servePluginStaticDirectoryValidator = [ 11const servePluginStaticDirectoryValidator = [
11 param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), 12 param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'),
@@ -48,13 +49,25 @@ const listPluginsValidator = [
48] 49]
49 50
50const installPluginValidator = [ 51const installPluginValidator = [
51 body('npmName').custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), 52 body('npmName')
53 .optional()
54 .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
55 body('path')
56 .optional()
57 .custom(isSafePath).withMessage('Should have a valid safe path'),
52 58
53 (req: express.Request, res: express.Response, next: express.NextFunction) => { 59 (req: express.Request, res: express.Response, next: express.NextFunction) => {
54 logger.debug('Checking installPluginValidator parameters', { parameters: req.body }) 60 logger.debug('Checking installPluginValidator parameters', { parameters: req.body })
55 61
56 if (areValidationErrors(req, res)) return 62 if (areValidationErrors(req, res)) return
57 63
64 const body: InstallPlugin = req.body
65 if (!body.path && !body.npmName) {
66 return res.status(400)
67 .json({ error: 'Should have either a npmName or a path' })
68 .end()
69 }
70
58 return next() 71 return next()
59 } 72 }
60] 73]
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 60abaec65..226c08342 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -142,15 +142,17 @@ export class PluginModel extends Model<PluginModel> {
142 count: number, 142 count: number,
143 sort: string 143 sort: string
144 }) { 144 }) {
145 const { uninstalled = false } = options
145 const query: FindAndCountOptions = { 146 const query: FindAndCountOptions = {
146 offset: options.start, 147 offset: options.start,
147 limit: options.count, 148 limit: options.count,
148 order: getSort(options.sort), 149 order: getSort(options.sort),
149 where: {} 150 where: {
151 uninstalled
152 }
150 } 153 }
151 154
152 if (options.type) query.where['type'] = options.type 155 if (options.type) query.where['type'] = options.type
153 if (options.uninstalled) query.where['uninstalled'] = options.uninstalled
154 156
155 return PluginModel 157 return PluginModel
156 .findAndCountAll(query) 158 .findAndCountAll(query)
diff --git a/server/tests/api/search/search-activitypub-video-channels.ts b/server/tests/api/search/search-activitypub-video-channels.ts
index 8a008b8c6..d5f0a5457 100644
--- a/server/tests/api/search/search-activitypub-video-channels.ts
+++ b/server/tests/api/search/search-activitypub-video-channels.ts
@@ -67,6 +67,8 @@ describe('Test ActivityPub video channels search', function () {
67 }) 67 })
68 68
69 it('Should not find a remote video channel', async function () { 69 it('Should not find a remote video channel', async function () {
70 this.timeout(15000)
71
70 { 72 {
71 const search = 'http://localhost:' + servers[ 1 ].port + '/video-channels/channel1_server3' 73 const search = 'http://localhost:' + servers[ 1 ].port + '/video-channels/channel1_server3'
72 const res = await searchVideoChannel(servers[ 0 ].url, search, servers[ 0 ].accessToken) 74 const res = await searchVideoChannel(servers[ 0 ].url, search, servers[ 0 ].accessToken)
diff --git a/server/tools/cli.ts b/server/tools/cli.ts
index 2eec51aa4..67755022c 100644
--- a/server/tools/cli.ts
+++ b/server/tools/cli.ts
@@ -1,7 +1,8 @@
1import { Netrc } from 'netrc-parser' 1import { Netrc } from 'netrc-parser'
2import { getAppNumber, isTestInstance } from '../helpers/core-utils' 2import { getAppNumber, isTestInstance } from '../helpers/core-utils'
3import { join } from 'path' 3import { join } from 'path'
4import { getVideoChannel, root } from '../../shared/extra-utils' 4import { root } from '../../shared/extra-utils/miscs/miscs'
5import { getVideoChannel } from '../../shared/extra-utils/videos/video-channels'
5import { Command } from 'commander' 6import { Command } from 'commander'
6import { VideoChannel, VideoPrivacy } from '../../shared/models/videos' 7import { VideoChannel, VideoPrivacy } from '../../shared/models/videos'
7 8
@@ -64,7 +65,11 @@ function deleteSettings () {
64 }) 65 })
65} 66}
66 67
67function getRemoteObjectOrDie (program: any, settings: Settings, netrc: Netrc) { 68function getRemoteObjectOrDie (
69 program: any,
70 settings: Settings,
71 netrc: Netrc
72): { url: string, username: string, password: string } {
68 if (!program['url'] || !program['username'] || !program['password']) { 73 if (!program['url'] || !program['username'] || !program['password']) {
69 // No remote and we don't have program parameters: quit 74 // No remote and we don't have program parameters: quit
70 if (settings.remotes.length === 0 || Object.keys(netrc.machines).length === 0) { 75 if (settings.remotes.length === 0 || Object.keys(netrc.machines).length === 0) {
@@ -161,6 +166,13 @@ async function buildVideoAttributesFromCommander (url: string, command: Command,
161 return videoAttributes 166 return videoAttributes
162} 167}
163 168
169function getServerCredentials (program: any) {
170 return Promise.all([ getSettings(), getNetrc() ])
171 .then(([ settings, netrc ]) => {
172 return getRemoteObjectOrDie(program, settings, netrc)
173 })
174}
175
164// --------------------------------------------------------------------------- 176// ---------------------------------------------------------------------------
165 177
166export { 178export {
@@ -172,6 +184,8 @@ export {
172 writeSettings, 184 writeSettings,
173 deleteSettings, 185 deleteSettings,
174 186
187 getServerCredentials,
188
175 buildCommonVideoOptions, 189 buildCommonVideoOptions,
176 buildVideoAttributesFromCommander 190 buildVideoAttributesFromCommander
177} 191}
diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts
index 1035d664a..d4ad56e47 100644
--- a/server/tools/peertube-auth.ts
+++ b/server/tools/peertube-auth.ts
@@ -1,8 +1,8 @@
1import * as program from 'commander' 1import * as program from 'commander'
2import * as prompt from 'prompt' 2import * as prompt from 'prompt'
3import { getSettings, writeSettings, getNetrc } from './cli' 3import { getNetrc, getSettings, writeSettings } from './cli'
4import { isHostValid } from '../helpers/custom-validators/servers'
5import { isUserUsernameValid } from '../helpers/custom-validators/users' 4import { isUserUsernameValid } from '../helpers/custom-validators/users'
5import { getAccessToken, login } from '../../shared/extra-utils'
6 6
7const Table = require('cli-table') 7const Table = require('cli-table')
8 8
@@ -76,6 +76,14 @@ program
76 } 76 }
77 } 77 }
78 }, async (_, result) => { 78 }, async (_, result) => {
79 // Check credentials
80 try {
81 await getAccessToken(result.url, result.username, result.password)
82 } catch (err) {
83 console.error(err.message)
84 process.exit(-1)
85 }
86
79 await setInstance(result.url, result.username, result.password, program['default']) 87 await setInstance(result.url, result.username, result.password, program['default'])
80 88
81 process.exit(0) 89 process.exit(0)
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index d7bb00e02..1f0350442 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -11,7 +11,7 @@ import * as prompt from 'prompt'
11import { remove } from 'fs-extra' 11import { remove } from 'fs-extra'
12import { sha256 } from '../helpers/core-utils' 12import { sha256 } from '../helpers/core-utils'
13import { buildOriginallyPublishedAt, safeGetYoutubeDL } from '../helpers/youtube-dl' 13import { buildOriginallyPublishedAt, safeGetYoutubeDL } from '../helpers/youtube-dl'
14import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getNetrc, getRemoteObjectOrDie, getSettings } from './cli' 14import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getServerCredentials } from './cli'
15 15
16type UserInfo = { 16type UserInfo = {
17 username: string 17 username: string
@@ -36,27 +36,25 @@ command
36 .option('-v, --verbose', 'Verbose mode') 36 .option('-v, --verbose', 'Verbose mode')
37 .parse(process.argv) 37 .parse(process.argv)
38 38
39Promise.all([ getSettings(), getNetrc() ]) 39getServerCredentials(command)
40 .then(([ settings, netrc ]) => { 40 .then(({ url, username, password }) => {
41 const { url, username, password } = getRemoteObjectOrDie(program, settings, netrc) 41 if (!program[ 'targetUrl' ]) {
42 console.error('--targetUrl field is required.')
42 43
43 if (!program[ 'targetUrl' ]) { 44 process.exit(-1)
44 console.error('--targetUrl field is required.') 45 }
45
46 process.exit(-1)
47 }
48 46
49 removeEndSlashes(url) 47 removeEndSlashes(url)
50 removeEndSlashes(program[ 'targetUrl' ]) 48 removeEndSlashes(program[ 'targetUrl' ])
51 49
52 const user = { username, password } 50 const user = { username, password }
53 51
54 run(url, user) 52 run(url, user)
55 .catch(err => { 53 .catch(err => {
56 console.error(err) 54 console.error(err)
57 process.exit(-1) 55 process.exit(-1)
58 }) 56 })
59 }) 57 })
60 58
61async function run (url: string, user: UserInfo) { 59async function run (url: string, user: UserInfo) {
62 if (!user.password) { 60 if (!user.password) {
diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts
new file mode 100644
index 000000000..d5e024383
--- /dev/null
+++ b/server/tools/peertube-plugins.ts
@@ -0,0 +1,162 @@
1import * as program from 'commander'
2import { PluginType } from '../../shared/models/plugins/plugin.type'
3import { getAccessToken } from '../../shared/extra-utils/users/login'
4import { getMyUserInformation } from '../../shared/extra-utils/users/users'
5import { installPlugin, listPlugins, uninstallPlugin } from '../../shared/extra-utils/server/plugins'
6import { getServerCredentials } from './cli'
7import { User, UserRole } from '../../shared/models/users'
8import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model'
9import { isAbsolute } from 'path'
10
11const Table = require('cli-table')
12
13program
14 .name('plugins')
15 .usage('[command] [options]')
16
17program
18 .command('list')
19 .description('List installed plugins')
20 .option('-u, --url <url>', 'Server url')
21 .option('-U, --username <username>', 'Username')
22 .option('-p, --password <token>', 'Password')
23 .option('-t, --only-themes', 'List themes only')
24 .option('-P, --only-plugins', 'List plugins only')
25 .action(() => pluginsListCLI())
26
27program
28 .command('install')
29 .description('Install a plugin or a theme')
30 .option('-u, --url <url>', 'Server url')
31 .option('-U, --username <username>', 'Username')
32 .option('-p, --password <token>', 'Password')
33 .option('-P --path <path>', 'Install from a path')
34 .option('-n, --npm-name <npmName>', 'Install from npm')
35 .action((options) => installPluginCLI(options))
36
37program
38 .command('uninstall')
39 .description('Uninstall a plugin or a theme')
40 .option('-u, --url <url>', 'Server url')
41 .option('-U, --username <username>', 'Username')
42 .option('-p, --password <token>', 'Password')
43 .option('-n, --npm-name <npmName>', 'NPM plugin/theme name')
44 .action(options => uninstallPluginCLI(options))
45
46if (!process.argv.slice(2).length) {
47 program.outputHelp()
48}
49
50program.parse(process.argv)
51
52// ----------------------------------------------------------------------------
53
54async function pluginsListCLI () {
55 const { url, username, password } = await getServerCredentials(program)
56 const accessToken = await getAdminTokenOrDie(url, username, password)
57
58 let type: PluginType
59 if (program['onlyThemes']) type = PluginType.THEME
60 if (program['onlyPlugins']) type = PluginType.PLUGIN
61
62 const res = await listPlugins({
63 url,
64 accessToken,
65 start: 0,
66 count: 100,
67 sort: 'name',
68 type
69 })
70 const plugins: PeerTubePlugin[] = res.body.data
71
72 const table = new Table({
73 head: ['name', 'version', 'homepage'],
74 colWidths: [ 50, 10, 50 ]
75 })
76
77 for (const plugin of plugins) {
78 const npmName = plugin.type === PluginType.PLUGIN
79 ? 'peertube-plugin-' + plugin.name
80 : 'peertube-theme-' + plugin.name
81
82 table.push([
83 npmName,
84 plugin.version,
85 plugin.homepage
86 ])
87 }
88
89 console.log(table.toString())
90 process.exit(0)
91}
92
93async function installPluginCLI (options: any) {
94 if (!options['path'] && !options['npmName']) {
95 console.error('You need to specify the npm name or the path of the plugin you want to install.\n')
96 program.outputHelp()
97 process.exit(-1)
98 }
99
100 if (options['path'] && !isAbsolute(options['path'])) {
101 console.error('Path should be absolute.')
102 process.exit(-1)
103 }
104
105 const { url, username, password } = await getServerCredentials(options)
106 const accessToken = await getAdminTokenOrDie(url, username, password)
107
108 try {
109 await installPlugin({
110 url,
111 accessToken,
112 npmName: options['npmName'],
113 path: options['path']
114 })
115 } catch (err) {
116 console.error('Cannot install plugin.', err)
117 process.exit(-1)
118 return
119 }
120
121 console.log('Plugin installed.')
122 process.exit(0)
123}
124
125async function uninstallPluginCLI (options: any) {
126 if (!options['npmName']) {
127 console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n')
128 program.outputHelp()
129 process.exit(-1)
130 }
131
132 const { url, username, password } = await getServerCredentials(options)
133 const accessToken = await getAdminTokenOrDie(url, username, password)
134
135 try {
136 await uninstallPlugin({
137 url,
138 accessToken,
139 npmName: options[ 'npmName' ]
140 })
141 } catch (err) {
142 console.error('Cannot uninstall plugin.', err)
143 process.exit(-1)
144 return
145 }
146
147 console.log('Plugin uninstalled.')
148 process.exit(0)
149}
150
151async function getAdminTokenOrDie (url: string, username: string, password: string) {
152 const accessToken = await getAccessToken(url, username, password)
153 const resMe = await getMyUserInformation(url, accessToken)
154 const me: User = resMe.body
155
156 if (me.role !== UserRole.ADMINISTRATOR) {
157 console.error('Cannot list plugins if you are not administrator.')
158 process.exit(-1)
159 }
160
161 return accessToken
162}
diff --git a/server/tools/peertube-upload.ts b/server/tools/peertube-upload.ts
index c00205e8f..d9f9a8ead 100644
--- a/server/tools/peertube-upload.ts
+++ b/server/tools/peertube-upload.ts
@@ -1,9 +1,9 @@
1import * as program from 'commander' 1import * as program from 'commander'
2import { access, constants } from 'fs-extra' 2import { access, constants } from 'fs-extra'
3import { isAbsolute } from 'path' 3import { isAbsolute } from 'path'
4import { getClient, login } from '../../shared/extra-utils' 4import { getAccessToken } from '../../shared/extra-utils'
5import { uploadVideo } from '../../shared/extra-utils/' 5import { uploadVideo } from '../../shared/extra-utils/'
6import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getNetrc, getRemoteObjectOrDie, getSettings } from './cli' 6import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getServerCredentials } from './cli'
7 7
8let command = program 8let command = program
9 .name('upload') 9 .name('upload')
@@ -11,7 +11,6 @@ let command = program
11command = buildCommonVideoOptions(command) 11command = buildCommonVideoOptions(command)
12 12
13command 13command
14
15 .option('-u, --url <url>', 'Server url') 14 .option('-u, --url <url>', 'Server url')
16 .option('-U, --username <username>', 'Username') 15 .option('-U, --username <username>', 'Username')
17 .option('-p, --password <token>', 'Password') 16 .option('-p, --password <token>', 'Password')
@@ -20,44 +19,28 @@ command
20 .option('-f, --file <file>', 'Video absolute file path') 19 .option('-f, --file <file>', 'Video absolute file path')
21 .parse(process.argv) 20 .parse(process.argv)
22 21
23Promise.all([ getSettings(), getNetrc() ]) 22getServerCredentials(command)
24 .then(([ settings, netrc ]) => { 23 .then(({ url, username, password }) => {
25 const { url, username, password } = getRemoteObjectOrDie(program, settings, netrc) 24 if (!program[ 'videoName' ] || !program[ 'file' ]) {
26 25 if (!program[ 'videoName' ]) console.error('--video-name is required.')
27 if (!program[ 'videoName' ] || !program[ 'file' ]) { 26 if (!program[ 'file' ]) console.error('--file is required.')
28 if (!program[ 'videoName' ]) console.error('--video-name is required.')
29 if (!program[ 'file' ]) console.error('--file is required.')
30 27
31 process.exit(-1) 28 process.exit(-1)
32 } 29 }
33 30
34 if (isAbsolute(program[ 'file' ]) === false) { 31 if (isAbsolute(program[ 'file' ]) === false) {
35 console.error('File path should be absolute.') 32 console.error('File path should be absolute.')
36 process.exit(-1) 33 process.exit(-1)
37 } 34 }
38 35
39 run(url, username, password).catch(err => { 36 run(url, username, password).catch(err => {
40 console.error(err) 37 console.error(err)
41 process.exit(-1) 38 process.exit(-1)
42 }) 39 })
43 }) 40 })
44 41
45async function run (url: string, username: string, password: string) { 42async function run (url: string, username: string, password: string) {
46 const resClient = await getClient(url) 43 const accessToken = await getAccessToken(url, username, password)
47 const client = {
48 id: resClient.body.client_id,
49 secret: resClient.body.client_secret
50 }
51
52 const user = { username, password }
53
54 let accessToken: string
55 try {
56 const res = await login(url, client, user)
57 accessToken = res.body.access_token
58 } catch (err) {
59 throw new Error('Cannot authenticate. Please check your username/password.')
60 }
61 44
62 await access(program[ 'file' ], constants.F_OK) 45 await access(program[ 'file' ], constants.F_OK)
63 46
diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts
index daa5586c3..e79a7e041 100644
--- a/server/tools/peertube.ts
+++ b/server/tools/peertube.ts
@@ -18,13 +18,10 @@ program
18 .command('get-access-token', 'get a peertube access token', { noHelp: true }).alias('token') 18 .command('get-access-token', 'get a peertube access token', { noHelp: true }).alias('token')
19 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w') 19 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w')
20 .command('repl', 'initiate a REPL to access internals') 20 .command('repl', 'initiate a REPL to access internals')
21 .command('plugins [action]', 'manage plugins on a local instance').alias('p')
21 22
22/* Not Yet Implemented */ 23/* Not Yet Implemented */
23program 24program
24 .command('plugins [action]',
25 'manage plugins on a local instance',
26 { noHelp: true } as program.CommandOptions
27 ).alias('p')
28 .command('diagnostic [action]', 25 .command('diagnostic [action]',
29 'like couple therapy, but for your instance', 26 'like couple therapy, but for your instance',
30 { noHelp: true } as program.CommandOptions 27 { noHelp: true } as program.CommandOptions
diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts
index 9d0bbaa38..53ddaa681 100644
--- a/shared/extra-utils/index.ts
+++ b/shared/extra-utils/index.ts
@@ -11,6 +11,7 @@ export * from './server/follows'
11export * from './requests/requests' 11export * from './requests/requests'
12export * from './requests/check-api-params' 12export * from './requests/check-api-params'
13export * from './server/servers' 13export * from './server/servers'
14export * from './server/plugins'
14export * from './videos/services' 15export * from './videos/services'
15export * from './videos/video-playlists' 16export * from './videos/video-playlists'
16export * from './users/users' 17export * from './users/users'
diff --git a/shared/extra-utils/miscs/miscs.ts b/shared/extra-utils/miscs/miscs.ts
index fb6430e4f..42250886c 100644
--- a/shared/extra-utils/miscs/miscs.ts
+++ b/shared/extra-utils/miscs/miscs.ts
@@ -8,7 +8,7 @@ import { pathExists, readFile } from 'fs-extra'
8import * as ffmpeg from 'fluent-ffmpeg' 8import * as ffmpeg from 'fluent-ffmpeg'
9 9
10const expect = chai.expect 10const expect = chai.expect
11let webtorrent = new WebTorrent() 11let webtorrent: WebTorrent.Instance
12 12
13function immutableAssign <T, U> (target: T, source: U) { 13function immutableAssign <T, U> (target: T, source: U) {
14 return Object.assign<{}, T, U>({}, target, source) 14 return Object.assign<{}, T, U>({}, target, source)
@@ -27,6 +27,9 @@ function wait (milliseconds: number) {
27} 27}
28 28
29function webtorrentAdd (torrent: string, refreshWebTorrent = false) { 29function webtorrentAdd (torrent: string, refreshWebTorrent = false) {
30 const WebTorrent = require('webtorrent')
31
32 if (!webtorrent) webtorrent = new WebTorrent()
30 if (refreshWebTorrent === true) webtorrent = new WebTorrent() 33 if (refreshWebTorrent === true) webtorrent = new WebTorrent()
31 34
32 return new Promise<WebTorrent.Torrent>(res => webtorrent.add(torrent, res)) 35 return new Promise<WebTorrent.Torrent>(res => webtorrent.add(torrent, res))
diff --git a/shared/extra-utils/server/plugins.ts b/shared/extra-utils/server/plugins.ts
new file mode 100644
index 000000000..6cd7cd17a
--- /dev/null
+++ b/shared/extra-utils/server/plugins.ts
@@ -0,0 +1,125 @@
1import { makeGetRequest, makePostBodyRequest } from '../requests/requests'
2import { PluginType } from '../../models/plugins/plugin.type'
3
4function listPlugins (parameters: {
5 url: string,
6 accessToken: string,
7 start?: number,
8 count?: number,
9 sort?: string,
10 type?: PluginType,
11 expectedStatus?: number
12}) {
13 const { url, accessToken, start, count, sort, type, expectedStatus = 200 } = parameters
14 const path = '/api/v1/plugins'
15
16 return makeGetRequest({
17 url,
18 path,
19 token: accessToken,
20 query: {
21 start,
22 count,
23 sort,
24 type
25 },
26 statusCodeExpected: expectedStatus
27 })
28}
29
30function getPlugin (parameters: {
31 url: string,
32 accessToken: string,
33 npmName: string,
34 expectedStatus?: number
35}) {
36 const { url, accessToken, npmName, expectedStatus = 200 } = parameters
37 const path = '/api/v1/plugins/' + npmName
38
39 return makeGetRequest({
40 url,
41 path,
42 token: accessToken,
43 statusCodeExpected: expectedStatus
44 })
45}
46
47function getPluginSettings (parameters: {
48 url: string,
49 accessToken: string,
50 npmName: string,
51 expectedStatus?: number
52}) {
53 const { url, accessToken, npmName, expectedStatus = 200 } = parameters
54 const path = '/api/v1/plugins/' + npmName + '/settings'
55
56 return makeGetRequest({
57 url,
58 path,
59 token: accessToken,
60 statusCodeExpected: expectedStatus
61 })
62}
63
64function getPluginRegisteredSettings (parameters: {
65 url: string,
66 accessToken: string,
67 npmName: string,
68 expectedStatus?: number
69}) {
70 const { url, accessToken, npmName, expectedStatus = 200 } = parameters
71 const path = '/api/v1/plugins/' + npmName + '/registered-settings'
72
73 return makeGetRequest({
74 url,
75 path,
76 token: accessToken,
77 statusCodeExpected: expectedStatus
78 })
79}
80
81function installPlugin (parameters: {
82 url: string,
83 accessToken: string,
84 path?: string,
85 npmName?: string
86 expectedStatus?: number
87}) {
88 const { url, accessToken, npmName, path, expectedStatus = 204 } = parameters
89 const apiPath = '/api/v1/plugins/install'
90
91 return makePostBodyRequest({
92 url,
93 path: apiPath,
94 token: accessToken,
95 fields: { npmName, path },
96 statusCodeExpected: expectedStatus
97 })
98}
99
100function uninstallPlugin (parameters: {
101 url: string,
102 accessToken: string,
103 npmName: string
104 expectedStatus?: number
105}) {
106 const { url, accessToken, npmName, expectedStatus = 204 } = parameters
107 const apiPath = '/api/v1/plugins/uninstall'
108
109 return makePostBodyRequest({
110 url,
111 path: apiPath,
112 token: accessToken,
113 fields: { npmName },
114 statusCodeExpected: expectedStatus
115 })
116}
117
118export {
119 listPlugins,
120 installPlugin,
121 getPlugin,
122 uninstallPlugin,
123 getPluginSettings,
124 getPluginRegisteredSettings
125}
diff --git a/shared/extra-utils/server/servers.ts b/shared/extra-utils/server/servers.ts
index 4c7d6862a..9167ebe5b 100644
--- a/shared/extra-utils/server/servers.ts
+++ b/shared/extra-utils/server/servers.ts
@@ -3,7 +3,7 @@
3import { ChildProcess, exec, fork } from 'child_process' 3import { ChildProcess, exec, fork } from 'child_process'
4import { join } from 'path' 4import { join } from 'path'
5import { root, wait } from '../miscs/miscs' 5import { root, wait } from '../miscs/miscs'
6import { copy, readdir, readFile, remove } from 'fs-extra' 6import { copy, pathExists, readdir, readFile, remove } from 'fs-extra'
7import { existsSync } from 'fs' 7import { existsSync } from 'fs'
8import { expect } from 'chai' 8import { expect } from 'chai'
9import { VideoChannel } from '../../models/videos' 9import { VideoChannel } from '../../models/videos'
@@ -241,20 +241,22 @@ async function reRunServer (server: ServerInfo, configOverride?: any) {
241 return server 241 return server
242} 242}
243 243
244async function checkTmpIsEmpty (server: ServerInfo) { 244function checkTmpIsEmpty (server: ServerInfo) {
245 return checkDirectoryIsEmpty(server, 'tmp') 245 return checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css' ])
246} 246}
247 247
248async function checkDirectoryIsEmpty (server: ServerInfo, directory: string) { 248async function checkDirectoryIsEmpty (server: ServerInfo, directory: string, exceptions: string[] = []) {
249 const testDirectory = 'test' + server.internalServerNumber 249 const testDirectory = 'test' + server.internalServerNumber
250 250
251 const directoryPath = join(root(), testDirectory, directory) 251 const directoryPath = join(root(), testDirectory, directory)
252 252
253 const directoryExists = existsSync(directoryPath) 253 const directoryExists = await pathExists(directoryPath)
254 expect(directoryExists).to.be.true 254 expect(directoryExists).to.be.true
255 255
256 const files = await readdir(directoryPath) 256 const files = await readdir(directoryPath)
257 expect(files).to.have.lengthOf(0) 257 const filtered = files.filter(f => exceptions.includes(f) === false)
258
259 expect(filtered).to.have.lengthOf(0)
258} 260}
259 261
260function killallServers (servers: ServerInfo[]) { 262function killallServers (servers: ServerInfo[]) {
diff --git a/shared/extra-utils/users/login.ts b/shared/extra-utils/users/login.ts
index ddeb9df2a..f9bfb3cb3 100644
--- a/shared/extra-utils/users/login.ts
+++ b/shared/extra-utils/users/login.ts
@@ -1,6 +1,7 @@
1import * as request from 'supertest' 1import * as request from 'supertest'
2 2
3import { ServerInfo } from '../server/servers' 3import { ServerInfo } from '../server/servers'
4import { getClient } from '../server/clients'
4 5
5type Client = { id: string, secret: string } 6type Client = { id: string, secret: string }
6type User = { username: string, password: string } 7type User = { username: string, password: string }
@@ -38,6 +39,23 @@ async function userLogin (server: Server, user: User, expectedStatus = 200) {
38 return res.body.access_token as string 39 return res.body.access_token as string
39} 40}
40 41
42async function getAccessToken (url: string, username: string, password: string) {
43 const resClient = await getClient(url)
44 const client = {
45 id: resClient.body.client_id,
46 secret: resClient.body.client_secret
47 }
48
49 const user = { username, password }
50
51 try {
52 const res = await login(url, client, user)
53 return res.body.access_token
54 } catch (err) {
55 throw new Error('Cannot authenticate. Please check your username/password.')
56 }
57}
58
41function setAccessTokensToServers (servers: ServerInfo[]) { 59function setAccessTokensToServers (servers: ServerInfo[]) {
42 const tasks: Promise<any>[] = [] 60 const tasks: Promise<any>[] = []
43 61
@@ -55,6 +73,7 @@ export {
55 login, 73 login,
56 serverLogin, 74 serverLogin,
57 userLogin, 75 userLogin,
76 getAccessToken,
58 setAccessTokensToServers, 77 setAccessTokensToServers,
59 Server, 78 Server,
60 Client, 79 Client,
diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts
index 1c39881d6..5fa8cde0c 100644
--- a/shared/extra-utils/users/users.ts
+++ b/shared/extra-utils/users/users.ts
@@ -1,11 +1,11 @@
1import * as request from 'supertest' 1import * as request from 'supertest'
2import { makeGetRequest, makePostBodyRequest, makePutBodyRequest, updateAvatarRequest } from '../requests/requests' 2import { makePostBodyRequest, makePutBodyRequest, updateAvatarRequest } from '../requests/requests'
3
4import { UserCreate, UserRole } from '../../index'
5import { NSFWPolicyType } from '../../models/videos/nsfw-policy.type' 3import { NSFWPolicyType } from '../../models/videos/nsfw-policy.type'
6import { ServerInfo, userLogin } from '..'
7import { UserAdminFlag } from '../../models/users/user-flag.model' 4import { UserAdminFlag } from '../../models/users/user-flag.model'
8import { UserRegister } from '../../models/users/user-register.model' 5import { UserRegister } from '../../models/users/user-register.model'
6import { UserRole } from '../../models/users/user-role'
7import { ServerInfo } from '../server/servers'
8import { userLogin } from './login'
9 9
10type CreateUserArgs = { url: string, 10type CreateUserArgs = { url: string,
11 accessToken: string, 11 accessToken: string,
diff --git a/shared/extra-utils/videos/video-channels.ts b/shared/extra-utils/videos/video-channels.ts
index 3e79cf15a..053842331 100644
--- a/shared/extra-utils/videos/video-channels.ts
+++ b/shared/extra-utils/videos/video-channels.ts
@@ -1,8 +1,10 @@
1import * as request from 'supertest' 1import * as request from 'supertest'
2import { VideoChannelCreate, VideoChannelUpdate } from '../../models/videos' 2import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
3import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
3import { makeGetRequest, updateAvatarRequest } from '../requests/requests' 4import { makeGetRequest, updateAvatarRequest } from '../requests/requests'
4import { getMyUserInformation, ServerInfo } from '..' 5import { ServerInfo } from '../server/servers'
5import { User } from '../..' 6import { User } from '../../models/users/user.model'
7import { getMyUserInformation } from '../users/users'
6 8
7function getVideoChannelsList (url: string, start: number, count: number, sort?: string) { 9function getVideoChannelsList (url: string, start: number, count: number, sort?: string) {
8 const path = '/api/v1/video-channels' 10 const path = '/api/v1/video-channels'
diff --git a/shared/models/plugins/install-plugin.model.ts b/shared/models/plugins/install-plugin.model.ts
index 03d87fe57..b1b46fa08 100644
--- a/shared/models/plugins/install-plugin.model.ts
+++ b/shared/models/plugins/install-plugin.model.ts
@@ -1,3 +1,4 @@
1export interface InstallPlugin { 1export interface InstallPlugin {
2 npmName: string 2 npmName?: string
3 path?: string
3} 4}