diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index d5a01d13a7..601e31a9c1 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { run } from 'helpers/run'; +import { run } from 'lib/run'; export default async () => { const args = ['build']; diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index 51806aa9d6..22775242ca 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -14,29 +14,20 @@ * limitations under the License. */ -import { spawn } from 'child_process'; -import { waitForExit } from 'helpers/run'; -import { watchDeps } from 'commands/watch-deps'; -import { createLogger } from 'commands/watch-deps/logger'; +import { run } from 'lib/run'; +import { createLogFunc } from 'lib/logging'; +import { watchDeps } from 'lib/watchDeps'; export default async () => { // Start dynamic watch and build of dependencies, then serve the app await watchDeps({ build: true }); - const child = spawn('react-scripts', ['start'], { + await run('react-scripts', ['start'], { env: { - FORCE_COLOR: 'true', EXTEND_ESLINT: 'true', SKIP_PREFLIGHT_CHECK: 'true', - ...process.env, }, - stdio: ['inherit', 'pipe', 'inherit'], - shell: true, + // We need to avoid clearing the terminal, or the build feedback of dependencies will be lost + stdoutLogFunc: createLogFunc(process.stdout), }); - - // We need to avoid clearing the terminal, or the build feedback of dependencies will be lost - const log = createLogger(); - child.stdout.setEncoding('utf8'); - child.stdout!.on('data', log.out); - await waitForExit(child); }; diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/cli/src/commands/build-cache/index.ts index a8a1a27e9d..e58313c6b2 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/cli/src/commands/build-cache/index.ts @@ -14,48 +14,9 @@ * limitations under the License. */ -import fs from 'fs-extra'; import { Command } from 'commander'; -import { run } from 'helpers/run'; -import { Cache } from './cache'; -import { parseOptions, Options } from './options'; - -function print(msg: string) { - process.stdout.write(`[build-cache] ${msg}\n`); -} - -// Wrap a build function with a cache, which won't call the build function on cache hit -export async function withCache( - options: Options, - buildFunc: () => Promise, -): Promise { - const key = await Cache.readInputKey(options.inputs); - if (!key) { - print('input directory is dirty, skipping cache'); - await fs.remove(options.output); - await buildFunc(); - return; - } - - const cache = await Cache.read(options); - - const cacheResult = cache.query(key); - if (cacheResult.hit) { - if (cacheResult.copy) { - print('external cache hit, copying archive to output folder'); - await cacheResult.copy(options.output); - } else { - print('cache hit, nothing to be done'); - } - return; - } - - print('cache miss, need to build'); - await fs.remove(options.output); - await buildFunc(); - - await cacheResult.archive(options.output, options.maxCacheEntries); -} +import { run } from 'lib/run'; +import { withCache, parseOptions } from 'lib/buildCache'; /* * The build-cache command is used to make builds a no-op if there are no changes to the package. @@ -69,5 +30,3 @@ export default async (cmd: Command, args: string[]) => { await run(args[0], args.slice(1)); }); }; - -export * from './options'; diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index f18250d210..06998a4ca3 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -16,8 +16,8 @@ import fs from 'fs-extra'; import { resolve as resolvePath, relative as relativePath } from 'path'; -import { getDefaultCacheOptions } from 'commands/build-cache/options'; -import { paths } from 'helpers/paths'; +import { paths } from 'lib/paths'; +import { getDefaultCacheOptions } from 'lib/buildCache'; export default async function clean() { const cacheOptions = getDefaultCacheOptions(); diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index cb34dc0686..b5c9ba69eb 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -21,9 +21,9 @@ import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import os from 'os'; -import { Task, templatingTask } from 'helpers/tasks'; -import { paths } from 'helpers/paths'; -import { version } from 'helpers/version'; +import { Task, templatingTask } from 'lib/tasks'; +import { paths } from 'lib/paths'; +import { version } from 'lib/version'; const exec = promisify(execCb); async function checkExists(rootDir: string, name: string) { diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 1af5832818..3c92397a3d 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -25,10 +25,10 @@ import { parseOwnerIds, addCodeownersEntry, getCodeownersFilePath, -} from './lib/codeowners'; -import { paths } from 'helpers/paths'; -import { version } from 'helpers/version'; -import { Task, templatingTask } from 'helpers/tasks'; +} from 'lib/codeowners'; +import { paths } from 'lib/paths'; +import { version } from 'lib/version'; +import { Task, templatingTask } from 'lib/tasks'; const exec = promisify(execCb); async function checkExists(rootDir: string, id: string) { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index d1b5a361a4..2d74b4122f 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -15,7 +15,7 @@ */ import { Command } from 'commander'; -import { run } from 'helpers/run'; +import { run } from 'lib/run'; export default async (cmd: Command) => { const args = ['lint', '--max-warnings=0', '--format=codeframe']; diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 9be71c5e5b..aedf39e5b4 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -15,11 +15,11 @@ */ import { rollup, watch, OutputOptions } from 'rollup'; -import conf from './rollup.config'; import { Command } from 'commander'; -import { withCache, getDefaultCacheOptions } from 'commands/build-cache'; -import { paths } from 'helpers/paths'; import chalk from 'chalk'; +import { withCache, getDefaultCacheOptions } from 'lib/buildCache'; +import { paths } from 'lib/paths'; +import conf from './rollup.config'; function logError(error: any) { console.log(''); diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts index c991fa3efb..f2c46d1976 100644 --- a/packages/cli/src/commands/plugin/rollup.config.ts +++ b/packages/cli/src/commands/plugin/rollup.config.ts @@ -22,7 +22,7 @@ import postcss from 'rollup-plugin-postcss'; import imageFiles from 'rollup-plugin-image-files'; import json from '@rollup/plugin-json'; import { RollupWatchOptions } from 'rollup'; -import { paths } from 'helpers/paths'; +import { paths } from 'lib/paths'; export default { input: 'src/index.ts', diff --git a/packages/cli/src/commands/plugin/testCommand.ts b/packages/cli/src/commands/plugin/testCommand.ts index a1c4572422..333af5494e 100644 --- a/packages/cli/src/commands/plugin/testCommand.ts +++ b/packages/cli/src/commands/plugin/testCommand.ts @@ -15,7 +15,7 @@ */ import { Command } from 'commander'; -import { run } from 'helpers/run'; +import { run } from 'lib/run'; export default async (cmd: Command) => { const args = ['test']; diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 1cd47864c6..17962d10b9 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -17,13 +17,13 @@ import fse from 'fs-extra'; import path from 'path'; import os from 'os'; -import { paths } from '../../helpers/paths'; +import { paths } from 'lib/paths'; import { addExportStatement, capitalize, createTemporaryPluginFolder, } from '../create-plugin/createPlugin'; -import { addCodeownersEntry } from '../create-plugin/lib/codeowners'; +import { addCodeownersEntry } from 'lib/codeowners'; import { removeReferencesFromAppPackage, removeReferencesFromPluginsFile, diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 76b8606973..1b8447fade 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -17,9 +17,9 @@ import fse from 'fs-extra'; import path from 'path'; import chalk from 'chalk'; import inquirer, { Answers, Question } from 'inquirer'; -import { getCodeownersFilePath } from '../create-plugin/lib/codeowners'; -import { paths } from 'helpers/paths'; -import { Task } from 'helpers/tasks'; +import { getCodeownersFilePath } from 'lib/codeowners'; +import { paths } from 'lib/paths'; +import { Task } from 'lib/tasks'; // import os from 'os'; const BACKSTAGE = '@backstage'; diff --git a/packages/cli/src/commands/testCommand.ts b/packages/cli/src/commands/testCommand.ts index 1a1b298b9c..0e180f614e 100644 --- a/packages/cli/src/commands/testCommand.ts +++ b/packages/cli/src/commands/testCommand.ts @@ -15,8 +15,8 @@ */ import { Command } from 'commander'; -import { paths } from 'helpers/paths'; -import { runCheck } from 'helpers/run'; +import { paths } from 'lib/paths'; +import { runCheck } from 'lib/run'; function includesAnyOf(hayStack: string[], ...needles: string[]) { for (const needle of needles) { diff --git a/packages/cli/src/commands/watch-deps/child.ts b/packages/cli/src/commands/watch-deps/child.ts deleted file mode 100644 index a18f384538..0000000000 --- a/packages/cli/src/commands/watch-deps/child.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { spawn } from 'child_process'; - -import { createLogger } from './logger'; - -export function startChild(args: string[]) { - const [command, ...commandArgs] = args; - const child = spawn(command, commandArgs, { - env: { FORCE_COLOR: 'true', ...process.env }, - stdio: ['inherit', 'pipe', 'pipe'], - shell: true, - }); - - // 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')); - }); - return child; -} diff --git a/packages/cli/src/commands/watch-deps/index.ts b/packages/cli/src/commands/watch-deps/index.ts index 68d2417e36..ec9e4be58f 100644 --- a/packages/cli/src/commands/watch-deps/index.ts +++ b/packages/cli/src/commands/watch-deps/index.ts @@ -14,71 +14,10 @@ * limitations under the License. */ -import chalk from 'chalk'; -import fs from 'fs-extra'; -import { createLoggerFactory } from './logger'; -import { findAllDeps } from './packages'; -import { startWatcher, startPackageWatcher } from './watcher'; -import { startCompiler } from './compiler'; -import { startChild } from './child'; -import { waitForExit, run } from 'helpers/run'; -import { paths } from 'helpers/paths'; import { Command } from 'commander'; - -const PACKAGE_BLACKLIST = [ - // We never want to watch for changes in the cli, but all packages will depend on it. - '@backstage/cli', -]; - -const WATCH_LOCATIONS = ['package.json', 'src', 'assets']; - -export type Options = { - build?: boolean; -}; - -// Start watching for dependency changes. -// The returned promise resolves when watchers have started for all current dependencies. -export async function watchDeps(options: Options = {}) { - const localPackagePath = paths.resolveTarget('package.json'); - - // 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, - ]); - - // Find all direct and transitive local dependencies of the current package. - const packageData = await fs.readFile(localPackagePath, 'utf8'); - const packageJson = JSON.parse(packageData); - const packageName = packageJson.name; - - const deps = await findAllDeps(packageName, PACKAGE_BLACKLIST); - - if (options.build) { - await run('lerna', [ - 'run', - '--scope', - packageName, - '--include-dependencies', - 'build', - ]); - } - - // We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected - const watcher = await startWatcher(deps, WATCH_LOCATIONS, pkg => { - startCompiler(pkg, logFactory(pkg.name)).promise.catch(error => { - process.stderr.write(`${error}\n`); - }); - }); - - await startPackageWatcher(localPackagePath, async () => { - const newDeps = await findAllDeps(packageName, PACKAGE_BLACKLIST); - await watcher.update(newDeps); - }); -} +import { run } from 'lib/run'; +import { createLogPipe } from 'lib/logging'; +import { watchDeps, Options } from 'lib/watchDeps'; /* * The watch-deps command is meant to improve iteration speed while working in a large monorepo @@ -99,6 +38,12 @@ export default async (cmd: Command, args: string[]) => { await watchDeps(options); if (args?.length) { - await waitForExit(startChild(args)); + // Use log pipe to avoid clearing the terminal + const logPipe = createLogPipe(); + + await run(args[0], args.slice(1), { + stdoutLogFunc: logPipe(process.stdout), + stderrLogFunc: logPipe(process.stderr), + }); } }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cdca2fa1ed..1c3f66bce7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,8 +16,8 @@ import program from 'commander'; import chalk from 'chalk'; -import { exitWithError } from 'helpers/errors'; -import { version } from 'helpers/version'; +import { exitWithError } from 'lib/errors'; +import { version } from 'lib/version'; const main = (argv: string[]) => { program.name('backstage-cli').version(version); diff --git a/packages/cli/src/commands/build-cache/archive.ts b/packages/cli/src/lib/buildCache/archive.ts similarity index 100% rename from packages/cli/src/commands/build-cache/archive.ts rename to packages/cli/src/lib/buildCache/archive.ts diff --git a/packages/cli/src/commands/build-cache/cache.ts b/packages/cli/src/lib/buildCache/cache.ts similarity index 98% rename from packages/cli/src/commands/build-cache/cache.ts rename to packages/cli/src/lib/buildCache/cache.ts index 4dddaedc6f..9c73f904ab 100644 --- a/packages/cli/src/commands/build-cache/cache.ts +++ b/packages/cli/src/lib/buildCache/cache.ts @@ -16,11 +16,11 @@ import fs from 'fs-extra'; import { resolve as resolvePath, relative as relativePath } from 'path'; -import { runPlain, runCheck } from 'helpers/run'; +import { runPlain, runCheck } from 'lib/run'; import { Options } from './options'; import { extractArchive, createArchive } from './archive'; -import { paths } from 'helpers/paths'; -import { version, isDev } from 'helpers/version'; +import { paths } from 'lib/paths'; +import { version, isDev } from 'lib/version'; const INFO_FILE = '.backstage-build-cache'; diff --git a/packages/cli/src/lib/buildCache/index.ts b/packages/cli/src/lib/buildCache/index.ts new file mode 100644 index 0000000000..70bcbd36b8 --- /dev/null +++ b/packages/cli/src/lib/buildCache/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './withCache'; +export * from './options'; diff --git a/packages/cli/src/commands/build-cache/options.ts b/packages/cli/src/lib/buildCache/options.ts similarity index 97% rename from packages/cli/src/commands/build-cache/options.ts rename to packages/cli/src/lib/buildCache/options.ts index 6903c01ddf..380f84fff5 100644 --- a/packages/cli/src/commands/build-cache/options.ts +++ b/packages/cli/src/lib/buildCache/options.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { Command } from 'commander'; -import { paths } from 'helpers/paths'; +import { paths } from 'lib/paths'; const DEFAULT_CACHE_DIR = '/node_modules/.cache/backstage-builds'; const DEFAULT_MAX_ENTRIES = 10; diff --git a/packages/cli/src/lib/buildCache/withCache.ts b/packages/cli/src/lib/buildCache/withCache.ts new file mode 100644 index 0000000000..d21de94865 --- /dev/null +++ b/packages/cli/src/lib/buildCache/withCache.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { Cache } from './cache'; +import { Options } from './options'; + +function print(msg: string) { + process.stdout.write(`[build-cache] ${msg}\n`); +} + +// Wrap a build function with a cache, which won't call the build function on cache hit +export async function withCache( + options: Options, + buildFunc: () => Promise, +): Promise { + const key = await Cache.readInputKey(options.inputs); + if (!key) { + print('input directory is dirty, skipping cache'); + await fs.remove(options.output); + await buildFunc(); + return; + } + + const cache = await Cache.read(options); + + const cacheResult = cache.query(key); + if (cacheResult.hit) { + if (cacheResult.copy) { + print('external cache hit, copying archive to output folder'); + await cacheResult.copy(options.output); + } else { + print('cache hit, nothing to be done'); + } + return; + } + + print('cache miss, need to build'); + await fs.remove(options.output); + await buildFunc(); + + await cacheResult.archive(options.output, options.maxCacheEntries); +} diff --git a/packages/cli/src/commands/create-plugin/lib/codeowners.test.ts b/packages/cli/src/lib/codeowners/codeowners.test.ts similarity index 100% rename from packages/cli/src/commands/create-plugin/lib/codeowners.test.ts rename to packages/cli/src/lib/codeowners/codeowners.test.ts diff --git a/packages/cli/src/commands/create-plugin/lib/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts similarity index 100% rename from packages/cli/src/commands/create-plugin/lib/codeowners.ts rename to packages/cli/src/lib/codeowners/codeowners.ts diff --git a/packages/cli/src/lib/codeowners/index.ts b/packages/cli/src/lib/codeowners/index.ts new file mode 100644 index 0000000000..97c613488c --- /dev/null +++ b/packages/cli/src/lib/codeowners/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './codeowners'; diff --git a/packages/cli/src/helpers/errors.ts b/packages/cli/src/lib/errors.ts similarity index 100% rename from packages/cli/src/helpers/errors.ts rename to packages/cli/src/lib/errors.ts diff --git a/packages/cli/src/lib/logging.ts b/packages/cli/src/lib/logging.ts new file mode 100644 index 0000000000..ef79b34097 --- /dev/null +++ b/packages/cli/src/lib/logging.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type LogFunc = (data: Buffer | string) => void; +export type LogPipe = (dst: NodeJS.WriteStream) => LogFunc; + +export type LogOptions = { + // If set, prefix each log message with this string + prefix?: string; + + // If true, clear terminal commands will be forwarded, otherwise they are removed + forwardClearTerm?: boolean; +}; + +// Creates a log pipe that binds to a destination stream and forwards logs with optional transforms. +// Use returned logPipe e.g. as follows: child.stdout.on('data', logPipe(process.stdout)) +export function createLogPipe(options: LogOptions = {}): LogPipe { + const { prefix = '', forwardClearTerm = false } = options; + + return (dst: NodeJS.WriteStream) => (data: Buffer | string) => { + let str = typeof data === 'string' ? data : data.toString('utf8'); + + if (!forwardClearTerm) { + str = trimClearTerm(str); + } + if (prefix) { + str = `${prefix}${str}`; + } + + dst.write(Buffer.from(str, 'utf8')); + }; +} + +// Wrapper around createLogPipe to avoid awkward immediate call of returned function +export function createLogFunc( + dst: NodeJS.WriteStream, + options: LogOptions = {}, +): LogFunc { + return createLogPipe(options)(dst); +} + +// Returns the string without terminal clear command if it was prefixed with one. +export function trimClearTerm(msg: string): string { + return msg.startsWith('\x1b\x63') ? msg.slice(2) : msg; +} diff --git a/packages/cli/src/helpers/paths.ts b/packages/cli/src/lib/paths.ts similarity index 100% rename from packages/cli/src/helpers/paths.ts rename to packages/cli/src/lib/paths.ts diff --git a/packages/cli/src/helpers/run.ts b/packages/cli/src/lib/run.ts similarity index 80% rename from packages/cli/src/helpers/run.ts rename to packages/cli/src/lib/run.ts index cbeb2c080c..9eac69e673 100644 --- a/packages/cli/src/helpers/run.ts +++ b/packages/cli/src/lib/run.ts @@ -22,10 +22,15 @@ import { } from 'child_process'; import { ExitCodeError } from './errors'; import { promisify } from 'util'; +import { LogFunc } from './logging'; const exec = promisify(execCb); type SpawnOptionsPartialEnv = Omit & { env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; }; // Runs a child command, returning a promise that is only resolved if the child exits with code 0. @@ -34,19 +39,33 @@ export async function run( args: string[] = [], options: SpawnOptionsPartialEnv = {}, ) { + const { stdoutLogFunc, stderrLogFunc } = options; const env: NodeJS.ProcessEnv = { ...process.env, FORCE_COLOR: 'true', ...(options.env ?? {}), }; + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + const child = spawn(name, args, { - stdio: 'inherit', + stdio, shell: true, ...options, env, }); + if (stdoutLogFunc && child.stdout) { + child.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && child.stderr) { + child.stderr.on('data', stderrLogFunc); + } + await waitForExit(child, name); } diff --git a/packages/cli/src/helpers/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts similarity index 100% rename from packages/cli/src/helpers/tasks.test.ts rename to packages/cli/src/lib/tasks.test.ts diff --git a/packages/cli/src/helpers/tasks.ts b/packages/cli/src/lib/tasks.ts similarity index 100% rename from packages/cli/src/helpers/tasks.ts rename to packages/cli/src/lib/tasks.ts diff --git a/packages/cli/src/helpers/version.ts b/packages/cli/src/lib/version.ts similarity index 100% rename from packages/cli/src/helpers/version.ts rename to packages/cli/src/lib/version.ts diff --git a/packages/cli/src/commands/watch-deps/compiler.ts b/packages/cli/src/lib/watchDeps/compiler.ts similarity index 84% rename from packages/cli/src/commands/watch-deps/compiler.ts rename to packages/cli/src/lib/watchDeps/compiler.ts index ee3ef28ae1..c8a0eb7d78 100644 --- a/packages/cli/src/commands/watch-deps/compiler.ts +++ b/packages/cli/src/lib/watchDeps/compiler.ts @@ -15,11 +15,11 @@ */ import { spawn } from 'child_process'; -import { Logger } from './logger'; +import { LogPipe } from 'lib/logging'; import chalk from 'chalk'; import { Package } from './packages'; -export function startCompiler(pkg: Package, log: Logger) { +export function startCompiler(pkg: Package, logPipe: LogPipe) { // 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, @@ -35,12 +35,9 @@ export function startCompiler(pkg: Package, log: Logger) { }); watch.stdin.end(); - watch.stdout!.on('data', (data: Buffer) => { - log.out(data.toString('utf8')); - }); - watch.stderr!.on('data', data => { - log.err(data.toString('utf8')); - }); + watch.stdout.on('data', logPipe(process.stdout)); + const logErr = logPipe(process.stderr); + watch.stderr.on('data', logErr); const promise = new Promise((resolve, reject) => { watch.on('error', error => { @@ -50,7 +47,7 @@ export function startCompiler(pkg: Package, log: Logger) { watch.on('close', (code: number) => { if (code !== 0) { const msg = `Compiler exited with code ${code}`; - log.err(chalk.red(msg)); + logErr(chalk.red(msg)); reject(new Error(msg)); } else { resolve(); diff --git a/packages/cli/src/lib/watchDeps/index.ts b/packages/cli/src/lib/watchDeps/index.ts new file mode 100644 index 0000000000..a6fc92ca91 --- /dev/null +++ b/packages/cli/src/lib/watchDeps/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './watchDeps'; diff --git a/packages/cli/src/commands/watch-deps/logger.ts b/packages/cli/src/lib/watchDeps/logger.ts similarity index 51% rename from packages/cli/src/commands/watch-deps/logger.ts rename to packages/cli/src/lib/watchDeps/logger.ts index 7d8fae37b0..7f3862509a 100644 --- a/packages/cli/src/commands/watch-deps/logger.ts +++ b/packages/cli/src/lib/watchDeps/logger.ts @@ -14,33 +14,12 @@ * limitations under the License. */ -export type Logger = { - out(msg: string): void; - err(msg: string): void; -}; +import { createLogPipe } from 'lib/logging'; 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) => { - const noClearMsg = msg.startsWith('\x1b\x63') ? msg.slice(2) : msg; - const prefixedMsg = noClearMsg.trimRight().replace(/^/gm, prefix); - stream.write(`${prefixedMsg}\n`, '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[]) { +// A factory for creating log pipes that rotate between different coloring functions +export function createLogPipeFactory(colorFuncs: ColorFunc[]) { let colorIndex = 0; return (name: string) => { @@ -49,6 +28,6 @@ export function createLoggerFactory(colorFuncs: ColorFunc[]) { colorIndex = (colorIndex + 1) % colorFuncs.length; const prefix = `${colorFunc(name)}: `; - return createLogger(prefix); + return createLogPipe({ prefix }); }; } diff --git a/packages/cli/src/commands/watch-deps/packages.ts b/packages/cli/src/lib/watchDeps/packages.ts similarity index 97% rename from packages/cli/src/commands/watch-deps/packages.ts rename to packages/cli/src/lib/watchDeps/packages.ts index f5744a41d4..4eee0f3069 100644 --- a/packages/cli/src/commands/watch-deps/packages.ts +++ b/packages/cli/src/lib/watchDeps/packages.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { paths } from 'helpers/paths'; +import { paths } from 'lib/paths'; const LernaProject = require('@lerna/project'); const PackageGraph = require('@lerna/package-graph'); diff --git a/packages/cli/src/lib/watchDeps/watchDeps.ts b/packages/cli/src/lib/watchDeps/watchDeps.ts new file mode 100644 index 0000000000..e4b5d1e4b3 --- /dev/null +++ b/packages/cli/src/lib/watchDeps/watchDeps.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import fs from 'fs-extra'; +import { createLogPipeFactory } from './logger'; +import { findAllDeps } from './packages'; +import { startWatcher, startPackageWatcher } from './watcher'; +import { startCompiler } from './compiler'; +import { run } from 'lib/run'; +import { paths } from 'lib/paths'; + +const PACKAGE_BLACKLIST = [ + // We never want to watch for changes in the cli, but all packages will depend on it. + '@backstage/cli', +]; + +const WATCH_LOCATIONS = ['package.json', 'src', 'assets']; + +export type Options = { + build?: boolean; +}; + +// Start watching for dependency changes. +// The returned promise resolves when watchers have started for all current dependencies. +export async function watchDeps(options: Options = {}) { + const localPackagePath = paths.resolveTarget('package.json'); + + // Rotate through different prefix colors to make it easier to differenciate between different deps + const createLogPipe = createLogPipeFactory([ + chalk.yellow, + chalk.blue, + chalk.magenta, + chalk.green, + chalk.cyan, + ]); + + // Find all direct and transitive local dependencies of the current package. + const packageData = await fs.readFile(localPackagePath, 'utf8'); + const packageJson = JSON.parse(packageData); + const packageName = packageJson.name; + + const deps = await findAllDeps(packageName, PACKAGE_BLACKLIST); + + if (options.build) { + await run('lerna', [ + 'run', + '--scope', + packageName, + '--include-dependencies', + 'build', + ]); + } + + // We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected + const watcher = await startWatcher(deps, WATCH_LOCATIONS, pkg => { + startCompiler(pkg, createLogPipe(pkg.name)).promise.catch(error => { + process.stderr.write(`${error}\n`); + }); + }); + + await startPackageWatcher(localPackagePath, async () => { + const newDeps = await findAllDeps(packageName, PACKAGE_BLACKLIST); + await watcher.update(newDeps); + }); +} diff --git a/packages/cli/src/commands/watch-deps/watcher.ts b/packages/cli/src/lib/watchDeps/watcher.ts similarity index 94% rename from packages/cli/src/commands/watch-deps/watcher.ts rename to packages/cli/src/lib/watchDeps/watcher.ts index 10be8cbbbc..c6c4487d0a 100644 --- a/packages/cli/src/commands/watch-deps/watcher.ts +++ b/packages/cli/src/lib/watchDeps/watcher.ts @@ -18,7 +18,7 @@ import { resolve as resolvePath } from 'path'; import chalk from 'chalk'; import chokidar from 'chokidar'; import { Package } from './packages'; -import { createLogger } from './logger'; +import { createLogFunc } from 'lib/logging'; export type Watcher = { update(newPackages: Package[]): Promise; @@ -36,7 +36,7 @@ export async function startWatcher( callback: (pkg: Package) => void, ): Promise { const watchedPackageLocations = new Set(); - const logger = createLogger(); + const log = createLogFunc(process.stdout); const watchPackage = async (pkg: Package) => { let signalled = false; @@ -71,7 +71,7 @@ export async function startWatcher( continue; } - logger.out(chalk.green(`Starting watch of new dependency ${pkg.name}`)); + log(chalk.green(`Starting watch of new dependency ${pkg.name}`)); promises.push(watchPackage(pkg)); }