diff --git a/.gitignore b/.gitignore index e0c121215f..b23f1e0c19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ secrets.env .DS_Store cjs/ diff --git a/frontend/packages/cli/package.json b/frontend/packages/cli/package.json index af2f191343..93a224d109 100644 --- a/frontend/packages/cli/package.json +++ b/frontend/packages/cli/package.json @@ -21,8 +21,18 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "commander": "^4.1.1" + "@types/inquirer": "^6.5.0", + "commander": "^4.1.1", + "dashify": "^2.0.0", + "handlebars": "^4.7.3", + "inquirer": "^7.0.4", + "replace-in-file": "^5.0.2" }, + "files": [ + "templates", + "bin", + "cjs" + ], "nodemonConfig": { "watch": "./src", "exec": "ts-node", diff --git a/frontend/packages/cli/src/commands/createPlugin.test.ts b/frontend/packages/cli/src/commands/createPlugin.test.ts new file mode 100644 index 0000000000..6836e3270e --- /dev/null +++ b/frontend/packages/cli/src/commands/createPlugin.test.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; +import { createPluginFolder, createFileFromTemplate } from './createPlugin'; + +describe('createPlugin', () => { + describe('createPluginFolder', () => { + it('should create a plugin directory in the correct place', () => { + const tempDir = fs.mkdtempSync('createPluginTest'); + try { + const pluginFolder = createPluginFolder(tempDir, 'foo'); + expect(fs.existsSync(pluginFolder)).toBe(true); + } finally { + fs.rmdirSync(tempDir, { recursive: true }); + } + }); + + it('should not create a plugin directory if it already exists', () => { + const tempDir = fs.mkdtempSync('createPluginTest'); + try { + const pluginFolder = createPluginFolder(tempDir, 'foo'); + expect(fs.existsSync(pluginFolder)).toBe(true); + expect(() => createPluginFolder(tempDir, 'foo')).toThrowError( + /A plugin with the same name already exists/, + ); + } finally { + fs.rmdirSync(tempDir, { recursive: true }); + } + }); + }); + + describe('createFileFromTemplate', () => { + it('should generate a valid output with inserted values', () => { + const tempDir = fs.mkdtempSync('createFileFromTemplate'); + try { + const sourceData = '{"name": "@spotify-backstage/{{id}}"}'; + const targetData = '{"name": "@spotify-backstage/foo"}'; + const sourcePath = path.join(tempDir, 'in.hbs'); + const targetPath = path.join(tempDir, 'out.json'); + fs.writeFileSync(sourcePath, sourceData); + + createFileFromTemplate(sourcePath, targetPath, { id: 'foo' }); + + expect(fs.existsSync(targetPath)).toBe(true); + expect(fs.readFileSync(targetPath).toString()).toBe(targetData); + } finally { + fs.rmdirSync(tempDir, { recursive: true }); + } + }); + }); +}); diff --git a/frontend/packages/cli/src/commands/createPlugin.ts b/frontend/packages/cli/src/commands/createPlugin.ts index b924655895..03e4366b0b 100644 --- a/frontend/packages/cli/src/commands/createPlugin.ts +++ b/frontend/packages/cli/src/commands/createPlugin.ts @@ -1,187 +1,83 @@ -const path = require('path'); -const fs = require('fs'); -const fsExtra = require('fs-extra'); -const replace = require('replace-in-file'); -const dashify = require('dashify'); -const inquirer = require('inquirer'); -const handlebars = require('handlebars'); +import fs from 'fs'; +import path from 'path'; +import handlebars from 'handlebars'; +import inquirer from 'inquirer'; -const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1); -const lowercaseFirstLetter = str => str.charAt(0).toLowerCase() + str.slice(1); +export const createPluginFolder = (rootDir: string, id: string): string => { + const destination = path.join(rootDir, 'packages', 'plugins', id); -const appendTextFile = (file, textToAppend) => { - const originalContents = fs.readFileSync(file, 'utf8'); - - // Make sure that there is a newline before and after the new text - if (originalContents && !originalContents.endsWith('\n')) { - textToAppend = `\n${textToAppend}`; - } - if (textToAppend && !textToAppend.endsWith('\n')) { - textToAppend = `${textToAppend}\n`; + if (fs.existsSync(destination)) { + throw new Error( + `A plugin with the same name already exists: ${destination}`, + ); } - fs.appendFileSync(file, textToAppend); + try { + fs.mkdirSync(destination, { recursive: true }); + return destination; + } catch (e) { + throw new Error( + `Failed to create plugin directory: ${destination}: ${e.message}`, + ); + } }; -const questions = [ - { - type: 'input', - name: 'id', - message: - "Enter an ID for the plugin (Please capitalize) e.g. 'MyAwesomePlugin' [required]", - validate: value => (value ? true : 'Please enter an ID for the plugin'), - }, - { - type: 'confirm', - name: 'ts', - message: 'Use Typescript?', - }, - { - type: 'input', - name: 'owner', - message: 'Which squad will own this plugin? [required]', - validate: value => - value ? true : 'Please enter a squad that will own the plugin', - }, - { - type: 'input', - name: 'title', - message: "Enter a title for the plugin e.g. 'My Awesome Plugin' [optional]", - default: '', - }, - { - type: 'input', - name: 'desc', - message: - "Enter a description for the plugin e.g. 'This Plugin does all awesome things!' [optional]", - default: '', - }, - { - type: 'input', - name: 'author', - message: "Author's slack handle e.g. alund [optional]", - default: '', - }, - { - type: 'input', - name: 'support_channel', - message: - 'Slack channel to contact for support on this plugin e.g. data-support [optional]', - default: '', - }, -]; - -const createPluginCommand = () => { - inquirer.prompt(questions).then(answers => { - const pluginId = capitalize(answers.id); - - const pluginFolderName = lowercaseFirstLetter( - pluginId.replace(/Plugin/g, ''), - ); - answers.folder = pluginFolderName; - - const pluginRoute = `/${dashify(pluginFolderName)}`; - answers.route = pluginRoute; - - const pluginRoot = path.join(__dirname, '../src/plugins/'); - const source = path.join(pluginRoot, 'scaffold'); - const destination = path.join(pluginRoot, pluginFolderName); - - const pluginImportText = `import { ${pluginId} } from 'plugins/${pluginFolderName}';\n// @@import`; - const pluginDefinitionText = `${pluginId},\n // @@definition`; - const pluginManagerBootstrap = path.join( - pluginRoot, - 'pluginManagerBootstrap.js', - ); - - if (fs.existsSync(destination)) { - return console.error( - `Uh Oh! Looks like another plugin exists with the same name.\nPlease check ${destination} directory.`, - ); - } - - try { - fs.mkdirSync(destination); - - const compileFileAndWrite = (sourceFile, destFile) => { - const hbFile = fs.readFileSync(path.join(source, sourceFile)); - const template = handlebars.compile(hbFile.toString()); - const contents = template({ name: destFile, ...answers }); - fs.writeFile(path.join(destination, destFile), contents, err => { - if (err) { - return console.log( - 'Error writing file ', - path.join(destination, destFile), - ); - } - console.log('Wrote ', path.join(destination, destFile)); - }); - }; - - ['Page', 'Plugin'].forEach(fileType => { - compileFileAndWrite( - `Scaffold${fileType}.hbs`, - `${pluginId}${fileType}${answers.ts ? '.tsx' : '.js'}`, - ); - }); - - compileFileAndWrite('ScaffoldPage.test.hbs', `${pluginId}Page.test.js`); - compileFileAndWrite('plugin-info.hbs', 'plugin-info.yaml'); - compileFileAndWrite('index.hbs', 'index.js'); - appendTextFile( - '.github/CODEOWNERS', - `/src/plugins/${pluginFolderName}/ @backstage/${answers.owner}`, - ); - - // import and add plugin to bootstrap - const options = { - files: pluginManagerBootstrap, - from: [/\/\/ @@import/g, /\/\/ @@definition/], - to: [pluginImportText, pluginDefinitionText], - }; - - replace(options, err => { - if (err) { - return console.error(err); - } - console.log(''); - console.log( - 'Added plugin to bootstrap, and the plugin directory to CODEOWNERS.', - ); - console.log( - `You are all set! Check http://localhost:5678${pluginRoute}`, - ); - console.log(''); - console.log( - 'NOTE: First thing to do now should be to create a Pull Request and wait for the', - ); - console.log( - 'current goalie of the Tools squad to review and merge it. After that is done,', - ); - console.log( - 'through the use of the CODEOWNERS feature, you should be able to continue', - ); - console.log('development inside your plugin directory on your own.'); - console.log(''); - console.log('Hack away!'); - }); - } catch (e) { - console.error(e); - fsExtra.removeSync(destination); - const options = { - files: pluginManagerBootstrap, - from: [pluginImportText, pluginImportText], - to: [/\/\/ @@import/g, /\/\/ @@definition/], - }; - - replace(options, err => { - if (err) { - return console.error(err); - } - console.log('Cleaned up'); - }); - } +export const createFileFromTemplate = ( + sourcePath: string, + destinationPath: string, + answers: { [key: string]: string }, +) => { + const template = fs.readFileSync(sourcePath); + const compiled = handlebars.compile(template.toString()); + const contents = compiled({ + name: path.basename(destinationPath), + ...answers, }); + try { + fs.writeFileSync(destinationPath, contents); + } catch (e) { + throw new Error(`Failed to create file: ${destinationPath}: ${e.message}`); + } }; -export default createPluginCommand; +const createPlugin = async (): Promise => { + const currentDir = process.argv[1]; + const questions = [ + { + type: 'input', + name: 'id', + message: 'Enter an ID for the plugin [required]', + validate: (value: any) => + value ? true : 'Please enter an ID for the plugin', + }, + ]; + + const answers: { [key: string]: string } = await inquirer.prompt(questions); + const destinationFolder = createPluginFolder( + path.join(currentDir, '..', '..', '..'), + answers.id, + ); + + const templateFolder = path.join( + currentDir, + '..', + '..', + '@spotify-backstage', + 'cli', + 'templates', + 'default-plugin', + ); + const files = [{ input: 'package.json.hbs', output: 'package.json' }]; + + files.forEach(file => { + createFileFromTemplate( + path.join(templateFolder, file.input), + path.join(destinationFolder, file.output), + answers, + ); + }); + + return destinationFolder; +}; + +export default createPlugin; diff --git a/frontend/packages/cli/src/index.ts b/frontend/packages/cli/src/index.ts index 2ea6cdc04a..ba9462dc3d 100644 --- a/frontend/packages/cli/src/index.ts +++ b/frontend/packages/cli/src/index.ts @@ -19,4 +19,4 @@ const main = (argv: string[]) => { }; // main(process.argv); -main([process.argv[0], process.argv[1], 'create-plugin', '--bla']); +main([process.argv[0], process.argv[1], 'create-plugin']); diff --git a/frontend/packages/cli/templates/default-plugin/package.json.hbs b/frontend/packages/cli/templates/default-plugin/package.json.hbs new file mode 100644 index 0000000000..d091fbc1ec --- /dev/null +++ b/frontend/packages/cli/templates/default-plugin/package.json.hbs @@ -0,0 +1,28 @@ +{ + "name": "@spotify-backstage/{{id}}", + "version": "{{version}}", + "main": "src/index.ts", + "main:src": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "scripts": { + "build": "web-scripts build", + "lint": "web-scripts lint", + "test": "web-scripts test", + "start": "nodemon ." + }, + "devDependencies": { + "@spotify/web-scripts": "^6.0.0", + "@types/node": "^13.7.2", + "nodemon": "^2.0.2", + "ts-node": "^8.6.2" + }, + "bin": { + "backstage-{{id}}": "bin/backstage-{{id}}" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "ts-node", + "ext": "ts" + } +} diff --git a/frontend/packages/cli/tsconfig.json b/frontend/packages/cli/tsconfig.json index d0711b5dff..84b9319b94 100644 --- a/frontend/packages/cli/tsconfig.json +++ b/frontend/packages/cli/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@spotify/web-scripts/config/tsconfig.json", "include": ["src"], "compilerOptions": { + "baseUrl": "src", "paths": { "*": ["src/*"] } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 37ed639f2d..9d0342195a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2860,6 +2860,14 @@ resolved "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" integrity sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw== +"@types/inquirer@^6.5.0": + version "6.5.0" + resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be" + integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw== + dependencies: + "@types/through" "*" + rxjs "^6.4.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -3009,6 +3017,13 @@ "@types/react-dom" "*" "@types/testing-library__dom" "*" +"@types/through@*": + version "0.0.30" + resolved "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" + integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "15.0.0" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" @@ -5646,6 +5661,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dashify@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/dashify/-/dashify-2.0.0.tgz#fff270ca2868ca427fee571de35691d6e437a648" + integrity sha512-hpA5C/YrPjucXypHPPc0oJ1l9Hf6wWbiOL7Ik42cxnsUOhWiCB/fylKbKqqJalW9FgkNQCw16YO8uW9Hs0Iy1A== + data-urls@^1.0.0, data-urls@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" @@ -7676,7 +7696,7 @@ handle-thing@^2.0.0: resolved "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== -handlebars@^4.4.0: +handlebars@^4.4.0, handlebars@^4.7.3: version "4.7.3" resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz#8ece2797826886cf8082d1726ff21d2a022550ee" integrity sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg== @@ -8288,7 +8308,7 @@ inquirer@6.5.0: strip-ansi "^5.1.0" through "^2.3.6" -inquirer@7.0.4, inquirer@^7.0.0: +inquirer@7.0.4, inquirer@^7.0.0, inquirer@^7.0.4: version "7.0.4" resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" integrity sha512-Bu5Td5+j11sCkqfqmUTiwv+tWisMtP0L7Q8WrqA2C/BbBhy1YTdFrvjjlrKq8oagA/tLQBski2Gcx/Sqyi2qSQ== @@ -14054,6 +14074,15 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" +replace-in-file@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/replace-in-file/-/replace-in-file-5.0.2.tgz#bd26203b66dfb5b8112ae36a2d2cf928ea4cfe12" + integrity sha512-1Vc7Sbr/rTuHgU1PZuBb7tGsFx3D4NKdhV4BpEF2MuN/6+SoXcFtx+dZ1Zz+5Dq4k5x9js87Y+gXQYPTQ9ppkA== + dependencies: + chalk "^3.0.0" + glob "^7.1.6" + yargs "^15.0.2" + request-promise-core@1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" @@ -16978,7 +17007,7 @@ yargs@^14.2.2: y18n "^4.0.0" yargs-parser "^15.0.0" -yargs@^15.0.0, yargs@^15.0.1: +yargs@^15.0.0, yargs@^15.0.1, yargs@^15.0.2: version "15.1.0" resolved "https://registry.npmjs.org/yargs/-/yargs-15.1.0.tgz#e111381f5830e863a89550bd4b136bb6a5f37219" integrity sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg==