import chalk from 'chalk'; import { gt, satisfies } from 'semver'; import type { CommonOptions } from 'execa'; import { command as execaCommand, sync as execaCommandSync } from 'execa'; import path from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { dedent } from 'ts-dedent'; import { commandLog } from '../helpers.js'; import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson.js'; import type { InstallationMetadata } from './types.js'; import { HandledError } from '../HandledError.js'; const logger = console; export type PackageManagerName = 'npm' | 'yarn1' | 'yarn2' | 'pnpm'; /** * Extract package name and version from input * * @param pkg A string like `@storybook/cli`, `react` or `react@^16` * @return A tuple of 2 elements: [packageName, packageVersion] */ export function getPackageDetails(pkg: string): [string, string?] { const idx = pkg.lastIndexOf('@'); // If the only `@` is the first character, it is a scoped package // If it isn't in the string, it will be -1 if (idx <= 0) { return [pkg, undefined]; } const packageName = pkg.slice(0, idx); const packageVersion = pkg.slice(idx + 1); return [packageName, packageVersion]; } interface JsPackageManagerOptions { cwd?: string; } export abstract class JsPackageManager { public abstract readonly type: PackageManagerName; public abstract initPackageJson(): Promise; public abstract getRunStorybookCommand(): string; public abstract getRunCommand(command: string): string; public readonly cwd?: string; public abstract getPackageJSON( packageName: string, basePath?: string ): Promise; public abstract getPackageVersion(packageName: string, basePath?: string): Promise; // NOTE: for some reason yarn prefers the npm registry in // local development, so always use npm async setRegistryURL(url: string) { if (url) { await this.executeCommand({ command: 'npm', args: ['config', 'set', 'registry', url] }); } else { await this.executeCommand({ command: 'npm', args: ['config', 'delete', 'registry'] }); } } async getRegistryURL() { const res = await this.executeCommand({ command: 'npm', args: ['config', 'get', 'registry'] }); const url = res.trim(); return url === 'undefined' ? undefined : url; } constructor(options?: JsPackageManagerOptions) { this.cwd = options?.cwd || process.cwd(); } /** Detect whether Storybook gets initialized in a monorepository/workspace environment * The cwd doesn't have to be the root of the monorepo, it can be a subdirectory * @returns true, if Storybook is initialized inside a monorepository/workspace */ public isStorybookInMonorepo() { let cwd = process.cwd(); // eslint-disable-next-line no-constant-condition while (true) { try { const turboJsonPath = `${cwd}/turbo.json`; const rushJsonPath = `${cwd}/rush.json`; if (existsSync(turboJsonPath) || existsSync(rushJsonPath)) { return true; } const packageJsonPath = require.resolve(`${cwd}/package.json`); // read packagejson with readFileSync const packageJsonFile = readFileSync(packageJsonPath, 'utf8'); const packageJson = JSON.parse(packageJsonFile) as PackageJsonWithDepsAndDevDeps; if (packageJson.workspaces) { return true; } } catch (err) { // Package.json not found or invalid in current directory } // Move up to the parent directory const parentDir = path.dirname(cwd); // Check if we have reached the root of the filesystem if (parentDir === cwd) { break; } // Update cwd to the parent directory cwd = parentDir; } return false; } /** * Install dependencies listed in `package.json` */ public async installDependencies() { let done = commandLog('Preparing to install dependencies'); done(); logger.log(); logger.log(); done = commandLog('Installing dependencies'); try { await this.runInstall(); done(); } catch (e) { done('An error occurred while installing dependencies.'); throw new HandledError(e); } } packageJsonPath(): string { return path.resolve(this.cwd, 'package.json'); } async readPackageJson(): Promise { const packageJsonPath = this.packageJsonPath(); if (!existsSync(packageJsonPath)) { throw new Error(`Could not read package.json file at ${packageJsonPath}`); } const jsonContent = await readFile(packageJsonPath, 'utf8'); return JSON.parse(jsonContent); } async writePackageJson(packageJson: PackageJson) { const packageJsonToWrite = { ...packageJson }; // make sure to not accidentally add empty fields if ( packageJsonToWrite.dependencies && Object.keys(packageJsonToWrite.dependencies).length === 0 ) { delete packageJsonToWrite.dependencies; } if ( packageJsonToWrite.devDependencies && Object.keys(packageJsonToWrite.devDependencies).length === 0 ) { delete packageJsonToWrite.devDependencies; } if ( packageJsonToWrite.peerDependencies && Object.keys(packageJsonToWrite.peerDependencies).length === 0 ) { delete packageJsonToWrite.peerDependencies; } const content = `${JSON.stringify(packageJsonToWrite, null, 2)}\n`; await writeFile(this.packageJsonPath(), content, 'utf8'); } /** * Read the `package.json` file available in the directory the command was call from * If there is no `package.json` it will create one. */ public async retrievePackageJson(): Promise { let packageJson; try { packageJson = await this.readPackageJson(); } catch (err) { if (err.message.includes('Could not read package.json')) { await this.initPackageJson(); packageJson = await this.readPackageJson(); } else { throw new Error( dedent` There was an error while reading the package.json file at ${this.packageJsonPath()}: ${ err.message } Please fix the error and try again. ` ); } } return { ...packageJson, dependencies: { ...packageJson.dependencies }, devDependencies: { ...packageJson.devDependencies }, peerDependencies: { ...packageJson.peerDependencies }, }; } public async getAllDependencies(): Promise> { const { dependencies, devDependencies, peerDependencies } = await this.retrievePackageJson(); return { ...dependencies, ...devDependencies, ...peerDependencies, }; } /** * 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/preview-api@${addonsVersion}`, * ]); */ public async addDependencies( options: { skipInstall?: boolean; installAsDevDependencies?: boolean; packageJson?: PackageJson; }, dependencies: string[] ) { const { skipInstall } = options; if (skipInstall) { const { packageJson } = options; const dependenciesMap = dependencies.reduce((acc, dep) => { const [packageName, packageVersion] = getPackageDetails(dep); return { ...acc, [packageName]: packageVersion }; }, {}); if (options.installAsDevDependencies) { packageJson.devDependencies = { ...packageJson.devDependencies, ...dependenciesMap, }; } else { packageJson.dependencies = { ...packageJson.dependencies, ...dependenciesMap, }; } await this.writePackageJson(packageJson); } else { try { await this.runAddDeps(dependencies, options.installAsDevDependencies); } catch (e) { logger.error('\nAn error occurred while installing dependencies:'); logger.log(e.message); throw new HandledError(e); } } } /** * Remove dependencies from a project using `yarn remove` or `npm uninstall`. * * @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 remove. * @example * removeDependencies(options, [ * `@storybook/react`, * `@storybook/addon-actions`, * ]); */ public async removeDependencies( options: { skipInstall?: boolean; packageJson?: PackageJson; }, dependencies: string[] ): Promise { const { skipInstall } = options; if (skipInstall) { const { packageJson } = options; dependencies.forEach((dep) => { if (packageJson.devDependencies) { delete packageJson.devDependencies[dep]; } if (packageJson.dependencies) { delete packageJson.dependencies[dep]; } }); await this.writePackageJson(packageJson); } else { try { await this.runRemoveDeps(dependencies); } catch (e) { logger.error('An error occurred while removing dependencies.'); logger.log(e.message); throw new HandledError(e); } } } /** * Return an array of strings matching following format: `@` * * @param packages */ public getVersionedPackages(packages: string[]): Promise { return Promise.all( packages.map(async (pkg) => { const [packageName, packageVersion] = getPackageDetails(pkg); return `${packageName}@${await this.getVersion(packageName, packageVersion)}`; }) ); } /** * 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 { return Promise.all( packageNames.map((packageName) => { return this.getVersion(packageName); }) ); } /** * Return the latest version of the input package available on npmjs registry. * If constraint are provided it return the latest version matching the constraints. * * For `@storybook/*` packages the latest version is retrieved from `cli/src/versions.json` file directly * * @param packageName The name of the package * @param constraint A valid semver constraint, example: '1.x || >=2.5.0 || 5.0.0 - 7.2.3' */ public async getVersion(packageName: string, constraint?: string): Promise { let current: string; if (/(@storybook|^sb$|^storybook$)/.test(packageName)) { // @ts-expect-error (Converted from ts-ignore) current = storybookPackagesVersions[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)}`); throw new HandledError(e); } 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 { 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 async addStorybookCommandInScripts(options?: { port: number; preCommand?: string }) { const sbPort = options?.port ?? 6006; const storybookCmd = `storybook dev -p ${sbPort}`; const buildStorybookCmd = `storybook build`; const preCommand = options?.preCommand ? this.getRunCommand(options.preCommand) : undefined; await this.addScripts({ storybook: [preCommand, storybookCmd].filter(Boolean).join(' && '), 'build-storybook': [preCommand, buildStorybookCmd].filter(Boolean).join(' && '), }); } public async addScripts(scripts: Record) { const packageJson = await this.retrievePackageJson(); await this.writePackageJson({ ...packageJson, scripts: { ...packageJson.scripts, ...scripts, }, }); } public async addPackageResolutions(versions: Record) { const packageJson = await this.retrievePackageJson(); const resolutions = this.getResolutions(packageJson, versions); this.writePackageJson({ ...packageJson, ...resolutions }); } protected abstract runInstall(): Promise; protected abstract runAddDeps( dependencies: string[], installAsDevDependencies: boolean ): Promise; protected abstract runRemoveDeps(dependencies: string[]): Promise; protected abstract getResolutions( packageJson: PackageJson, versions: Record ): Record; /** * 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( packageName: string, fetchAllVersions: T ): // Use generic and conditional type to force `string[]` if fetchAllVersions is true and `string` if false Promise; public abstract runPackageCommand( command: string, args: string[], cwd?: string, stdio?: string ): Promise; public abstract runPackageCommandSync( command: string, args: string[], cwd?: string, stdio?: 'inherit' | 'pipe' ): string; public abstract findInstallations(pattern?: string[]): Promise; public abstract parseErrorFromLogs(logs?: string): string; public executeCommandSync({ command, args = [], stdio, cwd, ignoreError = false, env, ...execaOptions }: CommonOptions & { command: string; args: string[]; cwd?: string; ignoreError?: boolean; }): string { try { const commandResult = execaCommandSync(command, args, { cwd: cwd ?? this.cwd, stdio: stdio ?? 'pipe', encoding: 'utf-8', shell: true, cleanup: true, env, ...execaOptions, }); return commandResult.stdout ?? ''; } catch (err) { if (ignoreError !== true) { throw err; } return ''; } } public async executeCommand({ command, args = [], stdio, cwd, ignoreError = false, env, ...execaOptions }: CommonOptions & { command: string; args: string[]; cwd?: string; ignoreError?: boolean; }): Promise { try { const commandResult = await execaCommand([command, ...args].join(' '), { cwd: cwd ?? this.cwd, stdio: stdio ?? 'pipe', encoding: 'utf-8', shell: true, cleanup: true, env, ...execaOptions, }); return commandResult.stdout ?? ''; } catch (err) { if (ignoreError !== true) { throw err; } return ''; } } }