UNPKG

14.1 kBJavaScriptView Raw
1const path = require('path')
2const fsEx = require('fs-extra')
3const inquirer = require('inquirer')
4const request = require('request')
5const unzip = require('unzip')
6const replace = require('replace-in-file')
7const childProcess = require('child_process')
8
9const logger = require('../logger')
10const UserSettings = require('../user/UserSettings')
11const AppSettings = require('../app/AppSettings')
12const utils = require('../utils/utils')
13
14const DEFAULT_NAME = 'cloud-sdk-boilerplate-extension-master'
15const BOILERPLATE_URL = process.env['BOILERPLATE_URL'] || 'https://github.com/shopgate/cloud-sdk-boilerplate-extension/archive/master.zip'
16const EXTENSION_NAME_REGEX = /^@[A-Za-z][A-Za-z0-9-_]{0,213}\/[A-Za-z][A-Za-z0-9-_]{0,213}$/
17
18const { EXTENSIONS_FOLDER } = require('../app/Constants')
19
20class ExtensionAction {
21 /**
22 * @param {Command} caporal
23 */
24 static register (caporal) {
25 caporal
26 .command('extension create')
27 .description('Creates a new extension')
28 .argument('[types...]', 'Types of the extension. Possible types are: frontend, backend')
29 .option('--extension [extensionName]', 'Name of the new extension (e.g: @myAwesomeOrg/awesomeExtension)')
30 .option('--trusted [type]', 'only valid if you\'re about to create a backend extension')
31 .action((args, options) => new ExtensionAction().createExtension(args, options))
32
33 caporal
34 .command('extension attach')
35 .argument('[extensions...]', 'Folder name of the extensions to attach')
36 .description('Attaches one or more extensions')
37 .action((args, options) => new ExtensionAction().attachExtensions(args, options))
38
39 caporal
40 .command('extension detach')
41 .argument('[extensions...]', 'Folder name of the extensions to detach')
42 .description('Detaches one or more extensions')
43 .action((args, options) => new ExtensionAction().detachExtensions(args, options))
44 }
45
46 /**
47 * @param {object} options
48 * @param {string[]} types
49 * @param {object} externalUserInput
50 */
51 _getUserInput (options, types = [], externalUserInput) {
52 const userInput = {
53 extensionName: options.extension,
54 trusted: options.trusted ? options.trusted === 'true' : null,
55 toBeCreated: {
56 frontend: types.includes('frontend'),
57 backend: types.includes('backend')
58 }
59 }
60
61 const questions = []
62
63 if (!userInput.toBeCreated.frontend && !userInput.toBeCreated.backend) {
64 questions.push({
65 name: 'types',
66 message: 'What type of extension are you about to create?',
67 type: 'checkbox',
68 validate: (input) => {
69 if (input.length === 0) return 'Please select at least one type'
70 return true
71 },
72 default: ['backend', 'frontend'],
73 choices: ['backend', 'frontend']
74 })
75 }
76
77 if (!userInput.extensionName || !EXTENSION_NAME_REGEX.test(userInput.extensionName)) {
78 questions.push({
79 name: 'extensionName',
80 message: 'Extension name (e.g: @myAwesomeOrg/awesomeExtension):',
81 validate: (input) => {
82 const valid = /^@[A-Za-z][A-Za-z0-9-_]{0,213}\/[A-Za-z][A-Za-z0-9-_]{0,213}$/.test(input)
83 if (!valid) return 'Please provide an extension name in the following format: @<organizationName>/<name>'
84 return true
85 },
86 when: (answers) => {
87 // Consider it unnecessary if there isn't a type neigher in answers.types nor types
88 return (answers.types && answers.types.length) > 0 || types.length > 0
89 }
90 })
91 }
92
93 if (userInput.trusted === null) {
94 questions.push({
95 name: 'trusted',
96 message: 'Does your backend extension need to run in a trusted environment?',
97 type: 'list',
98 choices: ['yes', 'no'],
99 default: 'no',
100 when: (answers) => {
101 // Consider it unnecessary if there isn't a type neigher in answers.types nor types
102 return (answers.types && answers.types.length) > 0 || types.length > 0
103 }
104 })
105 }
106
107 return new Promise((resolve, reject) => {
108 if (questions.length > 0) {
109 return inquirer.prompt(questions).then((answers) => {
110 if (answers.extensionName) userInput.extensionName = answers.extensionName
111 if (answers.types && answers.types.includes('frontend')) userInput.toBeCreated.frontend = true
112 if (answers.types && answers.types.includes('backend')) userInput.toBeCreated.backend = true
113 if (answers.trusted) userInput.trusted = answers.trusted === 'yes'
114 if (userInput.toBeCreated.backend === false && userInput.toBeCreated.frontend === false) throw new Error('No extension type selected')
115 Object.assign(externalUserInput, userInput)
116 resolve()
117 })
118 .catch(err => reject(err))
119 }
120
121 Object.assign(externalUserInput, userInput)
122 resolve()
123 })
124 .then(() => {
125 externalUserInput.organizationName = userInput.extensionName.split('/')[0].replace('@', '')
126 })
127 }
128
129 /**
130 * @param {string} extensionsFolder
131 * @param {object} state
132 */
133
134 _downloadBoilerplate (extensionsFolder, state) {
135 logger.debug('Downloading boilerplate ...')
136 return new Promise((resolve, reject) => {
137 const extractor = unzip.Extract({ path: extensionsFolder })
138 extractor.on('close', () => {
139 logger.debug('Downloading boilerplate ... done')
140 state.cloned = true
141 resolve()
142 })
143 extractor.on('error', (err) => {
144 if (process.env['LOG_LEVEL'] === 'debug') logger.error(err)
145 reject(new Error(`Error while downloading boilerplate: ${err.message}`))
146 })
147 request(BOILERPLATE_URL).pipe(extractor)
148 })
149 }
150
151 /**
152 * @param {object} userInput
153 * @param {string} defaultPath
154 * @param {string} extensionsFolder
155 * @param {object} state
156 */
157 _renameBoilerplate (userInput, defaultPath, extensionsFolder, state) {
158 logger.debug('Renamig boilerplate ...')
159 const ePath = path.join(extensionsFolder, userInput.extensionName.replace('/', '-'))
160 return new Promise((resolve, reject) => {
161 fsEx.rename(defaultPath, ePath, (err) => {
162 if (err) {
163 if (process.env['LOG_LEVEL'] === 'debug') logger.error(err)
164 return reject(new Error(`Error while renaming boilerplate ${err.message}`))
165 }
166 state.extensionPath = ePath
167 state.moved = true
168 logger.debug('Renamig boilerplate ... done')
169 resolve()
170 })
171 })
172 }
173
174 /**
175 * @param {object} userInput
176 * @param {object} state
177 */
178 _removeUnusedDirs (userInput, state) {
179 const promises = []
180
181 if (!userInput.toBeCreated.frontend) promises.push(fsEx.remove(path.join(state.extensionPath, 'frontend')))
182 if (!userInput.toBeCreated.backend) {
183 promises.push(fsEx.remove(path.join(state.extensionPath, 'extension')))
184 promises.push(fsEx.remove(path.join(state.extensionPath, 'pipelines')))
185 }
186
187 if (promises.length > 0) logger.debug('Removing unused dirs ...')
188
189 return Promise.all(promises).then(logger.debug('Removing unused dirs ... done'))
190 }
191
192 /**
193 * @param {object} userInput
194 * @param {object} state
195 */
196 _removePlaceholders (userInput, state) {
197 logger.debug(`Removing placeholders in ${state.extensionPath} ...`)
198 return replace({
199 files: [
200 `${state.extensionPath}/**/*.json`,
201 `${state.extensionPath}/*.json`
202 ],
203 from: [
204 /@awesomeOrganization\/awesomeExtension/g,
205 /awesomeOrganization/g
206 ],
207 to: [
208 userInput.extensionName,
209 userInput.organizationName
210 ]})
211 .then((changes) => logger.debug(`Removing placeholders in ${changes} ... done`))
212 .catch(err => {
213 // Because replace fails if no files match the pattern ...
214 if (err.message.startsWith('No files match the pattern')) return logger.debug(err.message)
215 })
216 }
217
218 /**
219 * @param {object} userInput
220 * @param {object} state
221 */
222 _updateBackendFiles (userInput, state) {
223 if (!userInput.toBeCreated.backend) return
224
225 logger.debug('Updating backend files')
226
227 const promises = []
228 // Rename default pipeline
229 const pipelineDirPath = path.join(state.extensionPath, 'pipelines')
230 promises.push(fsEx.move(path.join(pipelineDirPath, 'awesomeOrganization.awesomePipeline.json'), path.join(pipelineDirPath, `${userInput.organizationName}.awesomePipeline.json`)))
231
232 // Add trusted if necessary
233 if (userInput.trusted) {
234 const exConfPath = path.join(state.extensionPath, 'extension-config.json')
235
236 const p = fsEx.readJSON(exConfPath)
237 .then((exConf) => {
238 exConf.trusted = userInput.trusted
239 return fsEx.writeJson(exConfPath, exConf, { spaces: 2 })
240 })
241
242 promises.push(p)
243 }
244
245 return Promise.all(promises).then(logger.debug('Updating backend files ... done'))
246 }
247
248 /**
249 * @param {object} userInput
250 * @param {object} state
251 * @param {object=} command
252 */
253 _installFrontendDependencies (userInput, state, command) {
254 if (!userInput.toBeCreated.frontend) return
255 command = command || {command: /^win/.test(process.platform) ? 'npm.cmd' : 'npm', params: ['i']}
256
257 const frontendPath = path.join(state.extensionPath, 'frontend')
258
259 return new Promise((resolve, reject) => {
260 logger.info('Installing frontend depenencies ...')
261 const installProcess = childProcess.spawn(command.command, command.params, { env: process.env, cwd: frontendPath, stdio: 'inherit' })
262 installProcess.on('exit', (code, signal) => {
263 if (code === 0) return resolve()
264 reject(new Error(`Install process exited with code ${code} and signal ${signal}`))
265 })
266 })
267 }
268
269 _cleanUp (state, defaultPath) {
270 if (state.cloned) fsEx.removeSync(state.moved ? state.extensionPath : defaultPath)
271 }
272
273 /**
274 * @param {string[]} types
275 * @param {object} options
276 */
277 createExtension ({types}, options) {
278 new UserSettings().validate()
279 this.settings = new AppSettings().validate()
280
281 const userInput = {}
282 let state = {
283 cloned: false,
284 moved: false,
285 extensionPath: null
286 }
287
288 const extensionsFolder = path.join(this.settings.getApplicationFolder(), EXTENSIONS_FOLDER)
289 const defaultPath = path.join(extensionsFolder, DEFAULT_NAME)
290
291 return this._getUserInput(options, types, userInput)
292 .then(() => this._checkIfExtensionExists(userInput.extensionName))
293 .then(() => this._downloadBoilerplate(extensionsFolder, state))
294 .then(() => this._renameBoilerplate(userInput, defaultPath, extensionsFolder, state))
295 .then(() => this._removeUnusedDirs(userInput, state))
296 .then(() => this._removePlaceholders(userInput, state))
297 .then(() => this._updateBackendFiles(userInput, state))
298 .then(() => this._installFrontendDependencies(userInput, state))
299 .then(() => logger.info(`Extension "${userInput.extensionName}" created successfully`))
300 .catch((err) => {
301 logger.error(`An error occured while creating the extension: ${err}`)
302 this._cleanUp(state, defaultPath)
303 })
304 }
305
306 /**
307 * @param {String[]} extensions
308 */
309 attachExtensions ({extensions = []}) {
310 new UserSettings().validate()
311 this.settings = new AppSettings().validate()
312
313 let force = false
314 if (!extensions.length) {
315 extensions = this._getAllExtensions()
316 force = true
317 }
318 extensions.forEach((extensionFolderName) => {
319 const extensionInfo = this._getExtensionInfo(extensionFolderName, true)
320 if (!extensionInfo) {
321 return logger.warn(`There is no extension in folder: './extensions/${extensionFolderName}'. Skipping... Please make sure that you only pass the folder name of the extension as argument.`)
322 }
323 this.settings.attachExtension(extensionFolderName, {id: extensionInfo.id, trusted: extensionInfo.trusted}, force)
324
325 utils.generateComponentsJson(this.settings)
326 })
327 }
328
329 /**
330 * @param {String[]} extensions
331 */
332 detachExtensions ({extensions = []}) {
333 new UserSettings().validate()
334 this.settings = new AppSettings().validate()
335
336 if (!extensions.length) {
337 this.settings.detachAllExtensions()
338 } else {
339 extensions.forEach((extensionFolderName) => {
340 const extensionInfo = this._getExtensionInfo(extensionFolderName, true)
341 if (!extensionInfo) {
342 return logger.warn(`There is no extension in folder: './extensions/${extensionFolderName}'. Skipping... Please make sure that you only pass the folder name of the extension as argument.`)
343 }
344
345 this.settings.detachExtension(extensionInfo.id)
346 })
347 }
348 }
349
350 _checkIfExtensionExists (extensionName) {
351 const extensionsFolder = path.join(this.settings.getApplicationFolder(), EXTENSIONS_FOLDER, extensionName.replace('/', '-'))
352 return fsEx.pathExists(extensionsFolder)
353 .then((exists) => {
354 if (exists) throw new Error(`The extension '${extensionName}' already exists`)
355 })
356 }
357
358 /**
359 * @return {String[]} extensionId[]
360 */
361 _getAllExtensions () {
362 const extensionsFolder = path.join(this.settings.getApplicationFolder(), EXTENSIONS_FOLDER)
363 const files = fsEx.readdirSync(extensionsFolder)
364 const exts = []
365 files.forEach(file => {
366 if (this._getExtensionInfo(file, true)) exts.push(file)
367 })
368 return exts
369 }
370
371 /**
372 * @param {String} extensionName
373 * @param {Boolean} ignoreNotExisting
374 * @return {String|void} extensionId
375 */
376 _getExtensionInfo (extensionName, ignoreNotExisting) {
377 const extensionsFolder = path.join(this.settings.getApplicationFolder(), EXTENSIONS_FOLDER)
378 const file = path.join(extensionsFolder, extensionName, 'extension-config.json')
379 if (!fsEx.existsSync(file)) {
380 if (!ignoreNotExisting) logger.error(`"${file}" does not exist`)
381 return
382 }
383
384 try {
385 const json = fsEx.readJSONSync(file)
386 if (!json.id) return logger.warn(`"${file}" has no "id"-property`)
387 json.trusted = json.trusted || false
388 return json
389 } catch (e) {
390 logger.warn(`"${file}" is invalid json`)
391 }
392 }
393}
394
395module.exports = ExtensionAction