Skip to content

feat() - [Yarn Support - Part 1 and 2] Yarn and Package Manager implementation #4050

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 1, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/base-package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isInteractive } from "./common/helpers";

export class BasePackageManager {
constructor(
protected $childProcess: IChildProcess,
private $hostInfo: IHostInfo,
private packageManager: string
) { }

protected getPackageManagerExecutableName(): string {
let npmExecutableName = this.packageManager;

if (this.$hostInfo.isWindows) {
npmExecutableName += ".cmd";
}

return npmExecutableName;
}

protected async processPackageManagerInstall(params: string[], opts: { cwd: string }) {
const npmExecutable = this.getPackageManagerExecutableName();
const stdioValue = isInteractive() ? "inherit" : "pipe";
return await this.$childProcess.spawnFromEvent(npmExecutable, params, "close", { cwd: opts.cwd, stdio: stdioValue });
}

protected getFlagsString(config: any, asArray: boolean): any {
const array: Array<string> = [];
for (const flag in config) {
if (flag === "global" && this.packageManager !== 'yarn') {
array.push(`--${flag}`);
array.push(`${config[flag]}`);
} else if (config[flag]) {
if (flag === "dist-tags" || flag === "versions") {
array.push(` ${flag}`);
continue;
}
array.push(`--${flag}`);
}
}
if (asArray) {
return array;
}

return array.join(" ");
}
}
1 change: 1 addition & 0 deletions lib/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ interface IOptions extends IRelease, IDeviceIdentifier, IJustLaunch, IAvd, IAvai
framework: string;
frameworkName: string;
frameworkVersion: string;
yarn: string,
ipa: string;
tsc: boolean;
ts: boolean;
Expand Down
112 changes: 13 additions & 99 deletions lib/node-package-manager.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import * as path from "path";
import { BasePackageManager } from "./base-package-manager";
import { exported, cache } from "./common/decorators";
import { isInteractive } from "./common/helpers";
import { CACACHE_DIRECTORY_NAME } from "./constants";

export class NodePackageManager implements INodePackageManager {
export class NodePackageManager extends BasePackageManager implements INodePackageManager {
private static SCOPED_DEPENDENCY_REGEXP = /^(@.+?)(?:@(.+?))?$/;
private static DEPENDENCY_REGEXP = /^(.+?)(?:@(.+?))?$/;

constructor(private $fs: IFileSystem,
private $hostInfo: IHostInfo,
constructor(
$childProcess: IChildProcess,
private $errors: IErrors,
private $childProcess: IChildProcess,
private $fs: IFileSystem,
$hostInfo: IHostInfo,
private $logger: ILogger,
private $httpClient: Server.IHttpClient) { }
private $httpClient: Server.IHttpClient) {
super($childProcess, $hostInfo, 'npm');
}

@exported("npm")
public async install(packageName: string, pathToSave: string, config: INodePackageManagerInstallOptions): Promise<INpmInstallResultInfo> {
Expand Down Expand Up @@ -53,7 +56,7 @@ export class NodePackageManager implements INodePackageManager {
}

try {
const spawnResult: ISpawnResult = await this.getNpmInstallResult(params, cwd);
const spawnResult: ISpawnResult = await this.processPackageManagerInstall(params, { cwd });

// Whenever calling npm install without any arguments (hence installing all dependencies) no output is emitted on stdout
// Luckily, whenever you call npm install to install all dependencies chances are you won't need the name/version of the package you're installing because there is none.
Expand All @@ -66,7 +69,7 @@ export class NodePackageManager implements INodePackageManager {
// We cannot use the actual install with --json to get the information because of post-install scripts which may print on stdout
// dry-run install is quite fast when the dependencies are already installed even for many dependencies (e.g. angular) so we can live with this approach
// We need the --prefix here because without it no output is emitted on stdout because all the dependencies are already installed.
const spawnNpmDryRunResult = await this.$childProcess.spawnFromEvent(this.getNpmExecutableName(), params, "close");
const spawnNpmDryRunResult = await this.$childProcess.spawnFromEvent(this.getPackageManagerExecutableName(), params, "close");
return this.parseNpmInstallResult(spawnNpmDryRunResult.stdout, spawnResult.stdout, packageName);
} catch (err) {
if (err.message && err.message.indexOf("EPEERINVALID") !== -1) {
Expand Down Expand Up @@ -136,43 +139,12 @@ export class NodePackageManager implements INodePackageManager {
return path.join(cachePath.trim(), CACACHE_DIRECTORY_NAME);
}

private getNpmExecutableName(): string {
let npmExecutableName = "npm";

if (this.$hostInfo.isWindows) {
npmExecutableName += ".cmd";
}

return npmExecutableName;
}

private getFlagsString(config: any, asArray: boolean): any {
const array: Array<string> = [];
for (const flag in config) {
if (flag === "global") {
array.push(`--${flag}`);
array.push(`${config[flag]}`);
} else if (config[flag]) {
if (flag === "dist-tags" || flag === "versions") {
array.push(` ${flag}`);
continue;
}
array.push(`--${flag}`);
}
}
if (asArray) {
return array;
}

return array.join(" ");
}

private parseNpmInstallResult(npmDryRunInstallOutput: string, npmInstallOutput: string, userSpecifiedPackageName: string): INpmInstallResultInfo {
// TODO: Add tests for this functionality
try {
const originalOutput: INpmInstallCLIResult | INpm5InstallCliResult = JSON.parse(npmDryRunInstallOutput);
const npm5Output = <INpm5InstallCliResult> originalOutput;
const npmOutput = <INpmInstallCLIResult> originalOutput;
const npm5Output = <INpm5InstallCliResult>originalOutput;
const npmOutput = <INpmInstallCLIResult>originalOutput;
let name: string;
_.forOwn(npmOutput.dependencies, (peerDependency: INpmPeerDependencyInfo, key: string) => {
if (!peerDependency.required && !peerDependency.peerMissing) {
Expand Down Expand Up @@ -239,64 +211,6 @@ export class NodePackageManager implements INodePackageManager {
version
};
}

private async getNpmInstallResult(params: string[], cwd: string): Promise<ISpawnResult> {
return new Promise<ISpawnResult>((resolve, reject) => {
const npmExecutable = this.getNpmExecutableName();
const stdioValue = isInteractive() ? "inherit" : "pipe";

const childProcess = this.$childProcess.spawn(npmExecutable, params, { cwd, stdio: stdioValue });

let isFulfilled = false;
let capturedOut = "";
let capturedErr = "";

if (childProcess.stdout) {
childProcess.stdout.on("data", (data: string) => {
this.$logger.write(data.toString());
capturedOut += data;
});
}

if (childProcess.stderr) {
childProcess.stderr.on("data", (data: string) => {
capturedErr += data;
});
}

childProcess.on("close", (arg: any) => {
const exitCode = typeof arg === "number" ? arg : arg && arg.code;

if (exitCode === 0) {
isFulfilled = true;
const result = {
stdout: capturedOut,
stderr: capturedErr,
exitCode
};

resolve(result);
} else {
let errorMessage = `Command ${npmExecutable} ${params && params.join(" ")} failed with exit code ${exitCode}`;
if (capturedErr) {
errorMessage += ` Error output: \n ${capturedErr}`;
}

if (!isFulfilled) {
isFulfilled = true;
reject(new Error(errorMessage));
}
}
});

childProcess.on("error", (err: Error) => {
if (!isFulfilled) {
isFulfilled = true;
reject(err);
}
});
});
}
}

$injector.register("npm", NodePackageManager);
1 change: 1 addition & 0 deletions lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class Options {
tsc: { type: OptionType.Boolean },
ts: { type: OptionType.Boolean },
typescript: { type: OptionType.Boolean },
yarn: { type: OptionType.Boolean },
androidTypings: { type: OptionType.Boolean },
bundle: { type: OptionType.String },
all: { type: OptionType.Boolean },
Expand Down
56 changes: 56 additions & 0 deletions lib/package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@

import { exported } from './common/decorators';
export class PackageManager implements INodePackageManager {
private packageManager: INodePackageManager;

constructor(
private $errors: IErrors,
private $npm: INodePackageManager,
private $options: IOptions,
private $yarn: INodePackageManager,
private $userSettingsService: IUserSettingsService
) {
this._determinePackageManager();
Copy link
Contributor

@Fatme Fatme Oct 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is async so it is possible to call some method from the class and this.packageManager to be null.
So I suggest an approach similar to this https://github.com/NativeScript/nativescript-cli/blob/master/lib/common/mobile/android/android-debug-bridge.ts#L56.

@cache()
private async init(): Promise<void> {
    this.packageManager = await this._determinePackageManager();
}

@exported("packageManager")
@invokeInit()
public install(packageName: string, pathToSave: string, config: INodePackageManagerInstallOptions): Promise<INpmInstallResultInfo> {
    return this.packageManager.install(packageName, pathToSave, config);
}

This way this._determinePackageManager(); should be removed from constructor and all methods that relied on this.packageManager should be decorated with @invokeInit()

On the other side init() method is decorated with cache() decorator so the value will be persisted and this._determinePackageManager() will be called only once.

Also we can rewrite _determinePackageManager method:

private async _determinePackageManager(): Promise<void> {
    let pm = null;
    try {
        pm = await this.$userSettingsService.getSettingValue('packageManager');
    } catch (err) {
        this.$errors.fail(`Unable to read package manager config from user settings ${err}`);
    }

    if (pm === 'yarn' || this.$options.yarn) {
	this.packageManager = this.$yarn;
    } else {
	this.packageManager = this.$npm;
    }	
}

}
@exported("packageManager")
public install(packageName: string, pathToSave: string, config: INodePackageManagerInstallOptions): Promise<INpmInstallResultInfo> {
return this.packageManager.install(packageName, pathToSave, config);
}
@exported("packageManager")
public uninstall(packageName: string, config?: IDictionary<string | boolean>, path?: string): Promise<string> {
return this.packageManager.uninstall(packageName, config, path);
}
@exported("packageManager")
public view(packageName: string, config: Object): Promise<any> {
return this.packageManager.view(packageName, config);
}
@exported("packageManager")
public search(filter: string[], config: IDictionary<string | boolean>): Promise<string> {
return this.packageManager.search(filter, config);
}
public searchNpms(keyword: string): Promise<INpmsResult> {
return this.packageManager.searchNpms(keyword);
}
public getRegistryPackageData(packageName: string): Promise<any> {
return this.packageManager.getRegistryPackageData(packageName);
}
public getCachePath(): Promise<string> {
return this.packageManager.getCachePath();
}

private _determinePackageManager(): void {
this.$userSettingsService.getSettingValue('packageManager')
.then((pm: string) => {
if (pm === 'yarn' || this.$options.yarn) {
this.packageManager = this.$yarn;
} else {
this.packageManager = this.$npm;
}
})
.catch((err) => {
this.$errors.fail(`Unable to read package manager config from user settings ${err}`);
});
}
}

$injector.register('packageManager', PackageManager);
109 changes: 109 additions & 0 deletions lib/yarn-package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as path from "path";
import { BasePackageManager } from "./base-package-manager";
import { exported } from './common/decorators';

export class YarnPackageManager extends BasePackageManager implements INodePackageManager {

constructor(
$childProcess: IChildProcess,
private $errors: IErrors,
private $fs: IFileSystem,
$hostInfo: IHostInfo,
private $httpClient: Server.IHttpClient,
private $logger: ILogger,
private $pacoteService: IPacoteService
) {
super($childProcess, $hostInfo, 'yarn');
}

@exported("yarn")
public async install(packageName: string, pathToSave: string, config: INodePackageManagerInstallOptions): Promise<INpmInstallResultInfo> {
if (config.disableNpmInstall) {
return;
}
if (config.ignoreScripts) {
config['ignore-scripts'] = true;
}

const packageJsonPath = path.join(pathToSave, 'package.json');
const jsonContentBefore = this.$fs.readJson(packageJsonPath);

const flags = this.getFlagsString(config, true);
let params = [];
const isInstallingAllDependencies = packageName === pathToSave;
if (!isInstallingAllDependencies) {
params.push('add', packageName);
}

params = params.concat(flags);
const cwd = pathToSave;

try {
await this.processPackageManagerInstall(params, { cwd });

if (isInstallingAllDependencies) {
return null;
}

const packageMetadata = await this.$pacoteService.manifest(packageName, {});
return {
name: packageMetadata.name,
version: packageMetadata.version
};

} catch (e) {
this.$fs.writeJson(packageJsonPath, jsonContentBefore);
throw e;
}
}

@exported("yarn")
public uninstall(packageName: string, config?: IDictionary<string | boolean>, path?: string): Promise<string> {
const flags = this.getFlagsString(config, false);
return this.$childProcess.exec(`yarn remove ${packageName} ${flags}`, { cwd: path });
}

@exported("yarn")
public async view(packageName: string, config: Object): Promise<any> {
const wrappedConfig = _.extend({}, config, { json: true });

const flags = this.getFlagsString(wrappedConfig, false);
let viewResult: any;
try {
viewResult = await this.$childProcess.exec(`yarn info ${packageName} ${flags}`);
} catch (e) {
this.$errors.failWithoutHelp(e.message);
}
return JSON.parse(viewResult);
}

@exported("yarn")
public search(filter: string[], config: IDictionary<string | boolean>): Promise<string> {
throw new Error("Method not implemented. Yarn does not support searching for packages in the registry.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.$errors.fail("Method not implemented. Yarn does not support searching for packages in the registry.");

}

public async searchNpms(keyword: string): Promise<INpmsResult> {
const httpRequestResult = await this.$httpClient.httpRequest(`https://api.npms.io/v2/search?q=keywords:${keyword}`);
const result: INpmsResult = JSON.parse(httpRequestResult.body);
return result;
}

@exported("yarn")
public async getRegistryPackageData(packageName: string): Promise<any> {
const registry = await this.$childProcess.exec(`yarn config get registry`);
const url = `${registry.trim()}/${packageName}`;
this.$logger.trace(`Trying to get data from yarn registry for package ${packageName}, url is: ${url}`);
const responseData = (await this.$httpClient.httpRequest(url)).body;
this.$logger.trace(`Successfully received data from yarn registry for package ${packageName}. Response data is: ${responseData}`);
const jsonData = JSON.parse(responseData);
this.$logger.trace(`Successfully parsed data from yarn registry for package ${packageName}.`);
return jsonData;
}

@exported("yarn")
getCachePath(): Promise<string> {
throw new Error("Method not implemented.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.$errors.fail("Method not implemented.");

}
}

$injector.register("yarn", YarnPackageManager);