Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion packages/@sanity/cli/src/actions/deploy/deployStudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {styleText} from 'node:util'
import {createGzip, type Gzip} from 'node:zlib'

import {CLIError} from '@oclif/core/errors'
import {type Output} from '@sanity/cli-core'
import {spinner} from '@sanity/cli-core/ux'
import {type StudioManifest} from 'sanity'
import {pack} from 'tar-fs'
Expand All @@ -21,14 +22,14 @@ import {deployDebug} from './deployDebug.js'
import {deployStudioSchemasAndManifests} from './deployStudioSchemasAndManifests.js'
import {findUserApplicationForStudio} from './findUserApplicationForStudio.js'
import {type DeployAppOptions} from './types.js'
import {normalizeUrl, validateUrl} from './urlUtils.js'

export async function deployStudio(options: DeployAppOptions) {
const {cliConfig, flags, output, projectRoot, sourceDir} = options

const workDir = projectRoot.directory
const configPath = projectRoot.path

const appHost = cliConfig.studioHost
const appId = getAppId(cliConfig)
const projectId = cliConfig.api?.projectId
const installedSanityVersion = await getLocalPackageVersion('sanity', workDir)
Expand All @@ -37,6 +38,9 @@ export async function deployStudio(options: DeployAppOptions) {
const isExternal = !!flags.external
const urlType: 'external' | 'internal' = isExternal ? 'external' : 'internal'

// Resolve the app host from --url flag (takes precedence) or studioHost config
const appHost = resolveAppHost({flags, isExternal, output, studioHost: cliConfig.studioHost})

if (!installedSanityVersion) {
output.error(`Failed to find installed sanity version`, {exit: 1})
return
Expand All @@ -55,10 +59,22 @@ export async function deployStudio(options: DeployAppOptions) {
appId,
output,
projectId,
unattended: !!flags.yes,
urlType,
})

if (!userApplication) {
if (flags.yes) {
const flagHint = isExternal
? 'Use --url to specify the external studio URL'
: 'Use --url to specify the studio hostname'
output.error(
`Cannot prompt for ${isExternal ? 'external studio URL' : 'studio hostname'} in unattended mode. ${flagHint}.`,
{exit: 1},
)
return
}

if (isExternal) {
output.log('Your project has not been registered with an external studio URL.')
output.log('Please enter the full URL where your studio is hosted.')
Expand Down Expand Up @@ -181,3 +197,53 @@ export default defineCliConfig({
output.error(`Error deploying studio: ${error}`, {exit: 1})
}
}

function resolveAppHost({
flags,
isExternal,
output,
studioHost,
}: {
flags: DeployAppOptions['flags']
isExternal: boolean
output: Output
studioHost: string | undefined
}): string | undefined {
const url = flags.url
if (!url) {
return studioHost
}

if (isExternal) {
const normalized = normalizeUrl(url)
const validation = validateUrl(normalized)
if (validation !== true) {
output.error(validation, {exit: 1})
return undefined
}
return normalized
}

// For internal deploys, strip protocol prefix and .sanity.studio suffix if present
const hostname = url.replace(/^https?:\/\//i, '').replace(/\.sanity\.studio\/?$/i, '')

// If the result still looks like a URL (contains dots), the user likely meant --external
if (hostname.includes('.')) {
output.error(
`"${hostname}" does not look like a sanity.studio hostname. Did you mean to use --external?`,
{exit: 1},
)
return undefined
}

// Validate hostname characters (alphanumeric and hyphens only)
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(hostname)) {
output.error(
`Invalid studio hostname "${hostname}". Hostnames can only contain letters, numbers, and hyphens.`,
{exit: 1},
)
return undefined
}

return hostname
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ interface FindUserApplicationForStudioOptions {

appHost?: string
appId?: string
unattended?: boolean
urlType?: 'external' | 'internal'
}

export async function findUserApplicationForStudio(options: FindUserApplicationForStudioOptions) {
const {appHost, appId, output, projectId, urlType = 'internal'} = options
const {appHost, appId, output, projectId, unattended = false, urlType = 'internal'} = options

const spin = spinner('Checking project info').start()

Expand Down Expand Up @@ -63,6 +64,12 @@ export async function findUserApplicationForStudio(options: FindUserApplicationF
return null
}

// In unattended mode, we can't prompt the user to select a studio.
// Return null and let the caller handle the error messaging.
if (unattended) {
return null
}

// If there are user applications, allow the user to select one of the existing host names,
// or to create a new one
const newLabel =
Expand Down
Loading
Loading