From 0369d1ac061b9f887d28cc7b47896a1a8da35cc9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 27 Feb 2020 18:18:52 +0100 Subject: [PATCH] cli: added watch-deps command --- frontend/packages/cli/package.json | 1 + .../cli/src/commands/watch-deps/child.ts | 19 +++++++ .../cli/src/commands/watch-deps/compiler.ts | 49 +++++++++++++++++ .../cli/src/commands/watch-deps/index.ts | 53 +++++++++++++++++++ .../cli/src/commands/watch-deps/logger.ts | 40 ++++++++++++++ .../cli/src/commands/watch-deps/packages.ts | 46 ++++++++++++++++ .../cli/src/commands/watch-deps/watcher.ts | 45 ++++++++++++++++ frontend/packages/cli/src/index.ts | 6 +++ frontend/yarn.lock | 15 ++++++ 9 files changed, 274 insertions(+) create mode 100644 frontend/packages/cli/src/commands/watch-deps/child.ts create mode 100644 frontend/packages/cli/src/commands/watch-deps/compiler.ts create mode 100644 frontend/packages/cli/src/commands/watch-deps/index.ts create mode 100644 frontend/packages/cli/src/commands/watch-deps/logger.ts create mode 100644 frontend/packages/cli/src/commands/watch-deps/packages.ts create mode 100644 frontend/packages/cli/src/commands/watch-deps/watcher.ts diff --git a/frontend/packages/cli/package.json b/frontend/packages/cli/package.json index 0ed419b36a..8eda17adeb 100644 --- a/frontend/packages/cli/package.json +++ b/frontend/packages/cli/package.json @@ -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", diff --git a/frontend/packages/cli/src/commands/watch-deps/child.ts b/frontend/packages/cli/src/commands/watch-deps/child.ts new file mode 100644 index 0000000000..183a89d457 --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/child.ts @@ -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')); + }); +} diff --git a/frontend/packages/cli/src/commands/watch-deps/compiler.ts b/frontend/packages/cli/src/commands/watch-deps/compiler.ts new file mode 100644 index 0000000000..5389451032 --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/compiler.ts @@ -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((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'); + }, + }; +} diff --git a/frontend/packages/cli/src/commands/watch-deps/index.ts b/frontend/packages/cli/src/commands/watch-deps/index.ts new file mode 100644 index 0000000000..2a283cf094 --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/index.ts @@ -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); + } +}; diff --git a/frontend/packages/cli/src/commands/watch-deps/logger.ts b/frontend/packages/cli/src/commands/watch-deps/logger.ts new file mode 100644 index 0000000000..59020ec1ca --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/logger.ts @@ -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); + }; +} diff --git a/frontend/packages/cli/src/commands/watch-deps/packages.ts b/frontend/packages/cli/src/commands/watch-deps/packages.ts new file mode 100644 index 0000000000..a10ff05f4f --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/packages.ts @@ -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 { + const project = new LernaProject(resolvePath('.')); + const packages = await project.getPackages(); + const graph = new PackageGraph(packages); + + const deps = new Map(); + 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()]; +} diff --git a/frontend/packages/cli/src/commands/watch-deps/watcher.ts b/frontend/packages/cli/src/commands/watch-deps/watcher.ts new file mode 100644 index 0000000000..49e312ac84 --- /dev/null +++ b/frontend/packages/cli/src/commands/watch-deps/watcher.ts @@ -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 { + 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); +} diff --git a/frontend/packages/cli/src/index.ts b/frontend/packages/cli/src/index.ts index f21d9c3dbc..fdf9729bdb 100644 --- a/frontend/packages/cli/src/index.ts +++ b/frontend/packages/cli/src/index.ts @@ -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.', diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7cecc9aac7..aef3b663cd 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"