diff --git a/.changeset/young-rats-sin.md b/.changeset/young-rats-sin.md new file mode 100644 index 0000000000..0d26ac34a4 --- /dev/null +++ b/.changeset/young-rats-sin.md @@ -0,0 +1,11 @@ +--- +'astro': patch +--- + +Fixes an edge case with `astro add` that could install a prerelease instead of a stable release version. + +**Prior to this change** +`astro add svelte` installs `svelte@5.0.0-next.22` + +**After this change** +`astro add svelte` installs `svelte@4.2.8` diff --git a/packages/astro/package.json b/packages/astro/package.json index c7dd43ae3d..79992216b4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -200,6 +200,7 @@ "@types/probe-image-size": "^7.2.3", "@types/prompts": "^2.4.8", "@types/resolve": "^1.20.5", + "@types/semver": "^7.5.2", "@types/send": "^0.17.4", "@types/server-destroy": "^1.0.3", "@types/unist": "^3.0.2", diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index 80c0e10ff7..8ddc0e7de0 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -5,6 +5,7 @@ import { bold, cyan, dim, green, magenta, red, yellow } from 'kleur/colors'; import fsMod, { existsSync, promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import maxSatisfying from 'semver/ranges/max-satisfying.js'; import ora from 'ora'; import preferredPM from 'preferred-pm'; import prompts from 'prompts'; @@ -610,15 +611,7 @@ async function getInstallIntegrationsCommand({ logger.debug('add', `package manager: ${JSON.stringify(pm)}`); if (!pm) return null; - let dependencies = integrations - .map<[string, string | null][]>((i) => [[i.packageName, null], ...i.dependencies]) - .flat(1) - .filter((dep, i, arr) => arr.findIndex((d) => d[0] === dep[0]) === i) - .map(([name, version]) => - version === null ? name : `${name}@${version.split(/\s*\|\|\s*/).pop()}` - ) - .sort(); - + const dependencies = await convertIntegrationsToInstallSpecifiers(integrations); switch (pm.name) { case 'npm': return { pm: 'npm', command: 'install', flags: [], dependencies }; @@ -633,6 +626,35 @@ async function getInstallIntegrationsCommand({ } } +async function convertIntegrationsToInstallSpecifiers( + integrations: IntegrationInfo[] +): Promise { + const ranges: Record = {}; + for (let { packageName, dependencies } of integrations) { + ranges[packageName] = '*'; + for (const [name, range] of dependencies) { + ranges[name] = range; + } + } + return Promise.all( + Object.entries(ranges).map(([name, range]) => resolveRangeToInstallSpecifier(name, range)) + ); +} + +/** + * Resolves package with a given range to a STABLE version + * peerDependencies might specify a compatible prerelease, + * but `astro add` should only ever install stable releases + */ +async function resolveRangeToInstallSpecifier(name: string, range: string): Promise { + const versions = await fetchPackageVersions(name); + if (versions instanceof Error) return name; + // Filter out any prerelease versions + const stableVersions = versions.filter(v => !v.includes('-')); + const maxStable = maxSatisfying(stableVersions, range); + return `${name}@^${maxStable}`; +} + // Allow forwarding of standard `npm install` flags // See https://docs.npmjs.com/cli/v8/commands/npm-install#description const INHERITED_FLAGS = new Set([ @@ -725,7 +747,7 @@ async function fetchPackageJson( scope: string | undefined, name: string, tag: string -): Promise { +): Promise | Error> { const packageName = `${scope ? `${scope}/` : ''}${name}`; const registry = await getRegistry(); const res = await fetch(`${registry}/${packageName}/${tag}`); @@ -739,6 +761,21 @@ async function fetchPackageJson( } } +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/pnpm-lock.yaml b/pnpm-lock.yaml index 9b42c6504f..8b1ea077dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,6 +726,9 @@ importers: '@types/resolve': specifier: ^1.20.5 version: 1.20.5 + '@types/semver': + specifier: ^7.5.2 + version: 7.5.4 '@types/send': specifier: ^0.17.4 version: 0.17.4 @@ -5718,7 +5721,7 @@ packages: '@changesets/write': 0.2.3 '@manypkg/get-packages': 1.1.3 '@types/is-ci': 3.0.3 - '@types/semver': 7.5.4 + '@types/semver': 7.5.5 ansi-colors: 4.1.3 chalk: 2.4.2 enquirer: 2.4.1