diff options
author | 2025-06-05 14:25:23 +0000 | |
---|---|---|
committer | 2025-06-05 14:25:23 +0000 | |
commit | e586d7d704d475afe3373a1de6ae20d504f79d6d (patch) | |
tree | 7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/upgrade/src/actions | |
download | astro-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.ts | 64 | ||||
-rw-r--r-- | packages/upgrade/src/actions/help.ts | 15 | ||||
-rw-r--r-- | packages/upgrade/src/actions/install.ts | 197 | ||||
-rw-r--r-- | packages/upgrade/src/actions/verify.ts | 203 |
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, '') + ); +} |