diff options
Diffstat (limited to 'packages/upgrade')
-rw-r--r-- | packages/upgrade/CHANGELOG.md | 203 | ||||
-rw-r--r-- | packages/upgrade/README.md | 53 | ||||
-rw-r--r-- | packages/upgrade/package.json | 45 | ||||
-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 | ||||
-rw-r--r-- | packages/upgrade/src/index.ts | 32 | ||||
-rw-r--r-- | packages/upgrade/src/messages.ts | 220 | ||||
-rw-r--r-- | packages/upgrade/src/shell.ts | 60 | ||||
-rw-r--r-- | packages/upgrade/test/context.test.js | 20 | ||||
-rw-r--r-- | packages/upgrade/test/install.test.js | 212 | ||||
-rw-r--r-- | packages/upgrade/test/utils.js | 32 | ||||
-rw-r--r-- | packages/upgrade/test/verify.test.js | 77 | ||||
-rw-r--r-- | packages/upgrade/tsconfig.json | 14 | ||||
-rwxr-xr-x | packages/upgrade/upgrade.mjs | 15 |
16 files changed, 1462 insertions, 0 deletions
diff --git a/packages/upgrade/CHANGELOG.md b/packages/upgrade/CHANGELOG.md new file mode 100644 index 000000000..75f70ec52 --- /dev/null +++ b/packages/upgrade/CHANGELOG.md @@ -0,0 +1,203 @@ +# @astrojs/upgrade + +## 0.6.0 + +### Minor Changes + +- [#13809](https://github.com/withastro/astro/pull/13809) [`3c3b492`](https://github.com/withastro/astro/commit/3c3b492375bd6a63f1fb6cede3685aff999be3c9) Thanks [@ascorbic](https://github.com/ascorbic)! - Increases minimum Node.js version to 18.20.8 + + Node.js 18 has now reached end-of-life and should not be used. For now, Astro will continue to support Node.js 18.20.8, which is the final LTS release of Node.js 18, as well as Node.js 20 and Node.js 22 or later. We will drop support for Node.js 18 in a future release, so we recommend upgrading to Node.js 22 as soon as possible. See Astro's [Node.js support policy](https://docs.astro.build/en/upgrade-astro/#support) for more details. + + :warning: **Important note for users of Cloudflare Pages**: The current build image for Cloudflare Pages uses Node.js 18.17.1 by default, which is no longer supported by Astro. If you are using Cloudflare Pages you should [override the default Node.js version](https://developers.cloudflare.com/pages/configuration/build-image/#override-default-versions) to Node.js 22. This does not affect users of Cloudflare Workers, which uses Node.js 22 by default. + +## 0.5.2 + +### Patch Changes + +- [#13591](https://github.com/withastro/astro/pull/13591) [`5dd2d3f`](https://github.com/withastro/astro/commit/5dd2d3fde8a138ed611dedf39ffa5dfeeed315f8) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Removes unused code + +## 0.5.1 + +### Patch Changes + +- [#13395](https://github.com/withastro/astro/pull/13395) [`6d1c63f`](https://github.com/withastro/astro/commit/6d1c63fa46a624b1c4981d4324ebabf37cc2b958) Thanks [@bluwy](https://github.com/bluwy)! - Uses `package-manager-detector` to detect the package manager used in the project + +## 0.5.0 + +### Minor Changes + +- [#13330](https://github.com/withastro/astro/pull/13330) [`5e7646e`](https://github.com/withastro/astro/commit/5e7646efc12d47bbb65d8c80a160f4f27329903c) Thanks [@ematipico](https://github.com/ematipico)! - Adds the ability to identify `bun` as the preferred package manager. + +## 0.4.3 + +### Patch Changes + +- [#12739](https://github.com/withastro/astro/pull/12739) [`1f9571b`](https://github.com/withastro/astro/commit/1f9571b2b9839a5513fe2c03a90ff36235e8efe2) Thanks [@gnify](https://github.com/gnify)! - Updates displayed data to show both source and target versions + +## 0.4.2 + +### Patch Changes + +- [#12706](https://github.com/withastro/astro/pull/12706) [`f6c4214`](https://github.com/withastro/astro/commit/f6c4214042c68de137a69aa15dea81ed9cbc822a) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes a bug that caused registry URLs that specify a port to be incorrectly detected as offline. + +## 0.4.1 + +### Patch Changes + +- [#12576](https://github.com/withastro/astro/pull/12576) [`19b3ac0`](https://github.com/withastro/astro/commit/19b3ac0036cc6f27da887d19b16d804c6f0b8124) Thanks [@apatel369](https://github.com/apatel369)! - Fixes an issue where running `upgrade` in a directory without `astro` installed shows a false success message + +## 0.4.0 + +### Minor Changes + +- [#12539](https://github.com/withastro/astro/pull/12539) [`827093e`](https://github.com/withastro/astro/commit/827093e6175549771f9d93ddf3f2be4c2c60f0b7) Thanks [@bluwy](https://github.com/bluwy)! - Drops node 21 support + +### Patch Changes + +- [#12577](https://github.com/withastro/astro/pull/12577) [`b139390`](https://github.com/withastro/astro/commit/b139390deb738f96759cb787fe9e784be71f2134) Thanks [@apatel369](https://github.com/apatel369)! - Fixes an issue where `@astrojs/upgrade` announces integration updates for already up to date packages + +## 0.4.0-beta.0 + +### Minor Changes + +- [#12539](https://github.com/withastro/astro/pull/12539) [`827093e`](https://github.com/withastro/astro/commit/827093e6175549771f9d93ddf3f2be4c2c60f0b7) Thanks [@bluwy](https://github.com/bluwy)! - Drops node 21 support + +## 0.3.4 + +### Patch Changes + +- [#12118](https://github.com/withastro/astro/pull/12118) [`f47b347`](https://github.com/withastro/astro/commit/f47b347da899c6e1dcd0b2e7887f7fce6ec8e270) Thanks [@Namchee](https://github.com/Namchee)! - Removes the `strip-ansi` dependency in favor of the native Node API + +## 0.3.3 + +### Patch Changes + +- [#11733](https://github.com/withastro/astro/pull/11733) [`391324d`](https://github.com/withastro/astro/commit/391324df969db71d1c7ca25c2ed14c9eb6eea5ee) Thanks [@bluwy](https://github.com/bluwy)! - Reverts back to `arg` package for CLI argument parsing + +## 0.3.2 + +### Patch Changes + +- [#11645](https://github.com/withastro/astro/pull/11645) [`849e4c6`](https://github.com/withastro/astro/commit/849e4c6c23e61f7fa59f583419048b998bef2475) Thanks [@bluwy](https://github.com/bluwy)! - Refactors internally to use `node:util` `parseArgs` instead of `arg` + +## 0.3.1 + +### Patch Changes + +- [#11139](https://github.com/withastro/astro/pull/11139) [`aaf0635`](https://github.com/withastro/astro/commit/aaf0635cc0fb7e9f892c710ec6ff3b16d3f90ab4) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fixes @astrojs/upgrade not using the package manager that was used to install the project to install dependencies + +## 0.3.0 + +### Minor Changes + +- [#10689](https://github.com/withastro/astro/pull/10689) [`683d51a5eecafbbfbfed3910a3f1fbf0b3531b99`](https://github.com/withastro/astro/commit/683d51a5eecafbbfbfed3910a3f1fbf0b3531b99) Thanks [@ematipico](https://github.com/ematipico)! - Deprecate support for versions of Node.js older than `v18.17.1` for Node.js 18, older than `v20.0.3` for Node.js 20, and the complete Node.js v19 release line. + + This change is in line with Astro's [Node.js support policy](https://docs.astro.build/en/upgrade-astro/#support). + +## 0.2.3 + +### Patch Changes + +- [#10117](https://github.com/withastro/astro/pull/10117) [`51b6ff7403c1223b1c399e88373075972c82c24c`](https://github.com/withastro/astro/commit/51b6ff7403c1223b1c399e88373075972c82c24c) Thanks [@hippotastic](https://github.com/hippotastic)! - Fixes an issue where `create astro`, `astro add` and `@astrojs/upgrade` would fail due to unexpected package manager CLI output. + +## 0.2.2 + +### Patch Changes + +- [#9562](https://github.com/withastro/astro/pull/9562) [`67e06f9db1b0492ccfb4b053762dc91d69a53ecb`](https://github.com/withastro/astro/commit/67e06f9db1b0492ccfb4b053762dc91d69a53ecb) Thanks [@DET171](https://github.com/DET171)! - Updates the command used for installing packages with pnpm and yarn + +## 0.2.1 + +### Patch Changes + +- [#9317](https://github.com/withastro/astro/pull/9317) [`d1c91add0`](https://github.com/withastro/astro/commit/d1c91add074c2e08056f01df5a6043c9716b7e1f) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Improves dependency handling by ignoring packages that don't use a semver version + +## 0.2.0 + +### Minor Changes + +- [#9118](https://github.com/withastro/astro/pull/9118) [`000e8f465`](https://github.com/withastro/astro/commit/000e8f4654cae9982e21e0a858366c4844139db6) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Initial release! + + `@astrojs/upgrade` is an automated command-line tool for upgrading Astro and your official Astro integrations together. + + Inside of your existing `astro` project, run the following command to install the `latest` version of your integrations. + + **With NPM:** + + ```bash + npx @astrojs/upgrade + ``` + + **With Yarn:** + + ```bash + yarn dlx @astrojs/upgrade + ``` + + **With PNPM:** + + ```bash + pnpm dlx @astrojs/upgrade + ``` + +## 0.1.0-beta.0 + +### Minor Changes + +- [#9118](https://github.com/withastro/astro/pull/9118) [`000e8f465`](https://github.com/withastro/astro/commit/000e8f4654cae9982e21e0a858366c4844139db6) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Initial release! + + `@astrojs/upgrade` is an automated command-line tool for upgrading Astro and your official Astro integrations together. + + Inside of your existing `astro` project, run the following command to install the `latest` version of your integrations. + + **With NPM:** + + ```bash + npx @astrojs/upgrade + ``` + + **With Yarn:** + + ```bash + yarn dlx @astrojs/upgrade + ``` + + **With PNPM:** + + ```bash + pnpm dlx @astrojs/upgrade + ``` + +## 0.1.1 + +### Patch Changes + +- [#9213](https://github.com/withastro/astro/pull/9213) [`54e57fe9d`](https://github.com/withastro/astro/commit/54e57fe9d7600c888fc7b0bc3f5dbca5543f36cd) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Fix unhandled error when running `@astrojs/upgrade beta` outside of a monorepo + +## 0.1.0 + +### Minor Changes + +- [#8525](https://github.com/withastro/astro/pull/8525) [`5a3875018`](https://github.com/withastro/astro/commit/5a38750188d1af30ea5277cea70f454c363b5062) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Initial release! + + `@astrojs/upgrade` is an automated command-line tool for upgrading Astro and your official Astro integrations together. + + Inside of your existing `astro` project, run the following command to install the `latest` version of your integrations. + + **With NPM:** + + ```bash + npx @astrojs/upgrade + ``` + + **With Yarn:** + + ```bash + yarn dlx @astrojs/upgrade + ``` + + **With PNPM:** + + ```bash + pnpm dlx @astrojs/upgrade + ``` diff --git a/packages/upgrade/README.md b/packages/upgrade/README.md new file mode 100644 index 000000000..3744671f7 --- /dev/null +++ b/packages/upgrade/README.md @@ -0,0 +1,53 @@ +# @astrojs/upgrade + +A command-line tool for upgrading your Astro integrations and dependencies. + +You can run this command in your terminal to upgrade your official Astro integrations at the same time you upgrade your version of Astro. + +## Usage + +`@astrojs/upgrade` should not be added as a dependency to your project, but run as a temporary executable whenever you want to upgrade using [`npx`](https://docs.npmjs.com/cli/v10/commands/npx) or [`dlx`](https://pnpm.io/cli/dlx). + +**With NPM:** + +```bash +npx @astrojs/upgrade +``` + +**With Yarn:** + +```bash +yarn dlx @astrojs/upgrade +``` + +**With PNPM:** + +```bash +pnpm dlx @astrojs/upgrade +``` + +## Options + +### tag (optional) + +It is possible to pass a specific `tag` to resolve packages against. If not included, `@astrojs/upgrade` looks for the `latest` tag. + +For example, Astro often releases `beta` versions prior to an upcoming major release. Upgrade an existing Astro project and it's dependencies to the `beta` version using one of the following commands: + +**With NPM:** + +```bash +npx @astrojs/upgrade beta +``` + +**With Yarn:** + +```bash +yarn dlx @astrojs/upgrade beta +``` + +**With PNPM:** + +```bash +pnpm dlx @astrojs/upgrade beta +``` diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json new file mode 100644 index 000000000..5b22752ac --- /dev/null +++ b/packages/upgrade/package.json @@ -0,0 +1,45 @@ +{ + "name": "@astrojs/upgrade", + "version": "0.6.0", + "type": "module", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/withastro/astro.git", + "directory": "packages/upgrade" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./upgrade.mjs" + }, + "main": "./upgrade.mjs", + "bin": "./upgrade.mjs", + "scripts": { + "build": "astro-scripts build \"src/index.ts\" --bundle && tsc", + "build:ci": "astro-scripts build \"src/index.ts\" --bundle", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "astro-scripts test \"test/**/*.test.js\"" + }, + "files": [ + "dist", + "upgrade.js" + ], + "//a": "MOST PACKAGES SHOULD GO IN DEV_DEPENDENCIES! THEY WILL BE BUNDLED.", + "//b": "DEPENDENCIES IS FOR UNBUNDLED PACKAGES", + "dependencies": { + "@astrojs/cli-kit": "^0.4.1", + "package-manager-detector": "^1.1.0", + "semver": "^7.7.1", + "terminal-link": "^3.0.0" + }, + "devDependencies": { + "@types/semver": "^7.7.0", + "arg": "^5.0.2", + "astro-scripts": "workspace:*" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } +} 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, '') + ); +} 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..fea4131b9 --- /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 { detect } from 'package-manager-detector'; +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 detect())?.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.', +]; + +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..a795b2c6c --- /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'; + +interface ExecaOptions { + cwd?: string | URL; + stdio?: StdioOptions; + timeout?: number; +} +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 }; +} diff --git a/packages/upgrade/test/context.test.js b/packages/upgrade/test/context.test.js new file mode 100644 index 000000000..bbc887c2a --- /dev/null +++ b/packages/upgrade/test/context.test.js @@ -0,0 +1,20 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getContext } from '../dist/index.js'; + +describe('context', () => { + it('no arguments', async () => { + const ctx = await getContext([]); + assert.equal(ctx.version, 'latest'); + assert.equal(ctx.dryRun, undefined); + }); + it('tag', async () => { + const ctx = await getContext(['beta']); + assert.equal(ctx.version, 'beta'); + assert.equal(ctx.dryRun, undefined); + }); + it('dry run', async () => { + const ctx = await getContext(['--dry-run']); + assert.equal(ctx.dryRun, true); + }); +}); diff --git a/packages/upgrade/test/install.test.js b/packages/upgrade/test/install.test.js new file mode 100644 index 000000000..c4d56dfbe --- /dev/null +++ b/packages/upgrade/test/install.test.js @@ -0,0 +1,212 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { install } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('install', () => { + const fixture = setup(); + const ctx = { + cwd: '', + version: 'latest', + packageManager: 'npm', + dryRun: true, + }; + + it('up to date', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.0.0', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('◼ astro is up to date on v1.0.0'), true); + }); + + it('patch', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.0.1', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.0.1'), true); + }); + + it('minor', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.2.0', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.2.0'), true); + }); + + it('major (reject)', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: false }; + }, + exit: (code) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, 0); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), false); + }); + + it('major (accept)', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); + }); + + it('multiple major', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code) => { + exitCode = code; + }, + packages: [ + { + name: 'a', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + { + name: 'b', + currentVersion: '6.0.0', + targetVersion: '7.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ a can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(fixture.hasMessage('▲ b can be updated from v6.0.0 to v7.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + const [changelog, a, b] = fixture.messages().slice(-5); + assert.match(changelog, /^check/); + assert.match(a, /^a/); + assert.match(b, /^b/); + }); + + it('current patch minor major', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code) => { + exitCode = code; + }, + packages: [ + { + name: 'current', + currentVersion: '1.0.0', + targetVersion: '1.0.0', + }, + { + name: 'patch', + currentVersion: '1.0.0', + targetVersion: '1.0.1', + }, + { + name: 'minor', + currentVersion: '1.0.0', + targetVersion: '1.2.0', + }, + { + name: 'major', + currentVersion: '1.0.0', + targetVersion: '3.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('◼ current is up to date on v1.0.0'), true); + assert.equal(fixture.hasMessage('● patch can be updated from v1.0.0 to v1.0.1'), true); + assert.equal(fixture.hasMessage('● minor can be updated from v1.0.0 to v1.2.0'), true); + assert.equal(fixture.hasMessage('▲ major can be updated from v1.0.0 to v3.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); + const [changelog, major] = fixture.messages().slice(-4); + assert.match(changelog, /^check/); + assert.match(major, /^major/); + }); +}); diff --git a/packages/upgrade/test/utils.js b/packages/upgrade/test/utils.js new file mode 100644 index 000000000..20063ec53 --- /dev/null +++ b/packages/upgrade/test/utils.js @@ -0,0 +1,32 @@ +import { before, beforeEach } from 'node:test'; +import { stripVTControlCharacters } from 'node:util'; +import { setStdout } from '../dist/index.js'; + +export function setup() { + const ctx = { messages: [] }; + before(() => { + setStdout( + Object.assign({}, process.stdout, { + write(buf) { + ctx.messages.push(stripVTControlCharacters(String(buf)).trim()); + return true; + }, + }), + ); + }); + beforeEach(() => { + ctx.messages = []; + }); + + return { + messages() { + return ctx.messages; + }, + length() { + return ctx.messages.length; + }, + hasMessage(content) { + return !!ctx.messages.find((msg) => msg.includes(content)); + }, + }; +} diff --git a/packages/upgrade/test/verify.test.js b/packages/upgrade/test/verify.test.js new file mode 100644 index 000000000..203e0fbf0 --- /dev/null +++ b/packages/upgrade/test/verify.test.js @@ -0,0 +1,77 @@ +import * as assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { collectPackageInfo } from '../dist/index.js'; + +describe('collectPackageInfo', () => { + const context = { + cwd: '', + version: 'latest', + packageManager: 'npm', + dryRun: true, + packages: [], + }; + + beforeEach(() => { + context.packages = []; + }); + + it('detects astro', async () => { + collectPackageInfo(context, { astro: '1.0.0' }, {}); + assert.deepEqual(context.packages, [ + { name: 'astro', currentVersion: '1.0.0', targetVersion: 'latest' }, + ]); + }); + + it('detects @astrojs', async () => { + collectPackageInfo(context, { '@astrojs/preact': '1.0.0' }, {}); + assert.deepEqual(context.packages, [ + { name: '@astrojs/preact', currentVersion: '1.0.0', targetVersion: 'latest' }, + ]); + }); + + it('supports ^ prefixes', async () => { + collectPackageInfo(context, { astro: '^1.0.0' }, {}); + assert.deepEqual(context.packages, [ + { name: 'astro', currentVersion: '^1.0.0', targetVersion: 'latest' }, + ]); + }); + + it('supports ~ prefixes', async () => { + collectPackageInfo(context, { astro: '~1.0.0' }, {}); + assert.deepEqual(context.packages, [ + { name: 'astro', currentVersion: '~1.0.0', targetVersion: 'latest' }, + ]); + }); + + it('supports prereleases', async () => { + collectPackageInfo(context, { astro: '1.0.0-beta.0' }, {}); + assert.deepEqual(context.packages, [ + { name: 'astro', currentVersion: '1.0.0-beta.0', targetVersion: 'latest' }, + ]); + }); + + it('ignores self', async () => { + collectPackageInfo(context, { '@astrojs/upgrade': '0.0.1' }, {}); + assert.deepEqual(context.packages, []); + }); + + it('ignores linked packages', async () => { + collectPackageInfo(context, { '@astrojs/preact': 'link:../packages/preact' }, {}); + assert.deepEqual(context.packages, []); + }); + + it('ignores workspace packages', async () => { + collectPackageInfo(context, { '@astrojs/preact': 'workspace:*' }, {}); + assert.deepEqual(context.packages, []); + }); + + it('ignores github packages', async () => { + collectPackageInfo(context, { '@astrojs/preact': 'github:withastro/astro' }, {}); + assert.deepEqual(context.packages, []); + }); + + it('ignores tag', async () => { + collectPackageInfo(context, { '@astrojs/preact': 'beta' }, {}); + assert.deepEqual(context.packages, []); + }); +}); diff --git a/packages/upgrade/tsconfig.json b/packages/upgrade/tsconfig.json new file mode 100644 index 000000000..d15ade00f --- /dev/null +++ b/packages/upgrade/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "index.d.ts"], + "compilerOptions": { + "allowJs": true, + "emitDeclarationOnly": false, + "noEmit": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "./dist", + "declarationDir": "./dist/types" + } +} diff --git a/packages/upgrade/upgrade.mjs b/packages/upgrade/upgrade.mjs new file mode 100755 index 000000000..5e25e5e94 --- /dev/null +++ b/packages/upgrade/upgrade.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +'use strict'; + +const currentVersion = process.versions.node; +const requiredMajorVersion = parseInt(currentVersion.split('.')[0], 10); +const minimumMajorVersion = 18; + +if (requiredMajorVersion < minimumMajorVersion) { + console.error(`Node.js v${currentVersion} is out of date and unsupported!`); + console.error(`Please use Node.js v${minimumMajorVersion} or higher.`); + process.exit(1); +} + +import('./dist/index.js').then(({ main }) => main()); |