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

feat: add a new message telling the user that a new version of Astro is available (#10734)

* feat: add a new message telling the user that a new version of Astro is available

* chore: changeset

* fix: use gt

* fix: apply feednack

* fix: apply feedback

* nit: use private namespace

* fix: oops

* nit: apply feedback

* fix: build

* fix: don't check for updates in CI

* docs: update changeset
This commit is contained in:
Erika 2024-04-24 17:55:49 +02:00 committed by GitHub
parent 43ead8fbd5
commit 6fc4c0e420
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 265 additions and 73 deletions

View file

@ -0,0 +1,7 @@
---
"astro": minor
---
Astro will now automatically check for updates when you run the dev server. If a new version is available, a message will appear in the terminal with instructions on how to update. Updates will be checked once per 10 days, and the message will only appear if the project is multiple versions behind the latest release.
This behavior can be disabled by running `astro preferences disable checkUpdates` or setting the `ASTRO_DISABLE_UPDATE_CHECK` environment variable to `false`.

View file

@ -2106,6 +2106,14 @@ export interface AstroSettings {
tsConfigPath: string | undefined;
watchFiles: string[];
timer: AstroTimer;
/**
* Latest version of Astro, will be undefined if:
* - unable to check
* - the user has disabled the check
* - the check has not completed yet
* - the user is on the latest version already
*/
latestAstroVersion: string | undefined;
}
export type AsyncRendererComponentFn<U> = (
@ -3014,6 +3022,7 @@ export type DevToolbarMetadata = Window &
__astro_dev_toolbar__: {
root: string;
version: string;
latestAstroVersion: AstroSettings['latestAstroVersion'];
debugInfo: string;
};
};

View file

@ -33,6 +33,7 @@ import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
import { generate, parse, t, visit } from './babel.js';
import { ensureImport } from './imports.js';
import { wrapDefaultExport } from './wrapper.js';
import { fetchPackageVersions, fetchPackageJson } from '../install-package.js';
interface AddOptions {
flags: yargs.Arguments;
@ -95,26 +96,6 @@ const OFFICIAL_ADAPTER_TO_IMPORT_MAP: Record<string, string> = {
node: '@astrojs/node',
};
// 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 create-astro package
let _registry: string;
async function getRegistry(): Promise<string> {
if (_registry) return _registry;
const fallback = 'https://registry.npmjs.org';
const packageManager = (await preferredPM(process.cwd()))?.name || 'npm';
try {
const { stdout } = await execa(packageManager, ['config', 'get', 'registry']);
_registry = stdout?.trim()?.replace(/\/$/, '') || fallback;
// Detect cases where the shell command returned a non-URL (e.g. a warning)
if (!new URL(_registry).host) _registry = fallback;
} catch (e) {
_registry = fallback;
}
return _registry;
}
export async function add(names: string[], { flags }: AddOptions) {
ensureProcessNodeEnv('production');
applyPolyfill();
@ -805,39 +786,6 @@ async function tryToInstallIntegrations({
}
}
async function fetchPackageJson(
scope: string | undefined,
name: string,
tag: string
): Promise<Record<string, any> | Error> {
const packageName = `${scope ? `${scope}/` : ''}${name}`;
const registry = await getRegistry();
const res = await fetch(`${registry}/${packageName}/${tag}`);
if (res.status >= 200 && res.status < 300) {
return await res.json();
} else if (res.status === 404) {
// 404 means the package doesn't exist, so we don't need an error message here
return new Error();
} else {
return new Error(`Failed to fetch ${registry}/${packageName}/${tag} - GET ${res.status}`);
}
}
async function fetchPackageVersions(packageName: string): Promise<string[] | Error> {
const registry = await getRegistry();
const res = await fetch(`${registry}/${packageName}`, {
headers: { accept: 'application/vnd.npm.install-v1+json' },
});
if (res.status >= 200 && res.status < 300) {
return await res.json().then((data) => Object.keys(data.versions));
} else if (res.status === 404) {
// 404 means the package doesn't exist, so we don't need an error message here
return new Error();
} else {
return new Error(`Failed to fetch ${registry}/${packageName} - GET ${res.status}`);
}
}
export async function validateIntegrations(integrations: string[]): Promise<IntegrationInfo[]> {
const spinner = ora('Resolving packages...').start();
try {

View file

@ -5,6 +5,7 @@ import ci from 'ci-info';
import { execa } from 'execa';
import { bold, cyan, dim, magenta } from 'kleur/colors';
import ora from 'ora';
import preferredPM from 'preferred-pm';
import prompts from 'prompts';
import resolvePackage from 'resolve';
import whichPm from 'which-pm';
@ -97,6 +98,30 @@ function getInstallCommand(packages: string[], packageManager: string) {
}
}
/**
* Get the command to execute and download a package (e.g. `npx`, `yarn dlx`, `pnpx`, etc.)
* @param packageManager - Optional package manager to use. If not provided, Astro will attempt to detect the preferred package manager.
* @returns The command to execute and download a package
*/
export async function getExecCommand(packageManager?: string): Promise<string> {
if (!packageManager) {
packageManager = (await preferredPM(process.cwd()))?.name ?? 'npm';
}
switch (packageManager) {
case 'npm':
return 'npx';
case 'yarn':
return 'yarn dlx';
case 'pnpm':
return 'pnpx';
case 'bun':
return 'bunx';
default:
return 'npx';
}
}
async function installPackage(
packageNames: string[],
options: GetPackageOptions,
@ -161,3 +186,56 @@ async function installPackage(
return false;
}
}
export async function fetchPackageJson(
scope: string | undefined,
name: string,
tag: string
): Promise<Record<string, any> | Error> {
const packageName = `${scope ? `${scope}/` : ''}${name}`;
const registry = await getRegistry();
const res = await fetch(`${registry}/${packageName}/${tag}`);
if (res.status >= 200 && res.status < 300) {
return await res.json();
} else if (res.status === 404) {
// 404 means the package doesn't exist, so we don't need an error message here
return new Error();
} else {
return new Error(`Failed to fetch ${registry}/${packageName}/${tag} - GET ${res.status}`);
}
}
export async function fetchPackageVersions(packageName: string): Promise<string[] | Error> {
const registry = await getRegistry();
const res = await fetch(`${registry}/${packageName}`, {
headers: { accept: 'application/vnd.npm.install-v1+json' },
});
if (res.status >= 200 && res.status < 300) {
return await res.json().then((data) => Object.keys(data.versions));
} else if (res.status === 404) {
// 404 means the package doesn't exist, so we don't need an error message here
return new Error();
} else {
return new Error(`Failed to fetch ${registry}/${packageName} - GET ${res.status}`);
}
}
// 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 create-astro package
let _registry: string;
export async function getRegistry(): Promise<string> {
if (_registry) return _registry;
const fallback = 'https://registry.npmjs.org';
const packageManager = (await preferredPM(process.cwd()))?.name || 'npm';
try {
const { stdout } = await execa(packageManager, ['config', 'get', 'registry']);
_registry = stdout?.trim()?.replace(/\/$/, '') || fallback;
// Detect cases where the shell command returned a non-URL (e.g. a warning)
if (!new URL(_registry).host) _registry = fallback;
} catch (e) {
_registry = fallback;
}
return _registry;
}

View file

@ -124,7 +124,7 @@ interface SubcommandOptions {
json?: boolean;
}
// Default `location` to "project" to avoid reading default preferencesa
// Default `location` to "project" to avoid reading default preferences
async function getPreference(
settings: AstroSettings,
key: PreferenceKey,

View file

@ -104,6 +104,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
watchFiles: [],
devToolbarApps: [],
timer: new AstroTimer(),
latestAstroVersion: undefined, // Will be set later if applicable when the dev server starts
};
}

View file

@ -3,6 +3,7 @@ import type http from 'node:http';
import type { AddressInfo } from 'node:net';
import { green } from 'kleur/colors';
import { performance } from 'perf_hooks';
import { gt, major, minor, patch } from 'semver';
import type * as vite from 'vite';
import type { AstroInlineConfig } from '../../@types/astro.js';
import { attachContentServerListeners } from '../../content/index.js';
@ -11,6 +12,11 @@ import * as msg from '../messages.js';
import { ensureProcessNodeEnv } from '../util.js';
import { startContainer } from './container.js';
import { createContainerWithAutomaticRestart } from './restart.js';
import {
MAX_PATCH_DISTANCE,
fetchLatestAstroVersion,
shouldCheckForUpdates,
} from './update-check.js';
export interface DevServer {
address: AddressInfo;
@ -34,6 +40,45 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
const restart = await createContainerWithAutomaticRestart({ inlineConfig, fs });
const logger = restart.container.logger;
const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
const isPrerelease = currentVersion.includes('-');
if (!isPrerelease) {
try {
// Don't await this, we don't want to block the dev server from starting
shouldCheckForUpdates(restart.container.settings.preferences).then(async (shouldCheck) => {
if (shouldCheck) {
const version = await fetchLatestAstroVersion(restart.container.settings.preferences);
if (gt(version, currentVersion)) {
// Only update the latestAstroVersion if the latest version is greater than the current version, that way we don't need to check that again
// whenever we check for the latest version elsewhere
restart.container.settings.latestAstroVersion = version;
const sameMajor = major(version) === major(currentVersion);
const sameMinor = minor(version) === minor(currentVersion);
const patchDistance = patch(version) - patch(currentVersion);
if (sameMajor && sameMinor && patchDistance < MAX_PATCH_DISTANCE) {
// Don't bother the user with a log if they're only a few patch versions behind
// We can still tell them in the dev toolbar, which has a more opt-in nature
return;
}
logger.warn(
'SKIP_FORMAT',
msg.newVersionAvailable({
latestVersion: version,
})
);
}
}
});
} catch (e) {
// Just ignore the error, we don't want to block the dev server from starting and this is just a nice-to-have feature
}
}
// Start listening to the port
const devServerAddressInfo = await startContainer(restart.container);
logger.info(
@ -46,8 +91,7 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
})
);
const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
if (currentVersion.includes('-')) {
if (isPrerelease) {
logger.warn('SKIP_FORMAT', msg.prerelease({ currentVersion }));
}
if (restart.container.viteServer.config.server?.fs?.strict === false) {

View file

@ -47,8 +47,13 @@ export function shouldRestartContainer(
// Otherwise, watch for any astro.config.* file changes in project root
else {
const normalizedChangedFile = vite.normalizePath(changedFile);
shouldRestart =
configRE.test(normalizedChangedFile) || preferencesRE.test(normalizedChangedFile);
shouldRestart = configRE.test(normalizedChangedFile);
if (preferencesRE.test(normalizedChangedFile)) {
shouldRestart = settings.preferences.ignoreNextPreferenceReload ? false : true;
settings.preferences.ignoreNextPreferenceReload = false;
}
}
if (!shouldRestart && settings.watchFiles.length > 0) {

View file

@ -0,0 +1,49 @@
import ci from 'ci-info';
import { fetchPackageJson } from '../../cli/install-package.js';
import type { AstroPreferences } from '../../preferences/index.js';
export const MAX_PATCH_DISTANCE = 5; // If the patch distance is less than this, don't bother the user
const CHECK_MS_INTERVAL = 1_036_800_000; // 12 days, give or take
let _latestVersion: string | undefined = undefined;
export async function fetchLatestAstroVersion(
preferences: AstroPreferences | undefined
): Promise<string> {
if (_latestVersion) {
return _latestVersion;
}
const packageJson = await fetchPackageJson(undefined, 'astro', 'latest');
if (packageJson instanceof Error) {
throw packageJson;
}
const version = packageJson?.version;
if (!version) {
throw new Error('Failed to fetch latest Astro version');
}
if (preferences) {
await preferences.set('_variables.lastUpdateCheck', Date.now(), { reloadServer: false });
}
_latestVersion = version;
return version;
}
export async function shouldCheckForUpdates(preferences: AstroPreferences): Promise<boolean> {
if (ci.isCI) {
return false;
}
const timeSinceLastCheck = Date.now() - (await preferences.get('_variables.lastUpdateCheck'));
const hasCheckUpdatesEnabled = await preferences.get('checkUpdates.enabled');
return (
timeSinceLastCheck > CHECK_MS_INTERVAL &&
process.env.ASTRO_DISABLE_UPDATE_CHECK !== 'true' &&
hasCheckUpdatesEnabled
);
}

View file

@ -29,6 +29,7 @@ export type LoggerLabel =
| 'redirects'
| 'toolbar'
| 'assets'
| 'update'
// SKIP_FORMAT: A special label that tells the logger not to apply any formatting.
// Useful for messages that are already formatted, like the server start message.
| 'SKIP_FORMAT';

View file

@ -16,6 +16,7 @@ import {
} from 'kleur/colors';
import type { ResolvedServerUrls } from 'vite';
import type { ZodError } from 'zod';
import { getExecCommand } from '../cli/install-package.js';
import { getDocsForError, renderErrorMarkdown } from './errors/dev/utils.js';
import {
AstroError,
@ -104,6 +105,15 @@ export function serverShortcuts({ key, label }: { key: string; label: string }):
return [dim(' Press'), key, dim('to'), label].join(' ');
}
export function newVersionAvailable({ latestVersion }: { latestVersion: string }) {
const badge = bgYellow(black(` update `));
const headline = yellow(`▶ New version of Astro available: ${latestVersion}`);
const execCommand = getExecCommand();
const details = ` Run ${cyan(`${execCommand} @astrojs/upgrade`)} to update`;
return ['', `${badge} ${headline}`, details, ''].join('\n');
}
export function telemetryNotice() {
const headline = blue(`▶ Astro collects anonymous usage data.`);
const why = ' This information helps us improve Astro.';

View file

@ -3,6 +3,16 @@ export const DEFAULT_PREFERENCES = {
/** Specifies whether the user has the Dev Overlay enabled */
enabled: true,
},
checkUpdates: {
/** Specifies whether the user has the update check enabled */
enabled: true,
},
// Temporary variables that shouldn't be exposed to the users in the CLI, but are still useful to store in preferences
_variables: {
/** Time since last update check */
lastUpdateCheck: 0,
},
};
export type Preferences = typeof DEFAULT_PREFERENCES;
export type PublicPreferences = Omit<Preferences, '_variables'>;

View file

@ -6,7 +6,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import dget from 'dlv';
import { DEFAULT_PREFERENCES, type Preferences } from './defaults.js';
import { DEFAULT_PREFERENCES, type Preferences, type PublicPreferences } from './defaults.js';
import { PreferenceStore } from './store.js';
type DotKeys<T> = T extends object
@ -25,6 +25,13 @@ export type GetDotKey<
export type PreferenceLocation = 'global' | 'project';
export interface PreferenceOptions {
location?: PreferenceLocation;
/**
* If `true`, the server will be reloaded after setting the preference.
* If `false`, the server will not be reloaded after setting the preference.
*
* Defaults to `true`.
*/
reloadServer?: boolean;
}
type DeepPartial<T> = T extends object
@ -34,9 +41,9 @@ type DeepPartial<T> = T extends object
: T;
export type PreferenceKey = DotKeys<Preferences>;
export interface PreferenceList extends Record<PreferenceLocation, DeepPartial<Preferences>> {
export interface PreferenceList extends Record<PreferenceLocation, DeepPartial<PublicPreferences>> {
fromAstroConfig: DeepPartial<Preferences>;
defaults: Preferences;
defaults: PublicPreferences;
}
export interface AstroPreferences {
@ -49,8 +56,9 @@ export interface AstroPreferences {
value: GetDotKey<Preferences, Key>,
opts?: PreferenceOptions
): Promise<void>;
getAll(): Promise<Preferences>;
getAll(): Promise<PublicPreferences>;
list(opts?: PreferenceOptions): Promise<PreferenceList>;
ignoreNextPreferenceReload: boolean;
}
export function isValidKey(key: string): key is PreferenceKey {
@ -84,23 +92,32 @@ export default function createPreferences(config: AstroConfig): AstroPreferences
if (!location) return project.get(key) ?? global.get(key) ?? dget(DEFAULT_PREFERENCES, key);
return stores[location].get(key);
},
async set(key, value, { location = 'project' } = {}) {
async set(key, value, { location = 'project', reloadServer = true } = {}) {
stores[location].set(key, value);
if (!reloadServer) {
this.ignoreNextPreferenceReload = true;
}
},
async getAll() {
return Object.assign(
const allPrefs = Object.assign(
{},
DEFAULT_PREFERENCES,
stores['global'].getAll(),
stores['project'].getAll()
);
const { _variables, ...prefs } = allPrefs;
return prefs;
},
async list() {
const { _variables, ...defaultPrefs } = DEFAULT_PREFERENCES;
return {
global: stores['global'].getAll(),
project: stores['project'].getAll(),
fromAstroConfig: mapFrom(DEFAULT_PREFERENCES, config),
defaults: DEFAULT_PREFERENCES,
defaults: defaultPrefs,
};
function mapFrom(defaults: Preferences, astroConfig: Record<string, any>) {
@ -109,6 +126,7 @@ export default function createPreferences(config: AstroConfig): AstroPreferences
);
}
},
ignoreNextPreferenceReload: false,
};
}

View file

@ -89,6 +89,9 @@ export default {
},
];
const hasNewerVersion = (window as DevToolbarMetadata).__astro_dev_toolbar__
.latestAstroVersion;
const windowComponent = createWindowElement(
`<style>
#buttons-container {
@ -333,6 +336,14 @@ export default {
<astro-dev-toolbar-badge badge-style="gray" size="large">${
(window as DevToolbarMetadata).__astro_dev_toolbar__.version
}</astro-dev-toolbar-badge>
${
hasNewerVersion
? `<astro-dev-toolbar-badge badge-style="green" size="large">${
(window as DevToolbarMetadata).__astro_dev_toolbar__.latestAstroVersion
} available!</astro-dev-toolbar-badge>
`
: ''
}
</section>
<astro-dev-toolbar-button id="copy-debug-button">Copy debug info <astro-dev-toolbar-icon icon="copy" /></astro-dev-toolbar-button>
</header>

View file

@ -83,6 +83,7 @@ export class DevPipeline extends Pipeline {
const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = {
root: url.fileURLToPath(settings.config.root),
version: ASTRO_VERSION,
latestAstroVersion: settings.latestAstroVersion,
debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }),
};