aboutsummaryrefslogtreecommitdiff
path: root/packages/upgrade/src/actions
diff options
context:
space:
mode:
authorGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
committerGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
commite586d7d704d475afe3373a1de6ae20d504f79d6d (patch)
tree7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/upgrade/src/actions
downloadastro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.gz
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.zst
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.zip
Sync from a8e1c0a7402940e0fc5beef669522b315052df1blatest
Diffstat (limited to 'packages/upgrade/src/actions')
-rw-r--r--packages/upgrade/src/actions/context.ts64
-rw-r--r--packages/upgrade/src/actions/help.ts15
-rw-r--r--packages/upgrade/src/actions/install.ts197
-rw-r--r--packages/upgrade/src/actions/verify.ts203
4 files changed, 479 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..1588eb509
--- /dev/null
+++ b/packages/upgrade/src/actions/context.ts
@@ -0,0 +1,64 @@
+import { pathToFileURL } from 'node:url';
+import { prompt } from '@astrojs/cli-kit';
+import arg from 'arg';
+import { type DetectResult, detect } from 'package-manager-detector';
+
+export interface Context {
+ help: boolean;
+ prompt: typeof prompt;
+ version: string;
+ dryRun?: boolean;
+ cwd: URL;
+ stdin?: typeof process.stdin;
+ stdout?: typeof process.stdout;
+ packageManager: DetectResult;
+ 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 detect({
+ // Include the `install-metadata` strategy to have the package manager that's
+ // used for installation take precedence
+ strategies: ['install-metadata', 'lockfile', 'packageManager-field'],
+ })) ?? { agent: 'npm', 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..83f25833a
--- /dev/null
+++ b/packages/upgrade/src/actions/install.ts
@@ -0,0 +1,197 @@
+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 { resolveCommand } from 'package-manager-detector';
+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.name === 'yarn') await ensureYarnLock({ cwd });
+
+ const installCommand = resolveCommand(ctx.packageManager.agent, 'add', []);
+ if (!installCommand) {
+ // NOTE: Usually it's impossible to reach here as `package-manager-detector` should
+ // already match a supported agent
+ error('error', `Unable to find install command for ${ctx.packageManager.name}.`);
+ return ctx.exit(1);
+ }
+
+ await spinner({
+ start: `Installing dependencies with ${ctx.packageManager.name}...`,
+ end: `Installed dependencies!`,
+ while: async () => {
+ try {
+ if (dependencies.length > 0) {
+ await shell(
+ installCommand.command,
+ [
+ ...installCommand.args,
+ ...dependencies.map(
+ ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`,
+ ),
+ ],
+ { cwd, timeout: 90_000, stdio: 'ignore' },
+ );
+ }
+ if (devDependencies.length > 0) {
+ await shell(
+ installCommand.command,
+ [
+ ...installCommand.args,
+ ...devDependencies.map(
+ ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`,
+ ),
+ ],
+ { cwd, timeout: 90_000, stdio: 'ignore' },
+ );
+ }
+ } catch {
+ const manualInstallCommand = [
+ installCommand.command,
+ ...installCommand.args,
+ ...[...dependencies, ...devDependencies].map(
+ ({ name, targetVersion }) => `${name}@${targetVersion}`,
+ ),
+ ].join(' ');
+ newline();
+ error(
+ 'error',
+ `Dependencies failed to install, please run the following command manually:\n${color.bold(manualInstallCommand)}`,
+ );
+ 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, '')
+ );
+}