Skip to content

Commit 1933388

Browse files
authored
Merge pull request microsoft#678 from Microsoft/pgonzal/rushx
[rush] Add "rushx" binary for project-specific commands
2 parents e282124 + d047287 commit 1933388

File tree

14 files changed

+384
-35
lines changed

14 files changed

+384
-35
lines changed

apps/rush-lib/src/Rush.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,29 @@ import { IPackageJson } from '@microsoft/node-core-library';
88

99
import { RushCommandLineParser } from './cli/actions/RushCommandLineParser';
1010
import { RushConstants } from './RushConstants';
11+
import { RushX } from './RushX';
1112
import { CommandLineMigrationAdvisor } from './cli/actions/CommandLineMigrationAdvisor';
1213

1314
/**
14-
* Operations involving the rush tool and its operation.
15+
* General operations for the Rush engine.
1516
*
1617
* @public
1718
*/
1819
export class Rush {
1920
private static _version: string;
2021

2122
/**
22-
* Executes the Rush CLI. This is expected to be called by the @microsoft/rush package, which acts as a version
23-
* manager for the Rush tool. The rush-lib API is exposed through the index.ts/js file.
23+
* This API is used by the @microsoft/rush front end to launch the "rush" command-line.
24+
* Third-party tools should not use this API. Instead, they should execute the "rush" binary
25+
* and start a new NodeJS process.
2426
*
2527
* @param launcherVersion - The version of the @microsoft/rush wrapper used to call invoke the CLI.
2628
* @param isManaged - True if the tool was invoked from within a project with a rush.json file, otherwise false. We
2729
* consider a project without a rush.json to be "unmanaged" and we'll print that to the command line when
2830
* the tool is executed. This is mainly used for debugging purposes.
2931
*/
3032
public static launch(launcherVersion: string, isManaged: boolean): void {
31-
console.log(
32-
EOL +
33-
colors.bold(`Rush Multi-Project Build Tool ${Rush.version}` + colors.yellow(isManaged ? '' : ' (unmanaged)')) +
34-
colors.cyan(` - ${RushConstants.rushWebSiteUrl}`) +
35-
EOL
36-
);
33+
Rush._printStartupBanner(isManaged);
3734

3835
if (!CommandLineMigrationAdvisor.checkArgv(process.argv)) {
3936
// The migration advisor recognized an obsolete command-line
@@ -45,6 +42,22 @@ export class Rush {
4542
parser.execute();
4643
}
4744

45+
/**
46+
* This API is used by the @microsoft/rush front end to launch the "rushx" command-line.
47+
* Third-party tools should not use this API. Instead, they should execute the "rushx" binary
48+
* and start a new NodeJS process.
49+
*
50+
* @param launcherVersion - The version of the @microsoft/rush wrapper used to call invoke the CLI.
51+
* @param isManaged - True if the tool was invoked from within a project with a rush.json file, otherwise false. We
52+
* consider a project without a rush.json to be "unmanaged" and we'll print that to the command line when
53+
* the tool is executed. This is mainly used for debugging purposes.
54+
*/
55+
public static launchRushX(launcherVersion: string, isManaged: boolean): void {
56+
Rush._printStartupBanner(isManaged);
57+
58+
RushX.launchRushX(launcherVersion, isManaged);
59+
}
60+
4861
/**
4962
* The currently executing version of the "rush-lib" library.
5063
* This is the same as the Rush tool version for that release.
@@ -59,4 +72,13 @@ export class Rush {
5972

6073
return Rush._version;
6174
}
75+
76+
private static _printStartupBanner(isManaged: boolean): void {
77+
console.log(
78+
EOL +
79+
colors.bold(`Rush Multi-Project Build Tool ${Rush.version}` + colors.yellow(isManaged ? '' : ' (unmanaged)')) +
80+
colors.cyan(` - ${RushConstants.rushWebSiteUrl}`) +
81+
EOL
82+
);
83+
}
6284
}

apps/rush-lib/src/RushX.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as child_process from 'child_process';
5+
import * as colors from 'colors';
6+
import * as os from 'os';
7+
import * as path from 'path';
8+
9+
import {
10+
PackageJsonLookup,
11+
IPackageJson,
12+
Text,
13+
IPackageJsonScriptTable
14+
} from '@microsoft/node-core-library';
15+
import { Utilities } from './utilities/Utilities';
16+
17+
/**
18+
* Parses the "scripts" section from package.json
19+
*/
20+
class ProjectCommandSet {
21+
public readonly malformedScriptNames: string[] = [];
22+
public readonly commandNames: string[] = [];
23+
private readonly _scriptsByName: Map<string, string> = new Map<string, string>();
24+
25+
public constructor(packageJson: IPackageJson) {
26+
const scripts: IPackageJsonScriptTable = packageJson.scripts || { };
27+
28+
for (const scriptName of Object.keys(scripts)) {
29+
if (scriptName[0] === '-' || scriptName.length === 0) {
30+
this.malformedScriptNames.push(scriptName);
31+
} else {
32+
this.commandNames.push(scriptName);
33+
this._scriptsByName.set(scriptName, scripts[scriptName]);
34+
}
35+
}
36+
37+
this.commandNames.sort();
38+
}
39+
40+
public tryGetScriptBody(commandName: string): string | undefined {
41+
return this._scriptsByName.get(commandName);
42+
}
43+
44+
public getScriptBody(commandName: string): string {
45+
const result: string | undefined = this.tryGetScriptBody(commandName);
46+
if (result === undefined) {
47+
throw new Error(`The command "${commandName}" was not found`);
48+
}
49+
return result;
50+
}
51+
}
52+
53+
export class RushX {
54+
public static launchRushX(launcherVersion: string, isManaged: boolean): void {
55+
// NodeJS can sometimes accidentally terminate with a zero exit code (e.g. for an uncaught
56+
// promise exception), so we start with the assumption that the exit code is 1
57+
// and set it to 0 only on success.
58+
process.exitCode = 1;
59+
60+
try {
61+
// Find the governing package.json for this folder:
62+
const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup();
63+
64+
const packageJsonFilePath: string | undefined = packageJsonLookup.tryGetPackageJsonFilePathFor(process.cwd());
65+
if (!packageJsonFilePath) {
66+
console.log(colors.red('This command should be used inside a project folder.'));
67+
console.log(`Unable to find a package.json file in the current working directory or any of its parents.`);
68+
return;
69+
}
70+
71+
const packageJson: IPackageJson = packageJsonLookup.loadPackageJson(packageJsonFilePath);
72+
73+
const projectCommandSet: ProjectCommandSet = new ProjectCommandSet(packageJson);
74+
75+
// 0 = node.exe
76+
// 1 = rushx
77+
const args: string[] = process.argv.slice(2);
78+
79+
// Check for the following types of things:
80+
// rush
81+
// rush --help
82+
// rush -h
83+
// rush --unrecognized-option
84+
if (args.length === 0 || args[0][0] === '-') {
85+
RushX._showUsage(packageJson, projectCommandSet);
86+
return;
87+
}
88+
89+
const commandName: string = args[0];
90+
91+
const scriptBody: string | undefined = projectCommandSet.tryGetScriptBody(commandName);
92+
93+
if (scriptBody === undefined) {
94+
console.log(colors.red(`Error: The command "${commandName}" is not defined in the`
95+
+ ` package.json file for this project.`));
96+
97+
if (projectCommandSet.commandNames.length > 0) {
98+
console.log(os.EOL + 'Available commands for this project are: '
99+
+ projectCommandSet.commandNames.map(x => `"${x}"`).join(', '));
100+
}
101+
102+
console.log(`Use ${colors.yellow('"rushx --help"')} for more information.`);
103+
return;
104+
}
105+
106+
console.log('Executing: ' + JSON.stringify(scriptBody) + os.EOL);
107+
108+
const packageFolder: string = path.dirname(packageJsonFilePath);
109+
110+
const result: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(
111+
scriptBody,
112+
packageFolder,
113+
packageFolder
114+
);
115+
116+
result.on('close', (code) => {
117+
if (code) {
118+
console.log(colors.red(`The script failed with exit code ${code}`));
119+
}
120+
121+
// Pass along the exit code of the child process
122+
process.exitCode = code || 0;
123+
});
124+
} catch (error) {
125+
console.log(colors.red('Error: ' + error.message));
126+
}
127+
}
128+
129+
private static _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommandSet): void {
130+
console.log('usage: rushx [-h]');
131+
console.log(' rushx <command> ...' + os.EOL);
132+
133+
console.log('Optional arguments:');
134+
console.log(' -h, --help Show this help message and exit.' + os.EOL);
135+
136+
if (projectCommandSet.commandNames.length > 0) {
137+
console.log(`Project commands for ${colors.cyan(packageJson.name)}:`);
138+
139+
// Calculate the length of the longest script name, for formatting
140+
let maxLength: number = 0;
141+
for (const commandName of projectCommandSet.commandNames) {
142+
maxLength = Math.max(maxLength, commandName.length);
143+
}
144+
145+
for (const commandName of projectCommandSet.commandNames) {
146+
const escapedScriptBody: string = JSON.stringify(projectCommandSet.getScriptBody(commandName));
147+
148+
// The length of the string e.g. " command: "
149+
const firstPartLength: number = 2 + maxLength + 2;
150+
// The length for truncating the escaped escapedScriptBody so it doesn't wrap
151+
// to the next line
152+
const truncateLength: number = Math.max(0, Utilities.getConsoleWidth() - firstPartLength) - 1;
153+
154+
console.log(
155+
// Example: " command: "
156+
' ' + colors.cyan(Text.padEnd(commandName + ':', maxLength + 2))
157+
// Example: "do some thin..."
158+
+ Text.truncateWithEllipsis(escapedScriptBody, truncateLength)
159+
);
160+
}
161+
162+
if (projectCommandSet.malformedScriptNames.length > 0) {
163+
console.log(os.EOL + colors.yellow('Warning: Some "scripts" entries in the package.json file'
164+
+ ' have malformed names: '
165+
+ projectCommandSet.malformedScriptNames.map(x => `"${x}"`).join(', ')));
166+
}
167+
} else {
168+
console.log(colors.yellow('Warning: No commands are defined yet for this project.'));
169+
console.log('You can define a command by adding a "scripts" table to the project\'s package.json file.');
170+
}
171+
}
172+
}

apps/rush-lib/src/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,4 @@ export {
6262
VersionPolicyConfiguration
6363
} from './data/VersionPolicyConfiguration';
6464

65-
/**
66-
* @internal
67-
*/
6865
export { Rush } from './Rush';

apps/rush-lib/src/utilities/Utilities.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export class Utilities {
327327
* @param workingDirectory - working directory for running this command
328328
* @param initCwd = the folder containing a local .npmrc, which will be used
329329
* for the INIT_CWD environment variable
330-
* @param environment - environment variables for running this command
330+
* @param captureOutput - if true, map stdio to 'pipe' instead of the parent process's streams
331331
* @beta
332332
*/
333333
public static executeLifecycleCommand(
@@ -367,7 +367,7 @@ export class Utilities {
367367
* @param workingDirectory - working directory for running this command
368368
* @param initCwd = the folder containing a local .npmrc, which will be used
369369
* for the INIT_CWD environment variable
370-
* @param environment - environment variables for running this command
370+
* @param captureOutput - if true, map stdio to 'pipe' instead of the parent process's streams
371371
* @beta
372372
*/
373373
public static executeLifecycleCommandAsync(

apps/rush/bin/rushx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
require('../lib/start.js')
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as colors from 'colors';
5+
import * as path from 'path';
6+
import * as rushLib from '@microsoft/rush-lib';
7+
8+
type CommandName = 'rush' | 'rushx' | undefined;
9+
10+
/**
11+
* Both "rush" and "rushx" share the same src/start.ts entry point. This makes it
12+
* a little easier for them to share all the same startup checks and version selector
13+
* logic. RushCommandSelector looks at argv to determine whether we're doing "rush"
14+
* or "rushx" behavior, and then invokes the appropriate entry point in the selected
15+
* @microsoft/rush-lib.
16+
*/
17+
export class RushCommandSelector {
18+
public static failIfNotInvokedAsRush(version: string): void {
19+
if (RushCommandSelector._getCommandName() === 'rushx') {
20+
RushCommandSelector._failWithError(`This repository is using Rush version ${version}`
21+
+ ` which does not support the "rushx" command`);
22+
}
23+
}
24+
25+
// tslint:disable-next-line:no-any
26+
public static execute(launcherVersion: string, isManaged: boolean, selectedRushLib: any): void {
27+
const Rush: typeof rushLib.Rush = selectedRushLib.Rush;
28+
29+
if (!Rush) {
30+
// This should be impossible unless we somehow loaded an unexpected version
31+
RushCommandSelector._failWithError(`Unable to find the "Rush" entry point in @microsoft/rush-lib`);
32+
}
33+
34+
if (RushCommandSelector._getCommandName() === 'rushx') {
35+
if (!Rush.launchRushX) {
36+
RushCommandSelector._failWithError(`This repository is using Rush version ${Rush.version}`
37+
+ ` which does not support the "rushx" command`);
38+
}
39+
Rush.launchRushX(launcherVersion, isManaged);
40+
} else {
41+
Rush.launch(launcherVersion, isManaged);
42+
}
43+
}
44+
45+
private static _failWithError(message: string): never {
46+
console.log(colors.red(message));
47+
return process.exit(1);
48+
}
49+
50+
private static _getCommandName(): CommandName {
51+
if (process.argv.length >= 2) {
52+
// Example:
53+
// argv[0]: "C:\\Program Files\\nodejs\\node.exe"
54+
// argv[1]: "C:\\Program Files\\nodejs\\node_modules\\@microsoft\\rush\\bin\\rushx"
55+
const basename: string = path.basename(process.argv[1]).toUpperCase();
56+
if (basename === 'RUSHX') {
57+
return 'rushx';
58+
}
59+
if (basename === 'RUSH') {
60+
return 'rush';
61+
}
62+
}
63+
return undefined;
64+
}
65+
}

0 commit comments

Comments
 (0)