refactor(dev-infra): add spawnSync to child process utils, normalize naming of child-process utils (#42394)

Create a `spawnSync` command for common usage, additionally update naming to use `spawn` instead of
`spawnWithDebugOutput`

PR Close #42394
This commit is contained in:
Joey Perrott 2021-06-03 10:54:36 -07:00 committed by Andrew Kushnir
parent e6593ad94a
commit 08444c6679
8 changed files with 147 additions and 79 deletions

View File

@ -144,6 +144,7 @@ var GitCommandError = /** @class */ (function (_super) {
// we sanitize the command that will be part of the error message. // we sanitize the command that will be part of the error message.
_super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this; _super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this;
_this.args = args; _this.args = args;
Object.setPrototypeOf(_this, GitCommandError.prototype);
return _this; return _this;
} }
return GitCommandError; return GitCommandError;

View File

@ -316,6 +316,7 @@ var GitCommandError = /** @class */ (function (_super) {
// we sanitize the command that will be part of the error message. // we sanitize the command that will be part of the error message.
_super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this; _super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this;
_this.args = args; _this.args = args;
Object.setPrototypeOf(_this, GitCommandError.prototype);
return _this; return _this;
} }
return GitCommandError; return GitCommandError;
@ -3309,30 +3310,40 @@ function discoverNewConflictsForPr(newPrNumber, updatedAfter) {
info(`Retrieved ${allPendingPRs.length} total pending PRs`); info(`Retrieved ${allPendingPRs.length} total pending PRs`);
info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`); info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
// Fetch and checkout the PR being checked. // Fetch and checkout the PR being checked.
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`); git.run(['fetch', '-q', requestedPr.headRef.repository.url, requestedPr.headRef.name]);
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`); git.run(['checkout', '-q', '-B', tempWorkingBranch, 'FETCH_HEAD']);
// Rebase the PR against the PRs target branch. // Rebase the PR against the PRs target branch.
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`); git.run(['fetch', '-q', requestedPr.baseRef.repository.url, requestedPr.baseRef.name]);
const result = exec(`git rebase FETCH_HEAD`); try {
if (result.code) { git.run(['rebase', 'FETCH_HEAD'], { stdio: 'ignore' });
error('The requested PR currently has conflicts'); }
cleanUpGitState(previousBranchOrRevision); catch (err) {
process.exit(1); if (err instanceof GitCommandError) {
error('The requested PR currently has conflicts');
git.checkout(previousBranchOrRevision, true);
process.exit(1);
}
throw err;
} }
// Start the progress bar // Start the progress bar
progressBar.start(pendingPrs.length, 0); progressBar.start(pendingPrs.length, 0);
// Check each PR to determine if it can merge cleanly into the repo after the target PR. // Check each PR to determine if it can merge cleanly into the repo after the target PR.
for (const pr of pendingPrs) { for (const pr of pendingPrs) {
// Fetch and checkout the next PR // Fetch and checkout the next PR
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`); git.run(['fetch', '-q', pr.headRef.repository.url, pr.headRef.name]);
exec(`git checkout --detach FETCH_HEAD`); git.run(['checkout', '-q', '--detach', 'FETCH_HEAD']);
// Check if the PR cleanly rebases into the repo after the target PR. // Check if the PR cleanly rebases into the repo after the target PR.
const result = exec(`git rebase ${tempWorkingBranch}`); try {
if (result.code !== 0) { git.run(['rebase', tempWorkingBranch], { stdio: 'ignore' });
conflicts.push(pr); }
catch (err) {
if (err instanceof GitCommandError) {
conflicts.push(pr);
}
throw err;
} }
// Abort any outstanding rebase attempt. // Abort any outstanding rebase attempt.
exec(`git rebase --abort`); git.runGraceful(['rebase', '--abort'], { stdio: 'ignore' });
progressBar.increment(1); progressBar.increment(1);
} }
// End the progress bar as all PRs have been processed. // End the progress bar as all PRs have been processed.
@ -5824,7 +5835,7 @@ const ReleaseNotesCommandModule = {
* *
* @returns a Promise resolving on success, and rejecting on command failure with the status code. * @returns a Promise resolving on success, and rejecting on command failure with the status code.
*/ */
function spawnInteractiveCommand(command, args, options) { function spawnInteractive(command, args, options) {
if (options === void 0) { options = {}; } if (options === void 0) { options = {}; }
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var commandText = command + " " + args.join(' '); var commandText = command + " " + args.join(' ');
@ -5839,9 +5850,9 @@ function spawnInteractiveCommand(command, args, options) {
* output mode, stdout/stderr output is also printed to the console, or only on error. * output mode, stdout/stderr output is also printed to the console, or only on error.
* *
* @returns a Promise resolving with captured stdout and stderr on success. The promise * @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure. * rejects on command failure
*/ */
function spawnWithDebugOutput(command, args, options) { function spawn(command, args, options) {
if (options === void 0) { options = {}; } if (options === void 0) { options = {}; }
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var commandText = command + " " + args.join(' '); var commandText = command + " " + args.join(' ');
@ -5871,15 +5882,16 @@ function spawnWithDebugOutput(command, args, options) {
process.stderr.write(message); process.stderr.write(message);
} }
}); });
childProcess.on('exit', function (status, signal) { childProcess.on('exit', function (exitCode, signal) {
var exitDescription = status !== null ? "exit code \"" + status + "\"" : "signal \"" + signal + "\""; var exitDescription = exitCode !== null ? "exit code \"" + exitCode + "\"" : "signal \"" + signal + "\"";
var printFn = outputMode === 'on-error' ? error : debug; var printFn = outputMode === 'on-error' ? error : debug;
var status = statusFromExitCodeAndSignal(exitCode, signal);
printFn("Command \"" + commandText + "\" completed with " + exitDescription + "."); printFn("Command \"" + commandText + "\" completed with " + exitDescription + ".");
printFn("Process output: \n" + logOutput); printFn("Process output: \n" + logOutput);
// On success, resolve the promise. Otherwise reject with the captured stderr // On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`. // and stdout log output if the output mode was set to `silent`.
if (status === 0) { if (status === 0 || options.suppressErrorOnFailingExitCode) {
resolve({ stdout: stdout, stderr: stderr }); resolve({ stdout: stdout, stderr: stderr, status: status });
} }
else { else {
reject(outputMode === 'silent' ? logOutput : undefined); reject(outputMode === 'silent' ? logOutput : undefined);
@ -5887,6 +5899,10 @@ function spawnWithDebugOutput(command, args, options) {
}); });
}); });
} }
/** Convert the provided exitCode and signal to a single status code. */
function statusFromExitCodeAndSignal(exitCode, signal) {
return exitCode !== null ? exitCode : signal !== null ? signal : -1;
}
/** /**
* @license * @license
@ -5906,7 +5922,7 @@ function runNpmPublish(packagePath, distTag, registryUrl) {
if (registryUrl !== undefined) { if (registryUrl !== undefined) {
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
yield spawnWithDebugOutput('npm', args, { cwd: packagePath, mode: 'silent' }); yield spawn('npm', args, { cwd: packagePath, mode: 'silent' });
}); });
} }
/** /**
@ -5920,7 +5936,7 @@ function setNpmTagForPackage(packageName, distTag, version, registryUrl) {
if (registryUrl !== undefined) { if (registryUrl !== undefined) {
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
yield spawnWithDebugOutput('npm', args, { mode: 'silent' }); yield spawn('npm', args, { mode: 'silent' });
}); });
} }
/** /**
@ -5935,7 +5951,7 @@ function npmIsLoggedIn(registryUrl) {
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
try { try {
yield spawnWithDebugOutput('npm', args, { mode: 'silent' }); yield spawn('npm', args, { mode: 'silent' });
} }
catch (e) { catch (e) {
return false; return false;
@ -5958,7 +5974,7 @@ function npmLogin(registryUrl) {
} }
// The login command prompts for username, password and other profile information. Hence // The login command prompts for username, password and other profile information. Hence
// the process needs to be interactive (i.e. respecting current TTYs stdin). // the process needs to be interactive (i.e. respecting current TTYs stdin).
yield spawnInteractiveCommand('npm', args); yield spawnInteractive('npm', args);
}); });
} }
/** /**
@ -5975,7 +5991,7 @@ function npmLogout(registryUrl) {
args.splice(1, 0, '--registry', registryUrl); args.splice(1, 0, '--registry', registryUrl);
} }
try { try {
yield spawnWithDebugOutput('npm', args, { mode: 'silent' }); yield spawn('npm', args, { mode: 'silent' });
} }
finally { finally {
return npmIsLoggedIn(registryUrl); return npmIsLoggedIn(registryUrl);
@ -6097,7 +6113,7 @@ function invokeSetNpmDistCommand(npmDistTag, version) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
try { try {
// Note: No progress indicator needed as that is the responsibility of the command. // Note: No progress indicator needed as that is the responsibility of the command.
yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]); yield spawn('yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`)); info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
} }
catch (e) { catch (e) {
@ -6117,7 +6133,7 @@ function invokeReleaseBuildCommand() {
try { try {
// Since we expect JSON to be printed from the `ng-dev release build` command, // Since we expect JSON to be printed from the `ng-dev release build` command,
// we spawn the process in silent mode. We have set up an Ora progress spinner. // we spawn the process in silent mode. We have set up an Ora progress spinner.
const { stdout } = yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], { mode: 'silent' }); const { stdout } = yield spawn('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], { mode: 'silent' });
spinner.stop(); spinner.stop();
info(green(' ✓ Built release output for all packages.')); info(green(' ✓ Built release output for all packages.'));
// The `ng-dev release build` command prints a JSON array to stdout // The `ng-dev release build` command prints a JSON array to stdout
@ -6141,7 +6157,7 @@ function invokeYarnInstallCommand(projectDir) {
try { try {
// Note: No progress indicator needed as that is the responsibility of the command. // Note: No progress indicator needed as that is the responsibility of the command.
// TODO: Consider using an Ora spinner instead to ensure minimal console output. // TODO: Consider using an Ora spinner instead to ensure minimal console output.
yield spawnWithDebugOutput('yarn', ['install', '--frozen-lockfile', '--non-interactive'], { cwd: projectDir }); yield spawn('yarn', ['install', '--frozen-lockfile', '--non-interactive'], { cwd: projectDir });
info(green(' ✓ Installed project dependencies.')); info(green(' ✓ Installed project dependencies.'));
} }
catch (e) { catch (e) {
@ -7279,7 +7295,7 @@ class ReleaseTool {
try { try {
// Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it // Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it
// should be part of the shell's `$PATH`. This is necessary for compatibility with Windows. // should be part of the shell's `$PATH`. This is necessary for compatibility with Windows.
const pyVersion = yield spawnWithDebugOutput('env', ['python', '--version'], { mode: 'silent' }); const pyVersion = yield spawn('env', ['python', '--version'], { mode: 'silent' });
const version = pyVersion.stdout.trim() || pyVersion.stderr.trim(); const version = pyVersion.stdout.trim() || pyVersion.stderr.trim();
if (version.startsWith('Python 3.')) { if (version.startsWith('Python 3.')) {
debug(`Local python version: ${version}`); debug(`Local python version: ${version}`);

View File

@ -11,7 +11,7 @@ import {types as graphqlTypes} from 'typed-graphqlify';
import {error, info} from '../../utils/console'; import {error, info} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
import {GitClient} from '../../utils/git/git-client'; import {GitCommandError} from '../../utils/git/git-client';
import {getPendingPrs} from '../../utils/github'; import {getPendingPrs} from '../../utils/github';
import {exec} from '../../utils/shelljs'; import {exec} from '../../utils/shelljs';
@ -95,16 +95,20 @@ export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfte
info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`); info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
// Fetch and checkout the PR being checked. // Fetch and checkout the PR being checked.
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`); git.run(['fetch', '-q', requestedPr.headRef.repository.url, requestedPr.headRef.name]);
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`); git.run(['checkout', '-q', '-B', tempWorkingBranch, 'FETCH_HEAD']);
// Rebase the PR against the PRs target branch. // Rebase the PR against the PRs target branch.
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`); git.run(['fetch', '-q', requestedPr.baseRef.repository.url, requestedPr.baseRef.name]);
const result = exec(`git rebase FETCH_HEAD`); try {
if (result.code) { git.run(['rebase', 'FETCH_HEAD'], {stdio: 'ignore'});
error('The requested PR currently has conflicts'); } catch (err) {
cleanUpGitState(previousBranchOrRevision); if (err instanceof GitCommandError) {
process.exit(1); error('The requested PR currently has conflicts');
git.checkout(previousBranchOrRevision, true);
process.exit(1);
}
throw err;
} }
// Start the progress bar // Start the progress bar
@ -113,15 +117,19 @@ export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfte
// Check each PR to determine if it can merge cleanly into the repo after the target PR. // Check each PR to determine if it can merge cleanly into the repo after the target PR.
for (const pr of pendingPrs) { for (const pr of pendingPrs) {
// Fetch and checkout the next PR // Fetch and checkout the next PR
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`); git.run(['fetch', '-q', pr.headRef.repository.url, pr.headRef.name]);
exec(`git checkout --detach FETCH_HEAD`); git.run(['checkout', '-q', '--detach', 'FETCH_HEAD']);
// Check if the PR cleanly rebases into the repo after the target PR. // Check if the PR cleanly rebases into the repo after the target PR.
const result = exec(`git rebase ${tempWorkingBranch}`); try {
if (result.code !== 0) { git.run(['rebase', tempWorkingBranch], {stdio: 'ignore'});
conflicts.push(pr); } catch (err) {
if (err instanceof GitCommandError) {
conflicts.push(pr);
}
throw err;
} }
// Abort any outstanding rebase attempt. // Abort any outstanding rebase attempt.
exec(`git rebase --abort`); git.runGraceful(['rebase', '--abort'], {stdio: 'ignore'});
progressBar.increment(1); progressBar.increment(1);
} }

View File

@ -9,7 +9,7 @@
import * as ora from 'ora'; import * as ora from 'ora';
import * as semver from 'semver'; import * as semver from 'semver';
import {spawnWithDebugOutput} from '../../utils/child-process'; import {spawn} from '../../utils/child-process';
import {error, green, info, red} from '../../utils/console'; import {error, green, info, red} from '../../utils/console';
import {BuiltPackage} from '../config/index'; import {BuiltPackage} from '../config/index';
import {NpmDistTag} from '../versioning'; import {NpmDistTag} from '../versioning';
@ -40,7 +40,7 @@ import {FatalReleaseActionError} from './actions-error';
export async function invokeSetNpmDistCommand(npmDistTag: NpmDistTag, version: semver.SemVer) { export async function invokeSetNpmDistCommand(npmDistTag: NpmDistTag, version: semver.SemVer) {
try { try {
// Note: No progress indicator needed as that is the responsibility of the command. // Note: No progress indicator needed as that is the responsibility of the command.
await spawnWithDebugOutput( await spawn(
'yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]); 'yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`)); info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
} catch (e) { } catch (e) {
@ -59,8 +59,8 @@ export async function invokeReleaseBuildCommand(): Promise<BuiltPackage[]> {
try { try {
// Since we expect JSON to be printed from the `ng-dev release build` command, // Since we expect JSON to be printed from the `ng-dev release build` command,
// we spawn the process in silent mode. We have set up an Ora progress spinner. // we spawn the process in silent mode. We have set up an Ora progress spinner.
const {stdout} = await spawnWithDebugOutput( const {stdout} =
'yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], {mode: 'silent'}); await spawn('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], {mode: 'silent'});
spinner.stop(); spinner.stop();
info(green(' ✓ Built release output for all packages.')); info(green(' ✓ Built release output for all packages.'));
// The `ng-dev release build` command prints a JSON array to stdout // The `ng-dev release build` command prints a JSON array to stdout
@ -82,8 +82,7 @@ export async function invokeYarnInstallCommand(projectDir: string): Promise<void
try { try {
// Note: No progress indicator needed as that is the responsibility of the command. // Note: No progress indicator needed as that is the responsibility of the command.
// TODO: Consider using an Ora spinner instead to ensure minimal console output. // TODO: Consider using an Ora spinner instead to ensure minimal console output.
await spawnWithDebugOutput( await spawn('yarn', ['install', '--frozen-lockfile', '--non-interactive'], {cwd: projectDir});
'yarn', ['install', '--frozen-lockfile', '--non-interactive'], {cwd: projectDir});
info(green(' ✓ Installed project dependencies.')); info(green(' ✓ Installed project dependencies.'));
} catch (e) { } catch (e) {
error(e); error(e);

View File

@ -8,7 +8,7 @@
import {ListChoiceOptions, prompt} from 'inquirer'; import {ListChoiceOptions, prompt} from 'inquirer';
import {spawnWithDebugOutput} from '../../utils/child-process'; import {spawn} from '../../utils/child-process';
import {GithubConfig} from '../../utils/config'; import {GithubConfig} from '../../utils/config';
import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console'; import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
@ -138,8 +138,7 @@ export class ReleaseTool {
try { try {
// Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it // Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it
// should be part of the shell's `$PATH`. This is necessary for compatibility with Windows. // should be part of the shell's `$PATH`. This is necessary for compatibility with Windows.
const pyVersion = const pyVersion = await spawn('env', ['python', '--version'], {mode: 'silent'});
await spawnWithDebugOutput('env', ['python', '--version'], {mode: 'silent'});
const version = pyVersion.stdout.trim() || pyVersion.stderr.trim(); const version = pyVersion.stdout.trim() || pyVersion.stderr.trim();
if (version.startsWith('Python 3.')) { if (version.startsWith('Python 3.')) {
debug(`Local python version: ${version}`); debug(`Local python version: ${version}`);

View File

@ -7,7 +7,9 @@
*/ */
import * as semver from 'semver'; import * as semver from 'semver';
import {spawnInteractiveCommand, spawnWithDebugOutput} from '../../utils/child-process';
import {spawn, spawnInteractive} from '../../utils/child-process';
import {NpmDistTag} from './npm-registry'; import {NpmDistTag} from './npm-registry';
/** /**
@ -21,7 +23,7 @@ export async function runNpmPublish(
if (registryUrl !== undefined) { if (registryUrl !== undefined) {
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
await spawnWithDebugOutput('npm', args, {cwd: packagePath, mode: 'silent'}); await spawn('npm', args, {cwd: packagePath, mode: 'silent'});
} }
/** /**
@ -35,7 +37,7 @@ export async function setNpmTagForPackage(
if (registryUrl !== undefined) { if (registryUrl !== undefined) {
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
await spawnWithDebugOutput('npm', args, {mode: 'silent'}); await spawn('npm', args, {mode: 'silent'});
} }
/** /**
@ -49,7 +51,7 @@ export async function npmIsLoggedIn(registryUrl: string|undefined): Promise<bool
args.push('--registry', registryUrl); args.push('--registry', registryUrl);
} }
try { try {
await spawnWithDebugOutput('npm', args, {mode: 'silent'}); await spawn('npm', args, {mode: 'silent'});
} catch (e) { } catch (e) {
return false; return false;
} }
@ -70,7 +72,7 @@ export async function npmLogin(registryUrl: string|undefined) {
} }
// The login command prompts for username, password and other profile information. Hence // The login command prompts for username, password and other profile information. Hence
// the process needs to be interactive (i.e. respecting current TTYs stdin). // the process needs to be interactive (i.e. respecting current TTYs stdin).
await spawnInteractiveCommand('npm', args); await spawnInteractive('npm', args);
} }
/** /**
@ -86,7 +88,7 @@ export async function npmLogout(registryUrl: string|undefined): Promise<boolean>
args.splice(1, 0, '--registry', registryUrl); args.splice(1, 0, '--registry', registryUrl);
} }
try { try {
await spawnWithDebugOutput('npm', args, {mode: 'silent'}); await spawn('npm', args, {mode: 'silent'});
} finally { } finally {
return npmIsLoggedIn(registryUrl); return npmIsLoggedIn(registryUrl);
} }

View File

@ -6,21 +6,34 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {spawn, SpawnOptions} from 'child_process'; import {spawn as _spawn, SpawnOptions as _SpawnOptions, spawnSync as _spawnSync, SpawnSyncOptions as _SpawnSyncOptions} from 'child_process';
import {debug, error} from './console'; import {debug, error} from './console';
/** Interface describing the options for spawning a process. */
export interface SpawnedProcessOptions extends Omit<SpawnOptions, 'stdio'> { export interface SpawnSyncOptions extends Omit<_SpawnSyncOptions, 'shell'|'stdio'> {
/** Console output mode. Defaults to "enabled". */ /** Whether to prevent exit codes being treated as failures. */
mode?: 'enabled'|'silent'|'on-error'; suppressErrorOnFailingExitCode?: boolean;
} }
/** Interface describing the options for spawning a process. */
export interface SpawnOptions extends Omit<_SpawnOptions, 'shell'|'stdio'> {
/** Console output mode. Defaults to "enabled". */
mode?: 'enabled'|'silent'|'on-error';
/** Whether to prevent exit codes being treated as failures. */
suppressErrorOnFailingExitCode?: boolean;
}
/** Interface describing the options for spawning an interactive process. */
export type SpawnInteractiveCommandOptions = Omit<_SpawnOptions, 'shell'|'stdio'>;
/** Interface describing the result of a spawned process. */ /** Interface describing the result of a spawned process. */
export interface SpawnedProcessResult { export interface SpawnResult {
/** Captured stdout in string format. */ /** Captured stdout in string format. */
stdout: string; stdout: string;
/** Captured stderr in string format. */ /** Captured stderr in string format. */
stderr: string; stderr: string;
/** The exit code or signal of the process. */
status: number|NodeJS.Signals;
} }
/** /**
@ -29,12 +42,12 @@ export interface SpawnedProcessResult {
* *
* @returns a Promise resolving on success, and rejecting on command failure with the status code. * @returns a Promise resolving on success, and rejecting on command failure with the status code.
*/ */
export function spawnInteractiveCommand( export function spawnInteractive(
command: string, args: string[], options: Omit<SpawnOptions, 'stdio'> = {}) { command: string, args: string[], options: SpawnInteractiveCommandOptions = {}) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`; const commandText = `${command} ${args.join(' ')}`;
debug(`Executing command: ${commandText}`); debug(`Executing command: ${commandText}`);
const childProcess = spawn(command, args, {...options, shell: true, stdio: 'inherit'}); const childProcess = _spawn(command, args, {...options, shell: true, stdio: 'inherit'});
childProcess.on('exit', status => status === 0 ? resolve() : reject(status)); childProcess.on('exit', status => status === 0 ? resolve() : reject(status));
}); });
} }
@ -45,18 +58,17 @@ export function spawnInteractiveCommand(
* output mode, stdout/stderr output is also printed to the console, or only on error. * output mode, stdout/stderr output is also printed to the console, or only on error.
* *
* @returns a Promise resolving with captured stdout and stderr on success. The promise * @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure. * rejects on command failure
*/ */
export function spawnWithDebugOutput( export function spawn(
command: string, args: string[], command: string, args: string[], options: SpawnOptions = {}): Promise<SpawnResult> {
options: SpawnedProcessOptions = {}): Promise<SpawnedProcessResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`; const commandText = `${command} ${args.join(' ')}`;
const outputMode = options.mode; const outputMode = options.mode;
debug(`Executing command: ${commandText}`); debug(`Executing command: ${commandText}`);
const childProcess = spawn(command, args, {...options, shell: true, stdio: 'pipe'}); const childProcess = _spawn(command, args, {...options, shell: true, stdio: 'pipe'});
let logOutput = ''; let logOutput = '';
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@ -83,20 +95,51 @@ export function spawnWithDebugOutput(
} }
}); });
childProcess.on('exit', (status, signal) => { childProcess.on('exit', (exitCode, signal) => {
const exitDescription = status !== null ? `exit code "${status}"` : `signal "${signal}"`; const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`;
const printFn = outputMode === 'on-error' ? error : debug; const printFn = outputMode === 'on-error' ? error : debug;
const status = statusFromExitCodeAndSignal(exitCode, signal);
printFn(`Command "${commandText}" completed with ${exitDescription}.`); printFn(`Command "${commandText}" completed with ${exitDescription}.`);
printFn(`Process output: \n${logOutput}`); printFn(`Process output: \n${logOutput}`);
// On success, resolve the promise. Otherwise reject with the captured stderr // On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`. // and stdout log output if the output mode was set to `silent`.
if (status === 0) { if (status === 0 || options.suppressErrorOnFailingExitCode) {
resolve({stdout, stderr}); resolve({stdout, stderr, status});
} else { } else {
reject(outputMode === 'silent' ? logOutput : undefined); reject(outputMode === 'silent' ? logOutput : undefined);
} }
}); });
}); });
} }
/**
* Spawns a given command with the specified arguments inside a shell syncronously.
*
* @returns The command's stdout and stderr.
*/
export function spawnSync(
command: string, args: string[], options: SpawnOptions = {}): SpawnResult {
const commandText = `${command} ${args.join(' ')}`;
debug(`Executing command: ${commandText}`);
const {status: exitCode, signal, stdout, stderr} =
_spawnSync(command, args, {...options, encoding: 'utf8', shell: true, stdio: 'pipe'});
/** The status of the spawn result. */
const status = statusFromExitCodeAndSignal(exitCode, signal);
if (status === 0 || options.suppressErrorOnFailingExitCode) {
return {status, stdout, stderr};
}
throw new Error(stderr);
}
/** Convert the provided exitCode and signal to a single status code. */
function statusFromExitCodeAndSignal(exitCode: number|null, signal: NodeJS.Signals|null) {
return exitCode !== null ? exitCode : signal !== null ? signal : -1;
}

View File

@ -9,7 +9,6 @@
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {Options as SemVerOptions, parse, SemVer} from 'semver'; import {Options as SemVerOptions, parse, SemVer} from 'semver';
import {spawnWithDebugOutput} from '../child-process';
import {getConfig, GithubConfig, NgDevConfig} from '../config'; import {getConfig, GithubConfig, NgDevConfig} from '../config';
import {debug, info} from '../console'; import {debug, info} from '../console';
import {DryRunError, isDryRun} from '../dry-run'; import {DryRunError, isDryRun} from '../dry-run';
@ -24,6 +23,7 @@ export class GitCommandError extends Error {
// accidentally leak the Github token that might be used in a command, // accidentally leak the Github token that might be used in a command,
// we sanitize the command that will be part of the error message. // we sanitize the command that will be part of the error message.
super(`Command failed: git ${client.sanitizeConsoleOutput(args.join(' '))}`); super(`Command failed: git ${client.sanitizeConsoleOutput(args.join(' '))}`);
Object.setPrototypeOf(this, GitCommandError.prototype);
} }
} }