mirror of
https://github.com/withastro/astro.git
synced 2025-01-13 22:11:20 -05:00
Add @astrojs/upgrade
package for automatic package upgrades (#8525)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
328d999999
commit
5a38750188
18 changed files with 2130 additions and 33 deletions
27
.changeset/tasty-dryers-bathe.md
Normal file
27
.changeset/tasty-dryers-bathe.md
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
'@astrojs/upgrade': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
|
@ -51,6 +51,14 @@ async function install({ packageManager, cwd }: { packageManager: string; cwd: s
|
||||||
return shell(packageManager, ['install'], { cwd, timeout: 90_000, stdio: 'ignore' });
|
return shell(packageManager, ['install'], { cwd, timeout: 90_000, stdio: 'ignore' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
async function ensureYarnLock({ cwd }: { cwd: string }) {
|
||||||
const yarnLock = path.join(cwd, 'yarn.lock');
|
const yarnLock = path.join(cwd, 'yarn.lock');
|
||||||
if (fs.existsSync(yarnLock)) return;
|
if (fs.existsSync(yarnLock)) return;
|
||||||
|
|
53
packages/upgrade/README.md
Normal file
53
packages/upgrade/README.md
Normal file
|
@ -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
|
||||||
|
```
|
49
packages/upgrade/package.json
Normal file
49
packages/upgrade/package.json
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "@astrojs/upgrade",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"author": "withastro",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "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": "mocha --exit --timeout 20000 --parallel"
|
||||||
|
},
|
||||||
|
"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.2.3",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"which-pm-runs": "^1.1.0",
|
||||||
|
"terminal-link": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/semver": "^7.5.2",
|
||||||
|
"@types/which-pm-runs": "^1.0.0",
|
||||||
|
"arg": "^5.0.2",
|
||||||
|
"astro-scripts": "workspace:*",
|
||||||
|
"chai": "^4.3.7",
|
||||||
|
"mocha": "^10.2.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.14.1"
|
||||||
|
}
|
||||||
|
}
|
56
packages/upgrade/src/actions/context.ts
Normal file
56
packages/upgrade/src/actions/context.ts
Normal file
|
@ -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
|
||||||
|
}
|
15
packages/upgrade/src/actions/help.ts
Normal file
15
packages/upgrade/src/actions/help.ts
Normal file
|
@ -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.']
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
138
packages/upgrade/src/actions/install.ts
Normal file
138
packages/upgrade/src/actions/install.ts
Normal file
|
@ -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' });
|
||||||
|
}
|
165
packages/upgrade/src/actions/verify.ts
Normal file
165
packages/upgrade/src/actions/verify.ts
Normal file
|
@ -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, '')
|
||||||
|
}
|
||||||
|
|
40
packages/upgrade/src/index.ts
Normal file
40
packages/upgrade/src/index.ts
Normal file
|
@ -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,
|
||||||
|
};
|
207
packages/upgrade/src/messages.ts
Normal file
207
packages/upgrade/src/messages.ts
Normal file
|
@ -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');
|
||||||
|
}
|
60
packages/upgrade/src/shell.ts
Normal file
60
packages/upgrade/src/shell.ts
Normal file
|
@ -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 };
|
||||||
|
}
|
19
packages/upgrade/test/context.test.js
Normal file
19
packages/upgrade/test/context.test.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { getContext } from '../dist/index.js';
|
||||||
|
|
||||||
|
describe('context', () => {
|
||||||
|
it('no arguments', async () => {
|
||||||
|
const ctx = await getContext([]);
|
||||||
|
expect(ctx.version).to.eq('latest');
|
||||||
|
expect(ctx.dryRun).to.be.undefined;
|
||||||
|
});
|
||||||
|
it('tag', async () => {
|
||||||
|
const ctx = await getContext(['beta']);
|
||||||
|
expect(ctx.version).to.eq('beta');
|
||||||
|
expect(ctx.dryRun).to.be.undefined;
|
||||||
|
});
|
||||||
|
it('dry run', async () => {
|
||||||
|
const ctx = await getContext(['--dry-run']);
|
||||||
|
expect(ctx.dryRun).to.eq(true);
|
||||||
|
});
|
||||||
|
});
|
7
packages/upgrade/test/fixtures/basic/package.json
vendored
Normal file
7
packages/upgrade/test/fixtures/basic/package.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "@test/astro-upgrade-basic",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
211
packages/upgrade/test/install.test.js
Normal file
211
packages/upgrade/test/install.test.js
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { setup } from './utils.js';
|
||||||
|
import { install } from '../dist/index.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);
|
||||||
|
expect(fixture.hasMessage('◼ astro is up to date on v1.0.0')).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patch', async () => {
|
||||||
|
const context = {
|
||||||
|
...ctx,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'astro',
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
targetVersion: '1.0.1',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
await install(context);
|
||||||
|
expect(fixture.hasMessage('● astro can be updated to v1.0.1')).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('minor', async () => {
|
||||||
|
const context = {
|
||||||
|
...ctx,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'astro',
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
targetVersion: '1.2.0',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
await install(context);
|
||||||
|
expect(fixture.hasMessage('● astro can be updated to v1.2.0')).to.be.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);
|
||||||
|
expect(fixture.hasMessage('▲ astro can be updated to v2.0.0')).to.be.true;
|
||||||
|
expect(prompted).to.be.true;
|
||||||
|
expect(exitCode).to.eq(0);
|
||||||
|
expect(fixture.hasMessage('check Be sure to follow the CHANGELOG.')).to.be.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);
|
||||||
|
expect(fixture.hasMessage('▲ astro can be updated to v2.0.0')).to.be.true;
|
||||||
|
expect(prompted).to.be.true;
|
||||||
|
expect(exitCode).to.be.undefined;
|
||||||
|
expect(fixture.hasMessage('check Be sure to follow the CHANGELOG.')).to.be.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);
|
||||||
|
expect(fixture.hasMessage('▲ a can be updated to v2.0.0')).to.be.true;
|
||||||
|
expect(fixture.hasMessage('▲ b can be updated to v7.0.0')).to.be.true;
|
||||||
|
expect(prompted).to.be.true;
|
||||||
|
expect(exitCode).to.be.undefined;
|
||||||
|
const [changelog, a, b] = fixture.messages().slice(-5);
|
||||||
|
expect(changelog).to.match(/^check/);
|
||||||
|
expect(a).to.match(/^a/);
|
||||||
|
expect(b).to.match(/^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);
|
||||||
|
expect(fixture.hasMessage('◼ current is up to date on v1.0.0')).to.be.true;
|
||||||
|
expect(fixture.hasMessage('● patch can be updated to v1.0.1')).to.be.true;
|
||||||
|
expect(fixture.hasMessage('● minor can be updated to v1.2.0')).to.be.true;
|
||||||
|
expect(fixture.hasMessage('▲ major can be updated to v3.0.0')).to.be.true;
|
||||||
|
expect(prompted).to.be.true;
|
||||||
|
expect(exitCode).to.be.undefined;
|
||||||
|
expect(fixture.hasMessage('check Be sure to follow the CHANGELOG.')).to.be.true;
|
||||||
|
const [changelog, major] = fixture.messages().slice(-4);
|
||||||
|
expect(changelog).to.match(/^check/);
|
||||||
|
expect(major).to.match(/^major/);
|
||||||
|
});
|
||||||
|
});
|
53
packages/upgrade/test/utils.js
Normal file
53
packages/upgrade/test/utils.js
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { setStdout } from '../dist/index.js';
|
||||||
|
import stripAnsi from 'strip-ansi';
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const ctx = { messages: [] };
|
||||||
|
before(() => {
|
||||||
|
setStdout(
|
||||||
|
Object.assign({}, process.stdout, {
|
||||||
|
write(buf) {
|
||||||
|
ctx.messages.push(stripAnsi(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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBasicFixture = async () => {
|
||||||
|
const packagePath = new URL('./fixtures/basic/package.json', import.meta.url);
|
||||||
|
const packageJsonData = JSON.parse(
|
||||||
|
await fs.promises.readFile(packagePath, { encoding: 'utf-8' })
|
||||||
|
);
|
||||||
|
const overriddenPackageJson = Object.assign(packageJsonData, {
|
||||||
|
dependencies: {
|
||||||
|
astro: '1.0.0'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
fs.promises.writeFile(packagePath, JSON.stringify(overriddenPackageJson, null, 2), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetFixtures = () =>
|
||||||
|
Promise.allSettled([resetBasicFixture()]);
|
17
packages/upgrade/tsconfig.json
Normal file
17
packages/upgrade/tsconfig.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
15
packages/upgrade/upgrade.mjs
Executable file
15
packages/upgrade/upgrade.mjs
Executable file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
'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());
|
1023
pnpm-lock.yaml
generated
1023
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue