cli: added watch-deps command

This commit is contained in:
Patrik Oldsberg
2020-02-27 18:18:52 +01:00
parent 83b8786378
commit 0369d1ac06
9 changed files with 274 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"chokidar": "^3.3.1",
"commander": "^4.1.1",
"dashify": "^2.0.0",
"fs-extra": "^8.1.0",
@@ -0,0 +1,19 @@
import { spawn } from 'child_process';
import { createLogger } from './logger';
export function startChild(args: string[]) {
const [command, ...commandArgs] = args;
const child = spawn(command, commandArgs, {
stdio: ['inherit', 'pipe', 'pipe'],
});
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
const log = createLogger();
child.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
child.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
}
@@ -0,0 +1,49 @@
import { spawn } from 'child_process';
import { Logger } from './logger';
import chalk from 'chalk';
import { Package } from './packages';
export function startCompiler(pkg: Package, log: Logger) {
// First we figure out which yarn script is a available, falling back to "build --watch"
const scriptName = ['build:watch', 'watch'].find(
script => script in pkg.scripts,
);
const args = scriptName ? [scriptName] : ['build', '--watch'];
// Start the watch script inside the dependency
const watch = spawn('yarn', ['run', ...args], {
cwd: pkg.location,
stdio: 'pipe',
});
watch.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
watch.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
const promise = new Promise<void>((resolve, reject) => {
watch.on('error', error => {
reject(error);
});
watch.on('close', (code: number) => {
if (code !== 0) {
const msg = `Compiler exited with code ${code}`;
log.err(chalk.red(msg));
reject(new Error(msg));
} else {
resolve();
}
});
});
return {
promise,
close() {
watch.kill('SIGINT');
},
};
}
@@ -0,0 +1,53 @@
import { resolve as resolvePath } from 'path';
import { readFileSync } from 'fs';
import chalk from 'chalk';
import { createLoggerFactory } from './logger';
import { findAllDeps } from './packages';
import { startWatchers } from './watcher';
import { startCompiler } from './compiler';
import { startChild } from './child';
const PACKAGE_BLACKLIST = [
// We never want to watch for changes in the cli, but all packages will depend on it.
'@spotify-backstage/cli',
];
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
/*
* The watch-deps command is meant to improve iteration speed while working in a large monorepo
* with packages that are built independently, meaning packages depends on each other's build output.
*
* The command traverses all dependencies of the current package within the monorepo, and starts
* watching for updates in all those packages. If a change is detected, we stop listening for changes,
* and instead start up watch mode for that package. Starting watch mode means running the first
* available yarn script out of "build:watch", "watch", or "build" --watch.
*/
export default async (_command: any, args: string[]) => {
const localPackagePath = resolvePath('package.json');
const packageJson = JSON.parse(readFileSync(localPackagePath, 'utf8'));
// Find all direct and transitive local dependencies of the current package.
const allDeps = await findAllDeps(packageJson.name, PACKAGE_BLACKLIST);
// Rotate through different prefix colors to make it easier to differenciate between different deps
const logFactory = createLoggerFactory([
chalk.yellow,
chalk.blue,
chalk.magenta,
chalk.green,
chalk.cyan,
]);
// We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected
await startWatchers(allDeps, WATCH_LOCATIONS, pkg => {
startCompiler(pkg, logFactory(pkg.name)).promise.catch(error => {
console.error(error);
});
});
if (args.length) {
startChild(args);
}
};
@@ -0,0 +1,40 @@
export type Logger = {
out(msg: string): void;
err(msg: string): void;
};
export type ColorFunc = (msg: string) => string;
// Logger utility that prefixes logs and removes terminal clear commands
export function createLogger(prefix: string = ''): Logger {
const write = (stream: NodeJS.WriteStream, msg: string) => {
if (msg.startsWith('\x1b\x63')) {
msg = msg.slice(2);
}
const str = msg.trimRight().replace(/^/gm, prefix) + '\n';
stream.write(str, 'utf8');
};
return {
out(msg: string) {
write(process.stdout, msg);
},
err(msg: string) {
write(process.stderr, msg);
},
};
}
// A factory for creating loggers that rotate between different coloring functions
export function createLoggerFactory(colorFuncs: ColorFunc[]) {
let colorIndex = 0;
return (name: string) => {
const colorFunc = colorFuncs[colorIndex];
colorIndex = (colorIndex + 1) % colorFuncs.length;
const prefix = `${colorFunc(name)}: `;
return createLogger(prefix);
};
}
@@ -0,0 +1,46 @@
import { resolve as resolvePath } from 'path';
const LernaProject = require('@lerna/project');
const PackageGraph = require('@lerna/package-graph');
export type Package = {
name: string;
location: string;
scripts: { [name in string]: string };
};
// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist
export async function findAllDeps(
rootPackageName: string,
blacklist: string[],
): Promise<Package[]> {
const project = new LernaProject(resolvePath('.'));
const packages = await project.getPackages();
const graph = new PackageGraph(packages);
const deps = new Map<string, any>();
const searchNames = [rootPackageName];
while (searchNames.length) {
const name = searchNames.pop()!;
if (deps.has(name)) {
continue;
}
const node = graph.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
searchNames.push(...node.localDependencies.keys());
deps.set(name, node.pkg);
}
deps.delete(rootPackageName);
for (const name of blacklist) {
deps.delete(name);
}
return [...deps.values()];
}
@@ -0,0 +1,45 @@
import { resolve as resolvePath } from 'path';
import chokidar from 'chokidar';
import { Package } from './packages';
/*
* Watch for changes inside a collection of packages. When a change is detected, stop
* watching and call the callback with the package the change occured in.
*
* The returned promise is resolved once all watchers are ready.
*/
export async function startWatchers(
packages: Package[],
paths: string[],
callback: (pkg: Package) => void,
): Promise<void> {
const readyPromises = [];
for (const pkg of packages) {
let signalled = false;
const watchLocations = paths.map(path => resolvePath(pkg.location, path));
const watcher = chokidar
.watch(watchLocations, {
cwd: pkg.location,
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
if (!signalled) {
signalled = true;
callback(pkg);
}
watcher.close();
});
readyPromises.push(
new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
}),
);
}
await Promise.all(readyPromises);
}
+6
View File
@@ -1,6 +1,7 @@
import program from 'commander';
import createPluginCommand from './commands/createPlugin';
import servePlugin from './commands/servePlugin';
import watch from './commands/watch-deps';
process.on('unhandledRejection', err => {
throw err;
@@ -17,6 +18,11 @@ const main = (argv: string[]) => {
.description('Serves a plugin dev folder')
.action(servePlugin);
program
.command('watch-deps')
.description('Watch all dependencies while running another command')
.action(watch);
program.on('command:*', () => {
console.error(
'Invalid command: %s\nSee --help for a list of available commands.',
+15
View File
@@ -4629,6 +4629,21 @@ chokidar@^3.2.2, chokidar@^3.3.0:
optionalDependencies:
fsevents "~2.1.2"
chokidar@^3.3.1:
version "3.3.1"
resolved "https://artifactory.spotify.net/artifactory/api/npm/virtual-npm/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450"
integrity sha1-yE5bPRjZpNd1WP70ZrG/FrvrNFA=
dependencies:
anymatch "~3.1.1"
braces "~3.0.2"
glob-parent "~5.1.0"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.3.0"
optionalDependencies:
fsevents "~2.1.2"
chownr@^1.1.1, chownr@^1.1.2, chownr@^1.1.3:
version "1.1.3"
resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142"