@sanity/cli
Version:
Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets
186 lines (161 loc) • 5.55 kB
text/typescript
import {readFileSync} from 'node:fs'
import {join as joinPath} from 'node:path'
import getLatestVersion from 'get-latest-version'
import promiseProps from 'promise-props-recursive'
import semver from 'semver'
import semverCompare from 'semver-compare'
import {type CliCommandContext, type PackageJson} from '../../types'
import {getCliVersion} from '../../util/getCliVersion'
import {getLocalVersion} from '../../util/getLocalVersion'
/*
* The `sanity upgrade` command should only be responsible for upgrading the
* _studio_ related dependencies. Modules like @sanity/block-content-to-react
* shouldn't be upgraded using the same tag/range as the other studio modules.
*
* We don't have a guaranteed list of the "studio modules", so instead we
* explicitly exclude certain modules from being upgraded.
*/
const PACKAGES_TO_EXCLUDE = [
'@sanity/block-content-to-html',
'@sanity/block-content-to-react',
'@sanity/client',
]
const defaultOptions: FindModuleVersionOptions = {
includeCli: true,
}
interface PromisedModuleVersionInfo {
name: string
declared: string
installed: string | undefined
latest: Promise<string | undefined>
latestInRange: Promise<string | undefined>
isPinned: boolean
isGlobal: boolean
}
interface ModuleVersionInfo {
name: string
declared: string
installed: string | undefined
latest: string
latestInRange: string
isPinned: boolean
isGlobal: boolean
}
export interface ModuleVersionResult extends ModuleVersionInfo {
needsUpdate: boolean
}
export interface FindModuleVersionOptions {
includeCli?: boolean
target?: string
}
export async function findSanityModuleVersions(
context: CliCommandContext,
options: FindModuleVersionOptions = {},
): Promise<ModuleVersionResult[]> {
const {spinner} = context.output
const {target, includeCli} = {...defaultOptions, ...options}
const cliVersion = await getCliVersion()
// Declared @sanity modules and their wanted version ranges in package.json
const sanityModules = filterSanityModules(getLocalManifest(context.workDir))
// Figure out the latest versions which match the wanted range
const resolveOpts = {includeCli, target}
const spin = spinner('Resolving latest versions').start()
const versions = await promiseProps<ModuleVersionInfo[]>(
buildPackageArray(sanityModules, context.workDir, resolveOpts, cliVersion),
)
const packages = Object.values(versions)
spin.stop()
return packages.map((mod) => {
const current = mod.installed || semver.minVersion(mod.declared)?.toString() || ''
const needsUpdate =
target === 'latest'
? semverCompare(current, mod.latest) === -1
: typeof mod.latestInRange !== 'undefined' && mod.installed !== mod.latestInRange
return {...mod, needsUpdate}
})
}
function getLocalManifest(workDir: string): Partial<PackageJson> {
try {
const fileContent = readFileSync(joinPath(workDir, 'package.json'), 'utf8')
return JSON.parse(fileContent)
} catch {
return {}
}
}
function filterSanityModules(manifest: Partial<PackageJson>): Record<string, string> {
const dependencies = {
...manifest.dependencies,
...manifest.devDependencies,
}
return Object.keys(dependencies)
.filter((mod) => mod.startsWith('@sanity/') || mod === 'sanity')
.filter((mod) => !PACKAGES_TO_EXCLUDE.includes(mod))
.sort()
.reduce(
(versions, dependency) => {
versions[dependency] = dependencies[dependency]
return versions
},
{} as Record<string, string>,
)
}
function buildPackageArray(
packages: Record<string, string>,
workDir: string,
options: FindModuleVersionOptions = {},
cliVersion: string,
): PromisedModuleVersionInfo[] {
const {includeCli, target} = options
const modules = []
if (includeCli) {
const [cliMajor] = cliVersion.split('.')
const latest = tryFindLatestVersion('@sanity/cli', target || `^${cliMajor}`)
modules.push({
name: '@sanity/cli',
declared: `^${cliVersion}`,
installed: trimHash(cliVersion),
latest: latest.then((versions) => versions.latest),
latestInRange: latest.then((versions) => versions.latestInRange),
isPinned: false,
isGlobal: true,
})
}
return [
...modules,
...Object.keys(packages).map((pkgName) => {
const latest = tryFindLatestVersion(pkgName, target || packages[pkgName] || 'latest')
const localVersion = getLocalVersion(pkgName, workDir)
return {
name: pkgName,
declared: packages[pkgName],
installed: localVersion ? trimHash(localVersion) : undefined,
latest: latest.then((versions) => versions.latest),
latestInRange: latest.then((versions) => versions.latestInRange),
isPinned: isPinnedVersion(packages[pkgName]),
isGlobal: false,
}
}),
]
}
async function tryFindLatestVersion(pkgName: string, range: string) {
try {
const {latest, inRange} = await getLatestVersion(pkgName, {range, includeLatest: true})
return {latest, latestInRange: inRange}
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('No version exists')) {
throw err
}
const latest = await getLatestVersion(pkgName)
return {latest, latestInRange: undefined}
}
}
function isPinnedVersion(version: string): boolean {
return /^\d+\.\d+\.\d+/.test(version)
}
/**
* `2.27.3-cookieless-auth.34+8ba9c1504` →
* `2.27.3-cookieless-auth.34`
*/
function trimHash(version: string) {
return version.replace(/\+[a-z0-9]{8,}$/, '')
}