diff options
Diffstat (limited to 'packages/upgrade/src')
-rw-r--r-- | packages/upgrade/src/actions/context.ts | 60 | ||||
-rw-r--r-- | packages/upgrade/src/actions/help.ts | 15 | ||||
-rw-r--r-- | packages/upgrade/src/actions/install.ts | 190 | ||||
-rw-r--r-- | packages/upgrade/src/actions/verify.ts | 203 | ||||
-rw-r--r-- | packages/upgrade/src/index.ts | 32 | ||||
-rw-r--r-- | packages/upgrade/src/messages.ts | 220 | ||||
-rw-r--r-- | packages/upgrade/src/shell.ts | 60 |
7 files changed, 780 insertions, 0 deletions
diff --git a/packages/upgrade/src/actions/context.ts b/packages/upgrade/src/actions/context.ts new file mode 100644 index 000000000..2103a5327 --- /dev/null +++ b/packages/upgrade/src/actions/context.ts @@ -0,0 +1,60 @@ +import { pathToFileURL } from 'node:url'; +import { prompt } from '@astrojs/cli-kit'; +import arg from 'arg'; +import detectPackageManager from 'preferred-pm'; + +export interface Context { + help: boolean; + prompt: typeof prompt; + version: string; + dryRun?: boolean; + cwd: URL; + stdin?: typeof process.stdin; + stdout?: typeof process.stdout; + packageManager: string; + packages: PackageInfo[]; + exit(code: number): never; +} + +export interface PackageInfo { + name: string; + currentVersion: string; + targetVersion: string; + tag?: string; + isDevDependency?: boolean; + isMajor?: boolean; + changelogURL?: string; + changelogTitle?: string; +} + +export async function getContext(argv: string[]): Promise<Context> { + const flags = arg( + { + '--dry-run': Boolean, + '--help': Boolean, + + '-h': '--help', + }, + { argv, permissive: true }, + ); + + const packageManager = (await detectPackageManager(process.cwd()))?.name ?? 'npm'; + const { + _: [version = 'latest'] = [], + '--help': help = false, + '--dry-run': dryRun, + } = flags; + + return { + help, + prompt, + packageManager, + packages: [], + cwd: new URL(pathToFileURL(process.cwd()) + '/'), + dryRun, + version, + exit(code) { + process.exit(code); + }, + } satisfies Context; +} diff --git a/packages/upgrade/src/actions/help.ts b/packages/upgrade/src/actions/help.ts new file mode 100644 index 000000000..2e25b7e84 --- /dev/null +++ b/packages/upgrade/src/actions/help.ts @@ -0,0 +1,15 @@ +import { printHelp } from '../messages.js'; + +export function help() { + printHelp({ + commandName: '@astrojs/upgrade', + usage: '[version] [...flags]', + headline: 'Upgrade Astro dependencies.', + tables: { + Flags: [ + ['--help (-h)', 'See all available flags.'], + ['--dry-run', 'Walk through steps without executing.'], + ], + }, + }); +} diff --git a/packages/upgrade/src/actions/install.ts b/packages/upgrade/src/actions/install.ts new file mode 100644 index 000000000..ac62b1598 --- /dev/null +++ b/packages/upgrade/src/actions/install.ts @@ -0,0 +1,190 @@ +import type { Context, PackageInfo } from './context.js'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { color, say } from '@astrojs/cli-kit'; +import { random, sleep } from '@astrojs/cli-kit/utils'; +import { + banner, + bye, + celebrations, + changelog, + done, + error, + info, + newline, + pluralize, + spinner, + success, + title, + upgrade, + warn, +} from '../messages.js'; +import { shell } from '../shell.js'; + +export async function install( + ctx: Pick< + Context, + 'version' | 'packages' | 'packageManager' | 'prompt' | 'dryRun' | 'exit' | 'cwd' + >, +) { + await banner(); + newline(); + const { current, dependencies, devDependencies } = filterPackages(ctx); + const toInstall = [...dependencies, ...devDependencies].sort(sortPackages); + for (const packageInfo of current.sort(sortPackages)) { + const tag = /^\d/.test(packageInfo.targetVersion) + ? packageInfo.targetVersion + : packageInfo.targetVersion.slice(1); + await info(`${packageInfo.name}`, `is up to date on`, `v${tag}`); + await sleep(random(50, 150)); + } + if (toInstall.length === 0 && !ctx.dryRun) { + newline(); + await success(random(celebrations), random(done)); + return; + } + const majors: PackageInfo[] = []; + for (const packageInfo of toInstall) { + const word = ctx.dryRun ? 'can' : 'will'; + await upgrade(packageInfo, `${word} be updated`); + if (packageInfo.isMajor) { + majors.push(packageInfo); + } + } + if (majors.length > 0) { + const { proceed } = await ctx.prompt({ + name: 'proceed', + type: 'confirm', + label: title('wait'), + message: `${pluralize( + ['One package has', 'Some packages have'], + majors.length, + )} breaking changes. Continue?`, + initial: true, + }); + if (!proceed) { + return ctx.exit(0); + } + + newline(); + + await warn('check', `Be sure to follow the ${pluralize('CHANGELOG', majors.length)}.`); + for (const pkg of majors.sort(sortPackages)) { + await changelog(pkg.name, pkg.changelogTitle!, pkg.changelogURL!); + } + } + + newline(); + if (ctx.dryRun) { + await info('--dry-run', `Skipping dependency installation`); + } else { + await runInstallCommand(ctx, dependencies, devDependencies); + } +} + +function filterPackages(ctx: Pick<Context, 'packages'>) { + const current: PackageInfo[] = []; + const dependencies: PackageInfo[] = []; + const devDependencies: PackageInfo[] = []; + for (const packageInfo of ctx.packages) { + const { currentVersion, targetVersion, isDevDependency } = packageInfo; + // Remove prefix from version before comparing + if (currentVersion.replace(/^\D+/, '') === targetVersion.replace(/^\D+/, '')) { + current.push(packageInfo); + } else { + const arr = isDevDependency ? devDependencies : dependencies; + arr.push(packageInfo); + } + } + return { current, dependencies, devDependencies }; +} + +/** + * An `Array#sort` comparator function to normalize how packages are displayed. + * This only changes how the packages are displayed in the CLI, it is not persisted to `package.json`. + */ +function sortPackages(a: PackageInfo, b: PackageInfo): number { + if (a.isMajor && !b.isMajor) return 1; + if (b.isMajor && !a.isMajor) return -1; + if (a.name === 'astro') return -1; + if (b.name === 'astro') return 1; + if (a.name.startsWith('@astrojs') && !b.name.startsWith('@astrojs')) return -1; + if (b.name.startsWith('@astrojs') && !a.name.startsWith('@astrojs')) return 1; + return a.name.localeCompare(b.name); +} + +async function runInstallCommand( + ctx: Pick<Context, 'cwd' | 'packageManager' | 'exit'>, + dependencies: PackageInfo[], + devDependencies: PackageInfo[], +) { + const cwd = fileURLToPath(ctx.cwd); + if (ctx.packageManager === 'yarn') await ensureYarnLock({ cwd }); + + const installCmd = + ctx.packageManager === 'yarn' || ctx.packageManager === 'pnpm' ? 'add' : 'install'; + + await spinner({ + start: `Installing dependencies with ${ctx.packageManager}...`, + end: `Installed dependencies!`, + while: async () => { + try { + if (dependencies.length > 0) { + await shell( + ctx.packageManager, + [ + installCmd, + ...dependencies.map( + ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`, + ), + ], + { cwd, timeout: 90_000, stdio: 'ignore' }, + ); + } + if (devDependencies.length > 0) { + await shell( + ctx.packageManager, + [ + installCmd, + '--save-dev', + ...devDependencies.map( + ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`, + ), + ], + { cwd, timeout: 90_000, stdio: 'ignore' }, + ); + } + } catch { + const packages = [...dependencies, ...devDependencies] + .map(({ name, targetVersion }) => `${name}@${targetVersion}`) + .join(' '); + newline(); + error( + 'error', + `Dependencies failed to install, please run the following command manually:\n${color.bold( + `${ctx.packageManager} ${installCmd} ${packages}`, + )}`, + ); + return ctx.exit(1); + } + }, + }); + + await say([`${random(celebrations)} ${random(done)}`, random(bye)], { clear: false }); +} + +/** + * Yarn Berry (PnP) versions will throw an error if there isn't an existing `yarn.lock` file + * If a `yarn.lock` file doesn't exist, this function writes an empty `yarn.lock` one. + * Unfortunately this hack is required to run `yarn install`. + * + * The empty `yarn.lock` file is immediately overwritten by the installation process. + * See https://github.com/withastro/astro/pull/8028 + */ +async function ensureYarnLock({ cwd }: { cwd: string }) { + const yarnLock = path.join(cwd, 'yarn.lock'); + if (fs.existsSync(yarnLock)) return; + return fs.promises.writeFile(yarnLock, '', { encoding: 'utf-8' }); +} diff --git a/packages/upgrade/src/actions/verify.ts b/packages/upgrade/src/actions/verify.ts new file mode 100644 index 000000000..3b7c15a9e --- /dev/null +++ b/packages/upgrade/src/actions/verify.ts @@ -0,0 +1,203 @@ +import type { Context, PackageInfo } from './context.js'; + +import dns from 'node:dns/promises'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { color } from '@astrojs/cli-kit'; +import semverCoerce from 'semver/functions/coerce.js'; +import semverDiff from 'semver/functions/diff.js'; +import semverParse from 'semver/functions/parse.js'; +import { bannerAbort, error, getRegistry, info, newline } from '../messages.js'; + +export async function verify( + ctx: Pick<Context, 'version' | 'packages' | 'cwd' | 'dryRun' | 'exit'>, +) { + const registry = await getRegistry(); + + if (!ctx.dryRun) { + const online = await isOnline(registry); + if (!online) { + bannerAbort(); + newline(); + error('error', `Unable to connect to the internet.`); + ctx.exit(1); + } + } + + const isAstroProject = await verifyAstroProject(ctx); + if (!isAstroProject) { + bannerAbort(); + newline(); + error('error', `Astro installation not found in the current directory.`); + ctx.exit(1); + } + + const ok = await verifyVersions(ctx, registry); + if (!ok) { + bannerAbort(); + newline(); + error('error', `Version ${color.reset(ctx.version)} ${color.dim('could not be found!')}`); + await info('check', 'https://github.com/withastro/astro/releases'); + ctx.exit(1); + } +} + +function isOnline(registry: string): Promise<boolean> { + const { hostname } = new URL(registry); + return dns.lookup(hostname).then( + () => true, + () => false, + ); +} + +function safeJSONParse(value: string) { + try { + return JSON.parse(value); + } catch {} + return {}; +} + +async function verifyAstroProject(ctx: Pick<Context, 'cwd' | 'version' | 'packages'>) { + const packageJson = new URL('./package.json', ctx.cwd); + if (!existsSync(packageJson)) return false; + const contents = await readFile(packageJson, { encoding: 'utf-8' }); + if (!contents.includes('astro')) return false; + + const { dependencies = {}, devDependencies = {} } = safeJSONParse(contents); + if (dependencies['astro'] === undefined && devDependencies['astro'] === undefined) return false; + + // Side-effect! Persist dependency info to the shared context + collectPackageInfo(ctx, dependencies, devDependencies); + + return true; +} + +function isAstroPackage(name: string, _version: string) { + return name === 'astro' || name.startsWith('@astrojs/'); +} + +function isAllowedPackage(name: string, _version: string) { + return name !== '@astrojs/upgrade'; +} + +function isValidVersion(_name: string, version: string) { + return semverCoerce(version, { loose: true }) !== null; +} + +function isSupportedPackage(name: string, version: string): boolean { + for (const validator of [isAstroPackage, isAllowedPackage, isValidVersion]) { + if (!validator(name, version)) return false; + } + return true; +} + +export function collectPackageInfo( + ctx: Pick<Context, 'version' | 'packages'>, + dependencies: Record<string, string> = {}, + devDependencies: Record<string, string> = {}, +) { + for (const [name, currentVersion] of Object.entries(dependencies)) { + if (!isSupportedPackage(name, currentVersion)) continue; + ctx.packages.push({ + name, + currentVersion, + targetVersion: ctx.version, + }); + } + for (const [name, currentVersion] of Object.entries(devDependencies)) { + if (!isSupportedPackage(name, currentVersion)) continue; + ctx.packages.push({ + name, + currentVersion, + targetVersion: ctx.version, + isDevDependency: true, + }); + } +} + +async function verifyVersions( + ctx: Pick<Context, 'version' | 'packages' | 'exit'>, + registry: string, +) { + const tasks: Promise<void>[] = []; + for (const packageInfo of ctx.packages) { + tasks.push(resolveTargetVersion(packageInfo, registry)); + } + try { + await Promise.all(tasks); + } catch { + return false; + } + for (const packageInfo of ctx.packages) { + if (!packageInfo.targetVersion) { + return false; + } + } + return true; +} + +async function resolveTargetVersion(packageInfo: PackageInfo, registry: string): Promise<void> { + const packageMetadata = await fetch(`${registry}/${packageInfo.name}`, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + }); + if (packageMetadata.status >= 400) { + throw new Error(`Unable to resolve "${packageInfo.name}"`); + } + const { 'dist-tags': distTags } = await packageMetadata.json(); + let version = distTags[packageInfo.targetVersion]; + if (version) { + packageInfo.tag = packageInfo.targetVersion; + packageInfo.targetVersion = version; + } else { + packageInfo.targetVersion = 'latest'; + version = distTags.latest; + } + if (packageInfo.currentVersion === version) { + return; + } + const prefix = packageInfo.targetVersion === 'latest' ? '^' : ''; + packageInfo.targetVersion = `${prefix}${version}`; + const fromVersion = semverCoerce(packageInfo.currentVersion)!; + const toVersion = semverParse(version)!; + const bump = semverDiff(fromVersion, toVersion); + if ((bump === 'major' && toVersion.prerelease.length === 0) || bump === 'premajor') { + packageInfo.isMajor = true; + if (packageInfo.name === 'astro') { + const upgradeGuide = `https://docs.astro.build/en/guides/upgrade-to/v${toVersion.major}/`; + const docsRes = await fetch(upgradeGuide); + // OK if this request fails, it's probably a prerelease without a public migration guide. + // In that case, we should fallback to the CHANGELOG check below. + if (docsRes.status === 200) { + packageInfo.changelogURL = upgradeGuide; + packageInfo.changelogTitle = `Upgrade to Astro v${toVersion.major}`; + return; + } + } + const latestMetadata = await fetch(`${registry}/${packageInfo.name}/latest`); + if (latestMetadata.status >= 400) { + throw new Error(`Unable to resolve "${packageInfo.name}"`); + } + const { repository } = await latestMetadata.json(); + const branch = bump === 'premajor' ? 'next' : 'main'; + packageInfo.changelogURL = extractChangelogURLFromRepository(repository, version, branch); + packageInfo.changelogTitle = 'CHANGELOG'; + } else { + // Dependency updates should not include the specific dist-tag + // since they are just for compatibility + packageInfo.tag = undefined; + } +} + +function extractChangelogURLFromRepository( + repository: Record<string, string>, + version: string, + branch = 'main', +) { + return ( + repository.url.replace('git+', '').replace('.git', '') + + `/blob/${branch}/` + + repository.directory + + '/CHANGELOG.md#' + + version.replace(/\./g, '') + ); +} diff --git a/packages/upgrade/src/index.ts b/packages/upgrade/src/index.ts new file mode 100644 index 000000000..28e32e473 --- /dev/null +++ b/packages/upgrade/src/index.ts @@ -0,0 +1,32 @@ +import { getContext } from './actions/context.js'; + +import { help } from './actions/help.js'; +import { install } from './actions/install.js'; +import { collectPackageInfo, verify } from './actions/verify.js'; +import { setStdout } from './messages.js'; + +const exit = () => process.exit(0); +process.on('SIGINT', exit); +process.on('SIGTERM', exit); + +export async function main() { + // NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed + // to no longer require `--` to pass args and instead pass `--` directly to us. This + // broke our arg parser, since `--` is a special kind of flag. Filtering for `--` here + // fixes the issue so that create-astro now works on all npm versions. + const cleanArgv = process.argv.slice(2).filter((arg) => arg !== '--'); + const ctx = await getContext(cleanArgv); + if (ctx.help) { + help(); + return; + } + + const steps = [verify, install]; + + for (const step of steps) { + await step(ctx); + } + process.exit(0); +} + +export { getContext, install, setStdout, verify, collectPackageInfo }; diff --git a/packages/upgrade/src/messages.ts b/packages/upgrade/src/messages.ts new file mode 100644 index 000000000..032faa1ac --- /dev/null +++ b/packages/upgrade/src/messages.ts @@ -0,0 +1,220 @@ +/* eslint no-console: 'off' */ +import { color, label, spinner as load } from '@astrojs/cli-kit'; +import { align } from '@astrojs/cli-kit/utils'; +import detectPackageManager from 'preferred-pm'; +import terminalLink from 'terminal-link'; +import type { PackageInfo } from './actions/context.js'; +import { shell } from './shell.js'; + +// Users might lack access to the global npm registry, this function +// checks the user's project type and will return the proper npm registry +// +// A copy of this function also exists in the astro package +let _registry: string; +export async function getRegistry(): Promise<string> { + if (_registry) return _registry; + const fallback = 'https://registry.npmjs.org'; + const packageManager = (await detectPackageManager(process.cwd()))?.name || 'npm'; + try { + const { stdout } = await shell(packageManager, ['config', 'get', 'registry']); + _registry = stdout?.trim()?.replace(/\/$/, '') || fallback; + // Detect cases where the shell command returned a non-URL (e.g. a warning) + if (!new URL(_registry).host) _registry = fallback; + } catch { + _registry = fallback; + } + return _registry; +} + +let stdout = process.stdout; +/** @internal Used to mock `process.stdout.write` for testing purposes */ +export function setStdout(writable: typeof process.stdout) { + stdout = writable; +} + +export async function spinner(args: { + start: string; + end: string; + while: (...args: any) => Promise<any>; +}) { + await load(args, { stdout }); +} + +export function pluralize(word: string | [string, string], n: number) { + const [singular, plural] = Array.isArray(word) ? word : [word, word + 's']; + if (n === 1) return singular; + return plural; +} + +export const celebrations = [ + 'Beautiful.', + 'Excellent!', + 'Sweet!', + 'Nice!', + 'Huzzah!', + 'Success.', + 'Nice.', + 'Wonderful.', + 'Lovely!', + "Lookin' good.", + 'Awesome.', +]; + +export const done = [ + "You're on the latest and greatest.", + 'Your integrations are up-to-date.', + 'Everything is current.', + 'Everything is up to date.', + 'Integrations are all up to date.', + 'Everything is on the latest and greatest.', + 'Integrations are up to date.', +]; + +export const bye = [ + 'Thanks for using Astro!', + 'Have fun building!', + 'Take it easy, astronaut!', + "Can't wait to see what you build.", + 'Good luck out there.', + 'See you around, astronaut.', +]; + +export const log = (message: string) => stdout.write(message + '\n'); + +export const newline = () => stdout.write('\n'); + +export const banner = async () => + log( + `\n${label('astro', color.bgGreen, color.black)} ${color.bold( + 'Integration upgrade in progress.', + )}`, + ); + +export const bannerAbort = () => + log(`\n${label('astro', color.bgRed)} ${color.bold('Integration upgrade aborted.')}`); + +export const warn = async (prefix: string, text: string) => { + log(`${label(prefix, color.bgCyan, color.black)} ${text}`); +}; + +export const info = async (prefix: string, text: string, version = '') => { + const length = 11 + prefix.length + text.length + version?.length; + const symbol = '◼'; + if (length > stdout.columns) { + log(`${' '.repeat(5)} ${color.cyan(symbol)} ${prefix}`); + log(`${' '.repeat(9)}${color.dim(text)} ${color.reset(version)}`); + } else { + log( + `${' '.repeat(5)} ${color.cyan(symbol)} ${prefix} ${color.dim(text)} ${color.reset(version)}`, + ); + } +}; + +export const upgrade = async (packageInfo: PackageInfo, text: string) => { + const { name, isMajor = false, targetVersion, currentVersion } = packageInfo; + + const bg = isMajor ? (v: string) => color.bgYellow(color.black(` ${v} `)) : color.green; + const style = isMajor ? color.yellow : color.green; + const symbol = isMajor ? '▲' : '●'; + + const fromVersion = currentVersion.replace(/^\D+/, ''); + const toVersion = targetVersion.replace(/^\D+/, ''); + const version = `from v${fromVersion} to v${toVersion}`; + + const length = 12 + name.length + text.length + version.length; + if (length > stdout.columns) { + log(`${' '.repeat(5)} ${style(symbol)} ${name}`); + log(`${' '.repeat(9)}${color.dim(text)} ${bg(version)}`); + } else { + log(`${' '.repeat(5)} ${style(symbol)} ${name} ${color.dim(text)} ${bg(version)}`); + } +}; + +export const title = (text: string) => + align(label(text, color.bgYellow, color.black), 'end', 7) + ' '; + +export const success = async (prefix: string, text: string) => { + const length = 10 + prefix.length + text.length; + if (length > stdout.columns) { + log(`${' '.repeat(5)} ${color.green('✔')} ${prefix}`); + log(`${' '.repeat(9)}${color.dim(text)}`); + } else { + log(`${' '.repeat(5)} ${color.green('✔')} ${prefix} ${color.dim(text)}`); + } +}; + +export const error = async (prefix: string, text: string) => { + if (stdout.columns < 80) { + log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)}`); + log(`${' '.repeat(9)}${color.dim(text)}`); + } else { + log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)} ${color.dim(text)}`); + } +}; + +export const changelog = async (name: string, text: string, url: string) => { + const link = terminalLink(text, url, { fallback: () => url }); + const linkLength = terminalLink.isSupported ? text.length : url.length; + const symbol = ' '; + + const length = 12 + name.length + linkLength; + if (length > stdout.columns) { + log(`${' '.repeat(5)} ${symbol} ${name}`); + log(`${' '.repeat(9)}${color.cyan(color.underline(link))}`); + } else { + log(`${' '.repeat(5)} ${symbol} ${name} ${color.cyan(color.underline(link))}`); + } +}; + +export function printHelp({ + commandName, + usage, + tables, + description, +}: { + commandName: string; + headline?: string; + usage?: string; + tables?: Record<string, [command: string, help: string][]>; + description?: string; +}) { + const linebreak = () => ''; + const table = (rows: [string, string][], { padding }: { padding: number }) => { + const split = stdout.columns < 60; + let raw = ''; + + for (const row of rows) { + if (split) { + raw += ` ${row[0]}\n `; + } else { + raw += `${`${row[0]}`.padStart(padding)}`; + } + raw += ' ' + color.dim(row[1]) + '\n'; + } + + return raw.slice(0, -1); // remove latest \n + }; + + let message = []; + + if (usage) { + message.push(linebreak(), `${color.green(commandName)} ${color.bold(usage)}`); + } + + if (tables) { + function calculateTablePadding(rows: [string, string][]) { + return rows.reduce((val, [first]) => Math.max(val, first.length), 0); + } + const tableEntries = Object.entries(tables); + const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows))); + for (const [, tableRows] of tableEntries) { + message.push(linebreak(), table(tableRows, { padding })); + } + } + + if (description) { + message.push(linebreak(), `${description}`); + } + + log(message.join('\n') + '\n'); +} diff --git a/packages/upgrade/src/shell.ts b/packages/upgrade/src/shell.ts new file mode 100644 index 000000000..cd16e0d02 --- /dev/null +++ b/packages/upgrade/src/shell.ts @@ -0,0 +1,60 @@ +// This is an extremely simplified version of [`execa`](https://github.com/sindresorhus/execa) +// intended to keep our dependency size down +import type { ChildProcess, StdioOptions } from 'node:child_process'; +import type { Readable } from 'node:stream'; + +import { spawn } from 'node:child_process'; +import { text as textFromStream } from 'node:stream/consumers'; + +export interface ExecaOptions { + cwd?: string | URL; + stdio?: StdioOptions; + timeout?: number; +} +export interface Output { + stdout: string; + stderr: string; + exitCode: number; +} +const text = (stream: NodeJS.ReadableStream | Readable | null) => + stream ? textFromStream(stream).then((t) => t.trimEnd()) : ''; + +let signal: AbortSignal; +export async function shell( + command: string, + flags: string[], + opts: ExecaOptions = {}, +): Promise<Output> { + let child: ChildProcess; + let stdout = ''; + let stderr = ''; + if (!signal) { + const controller = new AbortController(); + // Ensure spawned process is cancelled on exit + process.once('beforeexit', () => controller.abort()); + process.once('exit', () => controller.abort()); + signal = controller.signal; + } + try { + child = spawn(command, flags, { + cwd: opts.cwd, + shell: true, + stdio: opts.stdio, + timeout: opts.timeout, + signal, + }); + const done = new Promise((resolve) => child.on('close', resolve)); + [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr)]); + await done; + } catch { + throw { stdout, stderr, exitCode: 1 }; + } + const { exitCode } = child; + if (exitCode === null) { + throw new Error('Timeout'); + } + if (exitCode !== 0) { + throw new Error(stderr); + } + return { stdout, stderr, exitCode }; +} |