[kbn/generate] refactor to better support new commands (#127382)

This commit is contained in:
Spencer 2022-03-10 08:24:25 -08:00 committed by GitHub
parent 0e99e9430c
commit 9fc23fa8bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 276 additions and 172 deletions

View file

@ -7,7 +7,7 @@
*/
module.exports = {
preset: '@kbn/test',
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-generate'],
};

View file

@ -6,180 +6,26 @@
* Side Public License, v 1.
*/
import Fsp from 'fs/promises';
import Path from 'path';
import { RunWithCommands } from '@kbn/dev-utils';
import Ejs from 'ejs';
import globby from 'globby';
import { REPO_ROOT } from '@kbn/utils';
import {
RunWithCommands,
createFlagError,
createFailError,
isFailError,
sortPackageJson,
} from '@kbn/dev-utils';
import { discoverBazelPackages, generatePackagesBuildBazelFile } from '@kbn/bazel-packages';
import normalizePath from 'normalize-path';
import { Render } from './lib/render';
import { ContextExtensions } from './generate_command';
const ROOT_PKG_DIR = Path.resolve(REPO_ROOT, 'packages');
const TEMPLATE_DIR = Path.resolve(__dirname, '../templates/package');
const jsonHelper = (arg: any) => JSON.stringify(arg, null, 2);
const jsHelper = (arg: string) => {
if (typeof arg !== 'string') {
throw new Error('js() only supports strings right now');
}
const hasSingle = arg.includes(`'`);
const hasBacktick = arg.includes('`');
if (!hasSingle) {
return `'${arg}'`;
}
if (!hasBacktick) {
return `\`${arg}\``;
}
return `'${arg.replaceAll(`'`, `\\'`)}'`;
};
import { PackageCommand } from './commands/package_command';
/**
* Runs the generate CLI. Called by `node scripts/generate` and not intended for use outside of that script
*/
export function runGenerateCli() {
new RunWithCommands({
description: 'Run generators for different components in Kibana',
})
.command({
name: 'package',
description: 'Generate a basic package',
usage: 'node scripts/generate package [name]',
flags: {
boolean: ['web', 'force', 'dev'],
string: ['dir'],
help: `
--dev Generate a package which is intended for dev-only use and can access things like devDependencies
--web Build webpack-compatible version of sources for this package. If your package is intended to be
used in the browser and Node.js then you need to opt-into these sources being created.
--dir Directory where this package will live, defaults to [./packages]
--force If the packageDir already exists, delete it before generation
`,
new RunWithCommands<ContextExtensions>(
{
description: 'Run generators for different components in Kibana',
extendContext(context) {
return {
render: new Render(context.log),
};
},
async run({ log, flags }) {
const [name] = flags._;
if (!name) {
throw createFlagError(`missing package name`);
}
if (!name.startsWith('@kbn/')) {
throw createFlagError(`package name must start with @kbn/`);
}
const typePkgName = `@types/${name.slice(1).replace('/', '__')}`;
const web = !!flags.web;
const dev = !!flags.dev;
const containingDir = flags.dir ? Path.resolve(`${flags.dir}`) : ROOT_PKG_DIR;
const packageDir = Path.resolve(containingDir, name.slice(1).replace('/', '-'));
const repoRelativeDir = normalizePath(Path.relative(REPO_ROOT, packageDir));
try {
await Fsp.readdir(packageDir);
if (!!flags.force) {
await Fsp.rm(packageDir, { recursive: true });
log.warning('deleted existing package at', packageDir);
} else {
throw createFailError(
`Package dir [${packageDir}] already exists, either choose a new package name, or pass --force to delete the package and regenerate it`
);
}
} catch (error) {
if (isFailError(error)) {
throw error;
}
}
const templateFiles = await globby('**/*', {
cwd: TEMPLATE_DIR,
absolute: false,
dot: true,
onlyFiles: true,
});
await Fsp.mkdir(packageDir, { recursive: true });
for (const rel of templateFiles) {
const destDir = Path.resolve(packageDir, Path.dirname(rel));
await Fsp.mkdir(destDir, { recursive: true });
if (Path.basename(rel) === '.empty') {
log.debug('created dir', destDir);
// ignore .empty files in the template, just create the directory
continue;
}
const ejs = !!rel.endsWith('.ejs');
const src = Path.resolve(TEMPLATE_DIR, rel);
const dest = Path.resolve(packageDir, ejs ? rel.slice(0, -4) : rel);
if (!ejs) {
await Fsp.copyFile(src, dest);
log.debug('copied', rel);
continue;
}
const vars = {
pkg: {
name,
web,
dev,
directoryName: Path.basename(repoRelativeDir),
repoRelativeDir,
},
// helpers
json: jsonHelper,
js: jsHelper,
relativePathTo: (rootRelativePath: string) => {
return Path.relative(Path.dirname(dest), Path.resolve(REPO_ROOT, rootRelativePath));
},
};
log.verbose('rendering', src, 'with variables', vars);
let content = await Ejs.renderFile(src, vars);
if (Path.basename(dest) === 'package.json') {
content = sortPackageJson(content);
}
await Fsp.writeFile(dest, content);
log.debug('rendered', rel);
}
log.info('Wrote plugin files to', packageDir);
const packageJsonPath = Path.resolve(REPO_ROOT, 'package.json');
const packageJson = JSON.parse(await Fsp.readFile(packageJsonPath, 'utf8'));
const [addDeps, removeDeps] = dev
? [packageJson.devDependencies, packageJson.dependencies]
: [packageJson.dependencies, packageJson.devDependencies];
addDeps[name] = `link:bazel-bin/${repoRelativeDir}`;
addDeps[typePkgName] = `link:bazel-bin/${repoRelativeDir}/npm_module_types`;
delete removeDeps[name];
delete removeDeps[typePkgName];
await Fsp.writeFile(packageJsonPath, sortPackageJson(JSON.stringify(packageJson)));
log.info('Updated package.json file');
await Fsp.writeFile(
Path.resolve(REPO_ROOT, 'packages/BUILD.bazel'),
generatePackagesBuildBazelFile(await discoverBazelPackages())
);
log.info('Updated packages/BUILD.bazel');
log.success(`Generated ${name}! Please bootstrap to make sure it works.`);
},
})
.execute();
},
[PackageCommand]
).execute();
}

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Fsp from 'fs/promises';
import Path from 'path';
import normalizePath from 'normalize-path';
import globby from 'globby';
import { REPO_ROOT } from '@kbn/utils';
import { discoverBazelPackages, generatePackagesBuildBazelFile } from '@kbn/bazel-packages';
import { createFailError, createFlagError, isFailError, sortPackageJson } from '@kbn/dev-utils';
import { ROOT_PKG_DIR, PKG_TEMPLATE_DIR } from '../paths';
import type { GenerateCommand } from '../generate_command';
export const PackageCommand: GenerateCommand = {
name: 'package',
description: 'Generate a basic package',
usage: 'node scripts/generate package [name]',
flags: {
boolean: ['web', 'force', 'dev'],
string: ['dir'],
help: `
--dev Generate a package which is intended for dev-only use and can access things like devDependencies
--web Build webpack-compatible version of sources for this package. If your package is intended to be
used in the browser and Node.js then you need to opt-into these sources being created.
--dir Directory where this package will live, defaults to [./packages]
--force If the packageDir already exists, delete it before generation
`,
},
async run({ log, flags, render }) {
const [name] = flags._;
if (!name) {
throw createFlagError(`missing package name`);
}
if (!name.startsWith('@kbn/')) {
throw createFlagError(`package name must start with @kbn/`);
}
const typePkgName = `@types/${name.slice(1).replace('/', '__')}`;
const web = !!flags.web;
const dev = !!flags.dev;
const containingDir = flags.dir ? Path.resolve(`${flags.dir}`) : ROOT_PKG_DIR;
const packageDir = Path.resolve(containingDir, name.slice(1).replace('/', '-'));
const repoRelativeDir = normalizePath(Path.relative(REPO_ROOT, packageDir));
try {
await Fsp.readdir(packageDir);
if (!!flags.force) {
await Fsp.rm(packageDir, { recursive: true });
log.warning('deleted existing package at', packageDir);
} else {
throw createFailError(
`Package dir [${packageDir}] already exists, either choose a new package name, or pass --force to delete the package and regenerate it`
);
}
} catch (error) {
if (isFailError(error)) {
throw error;
}
}
const templateFiles = await globby('**/*', {
cwd: PKG_TEMPLATE_DIR,
absolute: false,
dot: true,
onlyFiles: true,
});
if (!templateFiles.length) {
throw new Error('unable to find package template files');
}
await Fsp.mkdir(packageDir, { recursive: true });
for (const rel of templateFiles) {
const destDir = Path.resolve(packageDir, Path.dirname(rel));
await Fsp.mkdir(destDir, { recursive: true });
if (Path.basename(rel) === '.empty') {
log.debug('created dir', destDir);
// ignore .empty files in the template, just create the directory
continue;
}
const ejs = !!rel.endsWith('.ejs');
const src = Path.resolve(PKG_TEMPLATE_DIR, rel);
const dest = Path.resolve(packageDir, ejs ? rel.slice(0, -4) : rel);
if (!ejs) {
// read+write rather than `Fsp.copyFile` so that permissions of bazel-out are not copied to target
await Fsp.writeFile(dest, await Fsp.readFile(src));
log.debug('copied', rel);
continue;
}
await render.toFile(src, dest, {
pkg: {
name,
web,
dev,
directoryName: Path.basename(repoRelativeDir),
repoRelativeDir,
},
});
}
log.info('Wrote plugin files to', packageDir);
const packageJsonPath = Path.resolve(REPO_ROOT, 'package.json');
const packageJson = JSON.parse(await Fsp.readFile(packageJsonPath, 'utf8'));
const [addDeps, removeDeps] = dev
? [packageJson.devDependencies, packageJson.dependencies]
: [packageJson.dependencies, packageJson.devDependencies];
addDeps[name] = `link:bazel-bin/${repoRelativeDir}`;
addDeps[typePkgName] = `link:bazel-bin/${repoRelativeDir}/npm_module_types`;
delete removeDeps[name];
delete removeDeps[typePkgName];
await Fsp.writeFile(packageJsonPath, sortPackageJson(JSON.stringify(packageJson)));
log.info('Updated package.json file');
await Fsp.writeFile(
Path.resolve(REPO_ROOT, 'packages/BUILD.bazel'),
generatePackagesBuildBazelFile(await discoverBazelPackages())
);
log.info('Updated packages/BUILD.bazel');
log.success(`Generated ${name}! Please bootstrap to make sure it works.`);
},
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Command } from '@kbn/dev-utils';
import { Render } from './lib/render';
export interface ContextExtensions {
render: Render;
}
export type GenerateCommand = Command<ContextExtensions>;

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import Fsp from 'fs/promises';
import Ejs from 'ejs';
import normalizePath from 'normalize-path';
import { ToolingLog, sortPackageJson } from '@kbn/dev-utils';
import { REPO_ROOT } from '@kbn/utils';
export type Vars = Record<string, unknown>;
export interface RenderContext extends Vars {
/**
* convert any serializable value into prett-printed JSON
*/
json(arg: any): string;
/**
* convert string values into valid and pretty JS
*/
js(arg: string): string;
/**
* create a normalized relative path from the generated files location to a repo-relative path
*/
relativePathTo(rootRelativePath: string): string;
}
export class Render {
jsonHelper: RenderContext['json'] = (arg) => JSON.stringify(arg, null, 2);
jsHelper: RenderContext['js'] = (arg) => {
if (typeof arg !== 'string') {
throw new Error('js() only supports strings right now');
}
const hasSingle = arg.includes(`'`);
const hasBacktick = arg.includes('`');
if (!hasSingle) {
return `'${arg}'`;
}
if (!hasBacktick) {
return `\`${arg}\``;
}
return `'${arg.replaceAll(`'`, `\\'`)}'`;
};
constructor(private readonly log: ToolingLog) {}
/**
* Render an ejs template to a string
*/
async toString(templatePath: string, destPath: string, vars: Vars) {
const context: RenderContext = {
...vars,
// helpers
json: this.jsonHelper,
js: this.jsHelper,
relativePathTo: (rootRelativePath: string) =>
normalizePath(
Path.relative(Path.dirname(destPath), Path.resolve(REPO_ROOT, rootRelativePath))
),
};
this.log.debug('Rendering', templatePath, 'with context', context);
const content = await Ejs.renderFile(templatePath, context);
return Path.basename(destPath) === 'package.json' ? sortPackageJson(content) : content;
}
/**
* Render an ejs template to a file
*/
async toFile(templatePath: string, destPath: string, vars: Vars) {
const content = await this.toString(templatePath, destPath, vars);
this.log.debug('Writing to', destPath);
return await Fsp.writeFile(destPath, content);
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import { REPO_ROOT } from '@kbn/utils';
export const ROOT_PKG_DIR = Path.resolve(REPO_ROOT, 'packages');
export const TEMPLATE_DIR = Path.resolve(__dirname, '../templates');
export const PKG_TEMPLATE_DIR = Path.resolve(TEMPLATE_DIR, 'package');