UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

212 lines (183 loc) • 8.26 kB
import {type CliCommandContext, type PackageJson} from '@sanity/cli' import execa from 'execa' import {readFile} from 'fs/promises' import oneline from 'oneline' import path from 'path' import resolveFrom from 'resolve-from' import semver, {type SemVer} from 'semver' import {peerDependencies} from '../../../../package.json' const defaultStudioManifestProps: PartialPackageManifest = { name: 'studio', version: '1.0.0', } interface CheckResult { didInstall: boolean } /** * Checks that the studio has declared and installed the required dependencies * needed by the Sanity modules. While we generally use regular, explicit * dependencies in modules, there are certain dependencies that are better * served being peer dependencies, such as react and styled-components. * * If these dependencies are not installed/declared, we want to prompt the user * whether or not to add them to `package.json` and install them */ export async function checkRequiredDependencies(context: CliCommandContext): Promise<CheckResult> { const {workDir: studioPath, output} = context const [studioPackageManifest, installedStyledComponentsVersion] = await Promise.all([ await readPackageManifest(path.join(studioPath, 'package.json'), defaultStudioManifestProps), await readModuleVersion(studioPath, 'styled-components'), ]) const wantedStyledComponentsVersionRange = peerDependencies['styled-components'] // The studio _must_ now declare `styled-components` as a dependency. If it's not there, // we'll want to automatically _add it_ to the manifest and tell the user to reinstall // dependencies before running whatever command was being run const declaredStyledComponentsVersion = studioPackageManifest.dependencies['styled-components'] if (!declaredStyledComponentsVersion) { const [file, ...args] = process.argv const deps = {'styled-components': wantedStyledComponentsVersionRange} await installDependencies(deps, context) // Re-run the same command (sanity dev/sanity build etc) after installation, // as it can have shifted the entire `node_modules` folder around, result in // broken assumptions about installation paths. This is a hack, and should be // solved properly. await execa(file, args, {cwd: studioPath, stdio: 'inherit'}) return {didInstall: true} } // Theoretically the version specified in package.json could be incorrect, eg `foo` let minDeclaredStyledComponentsVersion: SemVer | null = null try { minDeclaredStyledComponentsVersion = semver.minVersion(declaredStyledComponentsVersion) } catch (err) { // Intentional fall-through (variable will be left as null, throwing below) } if (!minDeclaredStyledComponentsVersion) { throw new Error(oneline` Declared dependency \`styled-components\` has an invalid version range: \`${declaredStyledComponentsVersion}\`. `) } // The declared version should be semver-compatible with the version specified as a // peer dependency in `sanity`. If not, we should tell the user to change it. // // Exception: Ranges are hard to compare. `>=5.0.0 && <=5.3.2 || ^6`... Comparing this // to anything is going to be challenging, so only compare "simple" ranges/versions // (^x.x.x / ~x.x.x / x.x.x) if ( isComparableRange(declaredStyledComponentsVersion) && !semver.satisfies(minDeclaredStyledComponentsVersion, wantedStyledComponentsVersionRange) ) { output.warn(oneline` Declared version of styled-components (${declaredStyledComponentsVersion}) is not compatible with the version required by sanity (${wantedStyledComponentsVersionRange}). This might cause problems! `) } // Ensure the studio has _installed_ a version of `styled-components` if (!installedStyledComponentsVersion) { throw new Error(oneline` Declared dependency \`styled-components\` is not installed - run \`npm install\`, \`yarn install\` or \`pnpm install\` to install it before re-running this command. `) } // The studio should have an _installed_ version of `styled-components`, and it should // be semver compatible with the version specified in `sanity` peer dependencies. if (!semver.satisfies(installedStyledComponentsVersion, wantedStyledComponentsVersionRange)) { output.warn(oneline` Installed version of styled-components (${installedStyledComponentsVersion}) is not compatible with the version required by sanity (${wantedStyledComponentsVersionRange}). This might cause problems! `) } return {didInstall: false} } /** * Reads the version number of the _installed_ module, or returns `null` if not found * * @param studioPath - Path of the studio * @param moduleName - Name of module to get installed version for * @returns Version number, of null */ async function readModuleVersion(studioPath: string, moduleName: string): Promise<string | null> { const manifestPath = resolveFrom.silent(studioPath, path.join(moduleName, 'package.json')) return manifestPath ? (await readPackageManifest(manifestPath)).version : null } /** * Read the `package.json` file at the given path and return an object that guarantees * the presence of name, version, dependencies, dev dependencies and peer dependencies * * @param packageJsonPath - Path to package.json to read * @returns Reduced package.json with guarantees for name, version and dependency fields */ async function readPackageManifest( packageJsonPath: string, defaults: Partial<PartialPackageManifest> = {}, ): Promise<PackageManifest> { let manifest: unknown try { manifest = {...defaults, ...(await readPackageJson(packageJsonPath))} } catch (err) { throw new Error(`Failed to read "${packageJsonPath}": ${err.message}`) } if (!isPackageManifest(manifest)) { throw new Error(`Failed to read "${packageJsonPath}": Invalid package manifest`) } const {name, version, dependencies = {}, devDependencies = {}} = manifest return {name, version, dependencies, devDependencies} } /** * Install the passed dependencies at the given version/version range, * prompting the user which package manager to use. We will try to detect * a package manager from files in the directory and show that as the default * * @param dependencies - Object of dependencies `({[package name]: version})` * @param context - CLI context */ async function installDependencies( dependencies: Record<string, string>, context: CliCommandContext, ): Promise<void> { const {output, prompt, workDir, cliPackageManager} = context const packages: string[] = [] output.print('The Sanity studio needs to install missing dependencies:') for (const [pkgName, version] of Object.entries(dependencies)) { const declaration = `${pkgName}@${version}` output.print(`- ${declaration}`) packages.push(declaration) } if (!cliPackageManager) { output.error( 'ERROR: Could not determine package manager choice - run `npm install` or equivalent', ) return } const {getPackageManagerChoice, installNewPackages} = cliPackageManager const {mostOptimal, chosen: pkgManager} = await getPackageManagerChoice(workDir, {prompt}) if (mostOptimal && pkgManager !== mostOptimal) { output.warn( `WARN: This project appears to be installed with or using ${mostOptimal} - using a different package manager _may_ result in errors.`, ) } await installNewPackages({packages, packageManager: pkgManager}, context) } function isPackageManifest(item: unknown): item is PartialPackageManifest { return typeof item === 'object' && item !== null && 'name' in item && 'version' in item } function isComparableRange(range: string): boolean { return /^[\^~]?\d+(\.\d+)?(\.\d+)?$/.test(range) } function readPackageJson(filePath: string): Promise<PackageJson> { return readFile(filePath, 'utf8').then((res) => JSON.parse(res)) } interface PackageManifest extends DependencyDeclarations { name: string version: string } interface PartialPackageManifest extends Partial<DependencyDeclarations> { name: string version: string } interface DependencyDeclarations { dependencies: Record<string, string | undefined> devDependencies: Record<string, string | undefined> }