diff options
Diffstat (limited to 'packages/upgrade/src')
-rw-r--r-- | packages/upgrade/src/actions/context.ts | 56 | ||||
-rw-r--r-- | packages/upgrade/src/actions/help.ts | 15 | ||||
-rw-r--r-- | packages/upgrade/src/actions/install.ts | 138 | ||||
-rw-r--r-- | packages/upgrade/src/actions/verify.ts | 165 | ||||
-rw-r--r-- | packages/upgrade/src/index.ts | 40 | ||||
-rw-r--r-- | packages/upgrade/src/messages.ts | 207 | ||||
-rw-r--r-- | packages/upgrade/src/shell.ts | 60 |
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 }; +} |