diff --git a/packages/cli/package.json b/packages/cli/package.json index bc1a349aa3..418e5f9fe2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,6 +29,7 @@ "backstage-cli": "bin/backstage-cli" }, "devDependencies": { + "@types/diff": "^4.0.2", "@types/fs-extra": "^8.1.0", "@types/html-webpack-plugin": "^3.2.2", "@types/inquirer": "^6.5.0", @@ -58,6 +59,7 @@ "chokidar": "^3.3.1", "commander": "^4.1.1", "dashify": "^2.0.0", + "diff": "^4.0.2", "eslint-plugin-import": "^2.20.2", "eslint-plugin-monorepo": "^0.2.1", "fork-ts-checker-webpack-plugin": "^4.0.5", diff --git a/packages/cli/src/commands/plugin/diff/handlers.ts b/packages/cli/src/commands/plugin/diff/handlers.ts new file mode 100644 index 0000000000..df7ca081de --- /dev/null +++ b/packages/cli/src/commands/plugin/diff/handlers.ts @@ -0,0 +1,209 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import { dirname } from 'path'; +import { diffLines } from 'diff'; +import { paths } from 'lib/paths'; +import { TemplateFile, PromptFunc, FileHandler } from './types'; + +export async function writeTargetFile(targetPath: string, contents: string) { + const path = paths.resolveTarget(targetPath); + await fs.ensureDir(dirname(path)); + await fs.writeFile(path, contents, 'utf8'); +} + +class PackageJsonHandler { + static async handler(file: TemplateFile, prompt: PromptFunc) { + console.log('Checking package.json'); + + if (!file.targetExists) { + throw new Error(`${file.targetPath} doesn't exist`); + } + + const pkg = JSON.parse(file.templateContents); + const targetPkg = JSON.parse(file.targetContents); + + const handler = new PackageJsonHandler(file, prompt, pkg, targetPkg); + await handler.handle(); + } + + constructor( + private readonly file: TemplateFile, + private readonly prompt: PromptFunc, + private readonly pkg: any, + private readonly targetPkg: any, + ) {} + + async handle() { + await this.syncField('main'); + await this.syncField('types'); + await this.syncField('files'); + await this.syncScripts(); + await this.syncDependencies('dependencies'); + await this.syncDependencies('devDependencies'); + } + + // Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change. + private async syncField( + fieldName: string, + obj: any = this.pkg, + targetObj: any = this.targetPkg, + prefix?: string, + ) { + const fullFieldName = chalk.cyan( + prefix ? `${prefix}[${fieldName}]` : fieldName, + ); + const newValue = obj[fieldName]; + const coloredNewValue = chalk.cyan(JSON.stringify(newValue)); + + if (fieldName in targetObj) { + const oldValue = targetObj[fieldName]; + if (JSON.stringify(oldValue) === JSON.stringify(newValue)) { + return; + } + + const coloredOldValue = chalk.cyan(JSON.stringify(oldValue)); + const msg = `package.json has mismatched field, ${fullFieldName}, change from ${coloredOldValue} to ${coloredNewValue}?`; + if (await this.prompt(msg)) { + targetObj[fieldName] = newValue; + await this.write(); + } + } else { + if ( + await this.prompt( + `package.json is missing field ${fullFieldName}, set to ${coloredNewValue}?`, + ) + ) { + targetObj[fieldName] = newValue; + await this.write(); + } + } + } + + private async syncScripts() { + const pkgScripts = this.pkg.scripts; + const targetScripts = (this.targetPkg.scripts = + this.targetPkg.scripts || {}); + + for (const key of Object.keys(pkgScripts)) { + await this.syncField(key, pkgScripts, targetScripts, 'scripts'); + } + } + + private async syncDependencies(fieldName: string) { + const pkgDeps = this.pkg[fieldName]; + const targetDeps = (this.targetPkg[fieldName] = + this.targetPkg[fieldName] || {}); + + for (const key of Object.keys(pkgDeps)) { + await this.syncField(key, pkgDeps, targetDeps, fieldName); + } + } + + private async write() { + await fs.writeFile( + paths.resolveTarget(this.file.targetPath), + `${JSON.stringify(this.targetPkg, null, 2)}\n`, + ); + } +} + +// Make sure the file is an exact match of the template +async function exactMatchHandler(file: TemplateFile, prompt: PromptFunc) { + console.log(`Checking ${file.targetPath}`); + + const { targetPath, templateContents } = file; + const coloredPath = chalk.cyan(targetPath); + + if (!file.targetExists) { + if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) { + await writeTargetFile(targetPath, templateContents); + } + return; + } + if (file.targetContents === templateContents) { + return; + } + + const diffs = diffLines(file.targetContents, templateContents); + for (const diff of diffs) { + if (diff.added) { + process.stdout.write(chalk.green(`+${diff.value}`)); + } else if (diff.removed) { + process.stdout.write(chalk.red(`-${diff.value}`)); + } else { + process.stdout.write(` ${diff.value}`); + } + } + + if ( + await prompt( + `Outdated ${coloredPath}, do you want to apply the above patch?`, + ) + ) { + await writeTargetFile(targetPath, templateContents); + } +} + +// Adds the file if it is missing, but doesn't check existing files +async function existsHandler(file: TemplateFile, prompt: PromptFunc) { + console.log(`Making sure ${file.targetPath} exists`); + + const { targetPath, templateContents } = file; + const coloredPath = chalk.cyan(targetPath); + + if (!file.targetExists) { + if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) { + await writeTargetFile(targetPath, templateContents); + } + return; + } +} + +async function skipHandler(file: TemplateFile) { + console.log(`Skipping ${file.targetPath}`); +} + +export const handlers = { + skip: skipHandler, + exists: existsHandler, + exactMatch: exactMatchHandler, + packageJson: PackageJsonHandler.handler, +}; + +export async function handleAllFiles( + fileHandlers: FileHandler[], + files: TemplateFile[], + promptFunc: PromptFunc, +) { + for (const file of files) { + const { targetPath } = file; + const fileHandler = fileHandlers.find(handler => + handler.patterns.some(pattern => + typeof pattern === 'string' + ? pattern === targetPath + : pattern.test(targetPath), + ), + ); + if (fileHandler) { + await fileHandler.handler(file, promptFunc); + } else { + throw new Error(`No template file handler found for ${targetPath}`); + } + } +} diff --git a/packages/cli/src/commands/plugin/diff/index.ts b/packages/cli/src/commands/plugin/diff/index.ts new file mode 100644 index 0000000000..116ba9e633 --- /dev/null +++ b/packages/cli/src/commands/plugin/diff/index.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { Command } from 'commander'; +import { readTemplateFiles } from './read'; +import { handlers, handleAllFiles } from './handlers'; +import { PromptFunc } from './types'; + +const fileHandlers = [ + { + patterns: ['package.json'], + handler: handlers.packageJson, + }, + { + patterns: ['tsconfig.json'], + handler: handlers.exactMatch, + }, + { + // make sure files in 1st level of src/ and dev/ exist + patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], + handler: handlers.exists, + }, + { + patterns: ['README.md', /^src\//], + handler: handlers.skip, + }, +]; + +const inquirerPromptFunc: PromptFunc = async msg => { + const { result } = await inquirer.prompt({ + type: 'confirm', + name: 'result', + message: chalk.blue(msg), + }); + return result; +}; + +const checkPromptFunc: PromptFunc = async msg => { + throw new Error(`Check failed, the following change was needed: ${msg}`); +}; +const yesPromptFunc: PromptFunc = async msg => { + console.log(`Accepting: "${msg}"`); + return true; +}; + +export default async (cmd: Command) => { + let promptFunc = inquirerPromptFunc; + + if (cmd.check) { + promptFunc = checkPromptFunc; + } else if (cmd.yes) { + promptFunc = yesPromptFunc; + } + + const templateFiles = await readTemplateFiles('default-plugin'); + await handleAllFiles(fileHandlers, templateFiles, promptFunc); +}; diff --git a/packages/cli/src/commands/plugin/diff/read.ts b/packages/cli/src/commands/plugin/diff/read.ts new file mode 100644 index 0000000000..ef981a0081 --- /dev/null +++ b/packages/cli/src/commands/plugin/diff/read.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { relative as relativePath } from 'path'; +import handlebars from 'handlebars'; +import recursiveReadDir from 'recursive-readdir'; +import { paths } from 'lib/paths'; +import { version } from 'lib/version'; +import { PluginInfo, TemplateFile } from './types'; + +// Reads info from the existing plugin +export async function readPluginInfo(): Promise { + let name: string; + try { + const pkg = require(paths.resolveTarget('package.json')); + name = pkg.name; + } catch (error) { + throw new Error(`Failed to read target package, ${error}`); + } + + const pluginTsContents = await fs.readFile( + paths.resolveTarget('src/plugin.ts'), + 'utf8', + ); + // TODO: replace with some proper parsing logic or plugin metadata file + const pluginIdMatch = pluginTsContents.match(/id: ['"`](.+?)['"`]/); + if (!pluginIdMatch) { + throw new Error(`Failed to parse plugin.ts, no plugin ID found`); + } + + const id = pluginIdMatch[1]; + + return { id, name }; +} + +export async function readTemplateFile( + templateFile: string, + templateVars: any, +): Promise { + const contents = await fs.readFile(templateFile, 'utf8'); + + if (!templateFile.endsWith('.hbs')) { + return contents; + } + + return handlebars.compile(contents)(templateVars); +} + +export async function readTemplate( + templateDir: string, + templateVars: any, +): Promise { + const templateFilePaths = await recursiveReadDir(templateDir).catch(error => { + throw new Error(`Failed to read template directory: ${error.message}`); + }); + + const templateFiles = new Array(); + for (const templateFile of templateFilePaths) { + // Target file inside the target dir without template extension + const targetFile = templateFile + .replace(templateDir, paths.targetDir) + .replace(/\.hbs$/, ''); + const targetPath = relativePath(paths.targetDir, targetFile); + + const templateContents = await readTemplateFile(templateFile, templateVars); + + const targetExists = await fs.pathExists(targetFile); + if (targetExists) { + const targetContents = await fs.readFile(targetFile, 'utf8'); + templateFiles.push({ + targetPath, + targetExists, + targetContents, + templateContents, + }); + } else { + templateFiles.push({ + targetPath, + targetExists, + templateContents, + }); + } + } + + return templateFiles; +} + +// Read all template files for a given template, along with all matching files in the target dir +export async function readTemplateFiles(template: string) { + const pluginInfo = await readPluginInfo(); + const templateVars = { version, ...pluginInfo }; + + const templateDir = paths.resolveOwn('templates', template); + + return await readTemplate(templateDir, templateVars); +} diff --git a/packages/cli/src/commands/plugin/diff/types.ts b/packages/cli/src/commands/plugin/diff/types.ts new file mode 100644 index 0000000000..7a04cf8d2e --- /dev/null +++ b/packages/cli/src/commands/plugin/diff/types.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type PluginInfo = { + id: string; + name: string; +}; + +export type TemplateFile = { + // Relative path within the target directory + targetPath: string; + // Contents of the compiled template file + templateContents: string; +} & ( + | { + // Whether the template file exists in the target directory + targetExists: true; + // Contents of the file in the target directory, if it exists + targetContents: string; + } + | { + // Whether the template file exists in the target directory + targetExists: false; + } +); + +export type PromptFunc = (msg: string) => Promise; + +export type HandlerFunc = ( + file: TemplateFile, + prompt: PromptFunc, +) => Promise; + +export type FileHandler = { + patterns: Array; + handler: HandlerFunc; +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1c3f66bce7..6c6aec3776 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -62,6 +62,13 @@ const main = (argv: string[]) => { .description('Serves the dev/ folder of a plugin') .action(actionHandler(() => require('commands/plugin/serve'))); + program + .command('plugin:diff') + .option('--check', 'Fail if changes are required') + .option('--yes', 'Apply all changes') + .description('Diff an existing plugin with the creation template') + .action(actionHandler(() => require('commands/plugin/diff'))); + program .command('lint') .option('--fix', 'Attempt to automatically fix violations') diff --git a/yarn.lock b/yarn.lock index fd89c86b9f..ab56118174 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3947,6 +3947,11 @@ resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== +"@types/diff@^4.0.2": + version "4.0.2" + resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" + integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -8187,7 +8192,7 @@ diff-sequences@^25.1.0: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz#fd29a46f1c913fd66c22645dc75bffbe43051f32" integrity sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw== -diff@^4.0.1: +diff@^4.0.1, diff@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==