Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 15 commits into from Apr 24, 2024
Merged
5 changes: 5 additions & 0 deletions .changeset/pink-rivers-knock.md
@@ -0,0 +1,5 @@
---
"astro": minor
---

Astro will now notify you on start and in the dev toolbar when a new version of Astro is available. This behaviour can be configured using the new `checkUpdates` setting in the Astro config.
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
25 changes: 17 additions & 8 deletions packages/astro/src/@types/astro.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
Expand All @@ -1725,19 +1725,19 @@ 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
* {
* + "$schema": "../../../.astro/collections/test.schema.json",
* "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": [
* {
Expand All @@ -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;
Expand Down Expand Up @@ -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> = (
Expand Down Expand Up @@ -3014,6 +3022,7 @@ export type DevToolbarMetadata = Window &
__astro_dev_toolbar__: {
root: string;
version: string;
latestAstroVersion: AstroSettings['latestAstroVersion'];
debugInfo: string;
};
};
Expand Down
54 changes: 1 addition & 53 deletions packages/astro/src/cli/add/index.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions packages/astro/src/cli/install-package.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -161,3 +186,56 @@ async function installPackage(
return false;
}
}

export async function fetchPackageJson(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fished this out of astro add, code is unchanged. astro add is insanely big so fishing things out of it is honestly just good.

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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a quite odd pattern that astro add does everywhere where functions return errors, kinda neat

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;
Comment on lines +226 to +227
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this default to process.env.npm_config_registry or is that value just totally irrelevant?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value seems to be irrelevant in many cases, I've even seen npm issues saying that it doesn't respect it. No idea how that works.

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;
}
2 changes: 1 addition & 1 deletion packages/astro/src/cli/preferences/index.ts
Expand Up @@ -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,
Expand Down
48 changes: 46 additions & 2 deletions packages/astro/src/core/dev/dev.ts
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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) => {
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
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(
Expand All @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions packages/astro/src/core/dev/restart.ts
Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions packages/astro/src/core/dev/update-check.ts
@@ -0,0 +1,44 @@
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 });
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I had a choice to make here:

  • Use a new file, a new system, etc to store variables like this
  • Re-use preferences under a private namespace

I went with the later when I realized adding a new store with different values to preferences would require change a lot of type utils and stuff for very little value. I think it's not a problem... You're not supposed to change the config file manually, and the CLI won't show this private namespace.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That definitely seems reasonable. We have this same problem with telemetry, too, so probably worth generalizing a pattern at somepoint.

}

_latestVersion = version;
return version;
}

export async function shouldCheckForUpdates(preferences: AstroPreferences): Promise<boolean> {
const timeSinceLastCheck = Date.now() - (await preferences.get('_variables.lastUpdateCheck'));
const hasCheckUpdatesEnabled = await preferences.get('checkUpdates.enabled');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's very nice! Not sure if we have a preferences reference page yet, but this would definitely be something to document.


return (
timeSinceLastCheck > CHECK_MS_INTERVAL &&
process.env.ASTRO_DISABLE_UPDATE_CHECK !== 'true' &&
hasCheckUpdatesEnabled
);
}