Compare commits

...

8 commits

Author SHA1 Message Date
Nate Moore
bca381144e wip: add confirmation before updating major 2023-09-18 11:05:07 -05:00
Nate Moore
2a75c745eb chore: appease typescript 2023-09-15 13:05:30 -05:00
Nate Moore
cbb244421b
Merge branch 'main' into spike/upgrade 2023-09-14 15:04:13 -05:00
Nate Moore
ab01c48a26 chore: remove unused dependency 2023-09-12 16:23:22 -05:00
Nate Moore
7420004657 chore: delete tests 2023-09-12 16:21:23 -05:00
Nate Moore
878bb78afe chore: update lockfile 2023-09-12 16:05:23 -05:00
Nate Moore
685637d6cb feat: update upgrade cli 2023-09-12 16:04:48 -05:00
Nate Moore
eb91b3ce1d spike(upgrade): add WIP upgrade package 2023-09-12 16:04:47 -05:00
12 changed files with 705 additions and 1 deletions

View file

@ -0,0 +1,21 @@
# @astrojs/upgrade
## Upgrade utility for Astro projects
**With NPM:**
```bash
npm exec @astrojs/upgrade
```
**With Yarn:**
```bash
yarn exec @astrojs/upgrade
```
**With PNPM:**
```bash
pnpm exec @astrojs/upgrade
```

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": {
"@astrojs/upgrade": "./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"
},
"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"
},
"engines": {
"node": ">=18.14.1"
}
}

View file

@ -0,0 +1,53 @@
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;
isDevDependency?: boolean;
isMajor?: boolean;
}
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,107 @@
import type { Context, PackageInfo } from './context.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { color } from '@astrojs/cli-kit';
import { celebrations, done, error, info, log, spinner, success, upgrade, banner, title } from '../messages.js';
import { shell } from '../shell.js';
import { random, sleep } from '@astrojs/cli-kit/utils';
const pluralize = (n: number) => {
if (n === 1) return `One package has`;
return `Some packages have`;
}
export async function install(
ctx: Pick<Context, 'version' | 'packages' | 'packageManager' | 'prompt' | 'dryRun' | 'exit' | 'cwd'>
) {
await banner();
log('')
const { current, dependencies, devDependencies } = filterPackages(ctx);
const toInstall = [...dependencies, ...devDependencies];
for (const packageInfo of current) {
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) {
log('')
await success(random(celebrations), random(done))
return;
}
const majors: PackageInfo[] = []
for (const packageInfo of [...dependencies, ...devDependencies]) {
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('WARN!'),
message: `Continue? ${pluralize(majors.length)} breaking changes!`,
initial: true,
});
if (!proceed) ctx.exit(0);
}
log('')
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;
if (currentVersion === targetVersion) {
current.push(packageInfo);
} else {
const arr = isDevDependency ? devDependencies : dependencies;
arr.push(packageInfo);
}
}
return { current, dependencies, devDependencies }
}
async function runInstallCommand(ctx: Pick<Context, 'cwd' | 'packageManager'>, 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: random(done),
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 (e) {
const packages = [...dependencies, ...devDependencies].map(({ name, targetVersion }) => `${name}@${targetVersion}`).join(' ')
error(
'error',
`Dependencies failed to install, please run the following command manually:\n${color.bold(`${ctx.packageManager} install ${packages}`)}`
);
throw e;
}
},
});
}
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,128 @@
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 } from '../messages.js';
import semverDiff from 'semver/functions/diff.js';
import semverCoerce from 'semver/functions/coerce.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();
log('');
error('error', `Unable to connect to the internet.`);
ctx.exit(1);
}
}
await verifyAstroProject(ctx);
const ok = await verifyVersions(ctx, registry);
if (!ok) {
bannerAbort();
log('');
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'>, registry: string) {
const tasks: Promise<void>[] = [];
for (const packageInfo of ctx.packages) {
tasks.push(resolveTargetVersion(packageInfo, registry));
}
await Promise.all(tasks);
for (const packageInfo of ctx.packages) {
if (!packageInfo.targetVersion) {
return false;
}
}
return true;
}
async function resolveTargetVersion(packageInfo: PackageInfo, registry: string): Promise<void> {
const res = await fetch(`${registry}/${packageInfo.name}/${packageInfo.targetVersion}`);
const { code, version } = await res.json()
if (code === 'ResourceNotFound') {
if (packageInfo.targetVersion === 'latest') {
packageInfo.targetVersion = '';
return;
} else {
return resolveTargetVersion({ ...packageInfo, targetVersion: 'latest' }, registry);
}
}
if (packageInfo.currentVersion === version) {
return;
}
const prefix = packageInfo.targetVersion === 'latest' ? '^' : '';
packageInfo.targetVersion = `${prefix}${version}`;
const fromVersion = semverCoerce(packageInfo.currentVersion)!;
const toVersion = semverCoerce(packageInfo.targetVersion)!;
const bump = semverDiff(fromVersion, toVersion);
packageInfo.isMajor = bump === 'major';
}

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,169 @@
/* 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 semverCoerce from 'semver/functions/coerce.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
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 const celebrations = [
'Beautiful.',
'Excellent!',
'Sweet!',
'Nice!',
'Huzzah!',
'Success.',
'Nice.',
'Wonderful.',
'Lovely!',
'Lookin\' good.',
'Awesome.'
]
export const done = [
'You\'re on the latest and greatest.',
'Everything is current.',
'Integrations are all up to date.',
'All done. Thanks for using Astro!',
'Integrations up to date. Enjoy building!',
'All set, everything is up to date.',
]
export const log = (message: string) => stdout.write(message + '\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 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 = semverCoerce(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 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,51 @@
// 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()) : '';
export async function shell(
command: string,
flags: string[],
opts: ExecaOptions = {}
): Promise<Output> {
let child: ChildProcess;
let stdout = '';
let stderr = '';
try {
child = spawn(command, flags, {
cwd: opts.cwd,
shell: true,
stdio: opts.stdio,
timeout: opts.timeout,
});
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,13 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "index.d.ts"],
"compilerOptions": {
"allowJs": true,
"emitDeclarationOnly": false,
"noEmit": true,
"target": "ES2022",
"module": "ES2022",
"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());

View file

@ -5083,6 +5083,37 @@ importers:
specifier: ^10.2.0
version: 10.2.0
packages/upgrade:
dependencies:
'@astrojs/cli-kit':
specifier: ^0.2.3
version: 0.2.3
semver:
specifier: ^7.5.4
version: 7.5.4
which-pm-runs:
specifier: ^1.1.0
version: 1.1.0
devDependencies:
'@types/semver':
specifier: ^7.5.2
version: 7.5.2
'@types/which-pm-runs':
specifier: ^1.0.0
version: 1.0.0
arg:
specifier: ^5.0.2
version: 5.0.2
astro-scripts:
specifier: workspace:*
version: link:../../scripts
chai:
specifier: ^4.3.7
version: 4.3.7
mocha:
specifier: ^10.2.0
version: 10.2.0
scripts:
dependencies:
arg:
@ -5218,6 +5249,14 @@ packages:
- prettier-plugin-astro
dev: true
/@astrojs/cli-kit@0.2.3:
resolution: {integrity: sha512-MjB42mpIG/F2rFtdp4f3NylFCILuFSib2yITSq65fRaDFn8+UC8EMh6T7Jr3YqHAbUY5r8V8QWNgH4keOEO2BA==}
dependencies:
chalk: 5.3.0
log-update: 5.0.1
sisteransi: 1.0.5
dev: false
/@astrojs/cli-kit@0.3.0:
resolution: {integrity: sha512-nil0Kz2xuzR3xQX+FVHg2W8g+FvbeUeoCeU53duQjAFuHRJrbqWRmgfjYeM6f2780dsSuGiYMXmY+IaJqaqiaw==}
engines: {node: '>=18.14.1'}
@ -9025,6 +9064,10 @@ packages:
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
dev: true
/@types/semver@7.5.2:
resolution: {integrity: sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==}
dev: true
/@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
dependencies:
@ -10565,7 +10608,7 @@ packages:
dev: false
/concat-map@0.0.1:
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
/console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}