diff --git a/.changeset/pink-rivers-knock.md b/.changeset/pink-rivers-knock.md new file mode 100644 index 0000000000..7d2651447d --- /dev/null +++ b/.changeset/pink-rivers-knock.md @@ -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`. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 52bf7d397c..ac398674c0 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1667,7 +1667,7 @@ export interface AstroUserConfig { * @version 4.5.0 * @description * Enables a more reliable strategy to prevent scripts from being executed in pages where they are not used. - * + * * Scripts will directly render as declared in Astro files (including existing features like TypeScript, importing `node_modules`, * and deduplicating scripts). You can also now conditionally render scripts in your Astro file. @@ -1713,9 +1713,9 @@ export interface AstroUserConfig { * @version 4.5.0 * @description * This feature will auto-generate a JSON schema for content collections of `type: 'data'` which can be used as the `$schema` value for TypeScript-style autocompletion/hints in tools like VSCode. - * + * * To enable this feature, add the experimental flag: - * + * * ```diff * import { defineConfig } from 'astro/config'; @@ -1725,9 +1725,9 @@ export interface AstroUserConfig { * } * }); * ``` - * + * * This experimental implementation requires you to manually reference the schema in each data entry file of the collection: - * + * * ```diff * // src/content/test/entry.json * { @@ -1735,9 +1735,9 @@ export interface AstroUserConfig { * "test": "test" * } * ``` - * + * * Alternatively, you can set this in your [VSCode `json.schemas` settings](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings): - * + * * ```diff * "json.schemas": [ * { @@ -1748,7 +1748,7 @@ export interface AstroUserConfig { * } * ] * ``` - * + * * Note that this initial implementation uses a library with [known issues for advanced Zod schemas](https://github.com/StefanTerdell/zod-to-json-schema#known-issues), so you may wish to consult these limitations before enabling the experimental flag. */ contentCollectionJsonSchema?: boolean; @@ -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 = ( @@ -3014,6 +3022,7 @@ export type DevToolbarMetadata = Window & __astro_dev_toolbar__: { root: string; version: string; + latestAstroVersion: AstroSettings['latestAstroVersion']; debugInfo: string; }; }; diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index c38ab57c1d..ec793b2668 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -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 = { 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 { - 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 | 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 { - 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 { const spinner = ora('Resolving packages...').start(); try { diff --git a/packages/astro/src/cli/install-package.ts b/packages/astro/src/cli/install-package.ts index c84356cdb7..138e9156a6 100644 --- a/packages/astro/src/cli/install-package.ts +++ b/packages/astro/src/cli/install-package.ts @@ -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 { + 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 | 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 { + 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 { + 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; +} diff --git a/packages/astro/src/cli/preferences/index.ts b/packages/astro/src/cli/preferences/index.ts index ac7d7086b4..dd74ee5f7c 100644 --- a/packages/astro/src/cli/preferences/index.ts +++ b/packages/astro/src/cli/preferences/index.ts @@ -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, diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index 4751a0acdc..d01e4299c4 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -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 }; } diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts index 1fadbb72a7..34551f8563 100644 --- a/packages/astro/src/core/dev/dev.ts +++ b/packages/astro/src/core/dev/dev.ts @@ -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 { + 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 0) { diff --git a/packages/astro/src/core/dev/update-check.ts b/packages/astro/src/core/dev/update-check.ts new file mode 100644 index 0000000000..852dc8a136 --- /dev/null +++ b/packages/astro/src/core/dev/update-check.ts @@ -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 { + 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 { + 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 + ); +} diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index 45ab41ec92..73c529c7f0 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -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'; diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index bd2cfab04c..01d571cff8 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -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.'; diff --git a/packages/astro/src/preferences/defaults.ts b/packages/astro/src/preferences/defaults.ts index f1c4d78135..8f643f99f7 100644 --- a/packages/astro/src/preferences/defaults.ts +++ b/packages/astro/src/preferences/defaults.ts @@ -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; diff --git a/packages/astro/src/preferences/index.ts b/packages/astro/src/preferences/index.ts index 8a19c5d48d..ef16d32fd0 100644 --- a/packages/astro/src/preferences/index.ts +++ b/packages/astro/src/preferences/index.ts @@ -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 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 extends object @@ -34,9 +41,9 @@ type DeepPartial = T extends object : T; export type PreferenceKey = DotKeys; -export interface PreferenceList extends Record> { +export interface PreferenceList extends Record> { fromAstroConfig: DeepPartial; - defaults: Preferences; + defaults: PublicPreferences; } export interface AstroPreferences { @@ -49,8 +56,9 @@ export interface AstroPreferences { value: GetDotKey, opts?: PreferenceOptions ): Promise; - getAll(): Promise; + getAll(): Promise; list(opts?: PreferenceOptions): Promise; + 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) { @@ -109,6 +126,7 @@ export default function createPreferences(config: AstroConfig): AstroPreferences ); } }, + ignoreNextPreferenceReload: false, }; } diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts index 11e0932342..c70338ea8a 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts @@ -89,6 +89,9 @@ export default { }, ]; + const hasNewerVersion = (window as DevToolbarMetadata).__astro_dev_toolbar__ + .latestAstroVersion; + const windowComponent = createWindowElement( `