Merge pull request #702 from spotify/rugvip/pdiff

package/cli: added plugin:diff command
This commit is contained in:
Patrik Oldsberg
2020-05-05 11:06:16 +02:00
committed by GitHub
7 changed files with 456 additions and 1 deletions
+2
View File
@@ -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",
@@ -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}`);
}
}
}
@@ -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);
};
@@ -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<PluginInfo> {
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<string> {
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<TemplateFile[]> {
const templateFilePaths = await recursiveReadDir(templateDir).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
const templateFiles = new Array<TemplateFile>();
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);
}
@@ -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<boolean>;
export type HandlerFunc = (
file: TemplateFile,
prompt: PromptFunc,
) => Promise<void>;
export type FileHandler = {
patterns: Array<string | RegExp>;
handler: HandlerFunc;
};
+7
View File
@@ -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')
+6 -1
View File
@@ -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==