aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/tools
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/tools
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'server/tools')
-rw-r--r--server/tools/README.md3
-rw-r--r--server/tools/package.json11
-rw-r--r--server/tools/peertube-auth.ts171
-rw-r--r--server/tools/peertube-get-access-token.ts34
-rw-r--r--server/tools/peertube-import-videos.ts351
-rw-r--r--server/tools/peertube-plugins.ts165
-rw-r--r--server/tools/peertube-redundancy.ts172
-rw-r--r--server/tools/peertube-upload.ts77
-rw-r--r--server/tools/peertube.ts72
-rw-r--r--server/tools/shared/cli.ts262
-rw-r--r--server/tools/shared/index.ts1
-rw-r--r--server/tools/tsconfig.json12
-rw-r--r--server/tools/yarn.lock373
13 files changed, 0 insertions, 1704 deletions
diff --git a/server/tools/README.md b/server/tools/README.md
deleted file mode 100644
index d7ecd4004..000000000
--- a/server/tools/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
1# PeerTube CLI
2
3See https://docs.joinpeertube.org/maintain/tools#remote-tools
diff --git a/server/tools/package.json b/server/tools/package.json
deleted file mode 100644
index b20f38244..000000000
--- a/server/tools/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
1{
2 "name": "@peertube/cli",
3 "version": "1.0.0",
4 "private": true,
5 "dependencies": {
6 "application-config": "^2.0.0",
7 "cli-table3": "^0.6.0",
8 "netrc-parser": "^3.1.6"
9 },
10 "devDependencies": {}
11}
diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts
deleted file mode 100644
index c853469c2..000000000
--- a/server/tools/peertube-auth.ts
+++ /dev/null
@@ -1,171 +0,0 @@
1import CliTable3 from 'cli-table3'
2import { OptionValues, program } from 'commander'
3import { isUserUsernameValid } from '../helpers/custom-validators/users'
4import { assignToken, buildServer, getNetrc, getSettings, writeSettings } from './shared'
5
6import prompt = require('prompt')
7
8async function delInstance (url: string) {
9 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
10
11 const index = settings.remotes.indexOf(url)
12 settings.remotes.splice(index)
13
14 if (settings.default === index) settings.default = -1
15
16 await writeSettings(settings)
17
18 delete netrc.machines[url]
19
20 await netrc.save()
21}
22
23async function setInstance (url: string, username: string, password: string, isDefault: boolean) {
24 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
25
26 if (settings.remotes.includes(url) === false) {
27 settings.remotes.push(url)
28 }
29
30 if (isDefault || settings.remotes.length === 1) {
31 settings.default = settings.remotes.length - 1
32 }
33
34 await writeSettings(settings)
35
36 netrc.machines[url] = { login: username, password }
37 await netrc.save()
38}
39
40function isURLaPeerTubeInstance (url: string) {
41 return url.startsWith('http://') || url.startsWith('https://')
42}
43
44function stripExtraneousFromPeerTubeUrl (url: string) {
45 // Get everything before the 3rd /.
46 const urlLength = url.includes('/', 8)
47 ? url.indexOf('/', 8)
48 : url.length
49
50 return url.substring(0, urlLength)
51}
52
53program
54 .name('auth')
55 .usage('[command] [options]')
56
57program
58 .command('add')
59 .description('remember your accounts on remote instances for easier use')
60 .option('-u, --url <url>', 'Server url')
61 .option('-U, --username <username>', 'Username')
62 .option('-p, --password <token>', 'Password')
63 .option('--default', 'add the entry as the new default')
64 .action((options: OptionValues) => {
65 /* eslint-disable no-import-assign */
66 prompt.override = options
67 prompt.start()
68 prompt.get({
69 properties: {
70 url: {
71 description: 'instance url',
72 conform: (value) => isURLaPeerTubeInstance(value),
73 message: 'It should be an URL (https://peertube.example.com)',
74 required: true
75 },
76 username: {
77 conform: (value) => isUserUsernameValid(value),
78 message: 'Name must be only letters, spaces, or dashes',
79 required: true
80 },
81 password: {
82 hidden: true,
83 replace: '*',
84 required: true
85 }
86 }
87 }, async (_, result) => {
88
89 // Check credentials
90 try {
91 // Strip out everything after the domain:port.
92 // See https://github.com/Chocobozzz/PeerTube/issues/3520
93 result.url = stripExtraneousFromPeerTubeUrl(result.url)
94
95 const server = buildServer(result.url)
96 await assignToken(server, result.username, result.password)
97 } catch (err) {
98 console.error(err.message)
99 process.exit(-1)
100 }
101
102 await setInstance(result.url, result.username, result.password, options.default)
103
104 process.exit(0)
105 })
106 })
107
108program
109 .command('del <url>')
110 .description('unregisters a remote instance')
111 .action(async url => {
112 await delInstance(url)
113
114 process.exit(0)
115 })
116
117program
118 .command('list')
119 .description('lists registered remote instances')
120 .action(async () => {
121 const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
122
123 const table = new CliTable3({
124 head: [ 'instance', 'login' ],
125 colWidths: [ 30, 30 ]
126 }) as any
127
128 settings.remotes.forEach(element => {
129 if (!netrc.machines[element]) return
130
131 table.push([
132 element,
133 netrc.machines[element].login
134 ])
135 })
136
137 console.log(table.toString())
138
139 process.exit(0)
140 })
141
142program
143 .command('set-default <url>')
144 .description('set an existing entry as default')
145 .action(async url => {
146 const settings = await getSettings()
147 const instanceExists = settings.remotes.includes(url)
148
149 if (instanceExists) {
150 settings.default = settings.remotes.indexOf(url)
151 await writeSettings(settings)
152
153 process.exit(0)
154 } else {
155 console.log('<url> is not a registered instance.')
156 process.exit(-1)
157 }
158 })
159
160program.addHelpText('after', '\n\n Examples:\n\n' +
161 ' $ peertube auth add -u https://peertube.cpy.re -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"\n' +
162 ' $ peertube auth add -u https://peertube.cpy.re -U root\n' +
163 ' $ peertube auth list\n' +
164 ' $ peertube auth del https://peertube.cpy.re\n'
165)
166
167if (!process.argv.slice(2).length) {
168 program.outputHelp()
169}
170
171program.parse(process.argv)
diff --git a/server/tools/peertube-get-access-token.ts b/server/tools/peertube-get-access-token.ts
deleted file mode 100644
index 71a4826e8..000000000
--- a/server/tools/peertube-get-access-token.ts
+++ /dev/null
@@ -1,34 +0,0 @@
1import { program } from 'commander'
2import { assignToken, buildServer } from './shared'
3
4program
5 .option('-u, --url <url>', 'Server url')
6 .option('-n, --username <username>', 'Username')
7 .option('-p, --password <token>', 'Password')
8 .parse(process.argv)
9
10const options = program.opts()
11
12if (
13 !options.url ||
14 !options.username ||
15 !options.password
16) {
17 if (!options.url) console.error('--url field is required.')
18 if (!options.username) console.error('--username field is required.')
19 if (!options.password) console.error('--password field is required.')
20
21 process.exit(-1)
22}
23
24const server = buildServer(options.url)
25
26assignToken(server, options.username, options.password)
27 .then(() => {
28 console.log(server.accessToken)
29 process.exit(0)
30 })
31 .catch(err => {
32 console.error(err)
33 process.exit(-1)
34 })
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
deleted file mode 100644
index bbdaa09c0..000000000
--- a/server/tools/peertube-import-videos.ts
+++ /dev/null
@@ -1,351 +0,0 @@
1import { program } from 'commander'
2import { accessSync, constants } from 'fs'
3import { remove } from 'fs-extra'
4import { join } from 'path'
5import { YoutubeDLCLI, YoutubeDLInfo, YoutubeDLInfoBuilder } from '@server/helpers/youtube-dl'
6import { wait } from '@shared/core-utils'
7import { sha256 } from '@shared/extra-utils'
8import { doRequestAndSaveToFile } from '../helpers/requests'
9import {
10 assignToken,
11 buildCommonVideoOptions,
12 buildServer,
13 buildVideoAttributesFromCommander,
14 getLogger,
15 getServerCredentials
16} from './shared'
17
18import prompt = require('prompt')
19
20const processOptions = {
21 maxBuffer: Infinity
22}
23
24let command = program
25 .name('import-videos')
26
27command = buildCommonVideoOptions(command)
28
29command
30 .option('-u, --url <url>', 'Server url')
31 .option('-U, --username <username>', 'Username')
32 .option('-p, --password <token>', 'Password')
33 .option('--target-url <targetUrl>', 'Video target URL')
34 .option('--since <since>', 'Publication date (inclusive) since which the videos can be imported (YYYY-MM-DD)', parseDate)
35 .option('--until <until>', 'Publication date (inclusive) until which the videos can be imported (YYYY-MM-DD)', parseDate)
36 .option('--first <first>', 'Process first n elements of returned playlist')
37 .option('--last <last>', 'Process last n elements of returned playlist')
38 .option('--wait-interval <waitInterval>', 'Duration between two video imports (in seconds)', convertIntoMs)
39 .option('-T, --tmpdir <tmpdir>', 'Working directory', __dirname)
40 .usage('[global options] [ -- youtube-dl options]')
41 .parse(process.argv)
42
43const options = command.opts()
44
45const log = getLogger(options.verbose)
46
47getServerCredentials(command)
48 .then(({ url, username, password }) => {
49 if (!options.targetUrl) {
50 exitError('--target-url field is required.')
51 }
52
53 try {
54 accessSync(options.tmpdir, constants.R_OK | constants.W_OK)
55 } catch (e) {
56 exitError('--tmpdir %s: directory does not exist or is not accessible', options.tmpdir)
57 }
58
59 url = normalizeTargetUrl(url)
60 options.targetUrl = normalizeTargetUrl(options.targetUrl)
61
62 run(url, username, password)
63 .catch(err => exitError(err))
64 })
65 .catch(err => console.error(err))
66
67async function run (url: string, username: string, password: string) {
68 if (!password) password = await promptPassword()
69
70 const youtubeDLBinary = await YoutubeDLCLI.safeGet()
71
72 let info = await getYoutubeDLInfo(youtubeDLBinary, options.targetUrl, command.args)
73
74 if (!Array.isArray(info)) info = [ info ]
75
76 // Try to fix youtube channels upload
77 const uploadsObject = info.find(i => !i.ie_key && !i.duration && i.title === 'Uploads')
78
79 if (uploadsObject) {
80 console.log('Fixing URL to %s.', uploadsObject.url)
81
82 info = await getYoutubeDLInfo(youtubeDLBinary, uploadsObject.url, command.args)
83 }
84
85 let infoArray: any[]
86
87 infoArray = [].concat(info)
88 if (options.first) {
89 infoArray = infoArray.slice(0, options.first)
90 } else if (options.last) {
91 infoArray = infoArray.slice(-options.last)
92 }
93
94 log.info('Will download and upload %d videos.\n', infoArray.length)
95
96 let skipInterval = true
97 for (const [ index, info ] of infoArray.entries()) {
98 try {
99 if (index > 0 && options.waitInterval && !skipInterval) {
100 log.info('Wait for %d seconds before continuing.', options.waitInterval / 1000)
101 await wait(options.waitInterval)
102 }
103
104 skipInterval = await processVideo({
105 cwd: options.tmpdir,
106 url,
107 username,
108 password,
109 youtubeInfo: info
110 })
111 } catch (err) {
112 console.error('Cannot process video.', { info, url, err })
113 }
114 }
115
116 log.info('Video/s for user %s imported: %s', username, options.targetUrl)
117 process.exit(0)
118}
119
120async function processVideo (parameters: {
121 cwd: string
122 url: string
123 username: string
124 password: string
125 youtubeInfo: any
126}) {
127 const { youtubeInfo, cwd, url, username, password } = parameters
128
129 log.debug('Fetching object.', youtubeInfo)
130
131 const videoInfo = await fetchObject(youtubeInfo)
132 log.debug('Fetched object.', videoInfo)
133
134 if (
135 options.since &&
136 videoInfo.originallyPublishedAtWithoutTime &&
137 videoInfo.originallyPublishedAtWithoutTime.getTime() < options.since.getTime()
138 ) {
139 log.info('Video "%s" has been published before "%s", don\'t upload it.\n', videoInfo.name, formatDate(options.since))
140 return true
141 }
142
143 if (
144 options.until &&
145 videoInfo.originallyPublishedAtWithoutTime &&
146 videoInfo.originallyPublishedAtWithoutTime.getTime() > options.until.getTime()
147 ) {
148 log.info('Video "%s" has been published after "%s", don\'t upload it.\n', videoInfo.name, formatDate(options.until))
149 return true
150 }
151
152 const server = buildServer(url)
153 const { data } = await server.search.advancedVideoSearch({
154 search: {
155 search: videoInfo.name,
156 sort: '-match',
157 searchTarget: 'local'
158 }
159 })
160
161 log.info('############################################################\n')
162
163 if (data.find(v => v.name === videoInfo.name)) {
164 log.info('Video "%s" already exists, don\'t reupload it.\n', videoInfo.name)
165 return true
166 }
167
168 const path = join(cwd, sha256(videoInfo.url) + '.mp4')
169
170 log.info('Downloading video "%s"...', videoInfo.name)
171
172 try {
173 const youtubeDLBinary = await YoutubeDLCLI.safeGet()
174 const output = await youtubeDLBinary.download({
175 url: videoInfo.url,
176 format: YoutubeDLCLI.getYoutubeDLVideoFormat([], false),
177 output: path,
178 additionalYoutubeDLArgs: command.args,
179 processOptions
180 })
181
182 log.info(output.join('\n'))
183 await uploadVideoOnPeerTube({
184 cwd,
185 url,
186 username,
187 password,
188 videoInfo,
189 videoPath: path
190 })
191 } catch (err) {
192 log.error(err.message)
193 }
194
195 return false
196}
197
198async function uploadVideoOnPeerTube (parameters: {
199 videoInfo: YoutubeDLInfo
200 videoPath: string
201 cwd: string
202 url: string
203 username: string
204 password: string
205}) {
206 const { videoInfo, videoPath, cwd, url, username, password } = parameters
207
208 const server = buildServer(url)
209 await assignToken(server, username, password)
210
211 let thumbnailfile: string
212 if (videoInfo.thumbnailUrl) {
213 thumbnailfile = join(cwd, sha256(videoInfo.thumbnailUrl) + '.jpg')
214
215 await doRequestAndSaveToFile(videoInfo.thumbnailUrl, thumbnailfile)
216 }
217
218 const baseAttributes = await buildVideoAttributesFromCommander(server, program, videoInfo)
219
220 const attributes = {
221 ...baseAttributes,
222
223 originallyPublishedAtWithoutTime: videoInfo.originallyPublishedAtWithoutTime
224 ? videoInfo.originallyPublishedAtWithoutTime.toISOString()
225 : null,
226
227 thumbnailfile,
228 previewfile: thumbnailfile,
229 fixture: videoPath
230 }
231
232 log.info('\nUploading on PeerTube video "%s".', attributes.name)
233
234 try {
235 await server.videos.upload({ attributes })
236 } catch (err) {
237 if (err.message.indexOf('401') !== -1) {
238 log.info('Got 401 Unauthorized, token may have expired, renewing token and retry.')
239
240 server.accessToken = await server.login.getAccessToken(username, password)
241
242 await server.videos.upload({ attributes })
243 } else {
244 exitError(err.message)
245 }
246 }
247
248 await remove(videoPath)
249 if (thumbnailfile) await remove(thumbnailfile)
250
251 log.info('Uploaded video "%s"!\n', attributes.name)
252}
253
254/* ---------------------------------------------------------- */
255
256async function fetchObject (info: any) {
257 const url = buildUrl(info)
258
259 const youtubeDLCLI = await YoutubeDLCLI.safeGet()
260 const result = await youtubeDLCLI.getInfo({
261 url,
262 format: YoutubeDLCLI.getYoutubeDLVideoFormat([], false),
263 processOptions
264 })
265
266 const builder = new YoutubeDLInfoBuilder(result)
267
268 const videoInfo = builder.getInfo()
269
270 return { ...videoInfo, url }
271}
272
273function buildUrl (info: any) {
274 const webpageUrl = info.webpage_url as string
275 if (webpageUrl?.match(/^https?:\/\//)) return webpageUrl
276
277 const url = info.url as string
278 if (url?.match(/^https?:\/\//)) return url
279
280 // It seems youtube-dl does not return the video url
281 return 'https://www.youtube.com/watch?v=' + info.id
282}
283
284function normalizeTargetUrl (url: string) {
285 let normalizedUrl = url.replace(/\/+$/, '')
286
287 if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
288 normalizedUrl = 'https://' + normalizedUrl
289 }
290
291 return normalizedUrl
292}
293
294async function promptPassword () {
295 return new Promise<string>((res, rej) => {
296 prompt.start()
297 const schema = {
298 properties: {
299 password: {
300 hidden: true,
301 required: true
302 }
303 }
304 }
305 prompt.get(schema, function (err, result) {
306 if (err) {
307 return rej(err)
308 }
309 return res(result.password)
310 })
311 })
312}
313
314function parseDate (dateAsStr: string): Date {
315 if (!/\d{4}-\d{2}-\d{2}/.test(dateAsStr)) {
316 exitError(`Invalid date passed: ${dateAsStr}. Expected format: YYYY-MM-DD. See help for usage.`)
317 }
318 const date = new Date(dateAsStr)
319 date.setHours(0, 0, 0)
320 if (isNaN(date.getTime())) {
321 exitError(`Invalid date passed: ${dateAsStr}. See help for usage.`)
322 }
323 return date
324}
325
326function formatDate (date: Date): string {
327 return date.toISOString().split('T')[0]
328}
329
330function convertIntoMs (secondsAsStr: string): number {
331 const seconds = parseInt(secondsAsStr, 10)
332 if (seconds <= 0) {
333 exitError(`Invalid duration passed: ${seconds}. Expected duration to be strictly positive and in seconds`)
334 }
335 return Math.round(seconds * 1000)
336}
337
338function exitError (message: string, ...meta: any[]) {
339 // use console.error instead of log.error here
340 console.error(message, ...meta)
341 process.exit(-1)
342}
343
344function getYoutubeDLInfo (youtubeDLCLI: YoutubeDLCLI, url: string, args: string[]) {
345 return youtubeDLCLI.getInfo({
346 url,
347 format: YoutubeDLCLI.getYoutubeDLVideoFormat([], false),
348 additionalYoutubeDLArgs: [ '-j', '--flat-playlist', '--playlist-reverse', ...args ],
349 processOptions
350 })
351}
diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts
deleted file mode 100644
index 0660c855f..000000000
--- a/server/tools/peertube-plugins.ts
+++ /dev/null
@@ -1,165 +0,0 @@
1import CliTable3 from 'cli-table3'
2import { Command, OptionValues, program } from 'commander'
3import { isAbsolute } from 'path'
4import { PluginType } from '../../shared/models'
5import { assignToken, buildServer, getServerCredentials } from './shared'
6
7program
8 .name('plugins')
9 .usage('[command] [options]')
10
11program
12 .command('list')
13 .description('List installed plugins')
14 .option('-u, --url <url>', 'Server url')
15 .option('-U, --username <username>', 'Username')
16 .option('-p, --password <token>', 'Password')
17 .option('-t, --only-themes', 'List themes only')
18 .option('-P, --only-plugins', 'List plugins only')
19 .action((options, command) => pluginsListCLI(command, options))
20
21program
22 .command('install')
23 .description('Install a plugin or a theme')
24 .option('-u, --url <url>', 'Server url')
25 .option('-U, --username <username>', 'Username')
26 .option('-p, --password <token>', 'Password')
27 .option('-P --path <path>', 'Install from a path')
28 .option('-n, --npm-name <npmName>', 'Install from npm')
29 .option('--plugin-version <pluginVersion>', 'Specify the plugin version to install (only available when installing from npm)')
30 .action((options, command) => installPluginCLI(command, options))
31
32program
33 .command('update')
34 .description('Update a plugin or a theme')
35 .option('-u, --url <url>', 'Server url')
36 .option('-U, --username <username>', 'Username')
37 .option('-p, --password <token>', 'Password')
38 .option('-P --path <path>', 'Update from a path')
39 .option('-n, --npm-name <npmName>', 'Update from npm')
40 .action((options, command) => updatePluginCLI(command, options))
41
42program
43 .command('uninstall')
44 .description('Uninstall a plugin or a theme')
45 .option('-u, --url <url>', 'Server url')
46 .option('-U, --username <username>', 'Username')
47 .option('-p, --password <token>', 'Password')
48 .option('-n, --npm-name <npmName>', 'NPM plugin/theme name')
49 .action((options, command) => uninstallPluginCLI(command, options))
50
51if (!process.argv.slice(2).length) {
52 program.outputHelp()
53}
54
55program.parse(process.argv)
56
57// ----------------------------------------------------------------------------
58
59async function pluginsListCLI (command: Command, options: OptionValues) {
60 const { url, username, password } = await getServerCredentials(command)
61 const server = buildServer(url)
62 await assignToken(server, username, password)
63
64 let pluginType: PluginType
65 if (options.onlyThemes) pluginType = PluginType.THEME
66 if (options.onlyPlugins) pluginType = PluginType.PLUGIN
67
68 const { data } = await server.plugins.list({ start: 0, count: 100, sort: 'name', pluginType })
69
70 const table = new CliTable3({
71 head: [ 'name', 'version', 'homepage' ],
72 colWidths: [ 50, 20, 50 ]
73 }) as any
74
75 for (const plugin of data) {
76 const npmName = plugin.type === PluginType.PLUGIN
77 ? 'peertube-plugin-' + plugin.name
78 : 'peertube-theme-' + plugin.name
79
80 table.push([
81 npmName,
82 plugin.version,
83 plugin.homepage
84 ])
85 }
86
87 console.log(table.toString())
88 process.exit(0)
89}
90
91async function installPluginCLI (command: Command, options: OptionValues) {
92 if (!options.path && !options.npmName) {
93 console.error('You need to specify the npm name or the path of the plugin you want to install.\n')
94 program.outputHelp()
95 process.exit(-1)
96 }
97
98 if (options.path && !isAbsolute(options.path)) {
99 console.error('Path should be absolute.')
100 process.exit(-1)
101 }
102
103 const { url, username, password } = await getServerCredentials(command)
104 const server = buildServer(url)
105 await assignToken(server, username, password)
106
107 try {
108 await server.plugins.install({ npmName: options.npmName, path: options.path, pluginVersion: options.pluginVersion })
109 } catch (err) {
110 console.error('Cannot install plugin.', err)
111 process.exit(-1)
112 }
113
114 console.log('Plugin installed.')
115 process.exit(0)
116}
117
118async function updatePluginCLI (command: Command, options: OptionValues) {
119 if (!options.path && !options.npmName) {
120 console.error('You need to specify the npm name or the path of the plugin you want to update.\n')
121 program.outputHelp()
122 process.exit(-1)
123 }
124
125 if (options.path && !isAbsolute(options.path)) {
126 console.error('Path should be absolute.')
127 process.exit(-1)
128 }
129
130 const { url, username, password } = await getServerCredentials(command)
131 const server = buildServer(url)
132 await assignToken(server, username, password)
133
134 try {
135 await server.plugins.update({ npmName: options.npmName, path: options.path })
136 } catch (err) {
137 console.error('Cannot update plugin.', err)
138 process.exit(-1)
139 }
140
141 console.log('Plugin updated.')
142 process.exit(0)
143}
144
145async function uninstallPluginCLI (command: Command, options: OptionValues) {
146 if (!options.npmName) {
147 console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n')
148 program.outputHelp()
149 process.exit(-1)
150 }
151
152 const { url, username, password } = await getServerCredentials(command)
153 const server = buildServer(url)
154 await assignToken(server, username, password)
155
156 try {
157 await server.plugins.uninstall({ npmName: options.npmName })
158 } catch (err) {
159 console.error('Cannot uninstall plugin.', err)
160 process.exit(-1)
161 }
162
163 console.log('Plugin uninstalled.')
164 process.exit(0)
165}
diff --git a/server/tools/peertube-redundancy.ts b/server/tools/peertube-redundancy.ts
deleted file mode 100644
index c24eb5233..000000000
--- a/server/tools/peertube-redundancy.ts
+++ /dev/null
@@ -1,172 +0,0 @@
1import CliTable3 from 'cli-table3'
2import { Command, program } from 'commander'
3import { URL } from 'url'
4import validator from 'validator'
5import { forceNumber, uniqify } from '@shared/core-utils'
6import { HttpStatusCode, VideoRedundanciesTarget } from '@shared/models'
7import { assignToken, buildServer, getServerCredentials } from './shared'
8
9import bytes = require('bytes')
10program
11 .name('redundancy')
12 .usage('[command] [options]')
13
14program
15 .command('list-remote-redundancies')
16 .description('List remote redundancies on your videos')
17 .option('-u, --url <url>', 'Server url')
18 .option('-U, --username <username>', 'Username')
19 .option('-p, --password <token>', 'Password')
20 .action(() => listRedundanciesCLI('my-videos'))
21
22program
23 .command('list-my-redundancies')
24 .description('List your redundancies of remote videos')
25 .option('-u, --url <url>', 'Server url')
26 .option('-U, --username <username>', 'Username')
27 .option('-p, --password <token>', 'Password')
28 .action(() => listRedundanciesCLI('remote-videos'))
29
30program
31 .command('add')
32 .description('Duplicate a video in your redundancy system')
33 .option('-u, --url <url>', 'Server url')
34 .option('-U, --username <username>', 'Username')
35 .option('-p, --password <token>', 'Password')
36 .option('-v, --video <videoId>', 'Video id to duplicate')
37 .action((options, command) => addRedundancyCLI(options, command))
38
39program
40 .command('remove')
41 .description('Remove a video from your redundancies')
42 .option('-u, --url <url>', 'Server url')
43 .option('-U, --username <username>', 'Username')
44 .option('-p, --password <token>', 'Password')
45 .option('-v, --video <videoId>', 'Video id to remove from redundancies')
46 .action((options, command) => removeRedundancyCLI(options, command))
47
48if (!process.argv.slice(2).length) {
49 program.outputHelp()
50}
51
52program.parse(process.argv)
53
54// ----------------------------------------------------------------------------
55
56async function listRedundanciesCLI (target: VideoRedundanciesTarget) {
57 const { url, username, password } = await getServerCredentials(program)
58 const server = buildServer(url)
59 await assignToken(server, username, password)
60
61 const { data } = await server.redundancy.listVideos({ start: 0, count: 100, sort: 'name', target })
62
63 const table = new CliTable3({
64 head: [ 'video id', 'video name', 'video url', 'files', 'playlists', 'by instances', 'total size' ]
65 }) as any
66
67 for (const redundancy of data) {
68 const webVideoFiles = redundancy.redundancies.files
69 const streamingPlaylists = redundancy.redundancies.streamingPlaylists
70
71 let totalSize = ''
72 if (target === 'remote-videos') {
73 const tmp = webVideoFiles.concat(streamingPlaylists)
74 .reduce((a, b) => a + b.size, 0)
75
76 totalSize = bytes(tmp)
77 }
78
79 const instances = uniqify(
80 webVideoFiles.concat(streamingPlaylists)
81 .map(r => r.fileUrl)
82 .map(u => new URL(u).host)
83 )
84
85 table.push([
86 redundancy.id.toString(),
87 redundancy.name,
88 redundancy.url,
89 webVideoFiles.length,
90 streamingPlaylists.length,
91 instances.join('\n'),
92 totalSize
93 ])
94 }
95
96 console.log(table.toString())
97 process.exit(0)
98}
99
100async function addRedundancyCLI (options: { video: number }, command: Command) {
101 const { url, username, password } = await getServerCredentials(command)
102 const server = buildServer(url)
103 await assignToken(server, username, password)
104
105 if (!options.video || validator.isInt('' + options.video) === false) {
106 console.error('You need to specify the video id to duplicate and it should be a number.\n')
107 command.outputHelp()
108 process.exit(-1)
109 }
110
111 try {
112 await server.redundancy.addVideo({ videoId: options.video })
113
114 console.log('Video will be duplicated by your instance!')
115
116 process.exit(0)
117 } catch (err) {
118 if (err.message.includes(HttpStatusCode.CONFLICT_409)) {
119 console.error('This video is already duplicated by your instance.')
120 } else if (err.message.includes(HttpStatusCode.NOT_FOUND_404)) {
121 console.error('This video id does not exist.')
122 } else {
123 console.error(err)
124 }
125
126 process.exit(-1)
127 }
128}
129
130async function removeRedundancyCLI (options: { video: number }, command: Command) {
131 const { url, username, password } = await getServerCredentials(command)
132 const server = buildServer(url)
133 await assignToken(server, username, password)
134
135 if (!options.video || validator.isInt('' + options.video) === false) {
136 console.error('You need to specify the video id to remove from your redundancies.\n')
137 command.outputHelp()
138 process.exit(-1)
139 }
140
141 const videoId = forceNumber(options.video)
142
143 const myVideoRedundancies = await server.redundancy.listVideos({ target: 'my-videos' })
144 let videoRedundancy = myVideoRedundancies.data.find(r => videoId === r.id)
145
146 if (!videoRedundancy) {
147 const remoteVideoRedundancies = await server.redundancy.listVideos({ target: 'remote-videos' })
148 videoRedundancy = remoteVideoRedundancies.data.find(r => videoId === r.id)
149 }
150
151 if (!videoRedundancy) {
152 console.error('Video redundancy not found.')
153 process.exit(-1)
154 }
155
156 try {
157 const ids = videoRedundancy.redundancies.files
158 .concat(videoRedundancy.redundancies.streamingPlaylists)
159 .map(r => r.id)
160
161 for (const id of ids) {
162 await server.redundancy.removeVideo({ redundancyId: id })
163 }
164
165 console.log('Video redundancy removed!')
166
167 process.exit(0)
168 } catch (err) {
169 console.error(err)
170 process.exit(-1)
171 }
172}
diff --git a/server/tools/peertube-upload.ts b/server/tools/peertube-upload.ts
deleted file mode 100644
index 87da55005..000000000
--- a/server/tools/peertube-upload.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import { program } from 'commander'
2import { access, constants } from 'fs-extra'
3import { isAbsolute } from 'path'
4import { assignToken, buildCommonVideoOptions, buildServer, buildVideoAttributesFromCommander, getServerCredentials } from './shared'
5
6let command = program
7 .name('upload')
8
9command = buildCommonVideoOptions(command)
10
11command
12 .option('-u, --url <url>', 'Server url')
13 .option('-U, --username <username>', 'Username')
14 .option('-p, --password <token>', 'Password')
15 .option('-b, --thumbnail <thumbnailPath>', 'Thumbnail path')
16 .option('-v, --preview <previewPath>', 'Preview path')
17 .option('-f, --file <file>', 'Video absolute file path')
18 .parse(process.argv)
19
20const options = command.opts()
21
22getServerCredentials(command)
23 .then(({ url, username, password }) => {
24 if (!options.videoName || !options.file) {
25 if (!options.videoName) console.error('--video-name is required.')
26 if (!options.file) console.error('--file is required.')
27
28 process.exit(-1)
29 }
30
31 if (isAbsolute(options.file) === false) {
32 console.error('File path should be absolute.')
33 process.exit(-1)
34 }
35
36 run(url, username, password).catch(err => {
37 console.error(err)
38 process.exit(-1)
39 })
40 })
41 .catch(err => console.error(err))
42
43async function run (url: string, username: string, password: string) {
44 const server = buildServer(url)
45 await assignToken(server, username, password)
46
47 await access(options.file, constants.F_OK)
48
49 console.log('Uploading %s video...', options.videoName)
50
51 const baseAttributes = await buildVideoAttributesFromCommander(server, program)
52
53 const attributes = {
54 ...baseAttributes,
55
56 fixture: options.file,
57 thumbnailfile: options.thumbnail,
58 previewfile: options.preview
59 }
60
61 try {
62 await server.videos.upload({ attributes })
63 console.log(`Video ${options.videoName} uploaded.`)
64 process.exit(0)
65 } catch (err) {
66 const message = err.message || ''
67 if (message.includes('413')) {
68 console.error('Aborted: user quota is exceeded or video file is too big for this PeerTube instance.')
69 } else {
70 console.error(require('util').inspect(err))
71 }
72
73 process.exit(-1)
74 }
75}
76
77// ----------------------------------------------------------------------------
diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts
deleted file mode 100644
index b79917b4f..000000000
--- a/server/tools/peertube.ts
+++ /dev/null
@@ -1,72 +0,0 @@
1#!/usr/bin/env node
2
3import { CommandOptions, program } from 'commander'
4import { getSettings, version } from './shared'
5
6program
7 .version(version, '-v, --version')
8 .usage('[command] [options]')
9
10/* Subcommands automatically loaded in the directory and beginning by peertube-* */
11program
12 .command('auth [action]', 'register your accounts on remote instances to use them with other commands')
13 .command('upload', 'upload a video').alias('up')
14 .command('import-videos', 'import a video from a streaming platform').alias('import')
15 .command('get-access-token', 'get a peertube access token', { noHelp: true }).alias('token')
16 .command('plugins [action]', 'manage instance plugins/themes').alias('p')
17 .command('redundancy [action]', 'manage instance redundancies').alias('r')
18
19/* Not Yet Implemented */
20program
21 .command(
22 'diagnostic [action]',
23 'like couple therapy, but for your instance',
24 { noHelp: true } as CommandOptions
25 ).alias('d')
26 .command('admin',
27 'manage an instance where you have elevated rights',
28 { noHelp: true } as CommandOptions
29 ).alias('a')
30
31// help on no command
32if (!process.argv.slice(2).length) {
33 const logo = 'â–‘Pâ–‘eâ–‘eâ–‘râ–‘Tâ–‘uâ–‘bâ–‘eâ–‘'
34 console.log(`
35 ___/),.._ ` + logo + `
36/' ,. ."'._
37( "' '-.__"-._ ,-
38\\'='='), "\\ -._-"-. -"/
39 / ""/"\\,_\\,__"" _" /,-
40 / / -" _/"/
41 / | ._\\\\ |\\ |_.".-" /
42 / | __\\)|)|),/|_." _,."
43 / \\_." " ") | ).-""---''--
44 ( "/.""7__-""''
45 | " ."._--._
46 \\ \\ (_ __ "" ".,_
47 \\.,. \\ "" -"".-"
48 ".,_, (",_-,,,-".-
49 "'-,\\_ __,-"
50 ",)" ")
51 /"\\-"
52 ,"\\/
53 _,.__/"\\/_ (the CLI for red chocobos)
54 / \\) "./, ".
55 --/---"---" "-) )---- by Chocobozzz et al.\n`)
56}
57
58getSettings()
59 .then(settings => {
60 const state = (settings.default === undefined || settings.default === -1)
61 ? 'no instance selected, commands will require explicit arguments'
62 : 'instance ' + settings.remotes[settings.default] + ' selected'
63
64 program
65 .addHelpText('after', '\n\n State: ' + state + '\n\n' +
66 ' Examples:\n\n' +
67 ' $ peertube auth add -u "PEERTUBE_URL" -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"\n' +
68 ' $ peertube up <videoFile>\n'
69 )
70 .parse(process.argv)
71 })
72 .catch(err => console.error(err))
diff --git a/server/tools/shared/cli.ts b/server/tools/shared/cli.ts
deleted file mode 100644
index e010ab320..000000000
--- a/server/tools/shared/cli.ts
+++ /dev/null
@@ -1,262 +0,0 @@
1import { Command } from 'commander'
2import { Netrc } from 'netrc-parser'
3import { join } from 'path'
4import { createLogger, format, transports } from 'winston'
5import { getAppNumber, isTestInstance } from '@server/helpers/core-utils'
6import { loadLanguages } from '@server/initializers/constants'
7import { root } from '@shared/core-utils'
8import { UserRole, VideoPrivacy } from '@shared/models'
9import { PeerTubeServer } from '@shared/server-commands'
10
11let configName = 'PeerTube/CLI'
12if (isTestInstance()) configName += `-${getAppNumber()}`
13
14const config = require('application-config')(configName)
15
16const version = require(join(root(), 'package.json')).version
17
18async function getAdminTokenOrDie (server: PeerTubeServer, username: string, password: string) {
19 const token = await server.login.getAccessToken(username, password)
20 const me = await server.users.getMyInfo({ token })
21
22 if (me.role.id !== UserRole.ADMINISTRATOR) {
23 console.error('You must be an administrator.')
24 process.exit(-1)
25 }
26
27 return token
28}
29
30interface Settings {
31 remotes: any[]
32 default: number
33}
34
35async function getSettings (): Promise<Settings> {
36 const defaultSettings = {
37 remotes: [],
38 default: -1
39 }
40
41 const data = await config.read()
42
43 return Object.keys(data).length === 0
44 ? defaultSettings
45 : data
46}
47
48async function getNetrc () {
49 const Netrc = require('netrc-parser').Netrc
50
51 const netrc = isTestInstance()
52 ? new Netrc(join(root(), 'test' + getAppNumber(), 'netrc'))
53 : new Netrc()
54
55 await netrc.load()
56
57 return netrc
58}
59
60function writeSettings (settings: Settings) {
61 return config.write(settings)
62}
63
64function deleteSettings () {
65 return config.trash()
66}
67
68function getRemoteObjectOrDie (
69 program: Command,
70 settings: Settings,
71 netrc: Netrc
72): { url: string, username: string, password: string } {
73 const options = program.opts()
74
75 function exitIfNoOptions (optionNames: string[], errorPrefix: string = '') {
76 let exit = false
77
78 for (const key of optionNames) {
79 if (!options[key]) {
80 if (exit === false && errorPrefix) console.error(errorPrefix)
81
82 console.error(`--${key} field is required`)
83 exit = true
84 }
85 }
86
87 if (exit) process.exit(-1)
88 }
89
90 // If username or password are specified, both are mandatory
91 if (options.username || options.password) {
92 exitIfNoOptions([ 'username', 'password' ])
93 }
94
95 // If no available machines, url, username and password args are mandatory
96 if (Object.keys(netrc.machines).length === 0) {
97 exitIfNoOptions([ 'url', 'username', 'password' ], 'No account found in netrc')
98 }
99
100 if (settings.remotes.length === 0 || settings.default === -1) {
101 exitIfNoOptions([ 'url' ], 'No default instance found')
102 }
103
104 let url: string = options.url
105 let username: string = options.username
106 let password: string = options.password
107
108 if (!url && settings.default !== -1) url = settings.remotes[settings.default]
109
110 const machine = netrc.machines[url]
111 if ((!username || !password) && !machine) {
112 console.error('Cannot find existing configuration for %s.', url)
113 process.exit(-1)
114 }
115
116 if (!username && machine) username = machine.login
117 if (!password && machine) password = machine.password
118
119 return { url, username, password }
120}
121
122function buildCommonVideoOptions (command: Command) {
123 function list (val) {
124 return val.split(',')
125 }
126
127 return command
128 .option('-n, --video-name <name>', 'Video name')
129 .option('-c, --category <category_number>', 'Category number')
130 .option('-l, --licence <licence_number>', 'Licence number')
131 .option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)')
132 .option('-t, --tags <tags>', 'Video tags', list)
133 .option('-N, --nsfw', 'Video is Not Safe For Work')
134 .option('-d, --video-description <description>', 'Video description')
135 .option('-P, --privacy <privacy_number>', 'Privacy')
136 .option('-C, --channel-name <channel_name>', 'Channel name')
137 .option('--no-comments-enabled', 'Disable video comments')
138 .option('-s, --support <support>', 'Video support text')
139 .option('--no-wait-transcoding', 'Do not wait transcoding before publishing the video')
140 .option('--no-download-enabled', 'Disable video download')
141 .option('-v, --verbose <verbose>', 'Verbosity, from 0/\'error\' to 4/\'debug\'', 'info')
142}
143
144async function buildVideoAttributesFromCommander (server: PeerTubeServer, command: Command, defaultAttributes: any = {}) {
145 const options = command.opts()
146
147 const defaultBooleanAttributes = {
148 nsfw: false,
149 commentsEnabled: true,
150 downloadEnabled: true,
151 waitTranscoding: true
152 }
153
154 const booleanAttributes: { [id in keyof typeof defaultBooleanAttributes]: boolean } | {} = {}
155
156 for (const key of Object.keys(defaultBooleanAttributes)) {
157 if (options[key] !== undefined) {
158 booleanAttributes[key] = options[key]
159 } else if (defaultAttributes[key] !== undefined) {
160 booleanAttributes[key] = defaultAttributes[key]
161 } else {
162 booleanAttributes[key] = defaultBooleanAttributes[key]
163 }
164 }
165
166 const videoAttributes = {
167 name: options.videoName || defaultAttributes.name,
168 category: options.category || defaultAttributes.category || undefined,
169 licence: options.licence || defaultAttributes.licence || undefined,
170 language: options.language || defaultAttributes.language || undefined,
171 privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC,
172 support: options.support || defaultAttributes.support || undefined,
173 description: options.videoDescription || defaultAttributes.description || undefined,
174 tags: options.tags || defaultAttributes.tags || undefined
175 }
176
177 Object.assign(videoAttributes, booleanAttributes)
178
179 if (options.channelName) {
180 const videoChannel = await server.channels.get({ channelName: options.channelName })
181
182 Object.assign(videoAttributes, { channelId: videoChannel.id })
183
184 if (!videoAttributes.support && videoChannel.support) {
185 Object.assign(videoAttributes, { support: videoChannel.support })
186 }
187 }
188
189 return videoAttributes
190}
191
192function getServerCredentials (program: Command) {
193 return Promise.all([ getSettings(), getNetrc() ])
194 .then(([ settings, netrc ]) => {
195 return getRemoteObjectOrDie(program, settings, netrc)
196 })
197}
198
199function buildServer (url: string) {
200 loadLanguages()
201 return new PeerTubeServer({ url })
202}
203
204async function assignToken (server: PeerTubeServer, username: string, password: string) {
205 const bodyClient = await server.login.getClient()
206 const client = { id: bodyClient.client_id, secret: bodyClient.client_secret }
207
208 const body = await server.login.login({ client, user: { username, password } })
209
210 server.accessToken = body.access_token
211}
212
213function getLogger (logLevel = 'info') {
214 const logLevels = {
215 0: 0,
216 error: 0,
217 1: 1,
218 warn: 1,
219 2: 2,
220 info: 2,
221 3: 3,
222 verbose: 3,
223 4: 4,
224 debug: 4
225 }
226
227 const logger = createLogger({
228 levels: logLevels,
229 format: format.combine(
230 format.splat(),
231 format.simple()
232 ),
233 transports: [
234 new (transports.Console)({
235 level: logLevel
236 })
237 ]
238 })
239
240 return logger
241}
242
243// ---------------------------------------------------------------------------
244
245export {
246 version,
247 getLogger,
248 getSettings,
249 getNetrc,
250 getRemoteObjectOrDie,
251 writeSettings,
252 deleteSettings,
253
254 getServerCredentials,
255
256 buildCommonVideoOptions,
257 buildVideoAttributesFromCommander,
258
259 getAdminTokenOrDie,
260 buildServer,
261 assignToken
262}
diff --git a/server/tools/shared/index.ts b/server/tools/shared/index.ts
deleted file mode 100644
index 8a3f31e2f..000000000
--- a/server/tools/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './cli'
diff --git a/server/tools/tsconfig.json b/server/tools/tsconfig.json
deleted file mode 100644
index 39f8e74e4..000000000
--- a/server/tools/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
1{
2 "extends": "../../tsconfig.json",
3 "compilerOptions": {
4 "outDir": "../../dist/server/tools"
5 },
6 "include": [ ".", "../typings" ],
7 "references": [
8 { "path": "../" }
9 ],
10 "files": [],
11 "exclude": [ ] // Overwrite exclude property
12}
diff --git a/server/tools/yarn.lock b/server/tools/yarn.lock
deleted file mode 100644
index 025ef208d..000000000
--- a/server/tools/yarn.lock
+++ /dev/null
@@ -1,373 +0,0 @@
1# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2# yarn lockfile v1
3
4
5"@babel/code-frame@^7.0.0":
6 version "7.16.7"
7 resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789"
8 integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==
9 dependencies:
10 "@babel/highlight" "^7.16.7"
11
12"@babel/helper-validator-identifier@^7.16.7":
13 version "7.16.7"
14 resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad"
15 integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==
16
17"@babel/highlight@^7.16.7":
18 version "7.16.10"
19 resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.10.tgz#744f2eb81579d6eea753c227b0f570ad785aba88"
20 integrity sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==
21 dependencies:
22 "@babel/helper-validator-identifier" "^7.16.7"
23 chalk "^2.0.0"
24 js-tokens "^4.0.0"
25
26ansi-regex@^5.0.1:
27 version "5.0.1"
28 resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
29 integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
30
31ansi-styles@^3.2.1:
32 version "3.2.1"
33 resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
34 integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
35 dependencies:
36 color-convert "^1.9.0"
37
38application-config-path@^0.1.0:
39 version "0.1.0"
40 resolved "https://registry.yarnpkg.com/application-config-path/-/application-config-path-0.1.0.tgz#193c5f0a86541a4c66fba1e2dc38583362ea5e8f"
41 integrity sha1-GTxfCoZUGkxm+6Hi3DhYM2LqXo8=
42
43application-config@^2.0.0:
44 version "2.0.0"
45 resolved "https://registry.yarnpkg.com/application-config/-/application-config-2.0.0.tgz#15b4d54d61c0c082f9802227e3e85de876b47747"
46 integrity sha512-NC5/0guSZK3/UgUDfCk/riByXzqz0owL1L3r63JPSBzYk5QALrp3bLxbsR7qeSfvYfFmAhnp3dbqYsW3U9MpZQ==
47 dependencies:
48 application-config-path "^0.1.0"
49 load-json-file "^6.2.0"
50 write-json-file "^4.2.0"
51
52chalk@^2.0.0:
53 version "2.4.2"
54 resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
55 integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
56 dependencies:
57 ansi-styles "^3.2.1"
58 escape-string-regexp "^1.0.5"
59 supports-color "^5.3.0"
60
61cli-table3@^0.6.0:
62 version "0.6.1"
63 resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.1.tgz#36ce9b7af4847f288d3cdd081fbd09bf7bd237b8"
64 integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==
65 dependencies:
66 string-width "^4.2.0"
67 optionalDependencies:
68 colors "1.4.0"
69
70color-convert@^1.9.0:
71 version "1.9.3"
72 resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
73 integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
74 dependencies:
75 color-name "1.1.3"
76
77color-name@1.1.3:
78 version "1.1.3"
79 resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
80 integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
81
82colors@1.4.0:
83 version "1.4.0"
84 resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
85 integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
86
87cross-spawn@^6.0.0:
88 version "6.0.5"
89 resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
90 integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
91 dependencies:
92 nice-try "^1.0.4"
93 path-key "^2.0.1"
94 semver "^5.5.0"
95 shebang-command "^1.2.0"
96 which "^1.2.9"
97
98debug@^3.1.0:
99 version "3.2.7"
100 resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
101 integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
102 dependencies:
103 ms "^2.1.1"
104
105detect-indent@^6.0.0:
106 version "6.1.0"
107 resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
108 integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
109
110emoji-regex@^8.0.0:
111 version "8.0.0"
112 resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
113 integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
114
115error-ex@^1.3.1:
116 version "1.3.2"
117 resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
118 integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
119 dependencies:
120 is-arrayish "^0.2.1"
121
122escape-string-regexp@^1.0.5:
123 version "1.0.5"
124 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
125 integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
126
127execa@^0.10.0:
128 version "0.10.0"
129 resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50"
130 integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==
131 dependencies:
132 cross-spawn "^6.0.0"
133 get-stream "^3.0.0"
134 is-stream "^1.1.0"
135 npm-run-path "^2.0.0"
136 p-finally "^1.0.0"
137 signal-exit "^3.0.0"
138 strip-eof "^1.0.0"
139
140get-stream@^3.0.0:
141 version "3.0.0"
142 resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
143 integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
144
145graceful-fs@^4.1.15:
146 version "4.2.9"
147 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96"
148 integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==
149
150has-flag@^3.0.0:
151 version "3.0.0"
152 resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
153 integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
154
155imurmurhash@^0.1.4:
156 version "0.1.4"
157 resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
158 integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
159
160is-arrayish@^0.2.1:
161 version "0.2.1"
162 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
163 integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
164
165is-fullwidth-code-point@^3.0.0:
166 version "3.0.0"
167 resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
168 integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
169
170is-plain-obj@^2.0.0:
171 version "2.1.0"
172 resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
173 integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
174
175is-stream@^1.1.0:
176 version "1.1.0"
177 resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
178 integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
179
180is-typedarray@^1.0.0:
181 version "1.0.0"
182 resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
183 integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
184
185isexe@^2.0.0:
186 version "2.0.0"
187 resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
188 integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
189
190js-tokens@^4.0.0:
191 version "4.0.0"
192 resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
193 integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
194
195json-parse-even-better-errors@^2.3.0:
196 version "2.3.1"
197 resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
198 integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
199
200lines-and-columns@^1.1.6:
201 version "1.2.4"
202 resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
203 integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
204
205load-json-file@^6.2.0:
206 version "6.2.0"
207 resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-6.2.0.tgz#5c7770b42cafa97074ca2848707c61662f4251a1"
208 integrity sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==
209 dependencies:
210 graceful-fs "^4.1.15"
211 parse-json "^5.0.0"
212 strip-bom "^4.0.0"
213 type-fest "^0.6.0"
214
215make-dir@^3.0.0:
216 version "3.1.0"
217 resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
218 integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
219 dependencies:
220 semver "^6.0.0"
221
222ms@^2.1.1:
223 version "2.1.3"
224 resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
225 integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
226
227netrc-parser@^3.1.6:
228 version "3.1.6"
229 resolved "https://registry.yarnpkg.com/netrc-parser/-/netrc-parser-3.1.6.tgz#7243c9ec850b8e805b9bdc7eae7b1450d4a96e72"
230 integrity sha512-lY+fmkqSwntAAjfP63jB4z5p5WbuZwyMCD3pInT7dpHU/Gc6Vv90SAC6A0aNiqaRGHiuZFBtiwu+pu8W/Eyotw==
231 dependencies:
232 debug "^3.1.0"
233 execa "^0.10.0"
234
235nice-try@^1.0.4:
236 version "1.0.5"
237 resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
238 integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
239
240npm-run-path@^2.0.0:
241 version "2.0.2"
242 resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
243 integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
244 dependencies:
245 path-key "^2.0.0"
246
247p-finally@^1.0.0:
248 version "1.0.0"
249 resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
250 integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
251
252parse-json@^5.0.0:
253 version "5.2.0"
254 resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
255 integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
256 dependencies:
257 "@babel/code-frame" "^7.0.0"
258 error-ex "^1.3.1"
259 json-parse-even-better-errors "^2.3.0"
260 lines-and-columns "^1.1.6"
261
262path-key@^2.0.0, path-key@^2.0.1:
263 version "2.0.1"
264 resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
265 integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
266
267semver@^5.5.0:
268 version "5.7.1"
269 resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
270 integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
271
272semver@^6.0.0:
273 version "6.3.0"
274 resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
275 integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
276
277shebang-command@^1.2.0:
278 version "1.2.0"
279 resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
280 integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
281 dependencies:
282 shebang-regex "^1.0.0"
283
284shebang-regex@^1.0.0:
285 version "1.0.0"
286 resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
287 integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
288
289signal-exit@^3.0.0, signal-exit@^3.0.2:
290 version "3.0.7"
291 resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
292 integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
293
294sort-keys@^4.0.0:
295 version "4.2.0"
296 resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-4.2.0.tgz#6b7638cee42c506fff8c1cecde7376d21315be18"
297 integrity sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==
298 dependencies:
299 is-plain-obj "^2.0.0"
300
301string-width@^4.2.0:
302 version "4.2.3"
303 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
304 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
305 dependencies:
306 emoji-regex "^8.0.0"
307 is-fullwidth-code-point "^3.0.0"
308 strip-ansi "^6.0.1"
309
310strip-ansi@^6.0.1:
311 version "6.0.1"
312 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
313 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
314 dependencies:
315 ansi-regex "^5.0.1"
316
317strip-bom@^4.0.0:
318 version "4.0.0"
319 resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878"
320 integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==
321
322strip-eof@^1.0.0:
323 version "1.0.0"
324 resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
325 integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
326
327supports-color@^5.3.0:
328 version "5.5.0"
329 resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
330 integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
331 dependencies:
332 has-flag "^3.0.0"
333
334type-fest@^0.6.0:
335 version "0.6.0"
336 resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
337 integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
338
339typedarray-to-buffer@^3.1.5:
340 version "3.1.5"
341 resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
342 integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
343 dependencies:
344 is-typedarray "^1.0.0"
345
346which@^1.2.9:
347 version "1.3.1"
348 resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
349 integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
350 dependencies:
351 isexe "^2.0.0"
352
353write-file-atomic@^3.0.0:
354 version "3.0.3"
355 resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
356 integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
357 dependencies:
358 imurmurhash "^0.1.4"
359 is-typedarray "^1.0.0"
360 signal-exit "^3.0.2"
361 typedarray-to-buffer "^3.1.5"
362
363write-json-file@^4.2.0:
364 version "4.3.0"
365 resolved "https://registry.yarnpkg.com/write-json-file/-/write-json-file-4.3.0.tgz#908493d6fd23225344af324016e4ca8f702dd12d"
366 integrity sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==
367 dependencies:
368 detect-indent "^6.0.0"
369 graceful-fs "^4.1.15"
370 is-plain-obj "^2.0.0"
371 make-dir "^3.0.0"
372 sort-keys "^4.0.0"
373 write-file-atomic "^3.0.0"