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
5 changes: 5 additions & 0 deletions .changeset/deploy-create-update-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sanity/cli': minor
---

feat(deploy): report whether a deploy created a new application or updated an existing one (in the success output and via `target.action` in the dry-run/`--json` payload), and warn that redeploying without `deployment.appId` creates another application
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {logAppDeployed} from '../deployApp.js'
const mockOutput = () => ({log: vi.fn()}) as unknown as Output

describe('logAppDeployed', () => {
test("prints the dashboard URL from the deployed app's organization", () => {
test("updating prints the dashboard URL from the deployed app's organization", () => {
const output = mockOutput()

logAppDeployed({
applicationId: 'app-1',
cliConfig: {app: {organizationId: 'config-org'}, deployment: {appId: 'app-1'}} as CliConfig,
created: false,
organizationId: 'org-1',
output,
title: 'My App',
Expand All @@ -20,6 +21,30 @@ describe('logAppDeployed', () => {
expect(output.log).toHaveBeenCalledWith(
expect.stringContaining('Success! Application deployed to'),
)
expect(output.log).toHaveBeenCalledWith(
expect.stringContaining('Updated the existing application.'),
)
expect(output.log).toHaveBeenCalledWith(expect.stringContaining('/@org-1/application/app-1'))
})

test('creating without an appId warns that a redeploy creates another application', () => {
const output = mockOutput()

logAppDeployed({
applicationId: 'app-2',
cliConfig: {app: {organizationId: 'org-1'}} as CliConfig,
created: true,
organizationId: 'org-1',
output,
title: 'New App',
})

const logged = vi
.mocked(output.log)
.mock.calls.map((call) => call[0])
.join('\n')
expect(logged).toContain('Created a new application.')
expect(logged).toContain('creates another new application')
expect(logged).toContain("appId: 'app-2'")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ describe('checkStudioTarget', () => {
)
// The URL the human report shows is the same one the JSON reporter reads
expect(reporter.results[0]?.target).toEqual({
action: 'update',
applicationId: 'app-1',
title: null,
url: 'https://my-studio.sanity.studio',
Expand Down Expand Up @@ -186,6 +187,7 @@ describe('checkStudioTarget', () => {
expect(reporter.results[0]?.message).toContain('titled "My Studio"')
// The JSON target echoes the requested title, matching the human message
expect(reporter.results[0]?.target).toEqual({
action: 'create',
applicationId: null,
title: 'My Studio',
url: 'https://new-studio.sanity.studio',
Expand Down Expand Up @@ -300,6 +302,7 @@ describe('checkAppTarget', () => {
expect(reporter.results[0]).toMatchObject({status: 'pass'})
expect(reporter.results[0]?.message).toContain('Deploys to existing application "My App"')
expect(reporter.results[0]?.message).toContain('/@org-1/application/core-1')
expect(reporter.results[0]?.target?.action).toBe('update')
expect(reporter.results[0]?.target?.applicationId).toBe('core-1')
expect(reporter.results[0]?.target?.url).toContain('/@org-1/application/core-1')
})
Expand All @@ -325,7 +328,12 @@ describe('checkAppTarget', () => {
expect(reporter.results[0]).toMatchObject({status: 'pass'})
expect(reporter.results[0]?.message).toContain('Would create a new application "My App"')
// The JSON target carries the pending title; id and URL come on creation
expect(reporter.results[0]?.target).toEqual({applicationId: null, title: 'My App', url: null})
expect(reporter.results[0]?.target).toEqual({
action: 'create',
applicationId: null,
title: 'My App',
url: null,
})
})

test('needs-input → fail check (would prompt)', async () => {
Expand Down Expand Up @@ -386,6 +394,7 @@ describe('checkAppTarget (workbench backend)', () => {

expect(reporter.results[0]).toMatchObject({status: 'pass'})
expect(reporter.results[0]?.message).toContain('Deploys to existing application "Drop Desk"')
expect(reporter.results[0]?.target?.action).toBe('update')
})

test('unknown appId → fail check pointing to deployment.appId', async () => {
Expand Down Expand Up @@ -424,6 +433,7 @@ describe('checkStudioTarget (workbench backend)', () => {
expect(reporter.results[0]?.message).toContain(
'Deploys to existing studio https://my-studio.sanity.studio',
)
expect(target?.action).toBe('update')
expect(target?.url).toBe('https://my-studio.sanity.studio')
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ describe('runDeploy real deploy', () => {
const result = {
applicationType: 'studio' as const,
applicationVersion: '3.99.0',
target: {applicationId: 'app-1', title: 'My Studio', url: 'https://my-studio.sanity.studio'},
target: {
action: 'update' as const,
applicationId: 'app-1',
title: 'My Studio',
url: 'https://my-studio.sanity.studio',
},
}
const spec: DeploySpec = {
listFiles: async () => [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('deploymentPlanToJson', () => {

test('passes the resolved target through to the JSON', () => {
const target = {
action: 'update' as const,
applicationId: 'app-1',
title: 'My Studio',
url: 'https://my-studio.sanity.studio',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('findUserApplication', () => {
})

describe('findUserApplicationForStudio', () => {
test('should pass the title through when registering the configured host', async () => {
test('registering a configured host passes the title through and reports created', async () => {
const created = {title: 'My Studio'} as UserApplication
mockResolveStudio.mockResolvedValue({appHost: 'new-host', type: 'would-create'})
mockCreate.mockResolvedValue(created)
Expand All @@ -73,6 +73,23 @@ describe('findUserApplicationForStudio', () => {
body: {appHost: 'new-host', title: 'My Studio', type: 'studio', urlType: 'internal'},
projectId: 'project-1',
})
expect(result).toBe(created)
// Registering a new host is a create, not an update — see #1462 (bugbot).
expect(result).toEqual({application: created, created: true})
})

test('an existing host is an update, not a create', async () => {
const existing = {appHost: 'my-studio', title: 'My Studio'} as UserApplication
mockResolveStudio.mockResolvedValue({application: existing, type: 'found'})
const output = mockOutput()

const result = await findUserApplicationForStudio({
appId: 'app-1',
isExternal: false,
output,
projectId: 'project-1',
})

expect(mockCreate).not.toHaveBeenCalled()
expect(result).toEqual({application: existing, created: false})
})
})
44 changes: 36 additions & 8 deletions packages/@sanity/cli/src/actions/deploy/deployApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async function runAppDeployment(
const {cliConfig, flags, output, sourceDir} = options
const workDir = options.projectRoot.directory
const organizationId = cliConfig.app?.organizationId
const appId = getAppId(cliConfig)
const workbench = getWorkbench(cliConfig)
const dryRun = !!flags['dry-run']

Expand Down Expand Up @@ -112,6 +113,7 @@ async function runAppDeployment(
checkAppId(reporter, {cliConfig})

let application: UserApplicationResolved | null = null
let appCreated = false
if (flags.external) {
reporter.report({
message: EXTERNAL_APP_NOT_SUPPORTED,
Expand All @@ -121,12 +123,12 @@ async function runAppDeployment(
} else if (deployApplication && workbench) {
// Both modes, so a bad appId fails before the build rather than at the POST.
await checkAppTarget(reporter, {
appId: getAppId(cliConfig),
appId,
isWorkbenchApp: true,
title: appTitle,
})
} else if (deployApplication) {
application = await resolveAppApplication(options, {dryRun, reporter})
;({application, created: appCreated} = await resolveAppApplication(options, {dryRun, reporter}))
}

await checkBuild(reporter, {
Expand Down Expand Up @@ -225,7 +227,7 @@ async function runAppDeployment(
// Workbench apps deploy to Brett; plain coreApps use user-applications.
if (workbench && organizationId) {
const {applicationId} = await deployWorkbenchCoreApp({
appId: getAppId(cliConfig),
appId,
interfaces: buildExposes(workbench, {
appName: workbench.name,
appTitle,
Expand All @@ -240,13 +242,25 @@ async function runAppDeployment(
title: appTitle,
version,
})
logAppDeployed({applicationId, cliConfig, organizationId, output, title: appTitle})
logAppDeployed({
applicationId,
cliConfig,
created: !appId,

@mttdnt mttdnt Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For both deploy app and studio I am seeing many places the action signal can come from. In this case either from the appId, checkAppTarget or resolveAppApplication. If possible, could these be consolidated to a single source?

organizationId,
output,
title: appTitle,
})
return {
applicationType: 'coreApp',
applicationVersion: version,
...(exposes.length > 0 ? {exposes} : {}),
...(workbench.isSingleton === undefined ? {} : {isSingleton: workbench.isSingleton}),
target: {applicationId, title: appTitle, url: getCoreAppUrl(organizationId, applicationId)},
target: {
action: appId ? 'update' : 'create',
applicationId,
title: appTitle,
url: getCoreAppUrl(organizationId, applicationId),
},
}
}

Expand All @@ -259,6 +273,7 @@ async function runAppDeployment(
logAppDeployed({
applicationId: application.id,
cliConfig,
created: appCreated,
organizationId: application.organizationId,
output,
title: application.title,
Expand All @@ -267,6 +282,7 @@ async function runAppDeployment(
applicationType: 'coreApp',
applicationVersion: version,
target: {
action: appCreated ? 'create' : 'update',
applicationId: application.id,
title: application.title ?? null,
url: getCoreAppUrl(application.organizationId, application.id),
Expand All @@ -281,15 +297,15 @@ async function runAppDeployment(
async function resolveAppApplication(
options: DeployAppOptions,
{dryRun, reporter}: {dryRun: boolean; reporter: CheckReporter},
): Promise<UserApplicationResolved | null> {
): Promise<{application: UserApplicationResolved | null; created: boolean}> {
const {cliConfig, flags, output} = options
const organizationId = cliConfig.app?.organizationId ?? ''
// Create name from --title or `app.title` config; blank falls back to the prompt
const title = flags.title?.trim() || cliConfig.app?.title?.trim() || undefined

if (dryRun) {
await checkAppTarget(reporter, {appId: getAppId(cliConfig), organizationId, title})
return null
return {application: null, created: false}
}

let application = await findUserApplication({
Expand All @@ -305,9 +321,10 @@ async function resolveAppApplication(
deployDebug('No user application found. Creating a new one')
application = await createUserApplication(organizationId, title)
deployDebug('User application created', application)
return {application, created: true}
}

return application
return {application, created: false}
}

/** Syncs the application title from the manifest when it has changed. */
Expand Down Expand Up @@ -382,23 +399,34 @@ async function shipAppDeployment({
export function logAppDeployed({
applicationId,
cliConfig,
created,
organizationId,
output,
title,
}: {
applicationId: string
cliConfig: DeployAppOptions['cliConfig']
created: boolean
organizationId: string
output: DeployAppOptions['output']
title: string | null
}): void {
const url = getCoreAppUrl(organizationId, applicationId)
const named = title ? ` — "${title}"` : ''
output.log(`\nSuccess! Application deployed to ${styleText('cyan', url)}${named}`)
output.log(created ? 'Created a new application.' : 'Updated the existing application.')

if (getAppId(cliConfig)) return

output.log(`\n════ ${styleText('bold', 'Next step:')} ════`)
if (created) {
output.log(
styleText(
'yellow',
'\nDeploying again without `deployment.appId` creates another new application.',
),
)
}
output.log(
styleText('bold', '\nAdd the deployment.appId to your sanity.cli.js or sanity.cli.ts file:'),
)
Expand Down
14 changes: 11 additions & 3 deletions packages/@sanity/cli/src/actions/deploy/deployChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type DeployCheckStatus = 'fail' | 'pass' | 'skip' | 'warn'
* can't drift.
*/
export interface DeployTarget {
/** Whether the deploy creates a new application/studio or updates an existing one. */
action: 'create' | 'update'
/** The application the deploy targets; `null` when a deploy would create one. */
applicationId: string | null
/** The application's title; `null` when it has none (or isn't created yet). */
Expand Down Expand Up @@ -255,7 +257,12 @@ export function describeAppTarget(
return {
message: `Deploys to existing application "${title}" at ${url}`,
status: 'pass',
target: {applicationId: application.id, title: application.title ?? null, url},
target: {
action: 'update',
applicationId: application.id,
title: application.title ?? null,
url,
},
}
}
case 'invalid': {
Expand All @@ -279,7 +286,7 @@ export function describeAppTarget(
return {
message: `Would create a new application "${title}"`,
status: 'pass',
target: {applicationId: null, title, url: null},
target: {action: 'create', applicationId: null, title, url: null},
}
}
return {
Expand Down Expand Up @@ -357,6 +364,7 @@ export function describeStudioTarget(
message: `Deploys to existing studio ${url}`,
status: 'pass',
target: {
action: 'update',
applicationId: resolution.application.id,
title: resolution.application.title ?? null,
url,
Expand Down Expand Up @@ -392,7 +400,7 @@ export function describeStudioTarget(
status: 'pass',
// `title || null`, not `?? null`, so target.title tracks the same
// truthiness the message's `titled` suffix uses (an empty title is no title)
target: {applicationId: null, title: title || null, url},
target: {action: 'create', applicationId: null, title: title || null, url},
}
}
}
Expand Down
Loading
Loading