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

572 lines (500 loc) • 15.5 kB
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 } }