0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-30 22:03:56 -05:00

Add @astrojs/upgrade package for automatic package upgrades (#8525)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Nate Moore 2023-11-27 17:00:59 -06:00 committed by GitHub
parent 328d999999
commit 5a38750188
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 2130 additions and 33 deletions

View 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
```

View file

@ -51,6 +51,14 @@ async function install({ packageManager, cwd }: { packageManager: string; cwd: s
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 }) {
const yarnLock = path.join(cwd, 'yarn.lock');
if (fs.existsSync(yarnLock)) return;

View 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
```

View 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"
}
}

View 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
}

View 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.']
],
},
});
}

View 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' });
}

View 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, '')
}

View 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,
};

View 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');
}

View 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 };
}

View 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);
});
});

View file

@ -0,0 +1,7 @@
{
"name": "@test/astro-upgrade-basic",
"private": true,
"dependencies": {
"astro": "1.0.0"
}
}

View 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/);
});
});

View 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()]);

View 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
View 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());

File diff suppressed because it is too large Load diff