spike(upgrade): add WIP upgrade package

This commit is contained in:
Nate Moore 2023-09-01 08:09:45 -05:00
parent c23ddb9ab3
commit eb91b3ce1d
18 changed files with 792 additions and 0 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,48 @@
{
"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",
"node-fetch-native": "^1.4.0",
"which-pm-runs": "^1.1.0"
},
"devDependencies": {
"@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,52 @@
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;
}
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: 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,57 @@
import type { Context } 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 { error, info, spinner } from '../messages.js';
import { shell } from '../shell.js';
export async function install(
ctx: Pick<Context, 'version' | 'packages' | 'packageManager' | 'dryRun' | 'cwd'>
) {
if (ctx.dryRun) {
await info('--dry-run', `Skipping dependency installation`);
} else {
await spinner({
start: `Updating dependencies with ${ctx.packageManager}...`,
end: 'Dependencies updated',
while: () => {
return runCommand(ctx).catch((e) => {
error('error', e);
error(
'error',
`Dependencies failed to install, please run ${color.bold(
ctx.packageManager + ' install'
)} to install them manually after setup.`
);
});
},
});
}
}
async function runCommand(ctx: Pick<Context, 'packageManager' | 'packages' | 'cwd'>) {
const cwd = fileURLToPath(ctx.cwd);
if (ctx.packageManager === 'yarn') await ensureYarnLock({ cwd });
const dependencies: string[] = [];
const devDependencies: string[] = [];
for (const { name, targetVersion, isDevDependency } of ctx.packages) {
const arr = isDevDependency ? devDependencies : dependencies;
arr.push(`${name}@${targetVersion}`);
}
if (dependencies.length > 0) {
await shell(ctx.packageManager, ['install', ...dependencies], { cwd, timeout: 90_000, stdio: 'ignore' });
}
if (devDependencies.length > 0) {
await shell(ctx.packageManager, ['install', '--save-dev', ...devDependencies], { cwd, timeout: 90_000, stdio: 'ignore' });
}
}
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,109 @@
import type { Context, PackageInfo } from './context';
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';
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) {
// check for packageInfo.targetVersion, then fallback
// check if packageInfo.targetVersion === packageInfo.currentVersion
}
return true;
}
async function resolveTargetVersion(packageInfo: PackageInfo, registry: string): Promise<void> {
const res = await fetch(`${registry}/${packageInfo.name}/tags/${packageInfo.targetVersion}`);
// MUTATE packageInfo.targetVersion to resolvedValue
}

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,221 @@
/* eslint no-console: 'off' */
import { color, say as houston, label, spinner as load } from '@astrojs/cli-kit';
import { align, sleep } from '@astrojs/cli-kit/utils';
import fetch from 'node-fetch-native';
import { exec } from 'node:child_process';
import stripAnsi from 'strip-ansi';
import detectPackageManager from 'which-pm-runs';
import { shell } from './shell.js';
// Users might lack access to the global npm registry, this function
// checks the user's project type and will return the proper npm registry
//
// A copy of this function also exists in the astro package
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 say(messages: string | string[], { clear = false, hat = '' } = {}) {
return houston(messages, { clear, hat, stdout });
}
export async function spinner(args: {
start: string;
end: string;
while: (...args: any) => Promise<any>;
}) {
await load(args, { stdout });
}
export const title = (text: string) => align(label(text), 'end', 7) + ' ';
export const welcome = [
`Let's claim your corner of the internet.`,
`I'll be your assistant today.`,
`Let's build something awesome!`,
`Let's build something great!`,
`Let's build something fast!`,
`Let's build the web we want.`,
`Let's make the web weird!`,
`Let's make the web a better place!`,
`Let's create a new project!`,
`Let's create something unique!`,
`Time to build a new website.`,
`Time to build a faster website.`,
`Time to build a sweet new website.`,
`We're glad to have you on board.`,
`Keeping the internet weird since 2021.`,
`Initiating launch sequence...`,
`Initiating launch sequence... right... now!`,
`Awaiting further instructions.`,
];
export const getName = () =>
new Promise<string>((resolve) => {
exec('git config user.name', { encoding: 'utf-8' }, (_1, gitName) => {
if (gitName.trim()) {
return resolve(gitName.split(' ')[0].trim());
}
exec('whoami', { encoding: 'utf-8' }, (_3, whoami) => {
if (whoami.trim()) {
return resolve(whoami.split(' ')[0].trim());
}
return resolve('astronaut');
});
});
});
let v: string;
export const getVersion = () =>
new Promise<string>(async (resolve) => {
if (v) return resolve(v);
let registry = await getRegistry();
const { version } = await fetch(`${registry}/astro/latest`, { redirect: 'follow' }).then(
(res) => res.json(),
() => ({ version: '' })
);
v = version;
resolve(version);
});
export const log = (message: string) => stdout.write(message + '\n');
export const banner = async (version: string) =>
log(
`\n${label('astro', color.bgGreen, color.black)}${
version ? ' ' + color.green(color.bold(`v${version}`)) : ''
} ${color.bold('Launch sequence initiated.')}`
);
export const bannerAbort = () =>
log(`\n${label('astro', color.bgRed)} ${color.bold('Launch sequence aborted.')}`);
export const info = async (prefix: string, text: string) => {
await sleep(100);
if (stdout.columns < 80) {
log(`${' '.repeat(5)} ${color.cyan('◼')} ${color.cyan(prefix)}`);
log(`${' '.repeat(9)}${color.dim(text)}`);
} else {
log(`${' '.repeat(5)} ${color.cyan('◼')} ${color.cyan(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 typescriptByDefault = async () => {
await info(`No worries!`, 'TypeScript is supported in Astro by default,');
log(`${' '.repeat(9)}${color.dim('but you are free to continue writing JavaScript instead.')}`);
await sleep(1000);
};
export const nextSteps = async ({ projectDir, devCmd }: { projectDir: string; devCmd: string }) => {
const max = stdout.columns;
const prefix = max < 80 ? ' ' : ' '.repeat(9);
await sleep(200);
log(
`\n ${color.bgCyan(` ${color.black('next')} `)} ${color.bold(
'Liftoff confirmed. Explore your project!'
)}`
);
await sleep(100);
if (projectDir !== '') {
projectDir = projectDir.includes(' ') ? `"./${projectDir}"` : `./${projectDir}`;
const enter = [
`\n${prefix}Enter your project directory using`,
color.cyan(`cd ${projectDir}`, ''),
];
const len = enter[0].length + stripAnsi(enter[1]).length;
log(enter.join(len > max ? '\n' + prefix : ' '));
}
log(
`${prefix}Run ${color.cyan(devCmd)} to start the dev server. ${color.cyan('CTRL+C')} to stop.`
);
await sleep(100);
log(
`${prefix}Add frameworks like ${color.cyan(`react`)} or ${color.cyan(
'tailwind'
)} using ${color.cyan('astro add')}.`
);
await sleep(100);
log(`\n${prefix}Stuck? Join us at ${color.cyan(`https://astro.build/chat`)}`);
await sleep(200);
};
export function printHelp({
commandName,
headline,
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 (headline) {
message.push(
linebreak(),
`${title(commandName)} ${color.green(`v${process.env.PACKAGE_VERSION ?? ''}`)} ${headline}`
);
}
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

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,4 @@
{
"name": "@test/create-astro-not-empty",
"private": true
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,72 @@
import { expect } from 'chai';
import { install } from '../dist/index.js';
import { setup } from './utils.js';
describe('install', () => {
const fixture = setup();
it('--yes', async () => {
const context = {
cwd: '',
yes: true,
pkgManager: 'npm',
dryRun: true,
prompt: () => ({ deps: true }),
};
await install(context);
expect(fixture.hasMessage('Skipping dependency installation')).to.be.true;
});
it('prompt yes', async () => {
const context = {
cwd: '',
pkgManager: 'npm',
dryRun: true,
prompt: () => ({ deps: true }),
install: undefined,
};
await install(context);
expect(fixture.hasMessage('Skipping dependency installation')).to.be.true;
expect(context.install).to.eq(true);
});
it('prompt no', async () => {
const context = {
cwd: '',
pkgManager: 'npm',
dryRun: true,
prompt: () => ({ deps: false }),
install: undefined,
};
await install(context);
expect(fixture.hasMessage('Skipping dependency installation')).to.be.true;
expect(context.install).to.eq(false);
});
it('--install', async () => {
const context = {
cwd: '',
install: true,
pkgManager: 'npm',
dryRun: true,
prompt: () => ({ deps: false }),
};
await install(context);
expect(fixture.hasMessage('Skipping dependency installation')).to.be.true;
expect(context.install).to.eq(true);
});
it('--no-install', async () => {
const context = {
cwd: '',
install: false,
pkgManager: 'npm',
dryRun: true,
prompt: () => ({ deps: false }),
};
await install(context);
expect(fixture.hasMessage('Skipping dependency installation')).to.be.true;
expect(context.install).to.eq(false);
});
});

View file

@ -0,0 +1,31 @@
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));
},
};
}

View file

@ -0,0 +1,41 @@
import { expect } from 'chai';
import { verify } from '../dist/index.js';
import { setup } from './utils.js';
describe('verify', () => {
const fixture = setup();
const exit = (code) => {
throw code;
};
it('basics', async () => {
const context = { template: 'basics', exit };
await verify(context);
expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages');
});
it('missing', async () => {
const context = { template: 'missing', exit };
let err = null;
try {
await verify(context);
} catch (e) {
err = e;
}
expect(err).to.eq(1);
expect(fixture.hasMessage('Template missing does not exist!'));
});
it('starlight', async () => {
const context = { template: 'starlight', exit };
await verify(context);
expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages');
});
it('starlight/tailwind', async () => {
const context = { template: 'starlight/tailwind', exit };
await verify(context);
expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages');
});
});

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