Merge branch 'feature/strong-model-types' into develop
authorChocobozzz <me@florianbigard.com>
Thu, 22 Aug 2019 08:43:11 +0000 (10:43 +0200)
committerChocobozzz <me@florianbigard.com>
Thu, 22 Aug 2019 08:43:11 +0000 (10:43 +0200)
18 files changed:
.gitlab-ci.yml
README.md
client/src/app/app.component.ts
client/src/app/core/plugins/plugin.service.ts
client/src/app/shared/misc/utils.ts
client/src/app/videos/videos-routing.module.ts
client/src/sass/application.scss
client/src/sass/player/_player-variables.scss
client/src/sass/player/peertube-skin.scss
config/default.yaml
config/production.yaml.example
server/helpers/custom-validators/plugins.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/lib/emailer.ts
server/lib/peertube-socket.ts
support/doc/api/openapi.yaml
support/doc/tools.md

index 2f69eb1d2e3cf1021407731882b6ed0b59b81bb8..b8c9c5fdac92dede7e8ba33d4ffbc842e0c717fb 100644 (file)
@@ -3,13 +3,13 @@ image: chocobozzz/peertube-ci:10
 stages:
   - build-and-lint
   - test
-  - nightly
+  - docker-nightly
 
-before_script:
-  - 'sed -i -z "s/database:\n  hostname: ''localhost''/database:\n  hostname: ''postgres''/" config/test.yaml'
-  - 'sed -i -z "s/redis:\n  hostname: ''localhost''/redis:\n  hostname: ''redis''/" config/test.yaml'
-  - if [[ $CI_JOB_STAGE == "test" ]]; then psql -c "create user peertube with password 'peertube';"; fi
-  - NOCLIENT=1 yarn install --pure-lockfile --cache-folder .yarn-cache
+#before_script:
+#  - 'sed -i -z "s/database:\n  hostname: ''localhost''/database:\n  hostname: ''postgres''/" config/test.yaml'
+#  - 'sed -i -z "s/redis:\n  hostname: ''localhost''/redis:\n  hostname: ''redis''/" config/test.yaml'
+#  - if [[ $CI_JOB_STAGE == "test" ]]; then psql -c "create user peertube with password 'peertube';"; fi
+#  - NOCLIENT=1 yarn install --pure-lockfile --cache-folder .yarn-cache
 
 cache:
   key: yarn
@@ -85,7 +85,7 @@ cache:
 #    - NODE_PENDING_JOB_WAIT=1000 npm run ci -- api-$CI_NODE_INDEX
 
 build-nightly:
-  stage: nightly
+  stage: docker-nightly
   only:
     - schedules
   script:
@@ -98,3 +98,26 @@ build-nightly:
     - if [ ! -z ${DEPLOYEMENT_KEY+x} ]; then ssh-add <(echo "${DEPLOYEMENT_KEY}"); fi
     - if [ ! -z ${DEPLOYEMENT_KEY+x} ]; then scp ./peertube-nightly-* ${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:../../web/nightly; fi
 
+.docker: &docker
+  stage: docker-nightly
+  image:
+    name: gcr.io/kaniko-project/executor:debug
+    entrypoint: [""]
+  before_script:
+    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$CI_REGISTRY_AUTH\",\"email\":\"$CI_REGISTRY_EMAIL\"}}}" > /kaniko/.docker/config.json
+  script:
+    - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/support/docker/production/Dockerfile.stretch --destination $DOCKER_IMAGE_NAME
+
+build-docker-develop:
+  <<: *docker
+  only:
+    - schedules
+  variables:
+    DOCKER_IMAGE_NAME: chocobozzz/peertube:develop-stretch
+
+build-docker-tag:
+  <<: *docker
+  only:
+    - tags
+  variables:
+    DOCKER_IMAGE_NAME: chocobozzz/peertube:$CI_COMMIT_TAG-stretch
index 29478b085faf7d2c8228cc8dfb2a10771191ea21..4a785478c1a8dc261c41fd09061a6b5d0d9e2f2a 100644 (file)
--- a/README.md
+++ b/README.md
@@ -182,7 +182,7 @@ See the [architecture blueprint](https://docs.joinpeertube.org/#/contribute-arch
 
 See our REST API documentation:
   * OpenAPI 3.0.0 schema: [/support/doc/api/openapi.yaml](/support/doc/api/openapi.yaml)
-  * Spec explorer: [docs.joinpeertube.org/#/api-rest-reference.html](https://docs.joinpeertube.org/#/api-rest-reference.html)
+  * Spec explorer: [docs.joinpeertube.org/api-rest-reference.html](https://docs.joinpeertube.org/api-rest-reference.html)
 
 See our [ActivityPub documentation](https://docs.joinpeertube.org/#/api-activitypub).
 
index db1f91f8caac4eb2e9a3fc7c48dea540da651f46..50c5f5b9bb41ba3755be7abaf415bfc8f7bd5d4f 100644 (file)
@@ -226,7 +226,7 @@ export class AppComponent implements OnInit {
       new Hotkey('g o', (event: KeyboardEvent): boolean => {
         this.router.navigate([ '/videos/overview' ])
         return false
-      }, undefined, this.i18n('Go to the videos overview page')),
+      }, undefined, this.i18n('Go to the discover videos page')),
       new Hotkey('g t', (event: KeyboardEvent): boolean => {
         this.router.navigate([ '/videos/trending' ])
         return false
index 3bb82e8a987f8b32d008a8465d7cc37aad6718d4..3af36765af795ed8de83fbac93b4ccd25add3242 100644 (file)
@@ -18,6 +18,7 @@ import { PublicServerSetting } from '@shared/models/plugins/public-server.settin
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { RegisterClientHelpers } from '../../../types/register-client-option.model'
 import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model'
+import { importModule } from '@app/shared/misc/utils'
 
 interface HookStructValue extends RegisterClientHookOptions {
   plugin: ServerConfigPlugin
@@ -222,7 +223,7 @@ export class PluginService implements ClientHook {
     console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
 
     return this.zone.runOutsideAngular(() => {
-      return import(/* webpackIgnore: true */ clientScript.script)
+      return importModule(clientScript.script)
         .then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers }))
         .then(() => this.sortHooksByPriority())
         .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
index 85fc1c3a0987af8f03af863be655be1480790791..f26240d216eee3461aab8d85eb20f12326d386b0 100644 (file)
@@ -134,6 +134,41 @@ function scrollToTop () {
   window.scroll(0, 0)
 }
 
+// Thanks: https://github.com/uupaa/dynamic-import-polyfill
+function importModule (path: string) {
+  return new Promise((resolve, reject) => {
+    const vector = '$importModule$' + Math.random().toString(32).slice(2)
+    const script = document.createElement('script')
+
+    const destructor = () => {
+      delete window[ vector ]
+      script.onerror = null
+      script.onload = null
+      script.remove()
+      URL.revokeObjectURL(script.src)
+      script.src = ''
+    }
+
+    script.defer = true
+    script.type = 'module'
+
+    script.onerror = () => {
+      reject(new Error(`Failed to import: ${path}`))
+      destructor()
+    }
+    script.onload = () => {
+      resolve(window[ vector ])
+      destructor()
+    }
+    const absURL = (environment.apiUrl || window.location.origin) + path
+    const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module
+    const blob = new Blob([ loader ], { type: 'text/javascript' })
+    script.src = URL.createObjectURL(blob)
+
+    document.head.appendChild(script)
+  })
+}
+
 export {
   sortBy,
   durationToString,
@@ -147,5 +182,6 @@ export {
   objectToFormData,
   objectLineFeedToHtml,
   removeElementFromArray,
+  importModule,
   scrollToTop
 }
index 4eaae93cbce4e36aafae2d43d39875a3b96a74c8..f0049d8c4e01e26ac4d2cde06611e75c4a0a9516 100644 (file)
@@ -19,7 +19,7 @@ const videosRoutes: Routes = [
         component: VideoOverviewComponent,
         data: {
           meta: {
-            title: 'Videos overview'
+            title: 'Discover videos'
           }
         }
       },
index c64a8ebf8b788ee30245c72ac004716e58c2491c..4fa72232712441172142a999fad5b0973866375e 100644 (file)
@@ -1,5 +1,4 @@
 $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
-@import '_bootstrap';
 
 @import '_variables';
 @import '_mixins';
index 4e9e8736c02c1a24c9e2a4d6f7a6c8d0f385a16b..0c2359ac707b2a1b6412baf4015654d4e25ae825 100644 (file)
@@ -11,9 +11,3 @@ $slider-bg-color: lighten($primary-background-color, 33%);
 $progress-margin: 10px;
 
 $assets-path: '../../assets/' !default;
-
-body {
-  --embedForegroundColor: #{$primary-foreground-color};
-
-  --embedBigPlayBackgroundColor: #{$primary-background-color};
-}
index 039cf7e00d3ef082b7efa245deb6bec7286284a9..4bf48a5704d546b077b6e47d23056a9f1958565d 100644 (file)
@@ -2,6 +2,12 @@
 @import '_mixins';
 @import './_player-variables';
 
+body {
+  --embedForegroundColor: #{$primary-foreground-color};
+
+  --embedBigPlayBackgroundColor: #{$primary-background-color};
+}
+
 @mixin big-play-button-triangle-size($triangle-size) {
   width: $triangle-size;
   height: $triangle-size;
index b7a433b9954fd6728b45304744a13686dfda4e04..dfba23f59bb74f6f4f764371ac0a214595bcce27 100644 (file)
@@ -64,7 +64,7 @@ smtp:
 email:
   body:
     signature: "PeerTube"
-  object:
+  subject:
     prefix: "[PeerTube]"
 
 # From the project root directory
index 17a1be5025089e621d0d16c5718dfe54b06cb90b..267186e082293541d5623c0d3095fd6d9d0c072c 100644 (file)
@@ -65,7 +65,7 @@ smtp:
 email:
   body:
     signature: "PeerTube"
-  object:
+  subject:
     prefix: "[PeerTube]"
 
 # From the project root directory
index b5e32abc26839c70eba277499b7488409ddd3bb1..63af91a44a7ec5dcef814fb7ef1da4d68c842234 100644 (file)
@@ -41,7 +41,11 @@ function isPluginEngineValid (engine: any) {
 }
 
 function isPluginHomepage (value: string) {
-  return isUrlValid(value)
+  return exists(value) && (!value || isUrlValid(value))
+}
+
+function isPluginBugs (value: string) {
+  return exists(value) && (!value || isUrlValid(value))
 }
 
 function areStaticDirectoriesValid (staticDirs: any) {
@@ -85,7 +89,7 @@ function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginT
     isPluginEngineValid(packageJSON.engine) &&
     isPluginHomepage(packageJSON.homepage) &&
     exists(packageJSON.author) &&
-    isUrlValid(packageJSON.bugs) &&
+    isPluginBugs(packageJSON.bugs) &&
     (pluginType === PluginType.THEME || isSafePath(packageJSON.library)) &&
     areStaticDirectoriesValid(packageJSON.staticDirs) &&
     areCSSPathsValid(packageJSON.css) &&
index 55bc820f5e32e5c62e9b4b518e2787e5dd54648d..a986c3e0eb19a00c0f1088d31fec1e09f7551ca2 100644 (file)
@@ -11,7 +11,7 @@ function checkMissedConfig () {
     'trust_proxy',
     'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max',
     'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
-    'email.body.signature', 'email.object.prefix',
+    'email.body.signature', 'email.subject.prefix',
     'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache',
     'storage.redundancy', 'storage.tmp', 'storage.streaming_playlists', 'storage.plugins',
     'log.level',
index 58241e4ea9553dce28d556a9fd10c0343e586283..510f7d64d34a2c5d24f7a5525dfe7b3c3fbc0c27 100644 (file)
@@ -48,8 +48,8 @@ const CONFIG = {
     BODY: {
       SIGNATURE: config.get<string>('email.body.signature')
     },
-    OBJECT: {
-      PREFIX: config.get<string>('email.object.prefix') + ' '
+    SUBJECT: {
+      PREFIX: config.get<string>('email.subject.prefix') + ' '
     }
   },
   STORAGE: {
index a888b7a72ad0f6ac188e4642f893ebaa7aa9bd8e..76349ef8f0f23753c41b97cb02ccc875df07ae5d 100644 (file)
@@ -100,7 +100,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + channelName + ' just published a new video',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + channelName + ' just published a new video',
       text
     }
 
@@ -119,7 +119,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'New follower on your channel ' + followingName,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New follower on your channel ' + followingName,
       text
     }
 
@@ -137,7 +137,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'New instance follower',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New instance follower',
       text
     }
 
@@ -157,7 +157,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + `Your video ${video.name} is published`,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video ${video.name} is published`,
       text
     }
 
@@ -177,7 +177,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} is finished`,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} is finished`,
       text
     }
 
@@ -197,7 +197,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} encountered an error`,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} encountered an error`,
       text
     }
 
@@ -219,7 +219,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'New comment on your video ' + video.name,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New comment on your video ' + video.name,
       text
     }
 
@@ -241,7 +241,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'Mention on video ' + video.name,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Mention on video ' + video.name,
       text
     }
 
@@ -258,7 +258,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'Received a video abuse',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Received a video abuse',
       text
     }
 
@@ -281,7 +281,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'An auto-blacklisted video is awaiting review',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'An auto-blacklisted video is awaiting review',
       text
     }
 
@@ -296,7 +296,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'New user registration on ' + WEBSERVER.HOST,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New user registration on ' + WEBSERVER.HOST,
       text
     }
 
@@ -318,7 +318,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + `Video ${videoName} blacklisted`,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Video ${videoName} blacklisted`,
       text
     }
 
@@ -336,7 +336,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to,
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + `Video ${video.name} unblacklisted`,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Video ${video.name} unblacklisted`,
       text
     }
 
@@ -353,7 +353,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to: [ to ],
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'Reset your password',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Reset your password',
       text
     }
 
@@ -370,7 +370,7 @@ class Emailer {
 
     const emailPayload: EmailPayload = {
       to: [ to ],
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'Verify your email',
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Verify your email',
       text
     }
 
@@ -391,7 +391,7 @@ class Emailer {
     const to = user.email
     const emailPayload: EmailPayload = {
       to: [ to ],
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + 'Account ' + blockedWord,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Account ' + blockedWord,
       text
     }
 
@@ -411,7 +411,7 @@ class Emailer {
       fromDisplayName: fromEmail,
       replyTo: fromEmail,
       to: [ CONFIG.ADMIN.EMAIL ],
-      subject: CONFIG.EMAIL.OBJECT.PREFIX + subject,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + subject,
       text
     }
 
index 1c7b09175720c287c74751a9a9f3042a334bd066..ad2bb4845d45319feae5993ed5bec8a4a5dfbdc5 100644 (file)
@@ -8,7 +8,7 @@ class PeerTubeSocket {
 
   private static instance: PeerTubeSocket
 
-  private userNotificationSockets: { [ userId: number ]: SocketIO.Socket } = {}
+  private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {}
 
   private constructor () {}
 
@@ -22,22 +22,26 @@ class PeerTubeSocket {
 
         logger.debug('User %d connected on the notification system.', userId)
 
-        this.userNotificationSockets[userId] = socket
+        if (!this.userNotificationSockets[userId]) this.userNotificationSockets[userId] = []
+
+        this.userNotificationSockets[userId].push(socket)
 
         socket.on('disconnect', () => {
           logger.debug('User %d disconnected from SocketIO notifications.', userId)
 
-          delete this.userNotificationSockets[userId]
+          this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket)
         })
       })
   }
 
   sendNotification (userId: number, notification: UserNotificationModelForApi) {
-    const socket = this.userNotificationSockets[userId]
+    const sockets = this.userNotificationSockets[userId]
 
-    if (!socket) return
+    if (!sockets) return
 
-    socket.emit('new-notification', notification.toFormattedJSON())
+    for (const socket of sockets) {
+      socket.emit('new-notification', notification.toFormattedJSON())
+    }
   }
 
   static get Instance () {
index 78159f89c47f74fe7c471d4fdfd7bf96f671e1aa..89d44074824f3ebdcba978ab9acf9337fd5dbb27 100644 (file)
@@ -726,8 +726,7 @@ paths:
                   type: string
                   format: binary
             encoding:
-              profileImage:
-                # only accept png/jpeg
+              avatarfile:
                 contentType: image/png, image/jpeg
   /videos:
     get:
@@ -829,9 +828,11 @@ paths:
                 thumbnailfile:
                   description: Video thumbnail file
                   type: string
+                  format: binary
                 previewfile:
                   description: Video preview file
                   type: string
+                  format: binary
                 category:
                   description: Video category
                   type: string
@@ -874,6 +875,11 @@ paths:
                   format: date-time
                 scheduleUpdate:
                   $ref: '#/components/schemas/VideoScheduledUpdate'
+            encoding:
+              thumbnailfile:
+                contentType: image/jpeg
+              previewfile:
+                contentType: image/jpeg
     get:
       summary: Get a video by its id
       tags:
@@ -1029,9 +1035,11 @@ paths:
                 thumbnailfile:
                   description: Video thumbnail file
                   type: string
+                  format: binary
                 previewfile:
                   description: Video preview file
                   type: string
+                  format: binary
                 privacy:
                   $ref: '#/components/schemas/VideoPrivacySet'
                 category:
@@ -1080,6 +1088,13 @@ paths:
                 - videofile
                 - channelId
                 - name
+            encoding:
+              videofile:
+                contentType: video/mp4, video/webm, video/ogg, video/avi, video/quicktime, video/x-msvideo, video/x-flv, video/x-matroska, application/octet-stream
+              thumbnailfile:
+                contentType: image/jpeg
+              previewfile:
+                contentType: image/jpeg
       x-code-samples:
         - lang: Shell
           source: |
@@ -1142,9 +1157,11 @@ paths:
                 thumbnailfile:
                   description: Video thumbnail file
                   type: string
+                  format: binary
                 previewfile:
                   description: Video preview file
                   type: string
+                  format: binary
                 privacy:
                   $ref: '#/components/schemas/VideoPrivacySet'
                 category:
@@ -1188,6 +1205,13 @@ paths:
               required:
                 - channelId
                 - name
+            encoding:
+              torrentfile:
+                contentType: application/x-bittorrent
+              thumbnailfile:
+                contentType: image/jpeg
+              previewfile:
+                contentType: image/jpeg
   /videos/abuse:
     get:
       summary: Get list of reported video abuses
@@ -1308,6 +1332,9 @@ paths:
                   description: The file to upload.
                   type: string
                   format: binary
+            encoding:
+              captionfile:
+                contentType: text/vtt, application/x-subrip
       responses:
         '204':
           $ref: '#/paths/~1users~1me/put/responses/204'
@@ -1952,7 +1979,7 @@ components:
           description: 'Video file size in bytes'
         torrentUrl:
           type: string
-        torrentDownaloadUrl:
+        torrentDownloadUrl:
           type: string
         fileUrl:
           type: string
index cf427ec845d6ceb23b695745c9604bf7d1de57d9..88586bfaa7a33d1803d1884eafeeeba71c14a18e 100644 (file)
@@ -11,6 +11,7 @@
     - [peertube-import-videos.js](#peertube-import-videosjs)
     - [peertube-upload.js](#peertube-uploadjs)
     - [peertube-watch.js](#peertube-watchjs)
+    - [peertube-plugins.js](#peertube-pluginsjs)
 - [Server tools](#server-tools)
   - [parse-log](#parse-log)
   - [create-transcoding-job.js](#create-transcoding-jobjs)
@@ -19,6 +20,7 @@
   - [optimize-old-videos.js](#optimize-old-videosjs)
   - [update-host.js](#update-hostjs)
   - [reset-password.js](#reset-passwordjs)
+  - [plugin install/uninstall](#plugin-installuninstall)
   - [REPL (Read Eval Print Loop)](#repl-read-eval-print-loop)
     - [.help](#help)
     - [Lodash example](#lodash-example)
@@ -182,6 +184,22 @@ It provides support for different players:
 - chromecast
 
 
+#### peertube-plugins.js
+
+Install/update/uninstall or list local or NPM PeerTube plugins:
+
+```
+$ cd ${CLONE}
+$ node dist/server/tools/peertube-plugins.js --help
+$ node dist/server/tools/peertube-plugins.js list --help
+$ node dist/server/tools/peertube-plugins.js install --help
+$ node dist/server/tools/peertube-plugins.js update --help
+$ node dist/server/tools/peertube-plugins.js uninstall --help
+
+$ node dist/server/tools/peertube-plugins.js install --path /my/plugin/path
+$ node dist/server/tools/peertube-plugins.js install --npm-name peertube-theme-example
+```
+
 ## Server tools
 
 These scripts should be run on the server, in `peertube-latest` directory.
@@ -262,7 +280,7 @@ $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production
 The difference with `peertube plugins` CLI is that these scripts can be used even if PeerTube is not running.
 If PeerTube is running, you need to restart it for the changes to take effect (whereas with `peertube plugins` CLI, plugins/themes are dynamically loaded on the server).
 
-To install a plugin or a theme from the disk:
+To install/update a plugin or a theme from the disk:
 
 ```
 $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run npm run plugin:install -- --plugin-path /local/plugin/path