[kbn/pm] rewrite to avoid needing a build process (#136207)

* [kbn/pm] rewrite to avoid needing a build process

* uncomment timing reporting

* throw in a few missing comments

* Update README.md

* remove extra SomeDevLog interface from ci-stats-core

* remove non-stdio logging from bazel_runner, improve output formatting

* use private fields instead of just ts private props

* promote args to a positional arg

* optionally require the ci-stats-reporter after each command

* allow opt-ing out of vscode config management

* reduce to a single import

* add bit of docs regarding weird imports and package deps of kbn/pm

* clean extraDirs from Kibana's package.json file too

* tweak logging of run-in-packages to use --quiet and not just CI=true

* remove unlazy-loader

* add readme for @kbn/yarn-lock-validator

* convert @kbn/some-dev-logs docs to mdx

* remove missing navigation id and fix id in dev-cli-runner docs

* fix title of some-dev-logs docs page

* typo
This commit is contained in:
Spencer 2022-07-18 10:46:13 -05:00 committed by GitHub
parent 11f7ace59f
commit 4f817ad8a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
154 changed files with 3093 additions and 67794 deletions

44
kbn_pm/README.mdx Normal file
View file

@ -0,0 +1,44 @@
---
id: kibDevDocsOpsKbnPm
slug: /kibana-dev-docs/ops/kbn-pm
title: "@kbn/pm"
description: 'The tool which bootstraps the repo and helps work with packages'
date: 2022-07-14
tags: ['kibana', 'dev', 'contributor', 'operations', 'packages', 'scripts']
---
`@kbn/pm` is the tool that we use to bootstrap the Kibana repository, build packages with Bazel, and run scripts in packages.
## commands
### `yarn kbn bootstrap`
Use this command to install dependencies, build packages, and prepare the repo for local development.
### `yarn kbn watch`
Use this command to build all packages and make sure that they are rebuilt as you make changes.
### and more!
There are several commands supported by `@kbn/pm`, but rather than documenting them here they are documented in the help text. Please run `yarn kbn --help` locally to see the most up-to-date info.
## Why isn't this TypeScript?
Since this tool is required for bootstrapping the repository it needs to work without any dependencies installed and without a build toolchain. We accomplish this by writing the tool in vanilla JS (shocker!) and using TypeScript to validate the code which is typed via heavy use of JSDoc comments.
In order to use import/export syntax and enhance the developer experience a little we use the `.mjs` file extension.
In some cases we actually do use TypeScript files, just for defining complicated types. These files are then imported only in special TS-compatible JSDoc comments, so Node.js will never try to import them but they can be used to define types which are too complicated to define inline or in a JSDoc comment.
There are cases where `@kbn/pm` relies on code from packages, mostly to prevent reimplementing common functionality. This can only be done in one of two ways:
1. With a dynamic `await import(...)` statement that is always run after boostrap is complete, or is wrapped in a try/catch in case bootstrap didn't complete successfully.
2. By pulling in the source code of the un-built package.
Option 1 is used in several places, with contingencies in place in case bootstrap failed. Option 2 is used for two pieces of code which are needed in order to run bootstrap:
1. `@kbn/plugin-discovery` as we need to populate the `@kbn/synthetic-package-map` to run Bazel
2. `@kbn/bazel-runner` as we want to have the logic for running bazel in a single location
Because we load these two packages from source, without being built, before bootstrap is ever run, they can not depend on other packages and must be written in Vanilla JS as well.

130
kbn_pm/src/cli.mjs Normal file
View file

@ -0,0 +1,130 @@
/*
* 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.
*/
/**
* This is the script that's run by `yarn kbn`. This script has as little logic
* as possible so that it can:
* - run without being built and without any dependencies
* - can bootstrap the repository, installing all deps and building all packages
* - load additional commands from packages which will extend the functionality
* beyond bootstrapping
*/
import { Args } from './lib/args.mjs';
import { getHelp } from './lib/help.mjs';
import { createFlagError, isCliError } from './lib/cli_error.mjs';
import { COMMANDS } from './commands/index.mjs';
import { Log } from './lib/log.mjs';
const start = Date.now();
const args = new Args(process.argv.slice(2), process.env.CI ? ['--quiet'] : []);
const log = new Log(args.getLoggingLevel());
const cmdName = args.getCommandName();
/**
* @param {import('./lib/log.mjs').Log} log
*/
async function tryToGetCiStatsReporter(log) {
try {
const { CiStatsReporter } = await import('@kbn/ci-stats-reporter');
return CiStatsReporter.fromEnv(log);
} catch {
return;
}
}
try {
const cmd = cmdName ? COMMANDS.find((c) => c.name === cmdName) : undefined;
if (cmdName && !cmd) {
throw createFlagError(`Invalid command name [${cmdName}]`);
}
if (args.getBooleanValue('help')) {
log._write(await getHelp(cmdName));
process.exit(0);
}
if (!cmd) {
throw createFlagError('missing command name');
}
/** @type {import('@kbn/ci-stats-reporter').CiStatsTiming[]} */
const timings = [];
/** @type {import('./lib/command').CommandRunContext['time']} */
const time = async (id, block) => {
if (!cmd.reportTimings) {
return await block();
}
const start = Date.now();
log.verbose(`[${id}]`, 'start');
const [result] = await Promise.allSettled([block()]);
const ms = Date.now() - start;
log.verbose(`[${id}]`, result.status === 'fulfilled' ? 'success' : 'failure', 'in', ms, 'ms');
timings.push({
group: cmd.reportTimings.group,
id,
ms,
meta: {
success: result.status === 'fulfilled',
},
});
if (result.status === 'fulfilled') {
return result.value;
} else {
throw result.reason;
}
};
const [result] = await Promise.allSettled([
(async () =>
await cmd.run({
args,
log,
time,
}))(),
]);
if (cmd.reportTimings) {
timings.push({
group: cmd.reportTimings.group,
id: cmd.reportTimings.id,
ms: Date.now() - start,
meta: {
success: result.status === 'fulfilled',
},
});
}
if (timings.length) {
const reporter = await tryToGetCiStatsReporter(log);
if (reporter) {
await reporter.timings({ timings });
}
}
if (result.status === 'rejected') {
throw result.reason;
}
} catch (error) {
if (!isCliError(error)) {
throw error;
}
log.error(`[${cmdName}] failed: ${error.message}`);
if (error.showHelp) {
log._write('');
log._write(await getHelp(cmdName));
}
process.exit(error.exitCode ?? 1);
}

View file

@ -0,0 +1,127 @@
/*
* 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 { spawnSync } from '../../lib/spawn.mjs';
import * as Bazel from '../../lib/bazel.mjs';
import { haveNodeModulesBeenManuallyDeleted, removeYarnIntegrityFileIfExists } from './yarn.mjs';
import { setupRemoteCache } from './setup_remote_cache.mjs';
import { regenerateSyntheticPackageMap } from './regenerate_synthetic_package_map.mjs';
import { sortPackageJson } from './sort_package_json.mjs';
import { pluginDiscovery } from './plugins.mjs';
import { regenerateBaseTsconfig } from './regenerate_base_tsconfig.mjs';
/** @type {import('../../lib/command').Command} */
export const command = {
name: 'bootstrap',
intro: 'Bootstrap the Kibana repository, installs all dependencies and builds all packages',
description: `
This command should be run every time you checkout a new revision, or can be used to build all packages
once after making a change locally. Package builds are cached remotely so when you don't have local
changes build artifacts will be downloaded from the remote cache.
`,
flagsHelp: `
--force-install Use this flag to force bootstrap to install yarn dependencies. By default the',
command will attempt to only run yarn installs when necessary, but if you manually',
delete the node modules directory or have an issue in your node_modules directory',
you might need to force the install manually.',
--offline Run the installation process without consulting online resources. This is useful and',
sometimes necessary for using bootstrap on an airplane for instance. The local caches',
will be used exclusively, including a yarn-registry local mirror which is created and',
maintained by successful online bootstrap executions.',
--no-validate By default bootstrap validates the yarn.lock file to check for a handfull of',
conditions. If you run into issues with this process locally you can disable it by',
passing this flag.
--no-vscode By default bootstrap updates the .vscode directory to include commonly useful vscode
settings for local development. Disable this process either pass this flag or set
the KBN_BOOTSTRAP_NO_VSCODE=true environment variable.
--quiet Prevent logging more than basic success/error messages
`,
reportTimings: {
group: 'scripts/kbn bootstrap',
id: 'total',
},
async run({ args, log, time }) {
const offline = args.getBooleanValue('offline') ?? false;
const validate = args.getBooleanValue('validate') ?? true;
const quiet = args.getBooleanValue('quiet') ?? false;
const vscodeConfig =
args.getBooleanValue('vscode') ?? (process.env.KBN_BOOTSTRAP_NO_VSCODE ? false : true);
// Force install is set in case a flag is passed into yarn kbn bootstrap or
// our custom logic have determined there is a chance node_modules have been manually deleted and as such bazel
// tracking mechanism is no longer valid
const forceInstall =
args.getBooleanValue('force-install') ?? haveNodeModulesBeenManuallyDeleted();
Bazel.tryRemovingBazeliskFromYarnGlobal(log);
// Install bazel machinery tools if needed
Bazel.ensureInstalled(log);
// Setup remote cache settings in .bazelrc.cache if needed
setupRemoteCache(log);
// Bootstrap process for Bazel packages
// Bazel is now managing dependencies so yarn install
// will happen as part of this
//
// NOTE: Bazel projects will be introduced incrementally
// And should begin from the ones with none dependencies forward.
// That way non bazel projects could depend on bazel projects but not the other way around
// That is only intended during the migration process while non Bazel projects are not removed at all.
if (forceInstall) {
await time('force install dependencies', async () => {
removeYarnIntegrityFileIfExists();
await Bazel.expungeCache(log, { quiet });
await Bazel.installYarnDeps(log, { offline, quiet });
});
}
const plugins = await time('plugin discovery', async () => {
return pluginDiscovery();
});
// generate the synthetic package map which powers several other features, needed
// as an input to the package build
await time('regenerate synthetic package map', async () => {
regenerateSyntheticPackageMap(plugins);
});
// build packages
await time('build packages', async () => {
await Bazel.buildPackages(log, { offline, quiet });
});
await time('sort package json', async () => {
await sortPackageJson();
});
await time('regenerate tsconfig.base.json', async () => {
regenerateBaseTsconfig(plugins);
});
if (validate) {
// now that packages are built we can import `@kbn/yarn-lock-validator`
const { readYarnLock, validateDependencies } = await import('@kbn/yarn-lock-validator');
const yarnLock = await time('read yarn.lock', async () => {
return await readYarnLock();
});
await time('validate dependencies', async () => {
await validateDependencies(log, yarnLock);
});
}
if (vscodeConfig) {
await time('update vscode config', async () => {
// Update vscode settings
spawnSync('node', ['scripts/update_vscode_config']);
log.success('vscode config updated');
});
}
},
};

View file

@ -0,0 +1,61 @@
/* eslint-disable @kbn/eslint/require-license-header */
/**
* @notice
* This code includes a copy of the `normalize-path`
* https://github.com/jonschlinkert/normalize-path/blob/52c3a95ebebc2d98c1ad7606cbafa7e658656899/index.js
*
* The MIT License (MIT)
*
* Copyright (c) 2014-2018, Jon Schlinkert.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* @param {string} path
* @returns {string}
*/
export function normalizePath(path) {
if (typeof path !== 'string') {
throw new TypeError('expected path to be a string');
}
if (path === '\\' || path === '/') return '/';
const len = path.length;
if (len <= 1) return path;
// ensure that win32 namespaces has two leading slashes, so that the path is
// handled properly by the win32 version of path.parse() after being normalized
// https://msdn.microsoft.com/library/windows/desktop/aa365247(v=vs.85).aspx#namespaces
let prefix = '';
if (len > 4 && path[3] === '\\') {
const ch = path[2];
if ((ch === '?' || ch === '.') && path.slice(0, 2) === '\\\\') {
path = path.slice(2);
prefix = '//';
}
}
const segs = path.split(/[/\\]+/);
if (segs[segs.length - 1] === '') {
segs.pop();
}
return prefix + segs.join('/');
}

View file

@ -0,0 +1,51 @@
/*
* 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 { REPO_ROOT } from '../../lib/paths.mjs';
/** @type {string} */
const PLUGIN_DISCOVERY_SRC = '../../../../packages/kbn-plugin-discovery/src/index.js';
/**
* @param {string} pluginId
* @returns {string}
*/
export function convertPluginIdToPackageId(pluginId) {
if (pluginId === 'core') {
// core is the only non-plugin
return `@kbn/core`;
}
return `@kbn/${pluginId
.split('')
.flatMap((c) => (c.toUpperCase() === c ? `-${c.toLowerCase()}` : c))
.join('')}-plugin`
.replace(/-\w(-\w)+-/g, (match) => `-${match.split('-').join('')}-`)
.replace(/-plugin-plugin$/, '-plugin');
}
/**
* @returns {Promise<import('@kbn/plugin-discovery').KibanaPlatformPlugin[]>}
*/
export async function pluginDiscovery() {
/* eslint-disable no-unsanitized/method */
/** @type {import('@kbn/plugin-discovery')} */
const { getPluginSearchPaths, simpleKibanaPlatformPluginDiscovery } = await import(
PLUGIN_DISCOVERY_SRC
);
/* eslint-enable no-unsanitized/method */
const searchPaths = getPluginSearchPaths({
rootDir: REPO_ROOT,
examples: true,
oss: false,
testPlugins: true,
});
return simpleKibanaPlatformPluginDiscovery(searchPaths, []);
}

View file

@ -0,0 +1,39 @@
/*
* 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 Fs from 'fs';
import { REPO_ROOT } from '../../lib/paths.mjs';
import { convertPluginIdToPackageId } from './plugins.mjs';
import { normalizePath } from './normalize_path.mjs';
/**
* @param {import('@kbn/plugin-discovery').KibanaPlatformPlugin[]} plugins
*/
export function regenerateBaseTsconfig(plugins) {
const tsconfigPath = Path.resolve(REPO_ROOT, 'tsconfig.base.json');
const lines = Fs.readFileSync(tsconfigPath, 'utf-8').split('\n');
const packageMap = plugins
.slice()
.sort((a, b) => a.manifestPath.localeCompare(b.manifestPath))
.flatMap((p) => {
const id = convertPluginIdToPackageId(p.manifest.id);
const path = normalizePath(Path.relative(REPO_ROOT, p.directory));
return [` "${id}": ["${path}"],`, ` "${id}/*": ["${path}/*"],`];
});
const start = lines.findIndex((l) => l.trim() === '// START AUTOMATED PACKAGE LISTING');
const end = lines.findIndex((l) => l.trim() === '// END AUTOMATED PACKAGE LISTING');
Fs.writeFileSync(
tsconfigPath,
[...lines.slice(0, start + 1), ...packageMap, ...lines.slice(end)].join('\n')
);
}

View file

@ -0,0 +1,34 @@
/*
* 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 Fs from 'fs';
import { normalizePath } from './normalize_path.mjs';
import { REPO_ROOT } from '../../lib/paths.mjs';
import { convertPluginIdToPackageId } from './plugins.mjs';
/**
* @param {import('@kbn/plugin-discovery').KibanaPlatformPlugin[]} plugins
*/
export function regenerateSyntheticPackageMap(plugins) {
/** @type {Array<[string, string]>} */
const entries = [['@kbn/core', 'src/core']];
for (const plugin of plugins) {
entries.push([
convertPluginIdToPackageId(plugin.manifest.id),
normalizePath(Path.relative(REPO_ROOT, plugin.directory)),
]);
}
Fs.writeFileSync(
Path.resolve(REPO_ROOT, 'packages/kbn-synthetic-package-map/synthetic-packages.json'),
JSON.stringify(entries, null, 2)
);
}

View file

@ -0,0 +1,75 @@
/*
* 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 Fs from 'fs';
import { spawnSync } from 'child_process';
import { isFile } from '../../lib/fs.mjs';
import { dedent } from '../../lib/indent.mjs';
import { REPO_ROOT } from '../../lib/paths.mjs';
function isElasticCommitter() {
try {
const { stdout: email } = spawnSync('git', ['config', 'user.email'], {
encoding: 'utf8',
});
return email.trim().endsWith('@elastic.co');
} catch {
return false;
}
}
/**
*
* @param {string} settingsPath
* @returns
*/
function upToDate(settingsPath) {
if (!isFile(settingsPath)) {
return false;
}
const readSettingsFile = Fs.readFileSync(settingsPath, 'utf8');
return readSettingsFile.startsWith('# V2 ');
}
/**
* @param {import('@kbn/some-dev-log').SomeDevLog} log
*/
export function setupRemoteCache(log) {
// The remote cache is only for Elastic employees working locally (CI cache settings are handled elsewhere)
if (
process.env.FORCE_BOOTSTRAP_REMOTE_CACHE !== 'true' &&
(process.env.CI || !isElasticCommitter())
) {
return;
}
log.debug(`setting up remote cache settings if necessary`);
const settingsPath = Path.resolve(REPO_ROOT, '.bazelrc.cache');
// Checks if we should upgrade or install the config file
if (upToDate(settingsPath)) {
log.debug(`remote cache config already exists and is up-to-date, skipping`);
return;
}
const contents = dedent`
# V2 - This file is automatically generated by 'yarn kbn bootstrap'
# To regenerate this file, delete it and run 'yarn kbn bootstrap' again.
build --remote_cache=https://storage.googleapis.com/kibana-local-bazel-remote-cache
build --noremote_upload_local_results
build --incompatible_remote_results_ignore_disk
`;
Fs.writeFileSync(settingsPath, contents);
log.info(`remote cache settings written to ${settingsPath}`);
}

View file

@ -0,0 +1,20 @@
/*
* 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 Fs from 'fs';
import { REPO_ROOT } from '../../lib/paths.mjs';
export async function sortPackageJson() {
const { sortPackageJson } = await import('@kbn/sort-package-json');
const path = Path.resolve(REPO_ROOT, 'package.json');
const json = Fs.readFileSync(path, 'utf8');
Fs.writeFileSync(path, sortPackageJson(json));
}

View file

@ -0,0 +1,53 @@
/*
* 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 Fs from 'fs';
import { REPO_ROOT } from '../../lib/paths.mjs';
import { maybeRealpath, isFile, isDirectory } from '../../lib/fs.mjs';
// yarn integrity file checker
export function removeYarnIntegrityFileIfExists() {
try {
const nodeModulesRealPath = maybeRealpath(Path.resolve(REPO_ROOT, 'node_modules'));
const yarnIntegrityFilePath = Path.resolve(nodeModulesRealPath, '.yarn-integrity');
// check if the file exists and delete it in that case
if (isFile(yarnIntegrityFilePath)) {
Fs.unlinkSync(yarnIntegrityFilePath);
}
} catch {
// no-op
}
}
// yarn and bazel integration checkers
function areNodeModulesPresent() {
try {
return isDirectory(Path.resolve(REPO_ROOT, 'node_modules'));
} catch {
return false;
}
}
function haveBazelFoldersBeenCreatedBefore() {
try {
return (
isDirectory(Path.resolve(REPO_ROOT, 'bazel-bin/packages')) ||
isDirectory(Path.resolve(REPO_ROOT, 'bazel-kibana/packages')) ||
isDirectory(Path.resolve(REPO_ROOT, 'bazel-out/host'))
);
} catch {
return false;
}
}
export function haveNodeModulesBeenManuallyDeleted() {
return !areNodeModulesPresent() && haveBazelFoldersBeenCreatedBefore();
}

View file

@ -0,0 +1,44 @@
/*
* 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 { dedent } from '../lib/indent.mjs';
import { cleanPaths } from '../lib/clean.mjs';
import * as Bazel from '../lib/bazel.mjs';
import { findPluginCleanPaths } from '../lib/find_clean_paths.mjs';
/** @type {import('../lib/command').Command} */
export const command = {
name: 'clean',
description: 'Deletes output directories and resets internal caches',
reportTimings: {
group: 'scripts/kbn clean',
id: 'total',
},
flagsHelp: `
--quiet Prevent logging more than basic success/error messages
`,
async run({ args, log }) {
log.warning(dedent`
This command is only necessary for the circumstance where you need to recover a consistent
state when problems arise. If you need to run this command often, please let us know by
filling out this form: https://ela.st/yarn-kbn-clean.
Please note it might not solve problems with node_modules. To solve problems around node_modules
you might need to run 'yarn kbn reset'.
`);
await cleanPaths(log, await findPluginCleanPaths(log));
// Runs Bazel soft clean
if (Bazel.isInstalled(log)) {
await Bazel.clean(log, {
quiet: args.getBooleanValue('quiet'),
});
}
},
};

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.
*/
export const COMMANDS = [
(await import('./bootstrap/bootstrap_command.mjs')).command,
(await import('./watch_command.mjs')).command,
(await import('./run_in_packages_command.mjs')).command,
(await import('./clean_command.mjs')).command,
(await import('./reset_command.mjs')).command,
];

View file

@ -0,0 +1,47 @@
/*
* 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 '../lib/paths.mjs';
import { dedent } from '../lib/indent.mjs';
import { cleanPaths } from '../lib/clean.mjs';
import * as Bazel from '../lib/bazel.mjs';
import { findPluginCleanPaths, readCleanPatterns } from '../lib/find_clean_paths.mjs';
/** @type {import('../lib/command').Command} */
export const command = {
name: 'reset',
description:
'Deletes node_modules and output directories, resets internal and disk caches, and stops Bazel server',
reportTimings: {
group: 'scripts/kbn reset',
id: 'total',
},
flagsHelp: `
--quiet Prevent logging more than basic success/error messages
`,
async run({ args, log }) {
log.warning(dedent`
In most cases, 'yarn kbn clean' is all that should be needed to recover a consistent state when
problems arise. However for the rare cases where something get corrupt on node_modules you might need this command.
If you think you need to use this command very often (which is not normal), please let us know.
`);
await cleanPaths(log, [
Path.resolve(REPO_ROOT, 'node_modules'),
Path.resolve(REPO_ROOT, 'x-pack/node_modules'),
...readCleanPatterns(REPO_ROOT),
...(await findPluginCleanPaths(log)),
]);
const quiet = args.getBooleanValue('quiet');
Bazel.expungeCache(log, { quiet });
Bazel.cleanDiskCache(log);
},
};

View file

@ -0,0 +1,76 @@
/*
* 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 '../lib/paths.mjs';
import { spawnSync, spawnStreaming } from '../lib/spawn.mjs';
/** @type {import('../lib/command').Command} */
export const command = {
name: 'run-in-packages',
usage: '[...flags] <command> [...subFlags]',
description: `
Run script defined in package.json in each package that contains that script. Any flags passed
after the script name will be passed directly to the script.
`,
flagsHelp: `
--filter package name to be filter packages by, can be specified multiple times
and only packages matching this filter will be matched
--exclude package name to be excluded, can be specified multiple times
--quiet only log the output of commands if they fail
`,
reportTimings: {
group: 'scripts/kbn run',
id: 'total',
},
async run({ log, args }) {
const scriptName = args.getPositionalArgs()[0];
const rawArgs = args.getRawArgs();
const i = rawArgs.indexOf(scriptName);
const scriptArgs = i !== -1 ? rawArgs.slice(i + 1) : [];
const exclude = args.getStringValues('exclude') ?? [];
const include = args.getStringValues('include') ?? [];
const { discoverBazelPackages } = await import('@kbn/bazel-packages');
const packages = await discoverBazelPackages(REPO_ROOT);
for (const { pkg, normalizedRepoRelativeDir } of packages) {
if (
exclude.includes(pkg.name) ||
(include.length && !include.includes(pkg.name)) ||
!pkg.scripts ||
!Object.hasOwn(pkg.scripts, scriptName)
) {
continue;
}
log.debug(
`running [${scriptName}] script in [${pkg.name}]`,
scriptArgs.length ? `with args [${scriptArgs.join(' ')}]` : ''
);
const cwd = Path.resolve(REPO_ROOT, normalizedRepoRelativeDir);
if (args.getBooleanValue('quiet')) {
spawnSync('yarn', ['run', scriptName, ...scriptArgs], {
cwd,
description: `${scriptName} in ${pkg.name}`,
});
} else {
await spawnStreaming('yarn', ['run', scriptName, ...scriptArgs], {
cwd: cwd,
logPrefix: ' ',
});
}
log.success(`Ran [${scriptName}] in [${pkg.name}]`);
}
},
};

View file

@ -0,0 +1,31 @@
/*
* 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 * as Bazel from '../lib/bazel.mjs';
/** @type {import('../lib/command').Command} */
export const command = {
name: 'watch',
description: 'Runs a build in the Bazel built packages and keeps watching them for changes',
flagsHelp: `
--offline Run the installation process without consulting online resources. This is useful and',
sometimes necessary for using bootstrap on an airplane for instance. The local caches',
will be used exclusively, including a yarn-registry local mirror which is created and',
maintained by successful online bootstrap executions.',
`,
reportTimings: {
group: 'scripts/kbn watch',
id: 'total',
},
async run({ args, log }) {
await Bazel.watch(log, {
offline: args.getBooleanValue('offline') ?? true,
});
},
};

182
kbn_pm/src/lib/args.mjs Normal file
View file

@ -0,0 +1,182 @@
/*
* 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 { createFlagError } from './cli_error.mjs';
/**
* @param {string[]} argv
*/
function parseArgv(argv) {
/** @type {string[]} */
const raw = [];
/** @type {string[]} */
const positional = [];
/** @type {Map<string, string | string[] | boolean>} */
const flags = new Map();
for (const arg of argv) {
raw.push(arg);
if (!arg.startsWith('--')) {
// positional arguments are anything that doesn't start with "--"
positional.push(arg);
continue;
}
// flags always start with "--" and might have an =value attached.
// - If the flag name starts with "no-", like `--no-flag`, it will set `flag` to `false`
// - If the flag has multiple string values they will be turned into an array of values
const [name, ...value] = arg.slice(2).split('=');
if (value.length === 0) {
// boolean flag
if (name.startsWith('no-')) {
flags.set(name.slice(3), false);
} else {
flags.set(name, true);
}
} else {
flags.set(name, value.join('='));
}
}
return { raw, positional, flags };
}
export class Args {
#flags;
#positional;
#raw;
#defaults;
/**
* @param {string[]} argv
* @param {string[]} defaultArgv
*/
constructor(argv, defaultArgv) {
const { flags, positional, raw } = parseArgv(argv);
this.#flags = flags;
this.#positional = positional;
this.#raw = raw;
this.#defaults = parseArgv(defaultArgv).flags;
}
/**
* @returns {import('@kbn/some-dev-log').SomeLogLevel}
*/
getLoggingLevel() {
if (this.getBooleanValue('quiet')) {
return 'quiet';
}
if (this.getBooleanValue('verbose')) {
return 'verbose';
}
if (this.getBooleanValue('debug')) {
return 'debug';
}
return 'info';
}
/**
* Get the command name from the args
*/
getCommandName() {
return this.#positional[0];
}
/**
* Get the positional arguments, excludes the command name
*/
getPositionalArgs() {
return this.#positional.slice(1);
}
/**
* Get all of the passed args
*/
getRawArgs() {
return this.#raw.slice();
}
/**
* Get the value of a specific flag as a string, if the argument is specified multiple
* times then only the last value specified will be returned. If the flag was specified
* as a boolean then an error will be thrown. If the flag wasn't specified then
* undefined will be returned.
* @param {string} name
*/
getStringValue(name) {
const value = this.#flags.get(name) ?? this.#defaults.get(name);
if (Array.isArray(value)) {
return value.at(-1);
}
if (typeof value === 'boolean') {
throw createFlagError(`Expected [--${name}] to have a value, not be a boolean flag`);
}
return value;
}
/**
* Get the string values of a flag as an array of values. This will return all values for a
* given flag and any boolean values will cause an error to be thrown.
* @param {string} name
*/
getStringValues(name) {
const value = this.#flags.get(name) ?? this.#defaults.get(name);
if (typeof value === 'string') {
return [value];
}
if (value === undefined || Array.isArray(value)) {
return value;
}
throw createFlagError(`Expected [--${name}] to have a string value`);
}
/**
* Get the boolean value of a specific flag. If the flag wasn't defined then undefined will
* be returned. If the flag was specified with a string value then an error will be thrown.
* @param {string} name
*/
getBooleanValue(name) {
const value = this.#flags.get(name) ?? this.#defaults.get(name);
if (typeof value === 'boolean' || value === undefined) {
return value;
}
throw createFlagError(
`Unexpected value for [--${name}], this is a boolean flag and should be specified as just [--${name}] or [--no-${name}]`
);
}
/**
* Get the value of a specific flag parsed as a number. If the flag wasn't specified then
* undefined will be returned. If the flag was specified multiple times then the last value
* specified will be used. If the flag's value can't be parsed as a number then an error
* will be returned.
* @param {string} name
*/
getNumberValue(name) {
const value = this.getStringValue(name);
if (value === undefined) {
return value;
}
const parsed = parseFloat(value);
if (Number.isNaN(parsed)) {
throw createFlagError(`Expected value of [--${name}] to be parsable as a valid number`);
}
return parsed;
}
}

260
kbn_pm/src/lib/bazel.mjs Normal file
View file

@ -0,0 +1,260 @@
/*
* 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 Fs from 'fs';
import { spawnSync } from './spawn.mjs';
import * as Color from './colors.mjs';
import { createCliError } from './cli_error.mjs';
import { REPO_ROOT } from './paths.mjs';
import { cleanPaths } from './clean.mjs';
import { indent } from './indent.mjs';
const BAZEL_RUNNER_SRC = '../../../packages/kbn-bazel-runner/src/index.js';
async function getBazelRunner() {
/* eslint-disable no-unsanitized/method */
/** @type {import('@kbn/bazel-runner')} */
const { runBazel, runIBazel } = await import(BAZEL_RUNNER_SRC);
/* eslint-enable no-unsanitized/method */
return { runBazel, runIBazel };
}
/**
* @param {import('./log.mjs').Log} log
* @param {string} name
* @param {number} code
* @param {string} output
*/
function throwBazelError(log, name, code, output) {
const tag = Color.title('HINT');
log._write(
[
tag,
tag +
'If experiencing problems with node_modules try `yarn kbn bootstrap --force-install` or as last resort `yarn kbn reset && yarn kbn bootstrap`',
tag,
].join('\n')
);
throw createCliError(
`[${name}] exited with code [${code}]${output ? `\n output:\n${indent(4, output)}}` : ''}`
);
}
/**
* @param {import('./log.mjs').Log} log
* @param {string[]} inputArgs
* @param {{ quiet?: boolean; offline?: boolean, env?: Record<string, string> } | undefined} opts
*/
async function runBazel(log, inputArgs, opts = undefined) {
const bazel = (await getBazelRunner()).runBazel;
const args = [...(opts?.offline ? ['--config=offline'] : []), ...inputArgs];
log.debug(`> bazel ${args.join(' ')}`);
await bazel(args, {
env: opts?.env,
cwd: REPO_ROOT,
quiet: opts?.quiet,
logPrefix: Color.info('[bazel]'),
onErrorExit(code, output) {
throwBazelError(log, 'bazel', code, output);
},
});
}
/**
*
* @param {import('./log.mjs').Log} log
* @param {{ offline: boolean } | undefined} opts
*/
export async function watch(log, opts = undefined) {
const ibazel = (await getBazelRunner()).runIBazel;
const args = [
// --run_output=false arg will disable the iBazel notifications about gazelle
// and buildozer when running it. Could also be solved by adding a root
// `.bazel_fix_commands.json` but its not needed at the moment
'--run_output=false',
'build',
'//packages:build',
'--show_result=1',
...(opts?.offline ? ['--config=offline'] : []),
];
log.debug(`> ibazel ${args.join(' ')}`);
await ibazel(args, {
cwd: REPO_ROOT,
logPrefix: Color.info('[ibazel]'),
onErrorExit(code, output) {
throwBazelError(log, 'ibazel', code, output);
},
});
}
/**
* @param {import('./log.mjs').Log} log
* @param {{ quiet?: boolean } | undefined} opts
*/
export async function clean(log, opts = undefined) {
await runBazel(log, ['clean'], {
quiet: opts?.quiet,
});
log.success('soft cleaned bazel');
}
/**
* @param {import('./log.mjs').Log} log
* @param {{ quiet?: boolean } | undefined} opts
*/
export async function expungeCache(log, opts = undefined) {
await runBazel(log, ['clean', '--expunge'], {
quiet: opts?.quiet,
});
log.success('hard cleaned bazel');
}
/**
* @param {import('./log.mjs').Log} log
*/
export async function cleanDiskCache(log) {
const args = ['info', 'repository_cache'];
log.debug(`> bazel ${args.join(' ')}`);
const repositoryCachePath = spawnSync('bazel', args);
await cleanPaths(log, [
Path.resolve(Path.dirname(repositoryCachePath), 'disk-cache'),
Path.resolve(repositoryCachePath),
]);
log.success('removed disk caches');
}
/**
* @param {import('./log.mjs').Log} log
* @param {{ offline?: boolean, quiet?: boolean } | undefined} opts
*/
export async function installYarnDeps(log, opts = undefined) {
await runBazel(log, ['run', '@nodejs//:yarn'], {
offline: opts?.offline,
quiet: opts?.quiet,
env: {
SASS_BINARY_SITE:
'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-sass',
RE2_DOWNLOAD_MIRROR:
'https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/node-re2',
},
});
log.success('yarn deps installed');
}
/**
* @param {import('./log.mjs').Log} log
* @param {{ offline?: boolean, quiet?: boolean } | undefined} opts
*/
export async function buildPackages(log, opts = undefined) {
await runBazel(log, ['build', '//packages:build', '--show_result=1'], {
offline: opts?.offline,
quiet: opts?.quiet,
});
log.success('packages built');
}
/**
* @param {string} versionFilename
* @returns
*/
function readBazelToolsVersionFile(versionFilename) {
const version = Fs.readFileSync(Path.resolve(REPO_ROOT, versionFilename), 'utf8').trim();
if (!version) {
throw new Error(
`Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set`
);
}
return version;
}
/**
* @param {import('./log.mjs').Log} log
*/
export function tryRemovingBazeliskFromYarnGlobal(log) {
try {
log.debug('Checking if Bazelisk is installed on the yarn global scope');
const stdout = spawnSync('yarn', ['global', 'list']);
if (stdout.includes(`@bazel/bazelisk@`)) {
log.debug('Bazelisk was found on yarn global scope, removing it');
spawnSync('yarn', ['global', 'remove', `@bazel/bazelisk`]);
log.info(`bazelisk was installed on Yarn global packages and is now removed`);
return true;
}
return false;
} catch {
return false;
}
}
/**
* @param {import('./log.mjs').Log} log
*/
export function isInstalled(log) {
try {
log.debug('getting bazel version');
const stdout = spawnSync('bazel', ['--version']).trim();
const bazelVersion = readBazelToolsVersionFile('.bazelversion');
if (stdout === `bazel ${bazelVersion}`) {
return true;
} else {
log.info(`Bazel is installed (${stdout}), but was expecting ${bazelVersion}`);
return false;
}
} catch {
return false;
}
}
/**
* @param {import('./log.mjs').Log} log
*/
export function ensureInstalled(log) {
if (isInstalled(log)) {
return;
}
// Install bazelisk if not installed
log.debug(`reading bazel tools versions from version files`);
const bazeliskVersion = readBazelToolsVersionFile('.bazeliskversion');
const bazelVersion = readBazelToolsVersionFile('.bazelversion');
log.info(`installing Bazel tools`);
log.debug(
`bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`
);
spawnSync('npm', ['install', '--global', `@bazel/bazelisk@${bazeliskVersion}`], {
env: {
USE_BAZEL_VERSION: bazelVersion,
},
});
const isBazelBinAvailableAfterInstall = isInstalled(log);
if (!isBazelBinAvailableAfterInstall) {
throw new Error(
`an error occurred when installing the Bazel tools. Please make sure you have access to npm globally installed modules on your $PATH`
);
}
log.success(`bazel tools installed`);
}

30
kbn_pm/src/lib/clean.mjs Normal file
View file

@ -0,0 +1,30 @@
/*
* 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 Fs from 'fs';
import Fsp from 'fs/promises';
import Path from 'path';
/**
*
* @param {import('@kbn/some-dev-log').SomeDevLog} log
* @param {string[]} paths
*/
export async function cleanPaths(log, paths) {
for (const path of paths) {
if (!Fs.existsSync(path)) {
continue;
}
log.info('deleting', Path.relative(process.cwd(), path));
await Fsp.rm(path, {
recursive: true,
force: true,
});
}
}

View file

@ -0,0 +1,48 @@
/*
* 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 { isObj } from './obj_helpers.mjs';
/** @typedef {Error & { showHelp: boolean, exitCode?: number }} CliError */
/**
* Create a CliError instance
* @param {string} message
* @param {{ showHelp?: boolean, exitCode?: number } | undefined} options
* @returns {CliError}
*/
export function createCliError(message, options = undefined) {
/** @type {true} */
const __isCliError = true;
return Object.assign(new Error(message), {
__isCliError,
showHelp: options?.showHelp || false,
exitCode: options?.exitCode,
});
}
/**
* @param {string} message
*/
export function createFlagError(message) {
return createCliError(message, {
showHelp: true,
exitCode: 1,
});
}
/**
* Determine if the passed value is a CliError
*
* @param {unknown} error
* @returns {error is CliError}
*/
export function isCliError(error) {
return isObj(error) && !!error.__isCliError;
}

49
kbn_pm/src/lib/colors.mjs Normal file
View file

@ -0,0 +1,49 @@
/*
* 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.
*/
/**
* Print an error title, prints on a red background with white text
* @param {string} txt
*/
export const err = (txt) => `\x1b[41m\x1b[37m${txt}\x1b[39m\x1b[49m`;
/**
* Print some text with some spacing, with very high contrast and bold text
* @param {string} txt
*/
export const title = (txt) => `\x1b[100m\x1b[37m\x1b[1m ${txt} \x1b[22m\x1b[39m\x1b[49m`;
/**
* Print the yellow warning label
* @param {string} txt
*/
export const warning = (txt) => `\x1b[33m${txt}\x1b[39m`;
/**
* Print the simple blue info label
* @param {string} txt
*/
export const info = (txt) => `\x1b[94m${txt}\x1b[39m`;
/**
* Print a green success label
* @param {string} txt
*/
export const success = (txt) => `\x1b[32m${txt}\x1b[39m`;
/**
* Print the simple dim debug label
* @param {string} txt
*/
export const debug = (txt) => `\x1b[2m${txt}\x1b[22m`;
/**
* Print the bright verbose label
* @param {string} txt
*/
export const verbose = (txt) => `\x1b[35m${txt}\x1b[39m`;

71
kbn_pm/src/lib/command.ts Normal file
View file

@ -0,0 +1,71 @@
/*
* 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 type { Log } from './log.mjs';
import type { Args } from './args.mjs';
/**
* Helper function to easily time specific parts of a kbn command. Does not produce
* timings unless the reportTimings config is also defined
*/
export type SubCommandTimeFn = <T>(id: string, block: () => Promise<T>) => Promise<T>;
/**
* Argument passed to the command run function
*/
export interface CommandRunContext {
args: Args;
log: Log;
time: SubCommandTimeFn;
}
/**
* Description of a command that can be run by kbn/pm
*/
export interface Command {
/**
* The name of the command
*/
name: string;
/**
* Additionall usage details which should be added after the command name
*/
usage?: string;
/**
* Text to follow the name of the command in the help output
*/
intro?: string;
/**
* Summary of the functionality for this command, printed
* between the usage and flags help in the help output
*/
description?: string;
/**
* Description of the flags this command accepts
*/
flagsHelp?: string;
/**
* Function which executes the command.
*/
run(ctx: CommandRunContext): Promise<void>;
/**
* Configuration to send timing data to ci-stats for this command. If the
* time() fn is used those timing records will use the group from this config.
* If this config is not used then the time() fn won't report any data.
*/
reportTimings?: {
group: string;
id: string;
};
}

View file

@ -0,0 +1,75 @@
/*
* 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 Fs from 'fs';
import { REPO_ROOT } from './paths.mjs';
/**
* Attempt to load the synthetic package map, if bootstrap hasn't run successfully
* this might fail.
* @param {import('@kbn/some-dev-log').SomeDevLog} log
* @returns {Promise<import('@kbn/synthetic-package-map').PackageMap>}
*/
async function tryToGetSyntheticPackageMap(log) {
try {
const { readPackageMap } = await import('@kbn/synthetic-package-map');
return readPackageMap();
} catch (error) {
log.warning(
'unable to load synthetic package map, unable to clean target directories in synthetic packages'
);
return new Map();
}
}
/**
* @param {*} packageDir
* @returns {string[]}
*/
export function readCleanPatterns(packageDir) {
let json;
try {
const path = Path.resolve(packageDir, 'package.json');
json = JSON.parse(Fs.readFileSync(path, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
/** @type {string[]} */
const patterns = json.kibana?.clean?.extraPatterns ?? [];
return patterns.flatMap((pattern) => {
const absolute = Path.resolve(packageDir, pattern);
// sanity check to make sure that resolved patterns are "relative" to
// the package dir, if they start with a . then they traverse out of
// the package dir so we drop them
if (Path.relative(packageDir, absolute).startsWith('.')) {
return [];
}
return absolute;
});
}
/**
* @param {import('@kbn/some-dev-log').SomeDevLog} log
* @returns {Promise<string[]>}
*/
export async function findPluginCleanPaths(log) {
const packageMap = await tryToGetSyntheticPackageMap(log);
return [...packageMap.values()].flatMap((repoRelativePath) => {
const pkgDir = Path.resolve(REPO_ROOT, repoRelativePath);
return [Path.resolve(pkgDir, 'target'), ...readCleanPatterns(pkgDir)];
});
}

51
kbn_pm/src/lib/fs.mjs Normal file
View file

@ -0,0 +1,51 @@
/*
* 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 Fs from 'fs';
/**
* @param {string} path
* @returns {string}
*/
export function maybeRealpath(path) {
try {
return Fs.realpathSync.native(path);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
return path;
}
/**
* @param {string} path
* @returns {boolean}
*/
export function isDirectory(path) {
try {
const stat = Fs.statSync(path);
return stat.isDirectory();
} catch (error) {
return false;
}
}
/**
* @param {string} path
* @returns {boolean}
*/
export function isFile(path) {
try {
const stat = Fs.statSync(path);
return stat.isFile();
} catch (error) {
return false;
}
}

56
kbn_pm/src/lib/help.mjs Normal file
View file

@ -0,0 +1,56 @@
/*
* 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 { COMMANDS } from '../commands/index.mjs';
import { dedent, indent } from './indent.mjs';
import { title } from './colors.mjs';
/**
* @param {string | undefined} cmdName
* @returns {Promise<string>}
*/
export async function getHelp(cmdName = undefined) {
const cmd = cmdName && COMMANDS.find((c) => c.name === cmdName);
/**
* @param {number} depth
* @param {import('./command').Command} cmd
* @returns {string[]}
*/
const cmdLines = (depth, cmd) => {
const intro = cmd.intro ? dedent(cmd.intro) : '';
const desc = cmd.description ? dedent(cmd.description) : '';
const flags = cmd.flagsHelp ? dedent(cmd.flagsHelp) : '';
return [
indent(
depth,
`${title(`yarn kbn ${cmd.name}${cmd.usage ? ` ${cmd.usage}` : ''}`)}${
intro ? ` ${intro}` : ''
}`
),
'',
...(desc ? [indent(depth + 2, desc), ''] : []),
...(flags ? [indent(depth + 2, 'Flags:'), indent(depth + 4, flags), ''] : []),
];
};
if (cmd) {
return ['', ...cmdLines(0, cmd)].join('\n');
}
const lines = [
'Usage:',
' yarn kbn <command> [...flags]',
'',
'Commands:',
...COMMANDS.map((cmd) => cmdLines(2, cmd)).flat(),
];
return lines.join('\n');
}

55
kbn_pm/src/lib/indent.mjs Normal file
View file

@ -0,0 +1,55 @@
/*
* 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.
*/
const NON_WS_RE = /\S/;
/**
* @param {string} line
*/
const nonWsStart = (line) => line.match(NON_WS_RE)?.index ?? line.length;
/**
* Dedent the string, trimming all empty lines from the beggining of
* `txt` and finding the first line with non-whitespace characters, then
* subtracting the indent from that line from all subsequent lines
* @param {TemplateStringsArray | string} txts
* @param {...any} vars
*/
export function dedent(txts, ...vars) {
/** @type {string[]} */
const lines = (
Array.isArray(txts) ? txts.reduce((acc, txt, i) => `${acc}${vars[i - 1]}${txt}`) : txts
).split('\n');
while (lines.length && lines[0].trim() === '') {
lines.shift();
}
/** @type {number | undefined} */
let depth;
return lines
.map((l) => {
if (depth === undefined) {
depth = nonWsStart(l);
}
return l.slice(Math.min(nonWsStart(l), depth));
})
.join('\n');
}
/**
* @param {number} width
* @param {string} txt
* @returns {string}
*/
export const indent = (width, txt) =>
txt
.split('\n')
.map((l) => `${' '.repeat(width)}${l}`)
.join('\n');

120
kbn_pm/src/lib/log.mjs Normal file
View file

@ -0,0 +1,120 @@
/*
* 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 { format } from 'util';
import * as Colors from './colors.mjs';
/** @typedef {import('@kbn/some-dev-log').SomeDevLog} SomeDevLog */
/**
* @implements {SomeDevLog}
*/
export class Log {
#flags;
/**
*
* @param {import('@kbn/some-dev-log').SomeLogLevel} level
*/
constructor(level) {
this.#flags = {
error: true,
success: true,
info: level !== 'quiet',
warning: level !== 'quiet',
debug: level === 'debug' || level === 'verbose',
verbose: level === 'verbose',
};
}
/**
* Log an error message
* @param {string} msg
* @param {...any} rest
*/
error(msg, ...rest) {
if (this.#flags.error) {
this._fmt(' ERROR ', Colors.err, msg, rest);
}
}
/**
* Log a verbose message, only shown when using --verbose
* @param {string} msg
* @param {...any} rest
*/
warning(msg, ...rest) {
if (this.#flags.warning) {
this._fmt('warn', Colors.warning, msg, rest);
}
}
/**
* Log a standard message to the log
* @param {string} msg
* @param {...any} rest
*/
info(msg, ...rest) {
if (this.#flags.info) {
this._fmt('info', Colors.info, msg, rest);
}
}
/**
* Log a verbose message, only shown when using --verbose
* @param {string} msg
* @param {...any} rest
*/
success(msg, ...rest) {
if (this.#flags.success) {
this._fmt('success', Colors.success, msg, rest);
}
}
/**
* Log a debug message, only shown when using --debug or --verbose
* @param {string} msg
* @param {...any} rest
*/
debug(msg, ...rest) {
if (this.#flags.debug) {
this._fmt('debg', Colors.debug, msg, rest);
}
}
/**
* Log a verbose message, only shown when using --verbose
* @param {string} msg
* @param {...any} rest
*/
verbose(msg, ...rest) {
if (this.#flags.verbose) {
this._fmt('verb', Colors.verbose, msg, rest);
}
}
/**
* @param {string} tag
* @param {(txt: string) => string} color
* @param {string} msg
* @param {...any} rest
*/
_fmt(tag, color, msg, rest) {
const lines = format(msg, ...rest).split('\n');
const padding = ' '.repeat(tag.length + 1);
this._write(`${color(tag)} ${lines.map((l, i) => (i > 0 ? `${padding}${l}` : l)).join('\n')}`);
}
/**
* @param {string} txt
*/
_write(txt) {
console.log(txt);
}
}

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
/**
* @param {unknown} v
* @returns {v is Record<string, unknown>}
*/
export const isObj = (v) => typeof v === 'object' && v !== null;

11
kbn_pm/src/lib/paths.mjs Normal file
View file

@ -0,0 +1,11 @@
/*
* 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';
export const REPO_ROOT = Path.resolve(Path.dirname(new URL(import.meta.url).pathname), '../../..');

113
kbn_pm/src/lib/spawn.mjs Normal file
View file

@ -0,0 +1,113 @@
/*
* 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 ChildProcess from 'child_process';
import Readline from 'readline';
import { createCliError } from './cli_error.mjs';
import { REPO_ROOT } from './paths.mjs';
import { indent } from './indent.mjs';
/** @typedef {{ cwd?: string, env?: Record<string, string> }} SpawnOpts */
/**
* Run a child process and return it's stdout
* @param {string} cmd
* @param {string[]} args
* @param {undefined | (SpawnOpts & { description?: string })} opts
*/
export function spawnSync(cmd, args, opts = undefined) {
const result = ChildProcess.spawnSync(cmd === 'node' ? process.execPath : cmd, args, {
cwd: opts?.cwd ?? REPO_ROOT,
encoding: 'utf8',
env: {
...process.env,
...opts?.env,
},
});
if (result.status !== null && result.status > 0) {
throw createCliError(
`[${opts?.description ?? cmd}] exitted with ${result.status}:\n${
result.stdout.trim()
? ` stdout:\n${indent(4, result.stdout.trim())}\n\n`
: ' stdout: no output\n'
}${
result.stderr.trim()
? ` stderr:\n${indent(4, result.stderr.trim())}\n\n`
: ' stderr: no output\n'
}`
);
}
return result.stdout;
}
/**
* Print each line of output to the console
* @param {import('stream').Readable} stream
* @param {string | undefined} prefix
*/
async function printLines(stream, prefix) {
const int = Readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
for await (const line of int) {
console.log(prefix ? `${prefix} ${line}` : line);
}
}
/**
* @param {import('events').EventEmitter} emitter
* @param {string} event
* @returns {Promise<any>}
*/
function once(emitter, event) {
return new Promise((resolve) => {
emitter.once(event, resolve);
});
}
/**
* Run a child process and print the output to the log
* @param {string} cmd
* @param {string[]} args
* @param {undefined | (SpawnOpts & { logPrefix?: string })} options
*/
export async function spawnStreaming(cmd, args, options = undefined) {
const proc = ChildProcess.spawn(cmd, args, {
env: {
...process.env,
...options?.env,
},
cwd: options?.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
await Promise.all([
printLines(proc.stdout, options?.logPrefix),
printLines(proc.stderr, options?.logPrefix),
// Wait for process to exit, or error
Promise.race([
once(proc, 'exit').then((code) => {
if (typeof code !== 'number' || code === 0) {
return;
}
throw new Error(`[${cmd}] exitted with code [${code}]`);
}),
once(proc, 'error').then((error) => {
throw error;
}),
]),
]);
}

16
kbn_pm/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "target",
"allowJs": true,
"checkJs": true,
"composite": false,
"target": "ES2022",
"module": "ESNext"
},
"include": [
"src/**/*.mjs",
"src/**/*.ts",
],
"exclude": []
}