summaryrefslogtreecommitdiff
path: root/packages/upgrade/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/upgrade/src')
-rw-r--r--packages/upgrade/src/actions/context.ts56
-rw-r--r--packages/upgrade/src/actions/help.ts15
-rw-r--r--packages/upgrade/src/actions/install.ts138
-rw-r--r--packages/upgrade/src/actions/verify.ts165
-rw-r--r--packages/upgrade/src/index.ts40
-rw-r--r--packages/upgrade/src/messages.ts207
-rw-r--r--packages/upgrade/src/shell.ts60
7 files changed, 681 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..d5a5778d4
--- /dev/null
+++ b/packages/upgrade/src/actions/context.ts
@@ -0,0 +1,56 @@
+import { prompt } from '@astrojs/cli-kit';
+import arg from 'arg';
+import { pathToFileURL } from 'node:url';
+import detectPackageManager from 'which-pm-runs';
+
+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 = detectPackageManager()?.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..d61abc71e
--- /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..4696d5eb5
--- /dev/null
+++ b/packages/upgrade/src/actions/install.ts
@@ -0,0 +1,138 @@
+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 { pluralize, celebrations, done, error, info, log, spinner, success, upgrade, banner, title, changelog, warn, bye, newline } from '../messages.js';
+import { shell } from '../shell.js';
+import { random, sleep } from '@astrojs/cli-kit/utils';
+import { satisfies } from 'semver';
+
+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 to`)
+ 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 `currentVersion` before comparing
+ if (currentVersion.replace(/^\D+/, '') === targetVersion) {
+ 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 });
+
+ await spinner({
+ start: `Installing dependencies with ${ctx.packageManager}...`,
+ end: `Installed dependencies!`,
+ while: async () => {
+ try {
+ if (dependencies.length > 0) {
+ await shell(ctx.packageManager, ['install', ...dependencies.map(({ name, targetVersion }) => `${name}@${(targetVersion).replace(/^\^/, '')}`)], { cwd, timeout: 90_000, stdio: 'ignore' })
+ }
+ if (devDependencies.length > 0) {
+ await shell(ctx.packageManager, ['install', '--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} install ${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..be86c5344
--- /dev/null
+++ b/packages/upgrade/src/actions/verify.ts
@@ -0,0 +1,165 @@
+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 { bannerAbort, error, getRegistry, info, log, newline } from '../messages.js';
+import semverDiff from 'semver/functions/diff.js';
+import semverCoerce from 'semver/functions/coerce.js';
+import semverParse from 'semver/functions/parse.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);
+ }
+ }
+
+ await verifyAstroProject(ctx);
+
+ 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 { host } = new URL(registry);
+ return dns.lookup(host).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) {
+ return name === 'astro' || name.startsWith('@astrojs/');
+}
+
+function collectPackageInfo(ctx: Pick<Context, 'version' | 'packages'>, dependencies: Record<string, string>, devDependencies: Record<string, string>) {
+ for (const [name, currentVersion] of Object.entries(dependencies)) {
+ if (!isAstroPackage(name)) continue;
+ ctx.packages.push({
+ name,
+ currentVersion,
+ targetVersion: ctx.version,
+ })
+ }
+ for (const [name, currentVersion] of Object.entries(devDependencies)) {
+ if (!isAstroPackage(name)) 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 compatability
+ 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..ab216ad56
--- /dev/null
+++ b/packages/upgrade/src/index.ts
@@ -0,0 +1,40 @@
+import { getContext } from './actions/context.js';
+
+import { install } from './actions/install.js';
+import { help } from './actions/help.js';
+import { 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 {
+ install,
+ getContext,
+ setStdout,
+ verify,
+};
diff --git a/packages/upgrade/src/messages.ts b/packages/upgrade/src/messages.ts
new file mode 100644
index 000000000..d043db310
--- /dev/null
+++ b/packages/upgrade/src/messages.ts
@@ -0,0 +1,207 @@
+/* eslint no-console: 'off' */
+import type { PackageInfo } from './actions/context.js';
+import { color, label, spinner as load } from '@astrojs/cli-kit';
+import { align } from '@astrojs/cli-kit/utils';
+import detectPackageManager from 'which-pm-runs';
+import { shell } from './shell.js';
+import semverParse from 'semver/functions/parse.js';
+import terminalLink from 'terminal-link';
+
+// 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
+export async function getRegistry(): Promise<string> {
+ const packageManager = detectPackageManager()?.name || 'npm';
+ try {
+ const { stdout } = await shell(packageManager, ['config', 'get', 'registry']);
+ return stdout?.trim()?.replace(/\/$/, '') || 'https://registry.npmjs.org';
+ } catch (e) {
+ return 'https://registry.npmjs.org';
+ }
+}
+
+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 } = packageInfo;
+
+ const bg = isMajor ? (v: string) => color.bgYellow(color.black(` ${v} `)) : color.green;
+ const style = isMajor ? color.yellow : color.green;
+ const symbol = isMajor ? '▲' : '●';
+ const toVersion = semverParse(targetVersion)!;
+ const version = `v${toVersion.version}`;
+
+ 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..47eb4857a
--- /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 (e) {
+ 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 };
+}