diff --git a/code/lib/cli/package.json b/code/lib/cli/package.json index 3ece1eb5d7d2..3915dfbf19f5 100644 --- a/code/lib/cli/package.json +++ b/code/lib/cli/package.json @@ -81,6 +81,7 @@ "globby": "^11.0.2", "jscodeshift": "^0.14.0", "leven": "^3.1.0", + "ora": "^5.4.1", "prettier": "^2.8.0", "prompts": "^2.4.0", "puppeteer-core": "^2.1.1", diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index f4231f56c21b..760ee4348072 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -1,10 +1,11 @@ import path from 'path'; import fse from 'fs-extra'; import { dedent } from 'ts-dedent'; +import ora from 'ora'; import type { NpmOptions } from '../NpmOptions'; import type { SupportedRenderers, SupportedFrameworks, Builder } from '../project_types'; import { SupportedLanguage, externalFrameworks, CoreBuilder } from '../project_types'; -import { copyTemplateFiles, paddedLog } from '../helpers'; +import { copyTemplateFiles } from '../helpers'; import { configureMain, configurePreview } from './configure'; import type { JsPackageManager } from '../js-package-manager'; import { getPackageDetails } from '../js-package-manager'; @@ -16,6 +17,9 @@ import { extractEslintInfo, suggestESLintPlugin, } from '../automigrate/helpers/eslintPlugin'; +import { HandledError } from '../HandledError'; + +const logger = console; const defaultOptions: FrameworkOptions = { extraPackages: [], @@ -168,6 +172,31 @@ export async function baseGenerator( options: FrameworkOptions = defaultOptions, framework?: SupportedFrameworks ) { + // This is added so that we can handle the scenario where the user presses Ctrl+C and report a canceled event. + // Given that there are subprocesses running as part of this function, we need to handle the signal ourselves otherwise it might run into race conditions. + // TODO: This should be revisited once we have a better way to handle this. + let isNodeProcessExiting = false; + const setNodeProcessExiting = () => { + isNodeProcessExiting = true; + }; + process.on('SIGINT', setNodeProcessExiting); + + const stopIfExiting = async (callback: () => Promise) => { + if (isNodeProcessExiting) { + throw new HandledError('Canceled by the user'); + } + + try { + return await callback(); + } catch (error) { + if (isNodeProcessExiting) { + throw new HandledError('Canceled by the user'); + } + + throw error; + } + }; + const { extraAddons: extraAddonPackages, extraPackages, @@ -254,7 +283,15 @@ export async function baseGenerator( (packageToInstall) => !installedDependencies.has(getPackageDetails(packageToInstall)[0]) ); - const versionedPackages = await packageManager.getVersionedPackages(packages); + logger.log(); + const versionedPackagesSpinner = ora({ + indent: 2, + text: `Getting the correct version of ${packages.length} packages`, + }).start(); + const versionedPackages = await stopIfExiting(async () => + packageManager.getVersionedPackages(packages) + ); + versionedPackagesSpinner.succeed(); await fse.ensureDir(`./${storybookConfigFolder}`); @@ -333,23 +370,35 @@ export async function baseGenerator( } if (depsToInstall.length > 0) { - paddedLog('Installing Storybook dependencies'); - await packageManager.addDependencies({ ...npmOptions, packageJson }, depsToInstall); + const addDependenciesSpinner = ora({ + indent: 2, + text: 'Installing Storybook dependencies', + }).start(); + await stopIfExiting(async () => + packageManager.addDependencies({ ...npmOptions, packageJson }, depsToInstall) + ); + addDependenciesSpinner.succeed(); } if (addScripts) { - await packageManager.addStorybookCommandInScripts({ - port: 6006, - }); + await stopIfExiting(async () => + packageManager.addStorybookCommandInScripts({ + port: 6006, + }) + ); } if (addComponents) { const templateLocation = hasFrameworkTemplates(framework) ? framework : rendererId; - await copyTemplateFiles({ - renderer: templateLocation, - packageManager, - language, - destination: componentsDestinationPath, - }); + await stopIfExiting(async () => + copyTemplateFiles({ + renderer: templateLocation, + packageManager, + language, + destination: componentsDestinationPath, + }) + ); } + + process.off('SIGINT', setNodeProcessExiting); } diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index 97b2683e0983..b49ebc6a4d9a 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -199,7 +199,7 @@ const installStorybook = async ( try { return await runGenerator(); } catch (err) { - if (err?.stack) { + if (err?.message !== 'Canceled by the user' && err?.stack) { logger.error(`\n ${chalk.red(err.stack)}`); } throw new HandledError(err); @@ -286,7 +286,7 @@ async function doInitiate(options: CommandOptions, pkg: PackageJson): Promise; public abstract runPackageCommandSync(command: string, args: string[], cwd?: string): string; public abstract findInstallations(pattern?: string[]): Promise; + public abstract parseErrorFromLogs(logs?: string): string; public executeCommandSync({ command, diff --git a/code/lib/cli/src/js-package-manager/NPMProxy.test.ts b/code/lib/cli/src/js-package-manager/NPMProxy.test.ts index c0c8cb63be52..9de82b3114ba 100644 --- a/code/lib/cli/src/js-package-manager/NPMProxy.test.ts +++ b/code/lib/cli/src/js-package-manager/NPMProxy.test.ts @@ -1,5 +1,15 @@ import { NPMProxy } from './NPMProxy'; +// mock createLogStream +jest.mock('../utils', () => ({ + createLogStream: jest.fn(() => ({ + logStream: '', + readLogFile: jest.fn(), + moveLogFile: jest.fn(), + removeLogFile: jest.fn(), + })), +})); + describe('NPM Proxy', () => { let npmProxy: NPMProxy; @@ -426,4 +436,50 @@ describe('NPM Proxy', () => { `); }); }); + + describe('parseErrors', () => { + it('should parse npm errors', () => { + const NPM_ERROR_SAMPLE = ` + npm ERR! code ERESOLVE + npm ERR! ERESOLVE unable to resolve dependency tree + npm ERR! + npm ERR! While resolving: before-storybook@1.0.0 + npm ERR! Found: react@undefined + npm ERR! node_modules/react + npm ERR! react@"30" from the root project + npm ERR! + npm ERR! Could not resolve dependency: + npm ERR! peer react@"^16.8.0 || ^17.0.0 || ^18.0.0" from @storybook/react@7.1.0-alpha.17 + npm ERR! node_modules/@storybook/react + npm ERR! dev @storybook/react@"^7.1.0-alpha.17" from the root project + npm ERR! + npm ERR! Fix the upstream dependency conflict, or retry + npm ERR! this command with --force or --legacy-peer-deps + npm ERR! to accept an incorrect (and potentially broken) dependency resolution. + npm ERR! + npm ERR! + npm ERR! For a full report see: + npm ERR! /Users/yannbraga/.npm/_logs/2023-05-12T08_38_18_464Z-eresolve-report.txt + + npm ERR! A complete log of this run can be found in: + npm ERR! /Users/yannbraga/.npm/_logs/2023-05-12T08_38_18_464Z-debug-0.log + `; + + expect(npmProxy.parseErrorFromLogs(NPM_ERROR_SAMPLE)).toEqual( + 'NPM error ERESOLVE - Dependency resolution error.' + ); + }); + + it('should show unknown npm error', () => { + const NPM_ERROR_SAMPLE = ` + npm ERR! + npm ERR! While resolving: before-storybook@1.0.0 + npm ERR! Found: react@undefined + npm ERR! node_modules/react + npm ERR! react@"30" from the root project + `; + + expect(npmProxy.parseErrorFromLogs(NPM_ERROR_SAMPLE)).toEqual(`NPM error`); + }); + }); }); diff --git a/code/lib/cli/src/js-package-manager/NPMProxy.ts b/code/lib/cli/src/js-package-manager/NPMProxy.ts index 090bc93fed16..fe8729468342 100644 --- a/code/lib/cli/src/js-package-manager/NPMProxy.ts +++ b/code/lib/cli/src/js-package-manager/NPMProxy.ts @@ -1,8 +1,10 @@ import sort from 'semver/functions/sort'; import { platform } from 'os'; +import dedent from 'ts-dedent'; import { JsPackageManager } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; +import { createLogStream } from '../utils'; type NpmDependency = { version: string; @@ -19,6 +21,41 @@ export type NpmListOutput = { dependencies: NpmDependencies; }; +const NPM_ERROR_REGEX = /\bERR! code\s+([A-Z]+)\b/; +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'; @@ -98,17 +135,34 @@ export class NPMProxy extends JsPackageManager { } protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) { + const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream(); let args = [...dependencies]; if (installAsDevDependencies) { args = ['-D', ...args]; } - await this.executeCommand({ - command: 'npm', - args: ['install', ...this.getInstallArgs(), ...args], - stdio: 'inherit', - }); + try { + await this.executeCommand({ + command: 'npm', + args: ['install', ...args, ...this.getInstallArgs()], + stdio: ['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[]) { @@ -185,4 +239,24 @@ export class NPMProxy extends JsPackageManager { infoCommand: 'npm ls --depth=1', }; } + + public parseErrorFromLogs(logs: string): string { + let finalMessage = 'NPM error'; + console.log({ logs }); + 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(); + } } diff --git a/code/lib/cli/src/js-package-manager/PNPMProxy.test.ts b/code/lib/cli/src/js-package-manager/PNPMProxy.test.ts index eb82f1a06465..04b35ccdab37 100644 --- a/code/lib/cli/src/js-package-manager/PNPMProxy.test.ts +++ b/code/lib/cli/src/js-package-manager/PNPMProxy.test.ts @@ -375,4 +375,30 @@ describe('NPM Proxy', () => { `); }); }); + + describe('parseErrors', () => { + it('should parse pnpm errors', () => { + const PNPM_ERROR_SAMPLE = ` + ERR_PNPM_NO_MATCHING_VERSION No matching version found for react@29.2.0 + + This error happened while installing a direct dependency of /Users/yannbraga/open-source/sandboxes/react-vite/default-js/before-storybook + + The latest release of react is "18.2.0". + `; + + expect(pnpmProxy.parseErrorFromLogs(PNPM_ERROR_SAMPLE)).toEqual( + 'PNPM error ERR_PNPM_NO_MATCHING_VERSION No matching version found for react@29.2.0' + ); + }); + + it('should show unknown pnpm error', () => { + const PNPM_ERROR_SAMPLE = ` + This error happened while installing a direct dependency of /Users/yannbraga/open-source/sandboxes/react-vite/default-js/before-storybook + + The latest release of react is "18.2.0". + `; + + expect(pnpmProxy.parseErrorFromLogs(PNPM_ERROR_SAMPLE)).toEqual(`PNPM error`); + }); + }); }); diff --git a/code/lib/cli/src/js-package-manager/PNPMProxy.ts b/code/lib/cli/src/js-package-manager/PNPMProxy.ts index 1c644cdb387c..14757cce92bc 100644 --- a/code/lib/cli/src/js-package-manager/PNPMProxy.ts +++ b/code/lib/cli/src/js-package-manager/PNPMProxy.ts @@ -1,7 +1,9 @@ import { pathExistsSync } from 'fs-extra'; +import dedent from 'ts-dedent'; import { JsPackageManager } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; +import { createLogStream } from '../utils'; type PnpmDependency = { from: string; @@ -22,6 +24,8 @@ type PnpmListItem = { export type PnpmListOutput = PnpmListItem[]; +const PNPM_ERROR_REGEX = /(ELIFECYCLE|ERR_PNPM_[A-Z_]+)\s+(.*)/i; + export class PNPMProxy extends JsPackageManager { readonly type = 'pnpm'; @@ -120,12 +124,29 @@ export class PNPMProxy extends JsPackageManager { if (installAsDevDependencies) { args = ['-D', ...args]; } + const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream(); - await this.executeCommand({ - command: 'pnpm', - args: ['add', ...args, ...this.getInstallArgs()], - stdio: 'inherit', - }); + try { + await this.executeCommand({ + command: 'pnpm', + args: ['add', ...args, ...this.getInstallArgs()], + stdio: ['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[]) { @@ -206,4 +227,17 @@ export class PNPMProxy extends JsPackageManager { infoCommand: 'pnpm list --depth=1', }; } + + public parseErrorFromLogs(logs: string): string { + let finalMessage = 'PNPM error'; + const match = logs.match(PNPM_ERROR_REGEX); + if (match) { + const [errorCode] = match; + if (errorCode) { + finalMessage = `${finalMessage} ${errorCode}`; + } + } + + return finalMessage.trim(); + } } diff --git a/code/lib/cli/src/js-package-manager/Yarn1Proxy.test.ts b/code/lib/cli/src/js-package-manager/Yarn1Proxy.test.ts index fb9edaef3cc0..3edf536f2a96 100644 --- a/code/lib/cli/src/js-package-manager/Yarn1Proxy.test.ts +++ b/code/lib/cli/src/js-package-manager/Yarn1Proxy.test.ts @@ -1,3 +1,4 @@ +import dedent from 'ts-dedent'; import { Yarn1Proxy } from './Yarn1Proxy'; describe('Yarn 1 Proxy', () => { @@ -275,4 +276,29 @@ describe('Yarn 1 Proxy', () => { `); }); }); + + describe('parseErrors', () => { + it('should parse yarn1 errors', () => { + const YARN1_ERROR_SAMPLE = dedent` + yarn add v1.22.19 + [1/4] Resolving packages... + error Couldn't find any versions for "react" that matches "28.2.0" + info Visit https://yarnpkg.com/en/docs/cli/add for documentation about this command. + `; + + expect(yarn1Proxy.parseErrorFromLogs(YARN1_ERROR_SAMPLE)).toEqual( + `YARN1 error: Couldn't find any versions for "react" that matches "28.2.0"` + ); + }); + + it('should show unknown yarn1 error', () => { + const YARN1_ERROR_SAMPLE = dedent` + yarn install v1.22.19 + [1/4] 🔍 Resolving packages... + info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command. + `; + + expect(yarn1Proxy.parseErrorFromLogs(YARN1_ERROR_SAMPLE)).toEqual(`YARN1 error`); + }); + }); }); diff --git a/code/lib/cli/src/js-package-manager/Yarn1Proxy.ts b/code/lib/cli/src/js-package-manager/Yarn1Proxy.ts index ad85c1f5452b..a4f83c95c715 100644 --- a/code/lib/cli/src/js-package-manager/Yarn1Proxy.ts +++ b/code/lib/cli/src/js-package-manager/Yarn1Proxy.ts @@ -1,3 +1,5 @@ +import dedent from 'ts-dedent'; +import { createLogStream } from '../utils'; import { JsPackageManager } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; @@ -18,6 +20,8 @@ export type Yarn1ListOutput = { data: Yarn1ListData; }; +const YARN1_ERROR_REGEX = /^error\s(.*)$/gm; + export class Yarn1Proxy extends JsPackageManager { readonly type = 'yarn1'; @@ -88,11 +92,29 @@ export class Yarn1Proxy extends JsPackageManager { args = ['-D', ...args]; } - await this.executeCommand({ - command: 'yarn', - args: ['add', ...this.getInstallArgs(), ...args], - stdio: 'inherit', - }); + const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream(); + + try { + await this.executeCommand({ + command: 'yarn', + args: ['add', ...this.getInstallArgs(), ...args], + stdio: ['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[]) { @@ -165,4 +187,18 @@ export class Yarn1Proxy extends JsPackageManager { throw new Error('Something went wrong while parsing yarn output'); } + + public parseErrorFromLogs(logs: string): string { + let finalMessage = 'YARN1 error'; + const match = logs.match(YARN1_ERROR_REGEX); + + if (match) { + const errorMessage = match[0]?.replace(/^error\s(.*)$/, '$1'); + if (errorMessage) { + finalMessage = `${finalMessage}: ${errorMessage}`; + } + } + + return finalMessage.trim(); + } } diff --git a/code/lib/cli/src/js-package-manager/Yarn2Proxy.test.ts b/code/lib/cli/src/js-package-manager/Yarn2Proxy.test.ts index f875254a6858..51e5c1235cd4 100644 --- a/code/lib/cli/src/js-package-manager/Yarn2Proxy.test.ts +++ b/code/lib/cli/src/js-package-manager/Yarn2Proxy.test.ts @@ -1,3 +1,4 @@ +import dedent from 'ts-dedent'; import { Yarn2Proxy } from './Yarn2Proxy'; describe('Yarn 2 Proxy', () => { @@ -271,4 +272,37 @@ describe('Yarn 2 Proxy', () => { `); }); }); + + describe('parseErrors', () => { + it('should parse yarn2 errors', () => { + const YARN2_ERROR_SAMPLE = ` + ➤ YN0000: ┌ Resolution step + ➤ YN0001: │ Error: react@npm:28.2.0: No candidates found + at ge (/Users/yannbraga/.cache/node/corepack/yarn/3.5.1/yarn.js:439:8124) + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) + at async Promise.allSettled (index 8) + at async io (/Users/yannbraga/.cache/node/corepack/yarn/3.5.1/yarn.js:390:10398) + ➤ YN0000: └ Completed in 2s 369ms + ➤ YN0000: Failed with errors in 2s 372ms + ➤ YN0032: fsevents@npm:2.3.2: Implicit dependencies on node-gyp are discouraged + ➤ YN0061: @npmcli/move-file@npm:2.0.1 is deprecated: This functionality has been moved to @npmcli/fs + `; + + expect(yarn2Proxy.parseErrorFromLogs(YARN2_ERROR_SAMPLE)).toEqual( + 'YARN2 error YN0001 - EXCEPTION: react@npm:28.2.0: No candidates found' + ); + }); + + it('should show unknown yarn2 error', () => { + const YARN2_ERROR_SAMPLE = dedent` + ➤ YN0000: ┌ Resolution step + ➤ YN0000: └ Completed in 2s 369ms + ➤ YN0000: Failed with errors in 2s 372ms + ➤ YN0032: fsevents@npm:2.3.2: Implicit dependencies on node-gyp are discouraged + ➤ YN0061: @npmcli/move-file@npm:2.0.1 is deprecated: This functionality has been moved to @npmcli/fs + `; + + expect(yarn2Proxy.parseErrorFromLogs(YARN2_ERROR_SAMPLE)).toEqual(`YARN2 error`); + }); + }); }); diff --git a/code/lib/cli/src/js-package-manager/Yarn2Proxy.ts b/code/lib/cli/src/js-package-manager/Yarn2Proxy.ts index e565e7f12ee2..5f463bd491b0 100644 --- a/code/lib/cli/src/js-package-manager/Yarn2Proxy.ts +++ b/code/lib/cli/src/js-package-manager/Yarn2Proxy.ts @@ -1,8 +1,65 @@ +import dedent from 'ts-dedent'; +import { createLogStream } from '../utils'; import { JsPackageManager } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; import { parsePackageData } from './util'; +const YARN2_ERROR_REGEX = /(YN\d{4}):.*?Error:\s+(.*)/i; +const YARN2_ERROR_CODES = { + YN0000: 'UNNAMED', + YN0001: 'EXCEPTION', + YN0002: 'MISSING_PEER_DEPENDENCY', + YN0003: 'CYCLIC_DEPENDENCIES', + YN0004: 'DISABLED_BUILD_SCRIPTS', + YN0005: 'BUILD_DISABLED', + YN0006: 'SOFT_LINK_BUILD', + YN0007: 'MUST_BUILD', + YN0008: 'MUST_REBUILD', + YN0009: 'BUILD_FAILED', + YN0010: 'RESOLVER_NOT_FOUND', + YN0011: 'FETCHER_NOT_FOUND', + YN0012: 'LINKER_NOT_FOUND', + YN0013: 'FETCH_NOT_CACHED', + YN0014: 'YARN_IMPORT_FAILED', + YN0015: 'REMOTE_INVALID', + YN0016: 'REMOTE_NOT_FOUND', + YN0017: 'RESOLUTION_PACK', + YN0018: 'CACHE_CHECKSUM_MISMATCH', + YN0019: 'UNUSED_CACHE_ENTRY', + YN0020: 'MISSING_LOCKFILE_ENTRY', + YN0021: 'WORKSPACE_NOT_FOUND', + YN0022: 'TOO_MANY_MATCHING_WORKSPACES', + YN0023: 'CONSTRAINTS_MISSING_DEPENDENCY', + YN0024: 'CONSTRAINTS_INCOMPATIBLE_DEPENDENCY', + YN0025: 'CONSTRAINTS_EXTRANEOUS_DEPENDENCY', + YN0026: 'CONSTRAINTS_INVALID_DEPENDENCY', + YN0027: 'CANT_SUGGEST_RESOLUTIONS', + YN0028: 'FROZEN_LOCKFILE_EXCEPTION', + YN0029: 'CROSS_DRIVE_VIRTUAL_LOCAL', + YN0030: 'FETCH_FAILED', + YN0031: 'DANGEROUS_NODE_MODULES', + YN0032: 'NODE_GYP_INJECTED', + YN0046: 'AUTOMERGE_FAILED_TO_PARSE', + YN0047: 'AUTOMERGE_IMMUTABLE', + YN0048: 'AUTOMERGE_SUCCESS', + YN0049: 'AUTOMERGE_REQUIRED', + YN0050: 'DEPRECATED_CLI_SETTINGS', + YN0059: 'INVALID_RANGE_PEER_DEPENDENCY', + YN0060: 'INCOMPATIBLE_PEER_DEPENDENCY', + YN0061: 'DEPRECATED_PACKAGE', + YN0062: 'INCOMPATIBLE_OS', + YN0063: 'INCOMPATIBLE_CPU', + YN0068: 'UNUSED_PACKAGE_EXTENSION', + YN0069: 'REDUNDANT_PACKAGE_EXTENSION', + YN0071: 'NM_CANT_INSTALL_EXTERNAL_SOFT_LINK', + YN0072: 'NM_PRESERVE_SYMLINKS_REQUIRED', + YN0074: 'NM_HARDLINKS_MODE_DOWNGRADED', + YN0075: 'PROLOG_INSTANTIATION_ERROR', + YN0076: 'INCOMPATIBLE_ARCHITECTURE', + YN0077: 'GHOST_ARCHITECTURE', +}; + // This encompasses both yarn 2 and yarn 3 export class Yarn2Proxy extends JsPackageManager { readonly type = 'yarn2'; @@ -79,11 +136,29 @@ export class Yarn2Proxy extends JsPackageManager { args = ['-D', ...args]; } - await this.executeCommand({ - command: 'yarn', - args: ['add', ...this.getInstallArgs(), ...args], - stdio: 'inherit', - }); + const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream(); + + try { + await this.executeCommand({ + command: 'yarn', + args: ['add', ...this.getInstallArgs(), ...args], + stdio: ['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[]) { @@ -148,4 +223,28 @@ export class Yarn2Proxy extends JsPackageManager { infoCommand: 'yarn why', }; } + + public parseErrorFromLogs(logs: string): string { + let finalMessage = 'YARN2 error'; + const match = logs.match(YARN2_ERROR_REGEX); + + if (match) { + const errorCode = match[1] as keyof typeof YARN2_ERROR_CODES; + if (errorCode) { + finalMessage = `${finalMessage} ${errorCode}`; + } + + const errorType = YARN2_ERROR_CODES[errorCode]; + if (errorType) { + finalMessage = `${finalMessage} - ${errorType}`; + } + + const errorMessage = match[2]; + if (errorMessage) { + finalMessage = `${finalMessage}: ${errorMessage}`; + } + } + + return finalMessage.trim(); + } } diff --git a/code/lib/cli/src/utils.ts b/code/lib/cli/src/utils.ts index 4650fd055dcf..4ae92e61ad66 100644 --- a/code/lib/cli/src/utils.ts +++ b/code/lib/cli/src/utils.ts @@ -1,3 +1,8 @@ +import type { WriteStream } from 'fs-extra'; +import { move, remove, writeFile, readFile, createWriteStream } from 'fs-extra'; +import { join } from 'path'; +import tempy from 'tempy'; + export function parseList(str: string): string[] { return str .split(',') @@ -15,3 +20,65 @@ export function getEnvConfig(program: Record, configEnv: Record Promise; + removeLogFile: () => Promise; + clearLogFile: () => Promise; + readLogFile: () => Promise; + logStream: WriteStream; +}> => { + const finalLogPath = join(process.cwd(), logFileName); + const temporaryLogPath = tempy.file({ name: logFileName }); + + const logStream = createWriteStream(temporaryLogPath, { encoding: 'utf8' }); + + return new Promise((resolve, reject) => { + logStream.once('open', () => { + const moveLogFile = async () => move(temporaryLogPath, finalLogPath, { overwrite: true }); + const clearLogFile = async () => writeFile(temporaryLogPath, ''); + const removeLogFile = async () => remove(temporaryLogPath); + const readLogFile = async () => { + return readFile(temporaryLogPath, 'utf8'); + }; + resolve({ logStream, moveLogFile, clearLogFile, removeLogFile, readLogFile }); + }); + logStream.once('error', reject); + }); +}; diff --git a/code/lib/core-server/src/withTelemetry.ts b/code/lib/core-server/src/withTelemetry.ts index c95529533599..ac14a142cd39 100644 --- a/code/lib/core-server/src/withTelemetry.ts +++ b/code/lib/core-server/src/withTelemetry.ts @@ -100,16 +100,30 @@ export async function withTelemetry( options: TelemetryOptions, run: () => Promise ): Promise { + // We catch Ctrl+C user interactions to be able to detect a cancel event + process.on('SIGINT', async () => { + if (!options.cliOptions.disableTelemetry) { + await telemetry('canceled', { eventType }, { stripMetadata: true, immediate: true }); + process.exit(0); + } + + process.exit(0); + }); + if (!options.cliOptions.disableTelemetry) telemetry('boot', { eventType }, { stripMetadata: true }); try { return await run(); } catch (error) { + if (error?.message === 'Canceled by the user') { + return undefined; + } + const { printError = logger.error } = options; printError(error); - await sendTelemetryError(error, eventType, options); + throw error; } } diff --git a/code/lib/telemetry/src/types.ts b/code/lib/telemetry/src/types.ts index d33b89677343..e65da2747a34 100644 --- a/code/lib/telemetry/src/types.ts +++ b/code/lib/telemetry/src/types.ts @@ -9,6 +9,7 @@ export type EventType = | 'build' | 'upgrade' | 'init' + | 'canceled' | 'error' | 'error-metadata' | 'version-update'; diff --git a/code/yarn.lock b/code/yarn.lock index 35f47455fc2a..c3683eeacc92 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -5715,6 +5715,7 @@ __metadata: globby: ^11.0.2 jscodeshift: ^0.14.0 leven: ^3.1.0 + ora: ^5.4.1 prettier: ^2.8.0 prompts: ^2.4.0 puppeteer-core: ^2.1.1