import sort from 'semver/functions/sort.js'; import { platform } from 'os'; import { dedent } from 'ts-dedent'; import { findUpSync } from 'find-up'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import semver from 'semver'; import { JsPackageManager } from './JsPackageManager.js'; import type { PackageJson } from './PackageJson.js'; import type { InstallationMetadata, PackageMetadata } from './types.js'; import { createLogStream } from '../createLogStream.js'; type NpmDependency = { version: string; resolved?: string; overridden?: boolean; dependencies?: NpmDependencies; }; type NpmDependencies = { [key: string]: NpmDependency; }; export type NpmListOutput = { dependencies: NpmDependencies; }; const NPM_ERROR_REGEX = /npm ERR! code (\w+)/; const NPM_ERROR_CODES = { E401: 'Authentication failed or is required.', E403: 'Access to the resource is forbidden.', E404: 'Requested resource not found.', EACCES: 'Permission issue.', EAI_FAIL: 'DNS lookup failed.', EBADENGINE: 'Engine compatibility check failed.', EBADPLATFORM: 'Platform not supported.', ECONNREFUSED: 'Connection refused.', ECONNRESET: 'Connection reset.', EEXIST: 'File or directory already exists.', EINVALIDTYPE: 'Invalid type encountered.', EISGIT: 'Git operation failed or conflicts with an existing file.', EJSONPARSE: 'Error parsing JSON data.', EMISSINGARG: 'Required argument missing.', ENEEDAUTH: 'Authentication needed.', ENOAUDIT: 'No audit available.', ENOENT: 'File or directory does not exist.', ENOGIT: 'Git not found or failed to run.', ENOLOCK: 'Lockfile missing.', ENOSPC: 'Insufficient disk space.', ENOTFOUND: 'Resource not found.', EOTP: 'One-time password required.', EPERM: 'Permission error.', EPUBLISHCONFLICT: 'Conflict during package publishing.', ERESOLVE: 'Dependency resolution error.', EROFS: 'File system is read-only.', ERR_SOCKET_TIMEOUT: 'Socket timed out.', ETARGET: 'Package target not found.', ETIMEDOUT: 'Operation timed out.', ETOOMANYARGS: 'Too many arguments provided.', EUNKNOWNTYPE: 'Unknown type encountered.', }; export class NPMProxy extends JsPackageManager { readonly type = 'npm'; installArgs: string[] | undefined; async initPackageJson() { await this.executeCommand({ command: 'npm', args: ['init', '-y'] }); } getRunStorybookCommand(): string { return 'npm run storybook'; } getRunCommand(command: string): string { return `npm run ${command}`; } async getNpmVersion(): Promise { return this.executeCommand({ command: 'npm', args: ['--version'] }); } public async getPackageJSON( packageName: string, basePath = this.cwd ): Promise { const packageJsonPath = await findUpSync( (dir) => { const possiblePath = path.join(dir, 'node_modules', packageName, 'package.json'); return existsSync(possiblePath) ? possiblePath : undefined; }, { cwd: basePath } ); if (!packageJsonPath) { return null; } const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); return packageJson; } public async getPackageVersion(packageName: string, basePath = this.cwd): Promise { const packageJson = await this.getPackageJSON(packageName, basePath); return packageJson ? semver.coerce(packageJson.version)?.version ?? null : null; } getInstallArgs(): string[] { if (!this.installArgs) { this.installArgs = []; } return this.installArgs; } public runPackageCommandSync( command: string, args: string[], cwd?: string, stdio?: 'pipe' | 'inherit' ): string { return this.executeCommandSync({ command: 'npm', args: ['exec', '--', command, ...args], cwd, stdio, }); } public async runPackageCommand(command: string, args: string[], cwd?: string): Promise { return this.executeCommand({ command: 'npm', args: ['exec', '--', command, ...args], cwd, }); } public async findInstallations() { const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; const commandResult = await this.executeCommand({ command: 'npm', args: ['ls', '--json', '--depth=99', pipeToNull], // ignore errors, because npm ls will exit with code 1 if there are e.g. unmet peer dependencies ignoreError: true, env: { FORCE_COLOR: 'false', }, }); try { const parsedOutput = JSON.parse(commandResult); return this.mapDependencies(parsedOutput); } catch (e) { return undefined; } } protected getResolutions(packageJson: PackageJson, versions: Record) { return { overrides: { ...packageJson.overrides, ...versions, }, }; } protected async runInstall() { await this.executeCommand({ command: 'npm', args: ['install', ...this.getInstallArgs()], stdio: 'inherit', }); } protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) { const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream(); let args = [...dependencies]; if (installAsDevDependencies) { args = ['-D', ...args]; } try { await this.executeCommand({ command: 'npm', args: ['install', ...args, ...this.getInstallArgs()], stdio: process.env.CI ? 'inherit' : ['ignore', logStream, logStream], }); } catch (err) { const stdout = await readLogFile(); const errorMessage = this.parseErrorFromLogs(stdout); await moveLogFile(); throw new Error( dedent`${errorMessage} Please check the logfile generated at ./storybook.log for troubleshooting and try again.` ); } await removeLogFile(); } protected async runRemoveDeps(dependencies: string[]) { const args = [...dependencies]; await this.executeCommand({ command: 'npm', args: ['uninstall', ...this.getInstallArgs(), ...args], stdio: 'inherit', }); } protected async runGetVersions( packageName: string, fetchAllVersions: T ): Promise { const args = [fetchAllVersions ? 'versions' : 'version', '--json']; const commandResult = await this.executeCommand({ command: 'npm', args: ['info', packageName, ...args], }); try { const parsedOutput = JSON.parse(commandResult); if (parsedOutput.error) { // FIXME: improve error handling throw new Error(parsedOutput.error.summary); } else { return parsedOutput; } } catch (e) { throw new Error(`Unable to find versions of ${packageName} using npm`); } } protected mapDependencies(input: NpmListOutput): InstallationMetadata { const acc: Record = {}; const existingVersions: Record = {}; const duplicatedDependencies: Record = {}; const recurse = ([name, packageInfo]: [string, NpmDependency]): void => { if (!name || !name.includes('storybook')) return; const value = { version: packageInfo.version, location: '', }; if (!existingVersions[name]?.includes(value.version)) { if (acc[name]) { acc[name].push(value); } else { acc[name] = [value]; } existingVersions[name] = sort([...(existingVersions[name] || []), value.version]); if (existingVersions[name].length > 1) { duplicatedDependencies[name] = existingVersions[name]; } } if (packageInfo.dependencies) { Object.entries(packageInfo.dependencies).forEach(recurse); } }; Object.entries(input.dependencies).forEach(recurse); return { dependencies: acc, duplicatedDependencies, infoCommand: 'npm ls --depth=1', dedupeCommand: 'npm dedupe', }; } public parseErrorFromLogs(logs: string): string { let finalMessage = 'NPM error'; const match = logs.match(NPM_ERROR_REGEX); if (match) { const errorCode = match[1] as keyof typeof NPM_ERROR_CODES; if (errorCode) { finalMessage = `${finalMessage} ${errorCode}`; } const errorMessage = NPM_ERROR_CODES[errorCode]; if (errorMessage) { finalMessage = `${finalMessage} - ${errorMessage}`; } } return finalMessage.trim(); } }