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
572 lines (500 loc) • 15.5 kB
text/typescript
import fs from 'node:fs/promises'
import path from 'node:path'
import {PassThrough} from 'node:stream'
import {type Gzip} from 'node:zlib'
import {type CliCommandContext, type CliOutputter} from '@sanity/cli'
import {type SanityClient} from '@sanity/client'
import FormData from 'form-data'
import {customAlphabet} from 'nanoid'
import readPkgUp from 'read-pkg-up'
import {debug as debugIt} from '../../debug'
import {determineIsApp} from '../../util/determineIsApp'
export const debug = debugIt.extend('deploy')
// TODO: replace with `Promise.withResolvers()` once it lands in node
function promiseWithResolvers<T>() {
let resolve!: (t: T) => void
let reject!: (err: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return {promise, resolve, reject}
}
export interface ActiveDeployment {
deployedAt: string
deployedBy: string
isActiveDeployment: boolean
isAutoUpdating: boolean | null
size: string | null
createdAt: string
updatedAt: string
version: string
}
export interface UserApplication {
id: string
projectId: string | null
organizationId: string | null
title: string | null
appHost: string
urlType: 'internal' | 'external'
createdAt: string
updatedAt: string
type: 'studio' | 'coreApp'
activeDeployment?: ActiveDeployment | null
}
export interface GetUserApplicationsOptions {
client: SanityClient
organizationId?: string
}
export interface GetUserApplicationOptions extends GetUserApplicationsOptions {
appHost?: string
appId?: string
}
export async function getUserApplication({
client,
appHost,
appId,
}: GetUserApplicationOptions): Promise<UserApplication | null> {
let query
let uri = '/user-applications'
if (appId) {
uri = `/user-applications/${appId}`
query = {appType: 'coreApp'}
} else if (appHost) {
query = {appHost}
} else {
query = {default: 'true'}
}
try {
return await client.request({uri, query})
} catch (e) {
if (e?.statusCode === 404) {
return null
}
debug('Error getting user application', e)
throw e
}
}
export async function getUserApplications({
client,
organizationId,
}: GetUserApplicationsOptions): Promise<UserApplication[] | null> {
const query: Record<string, string> = organizationId
? {organizationId: organizationId, appType: 'coreApp'}
: {appType: 'studio'}
try {
return await client.request({
uri: '/user-applications',
query,
})
} catch (e) {
if (e?.statusCode === 404) {
return null
}
debug('Error getting user applications', e)
throw e
}
}
function createUserApplication(
client: SanityClient,
body: Pick<UserApplication, 'appHost' | 'urlType' | 'type'> & {
title?: string
},
organizationId?: string,
): Promise<UserApplication> {
const query: Record<string, string> = organizationId
? {organizationId: organizationId, appType: 'coreApp'}
: {appType: 'studio'}
return client.request({uri: '/user-applications', method: 'POST', body, query})
}
interface SelectApplicationOptions {
client: SanityClient
prompt: GetOrCreateUserApplicationOptions['context']['prompt']
message: string
createNewLabel: string
organizationId?: string
}
/**
* Shared utility for selecting an existing application or opting to create a new one
* @internal
*/
async function selectExistingApplication({
client,
prompt,
message,
createNewLabel,
organizationId,
}: SelectApplicationOptions): Promise<UserApplication | null> {
const userApplications = await getUserApplications({client, organizationId})
if (!userApplications?.length) {
return null
}
const choices = userApplications.map((app) => ({
value: app.appHost,
name: app.title ?? app.appHost,
}))
const selected = await prompt.single({
message,
type: 'list',
choices: [{value: 'new', name: createNewLabel}, new prompt.Separator(), ...choices],
})
if (selected === 'new') {
return null
}
return userApplications.find((app) => app.appHost === selected)!
}
export interface GetOrCreateUserApplicationOptions {
client: SanityClient
context: Pick<CliCommandContext, 'output' | 'prompt' | 'cliConfig'>
spinner: ReturnType<CliOutputter['spinner']>
}
/**
* These functions handle the logic for managing user applications when
* studioHost is not provided in the CLI config.
*
* @internal
*
* +-------------------------------+
* | Fetch Existing user-app? |
* +---------+--------------------+
* |
* +-----+-----+
* | |
* v v
* +---------+ +-------------------------+
* | Return | | Fetch all user apps |
* | user-app| +-------------------------+
* +---------+ |
* v
* +---------------------------+
* | User apps found? |
* +-----------+---------------+
* |
* +------v------+
* | |
* v v
* +--------------------+ +------------------------+
* | Show list and | | Prompt for hostname |
* | prompt selection | | and create new app |
* +--------------------+ +------------------------+
*/
export async function getOrCreateStudio({
client,
spinner,
context,
}: GetOrCreateUserApplicationOptions): Promise<UserApplication> {
const {output, prompt} = context
// if there is already an existing user-app, then just return it
const existingUserApplication = await getUserApplication({client})
// Complete the spinner so prompt can properly work
spinner.succeed()
if (existingUserApplication) {
return existingUserApplication
}
const selectedApp = await selectExistingApplication({
client,
prompt,
message: 'Select existing studio hostname',
createNewLabel: 'Create new studio hostname',
})
if (selectedApp) {
return selectedApp
}
// otherwise, prompt the user for a hostname
output.print('Your project has not been assigned a studio hostname.')
output.print('To deploy your Sanity Studio to our hosted sanity.studio service,')
output.print('you will need one. Please enter the part you want to use.')
const {promise, resolve} = promiseWithResolvers<UserApplication>()
await prompt.single({
type: 'input',
filter: (inp: string) => inp.replace(/\.sanity\.studio$/i, ''),
message: 'Studio hostname (<value>.sanity.studio):',
// if a string is returned here, it is relayed to the user and prompt allows
// the user to try again until this function returns true
validate: async (appHost: string) => {
try {
const response = await createUserApplication(client, {
appHost,
urlType: 'internal',
type: 'studio',
})
resolve(response)
return true
} catch (e) {
// if the name is taken, it should return a 409 so we relay to the user
if ([402, 409].includes(e?.statusCode)) {
return e?.response?.body?.message || 'Bad request' // just in case
}
debug('Error creating user application', e)
// otherwise, it's a fatal error
throw e
}
},
})
return await promise
}
/**
* Creates a core application with an auto-generated hostname
*
* @internal
*/
export async function getOrCreateApplication({
client,
context,
spinner,
}: GetOrCreateUserApplicationOptions): Promise<UserApplication> {
const {prompt, cliConfig} = context
const organizationId = cliConfig && 'app' in cliConfig && cliConfig.app?.organizationId
// Complete the spinner so prompt can properly work
spinner.succeed()
const selectedApp = await selectExistingApplication({
client,
prompt,
message: 'Select an existing deployed application',
createNewLabel: 'Create new deployed application',
organizationId: organizationId || undefined,
})
if (selectedApp) {
return selectedApp
}
// First get the title from the user
const title = await prompt.single({
type: 'input',
message: 'Enter a title for your application:',
validate: (input: string) => input.length > 0 || 'Title is required',
})
const {promise, resolve, reject} = promiseWithResolvers<UserApplication>()
// Try to create the application, retrying with new hostnames if needed
const tryCreateApp = async () => {
// appHosts have some restrictions (no uppercase, must start with a letter)
const generateId = () => {
const letters = 'abcdefghijklmnopqrstuvwxyz'
const firstChar = customAlphabet(letters, 1)()
const rest = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 11)()
return `${firstChar}${rest}`
}
// we will likely prepend this with an org ID or other parameter in the future
const appHost = generateId()
try {
const response = await createUserApplication(
client,
{
appHost,
urlType: 'internal',
title,
type: 'coreApp',
},
organizationId || undefined,
)
resolve(response)
return true
} catch (e) {
// if the name is taken, generate a new one and try again
if ([402, 409].includes(e?.statusCode)) {
debug('App host taken, retrying with new host')
return tryCreateApp()
}
debug('Error creating core application', e)
reject(e)
// otherwise, it's a fatal error
throw e
}
}
spinner.start('Creating application')
await tryCreateApp()
const response = await promise
spinner.succeed()
return response
}
export interface BaseConfigOptions {
client: SanityClient
context: Pick<CliCommandContext, 'output' | 'prompt' | 'cliConfig'>
spinner: ReturnType<CliOutputter['spinner']>
}
interface StudioConfigOptions extends BaseConfigOptions {
appHost: string
}
interface AppConfigOptions extends BaseConfigOptions {
appId?: string
}
async function getOrCreateStudioFromConfig({
client,
context,
spinner,
appHost,
}: StudioConfigOptions): Promise<UserApplication> {
const {output} = context
// if there is already an existing user-app, then just return it
const existingUserApplication = await getUserApplication({client, appHost})
// Complete the spinner so prompt can properly work
spinner.succeed()
if (existingUserApplication) {
return existingUserApplication
}
output.print('Your project has not been assigned a studio hostname.')
output.print(`Creating https://${appHost}.sanity.studio`)
output.print('')
spinner.start('Creating studio hostname')
try {
const response = await createUserApplication(client, {
appHost,
urlType: 'internal',
type: 'studio',
})
spinner.succeed()
return response
} catch (e) {
spinner.fail()
// if the name is taken, it should return a 409 so we relay to the user
if ([402, 409].includes(e?.statusCode)) {
throw new Error(e?.response?.body?.message || 'Bad request') // just in case
}
debug('Error creating user application from config', e)
// otherwise, it's a fatal error
throw e
}
}
async function getOrCreateAppFromConfig({
client,
context,
spinner,
appId,
}: AppConfigOptions): Promise<UserApplication> {
const {output, cliConfig} = context
const organizationId = cliConfig && 'app' in cliConfig && cliConfig.app?.organizationId
if (appId) {
const existingUserApplication = await getUserApplication({
client,
appId,
organizationId: organizationId || undefined,
})
spinner.succeed()
if (existingUserApplication) {
return existingUserApplication
}
}
// core apps cannot arbitrarily create ids or hosts, so send them to create option
output.print('The id provided in your configuration is not recognized.')
output.print('Checking existing applications...')
return getOrCreateApplication({client, context, spinner})
}
/**
* This function handles the logic for managing user applications when
* studioHost or appId is provided in the CLI config.
*
* @internal
*/
export async function getOrCreateUserApplicationFromConfig(
options: BaseConfigOptions & {
appHost?: string
appId?: string
},
): Promise<UserApplication> {
const {context} = options
const isApp = determineIsApp(context.cliConfig)
if (isApp) {
return getOrCreateAppFromConfig(options)
}
if (!options.appHost) {
throw new Error('Studio host was detected, but is invalid')
}
return getOrCreateStudioFromConfig({...options, appHost: options.appHost})
}
export interface CreateDeploymentOptions {
client: SanityClient
applicationId: string
version: string
isAutoUpdating: boolean
tarball: Gzip
isApp?: boolean
}
export async function createDeployment({
client,
tarball,
applicationId,
isAutoUpdating,
version,
isApp,
}: CreateDeploymentOptions): Promise<{location: string}> {
const formData = new FormData()
formData.append('isAutoUpdating', isAutoUpdating.toString())
formData.append('version', version)
formData.append('tarball', tarball, {contentType: 'application/gzip', filename: 'app.tar.gz'})
return client.request({
uri: `/user-applications/${applicationId}/deployments`,
method: 'POST',
headers: formData.getHeaders(),
body: formData.pipe(new PassThrough()),
query: isApp ? {appType: 'coreApp'} : {appType: 'studio'},
})
}
export interface DeleteUserApplicationOptions {
client: SanityClient
applicationId: string
appType: 'coreApp' | 'studio'
}
export async function deleteUserApplication({
applicationId,
client,
appType,
}: DeleteUserApplicationOptions): Promise<void> {
await client.request({
uri: `/user-applications/${applicationId}`,
query: {
appType,
},
method: 'DELETE',
})
}
export async function getInstalledSanityVersion(): Promise<string> {
const sanityPkgPath = (await readPkgUp({cwd: __dirname}))?.path
if (!sanityPkgPath) {
throw new Error('Unable to resolve `sanity` module root')
}
const pkg = JSON.parse(await fs.readFile(sanityPkgPath, 'utf-8'))
if (typeof pkg?.version !== 'string') {
throw new Error('Unable to find version of `sanity` module')
}
return pkg.version
}
export async function dirIsEmptyOrNonExistent(sourceDir: string): Promise<boolean> {
try {
const stats = await fs.stat(sourceDir)
if (!stats.isDirectory()) {
throw new Error(`Directory ${sourceDir} is not a directory`)
}
} catch (err) {
if (err.code === 'ENOENT') {
return true
}
throw err
}
const content = await fs.readdir(sourceDir)
return content.length === 0
}
export async function checkDir(sourceDir: string): Promise<void> {
try {
const stats = await fs.stat(sourceDir)
if (!stats.isDirectory()) {
throw new Error(`Directory ${sourceDir} is not a directory`)
}
} catch (err) {
const error = err.code === 'ENOENT' ? new Error(`Directory "${sourceDir}" does not exist`) : err
throw error
}
try {
await fs.stat(path.join(sourceDir, 'index.html'))
} catch (err) {
const error =
err.code === 'ENOENT'
? new Error(
[
`"${sourceDir}/index.html" does not exist -`,
'[SOURCE_DIR] must be a directory containing',
'a Sanity studio built using "sanity build"',
].join(' '),
)
: err
throw error
}
}