storybook/lib/cli/src/js-package-manager/JsPackageManager.ts
Gaëtan Maisse b0e1d93eca
refactor: create a executeCommand function and use it for every yarn or npm calls
It move the `spawnSync` usage to only a single place which is simpler to maintain.
It also simplify testing as there is no more need to mock an external dep
2020-06-09 08:19:11 +02:00

239 lines
7.1 KiB
TypeScript

import path from 'path';
import fs from 'fs';
import chalk from 'chalk';
import { gt, satisfies } from '@storybook/semver';
import { sync as spawnSync } from 'cross-spawn';
import { commandLog, writePackageJson } from '../helpers';
import { PackageJson } from './PackageJson';
const logger = console;
// Cannot be `import` as it's not under TS root dir
const { storybookCLIVersion, devDependencies } = require('../../package.json');
export abstract class JsPackageManager {
public abstract initPackageJson(): void;
public abstract getRunStorybookCommand(): string;
/**
* Install dependencies listed in `package.json`
*/
public installDependencies(): void {
let done = commandLog('Preparing to install dependencies');
done();
logger.log();
logger.log();
done = commandLog('Installing dependencies');
try {
this.runInstall();
} catch (e) {
done('An error occurred while installing dependencies.');
process.exit(1);
}
done();
}
public retrievePackageJson(): PackageJson {
const existing = JsPackageManager.getPackageJson();
if (existing) {
return existing;
}
// It will create a new package.json file
this.initPackageJson();
// read the newly created package.json file
return JsPackageManager.getPackageJson() || {};
}
/**
* Add dependencies to a project using `yarn add` or `npm install`.
*
* @param {Object} options contains `skipInstall`, `packageJson` and `installAsDevDependencies` which we use to determine how we install packages.
* @param {Array} dependencies contains a list of packages to add.
* @example
* addDependencies(options, [
* `@storybook/react@${storybookVersion}`,
* `@storybook/addon-actions@${actionsVersion}`,
* `@storybook/addon-links@${linksVersion}`,
* `@storybook/addons@${addonsVersion}`,
* ]);
*/
public addDependencies(
options: {
skipInstall?: boolean;
installAsDevDependencies?: boolean;
packageJson?: PackageJson;
},
dependencies: string[]
): void {
const { skipInstall } = options;
if (skipInstall) {
const { packageJson } = options;
const dependenciesMap = dependencies.reduce((acc, dep) => {
const idx = dep.lastIndexOf('@');
const packageName = dep.slice(0, idx);
const packageVersion = dep.slice(idx + 1);
return { ...acc, [packageName]: packageVersion };
}, {});
if (options.installAsDevDependencies) {
packageJson.devDependencies = {
...packageJson.devDependencies,
...dependenciesMap,
};
} else {
packageJson.dependencies = {
...packageJson.dependencies,
...dependenciesMap,
};
}
writePackageJson(packageJson);
} else {
try {
this.runAddDeps(dependencies, options.installAsDevDependencies);
} catch (e) {
logger.error('An error occurred while installing dependencies.');
logger.log(e.message);
process.exit(1);
}
}
}
/**
* Return an array of strings matching following format: `<package_name>@<package_latest_version>`
*
* @param packageNames
*/
public getVersionedPackages(...packageNames: string[]): Promise<string[]> {
return Promise.all(
packageNames.map(
async (packageName) => `${packageName}@${await this.getVersion(packageName)}`
)
);
}
/**
* Return an array of string standing for the latest version of the input packages.
* To be able to identify which version goes with which package the order of the input array is keep.
*
* @param packageNames
*/
public getVersions(...packageNames: string[]): Promise<string[]> {
return Promise.all(packageNames.map((packageName) => this.getVersion(packageName)));
}
public async getVersion(packageName: string, constraint?: string): Promise<string> {
let current;
if (packageName === '@storybook/cli') {
current = storybookCLIVersion;
} else if (/storybook/.test(packageName)) {
current = devDependencies[packageName];
}
let latest;
try {
latest = await this.latestVersion(packageName, constraint);
} catch (e) {
if (current) {
logger.warn(`\n ${chalk.yellow(e.message)}`);
return current;
}
logger.error(`\n ${chalk.red(e.message)}`);
process.exit(1);
}
const versionToUse =
current && (!constraint || satisfies(current, constraint)) && gt(current, latest)
? current
: latest;
return `^${versionToUse}`;
}
/**
* Get the latest version of the package available on npmjs.com.
* If constraint is set then it returns a version satisfying it, otherwise the latest version available is returned.
*
* @param packageName Name of the package
* @param constraint Version range to use to constraint the returned version
*/
public async latestVersion(packageName: string, constraint?: string): Promise<string> {
if (!constraint) {
return this.runGetVersions(packageName, false);
}
const versions = await this.runGetVersions(packageName, true);
// Get the latest version satisfying the constraint
return versions.reverse().find((version) => satisfies(version, constraint));
}
public addStorybookCommandInScripts(options?: { port: number; staticFolder?: string }) {
const packageJson = this.retrievePackageJson();
const sbPort = options?.port ?? 6006;
const storybookCmd = options?.staticFolder
? `start-storybook -p ${sbPort} -s ${options.staticFolder}`
: `start-storybook -p ${sbPort}`;
const buildStorybookCmd = options?.staticFolder
? `build-storybook -s ${options.staticFolder}`
: 'build-storybook';
writePackageJson({
...packageJson,
scripts: {
...packageJson.scripts,
storybook: storybookCmd,
'build-storybook': buildStorybookCmd,
},
});
}
protected abstract runInstall(): void;
protected abstract runAddDeps(dependencies: string[], installAsDevDependencies: boolean): void;
/**
* Get the latest or all versions of the input package available on npmjs.com
*
* @param packageName Name of the package
* @param fetchAllVersions Should return
*/
protected abstract runGetVersions<T extends boolean>(
packageName: string,
fetchAllVersions: T
): // Use generic and conditional type to force `string[]` if fetchAllVersions is true and `string` if false
Promise<T extends true ? string[] : string>;
private static getPackageJson(): PackageJson | false {
const packageJsonPath = path.resolve('package.json');
if (!fs.existsSync(packageJsonPath)) {
return false;
}
const jsonContent = fs.readFileSync(packageJsonPath, 'utf8');
return JSON.parse(jsonContent);
}
public executeCommand(command: string, args: string[], stdio?: 'pipe' | 'inherit'): string {
const commandResult = spawnSync(command, args, {
stdio: stdio ?? 'pipe',
encoding: 'utf-8',
});
if (commandResult.status !== 0) {
throw new Error(commandResult.stderr ?? '');
}
return commandResult.stdout ?? '';
}
}