aboutsummaryrefslogtreecommitdiff
path: root/packages/upgrade/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/upgrade/src')
-rw-r--r--packages/upgrade/src/actions/context.ts60
-rw-r--r--packages/upgrade/src/actions/help.ts15
-rw-r--r--packages/upgrade/src/actions/install.ts190
-rw-r--r--packages/upgrade/src/actions/verify.ts203
-rw-r--r--packages/upgrade/src/index.ts32
-rw-r--r--packages/upgrade/src/messages.ts220
-rw-r--r--packages/upgrade/src/shell.ts60
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 };
+}