-
-
Notifications
You must be signed in to change notification settings - Fork 9.3k
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
Core: Add bun support with npm fallback #29267
Changes from 1 commit
ef6e2ce
c9350ee
8773f66
d499768
1523974
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,332 @@ | ||
import { existsSync, readFileSync } from 'node:fs'; | ||
import { platform } from 'node:os'; | ||
import { join } from 'node:path'; | ||
|
||
import { logger } from '@storybook/core/node-logger'; | ||
import { FindPackageVersionsError } from '@storybook/core/server-errors'; | ||
|
||
import { findUp } from 'find-up'; | ||
import sort from 'semver/functions/sort.js'; | ||
import { dedent } from 'ts-dedent'; | ||
|
||
import { createLogStream } from '../utils/cli'; | ||
import { JsPackageManager } from './JsPackageManager'; | ||
import type { PackageJson } from './PackageJson'; | ||
import type { InstallationMetadata, PackageMetadata } from './types'; | ||
|
||
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 BUNProxy extends JsPackageManager { | ||
readonly type = 'bun'; | ||
|
||
installArgs: string[] | undefined; | ||
|
||
async initPackageJson() { | ||
await this.executeCommand({ command: 'bun', args: ['init'] }); | ||
} | ||
|
||
getRunStorybookCommand(): string { | ||
return 'bun run storybook'; | ||
} | ||
|
||
getRunCommand(command: string): string { | ||
return `bun run ${command}`; | ||
} | ||
|
||
async getNpmVersion(): Promise<string> { | ||
return this.executeCommand({ command: 'npm', args: ['--version'] }); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: getNpmVersion uses npm instead of bun. Consider implementing a getBunVersion method |
||
|
||
public async getPackageJSON( | ||
packageName: string, | ||
basePath = this.cwd | ||
): Promise<PackageJson | null> { | ||
const packageJsonPath = await findUp( | ||
(dir) => { | ||
const possiblePath = 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; | ||
} | ||
|
||
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: 'bun', | ||
args: ['run', command, ...args], | ||
cwd, | ||
stdio, | ||
}); | ||
} | ||
|
||
public async runPackageCommand(command: string, args: string[], cwd?: string): Promise<string> { | ||
return this.executeCommand({ | ||
command: 'bun', | ||
args: ['run', command, ...args], | ||
cwd, | ||
}); | ||
} | ||
|
||
public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: findInstallations method uses npm instead of bun. Implement a bun-specific version or handle potential differences |
||
const exec = async ({ packageDepth }: { packageDepth: number }) => { | ||
const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; | ||
return this.executeCommand({ | ||
command: 'npm', | ||
args: ['ls', '--json', `--depth=${packageDepth}`, pipeToNull], | ||
env: { | ||
FORCE_COLOR: 'false', | ||
}, | ||
}); | ||
}; | ||
|
||
try { | ||
const commandResult = await exec({ packageDepth: depth }); | ||
const parsedOutput = JSON.parse(commandResult); | ||
|
||
return this.mapDependencies(parsedOutput, pattern); | ||
} catch (e) { | ||
// when --depth is higher than 0, npm can return a non-zero exit code | ||
// in case the user's project has peer dependency issues. So we try again with no depth | ||
try { | ||
const commandResult = await exec({ packageDepth: 0 }); | ||
const parsedOutput = JSON.parse(commandResult); | ||
|
||
return this.mapDependencies(parsedOutput, pattern); | ||
} catch (err) { | ||
logger.warn(`An issue occurred while trying to find dependencies metadata using npm.`); | ||
return undefined; | ||
} | ||
} | ||
} | ||
|
||
protected getResolutions(packageJson: PackageJson, versions: Record<string, string>) { | ||
return { | ||
overrides: { | ||
...packageJson.overrides, | ||
...versions, | ||
}, | ||
}; | ||
} | ||
|
||
protected async runInstall() { | ||
await this.executeCommand({ | ||
command: 'bun', | ||
args: ['install', ...this.getInstallArgs()], | ||
stdio: 'inherit', | ||
}); | ||
} | ||
|
||
public async getRegistryURL() { | ||
const res = await this.executeCommand({ | ||
command: 'npm', | ||
// "npm config" commands are not allowed in workspaces per default | ||
// https://github.com/npm/cli/issues/6099#issuecomment-1847584792 | ||
args: ['config', 'get', 'registry', '-ws=false', '-iwr'], | ||
}); | ||
const url = res.trim(); | ||
return url === 'undefined' ? undefined : url; | ||
} | ||
Comment on lines
+186
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: getRegistryURL uses npm. Implement a bun-specific method or handle potential differences |
||
|
||
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: 'bun', | ||
args: ['add', ...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: 'bun', | ||
args: ['remove', ...args, ...this.getInstallArgs()], | ||
stdio: 'inherit', | ||
}); | ||
} | ||
|
||
protected async runGetVersions<T extends boolean>( | ||
valentinpalkovic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
packageName: string, | ||
fetchAllVersions: T | ||
): Promise<T extends true ? string[] : string> { | ||
const args = [fetchAllVersions ? 'versions' : 'version', '--json']; | ||
try { | ||
const commandResult = await this.executeCommand({ | ||
command: 'npm', | ||
args: ['info', packageName, ...args], | ||
}); | ||
|
||
const parsedOutput = JSON.parse(commandResult); | ||
|
||
if (parsedOutput.error?.summary) { | ||
// this will be handled in the catch block below | ||
throw parsedOutput.error.summary; | ||
} | ||
|
||
return parsedOutput; | ||
} catch (error) { | ||
throw new FindPackageVersionsError({ | ||
error, | ||
packageManager: 'NPM', | ||
packageName, | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* @param input The output of `npm ls --json` | ||
* @param pattern A list of package names to filter the result. * can be used as a placeholder | ||
*/ | ||
protected mapDependencies(input: NpmListOutput, pattern: string[]): InstallationMetadata { | ||
const acc: Record<string, PackageMetadata[]> = {}; | ||
const existingVersions: Record<string, string[]> = {}; | ||
const duplicatedDependencies: Record<string, string[]> = {}; | ||
|
||
const recurse = ([name, packageInfo]: [string, NpmDependency]): void => { | ||
// transform pattern into regex where `*` is replaced with `.*` | ||
if (!name || !pattern.some((p) => new RegExp(`^${p.replace(/\*/g, '.*')}$`).test(name))) { | ||
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', | ||
valentinpalkovic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
} | ||
|
||
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(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Consider renaming to BunProxy for consistency with other package manager proxies