From 7d02e1d15c73188310d4b8ee6a0c8f9eed6cfa5e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 3 May 2020 14:21:20 +0200 Subject: [PATCH] packages/cli: split diff command implementation into multiple files --- .../cli/src/commands/plugin/diff/handlers.ts | 209 +++++++++++ .../cli/src/commands/plugin/diff/index.ts | 337 ++---------------- packages/cli/src/commands/plugin/diff/read.ts | 110 ++++++ .../cli/src/commands/plugin/diff/types.ts | 50 +++ 4 files changed, 393 insertions(+), 313 deletions(-) create mode 100644 packages/cli/src/commands/plugin/diff/handlers.ts create mode 100644 packages/cli/src/commands/plugin/diff/read.ts create mode 100644 packages/cli/src/commands/plugin/diff/types.ts 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..ec39733adf --- /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}]` : prefix, + ); + const newValue = obj[fieldName]; + + if (fieldName in targetObj) { + const oldValue = targetObj[fieldName]; + if (JSON.stringify(oldValue) === JSON.stringify(newValue)) { + return; + } + + const msg = + `Outdated field, ${fullFieldName}, change from ` + + `${chalk.cyan(oldValue)} to ${chalk.cyan(newValue)}?`; + if (await this.prompt(msg)) { + targetObj[fieldName] = newValue; + await this.write(); + } + } else { + if ( + await this.prompt( + `Missing field ${fullFieldName}, set to ${chalk.cyan(newValue)}?`, + ) + ) { + 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), + ); + } +} + +// 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 index 6a3343215b..87e8628ee4 100644 --- a/packages/cli/src/commands/plugin/diff/index.ts +++ b/packages/cli/src/commands/plugin/diff/index.ts @@ -14,275 +14,31 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { relative as relativePath, dirname } from 'path'; -import { diffLines } from 'diff'; -import handlebars from 'handlebars'; -import recursiveReadDir from 'recursive-readdir'; -import { paths } from 'lib/paths'; -import { version } from 'lib/version'; import chalk from 'chalk'; import inquirer from 'inquirer'; +import { readTemplateFiles } from './read'; +import { handlers, handleAllFiles } from './handlers'; +import { PromptFunc } from './types'; -type PluginInfo = { - id: string; - name: string; -}; - -// Reads info from the existing plugin -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 }; -} - -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; - } -); - -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); -} - -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; -} - -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}]` : prefix, - ); - const newValue = obj[fieldName]; - - if (fieldName in targetObj) { - const oldValue = targetObj[fieldName]; - if (JSON.stringify(oldValue) === JSON.stringify(newValue)) { - return; - } - - const msg = - `Outdated field, ${fullFieldName}, change from ` + - `${chalk.cyan(oldValue)} to ${chalk.cyan(newValue)}?`; - if (await this.prompt(msg)) { - targetObj[fieldName] = newValue; - await this.write(); - } - } else { - if ( - await this.prompt( - `Missing field ${fullFieldName}, set to ${chalk.cyan(newValue)}?`, - ) - ) { - 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), - ); - } -} - -// 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 addOnlyHandler(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}`); -} - -type PromptFunc = (msg: string) => Promise; +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({ @@ -293,52 +49,7 @@ const inquirerPromptFunc: PromptFunc = async msg => { return result; }; -type FileHandler = { - patterns: Array; - handler: (file: TemplateFile, prompt: PromptFunc) => Promise; -}; - -const fileHandlers: FileHandler[] = [ - { - patterns: ['package.json'], - handler: PackageJsonHandler.handler, - }, - { - patterns: ['tsconfig.json'], - handler: exactMatchHandler, - }, - { - // make sure files in 1st level of src/ and dev/ exist - patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], - handler: addOnlyHandler, - }, - { - patterns: ['README.md', /^src\//], - handler: skipHandler, - }, -]; - export default async () => { - const pluginInfo = await readPluginInfo(); - const templateVars = { version, ...pluginInfo }; - - const templateDir = paths.resolveOwn('templates/default-plugin'); - - const templateFiles = await readTemplate(templateDir, templateVars); - - for (const templateFile of templateFiles) { - const { targetPath } = templateFile; - const fileHandler = fileHandlers.find(handler => - handler.patterns.some(pattern => - typeof pattern === 'string' - ? pattern === targetPath - : pattern.test(targetPath), - ), - ); - if (fileHandler) { - await fileHandler.handler(templateFile, inquirerPromptFunc); - } else { - throw new Error(`No template file handler found for ${targetPath}`); - } - } + const templateFiles = await readTemplateFiles('default-plugin'); + await handleAllFiles(fileHandlers, templateFiles, inquirerPromptFunc); }; 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; +};