diff --git a/.changeset/deploy-create-update-feedback.md b/.changeset/deploy-create-update-feedback.md new file mode 100644 index 000000000..eb75f540c --- /dev/null +++ b/.changeset/deploy-create-update-feedback.md @@ -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 diff --git a/packages/@sanity/cli/src/actions/deploy/__tests__/deployApp.test.ts b/packages/@sanity/cli/src/actions/deploy/__tests__/deployApp.test.ts index 142cbe99c..502561c15 100644 --- a/packages/@sanity/cli/src/actions/deploy/__tests__/deployApp.test.ts +++ b/packages/@sanity/cli/src/actions/deploy/__tests__/deployApp.test.ts @@ -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', @@ -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'") + }) }) diff --git a/packages/@sanity/cli/src/actions/deploy/__tests__/deployChecks.test.ts b/packages/@sanity/cli/src/actions/deploy/__tests__/deployChecks.test.ts index 6a44e281f..5879f235f 100644 --- a/packages/@sanity/cli/src/actions/deploy/__tests__/deployChecks.test.ts +++ b/packages/@sanity/cli/src/actions/deploy/__tests__/deployChecks.test.ts @@ -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', @@ -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', @@ -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') }) @@ -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 () => { @@ -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 () => { @@ -422,6 +431,7 @@ describe('checkAppTarget (workbench backend)', () => { 'Would create a new application "New App" with slug "drop-desk"', ) expect(reporter.results[0]?.target).toEqual({ + action: 'create', applicationId: null, slug: 'drop-desk', title: 'New App', @@ -445,6 +455,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') }) diff --git a/packages/@sanity/cli/src/actions/deploy/__tests__/deployRunner.test.ts b/packages/@sanity/cli/src/actions/deploy/__tests__/deployRunner.test.ts index 50f4d6355..f39c4daa4 100644 --- a/packages/@sanity/cli/src/actions/deploy/__tests__/deployRunner.test.ts +++ b/packages/@sanity/cli/src/actions/deploy/__tests__/deployRunner.test.ts @@ -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 () => [], diff --git a/packages/@sanity/cli/src/actions/deploy/__tests__/deploymentPlan.test.ts b/packages/@sanity/cli/src/actions/deploy/__tests__/deploymentPlan.test.ts index 236da86e4..a135a3f6d 100644 --- a/packages/@sanity/cli/src/actions/deploy/__tests__/deploymentPlan.test.ts +++ b/packages/@sanity/cli/src/actions/deploy/__tests__/deploymentPlan.test.ts @@ -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', diff --git a/packages/@sanity/cli/src/actions/deploy/__tests__/findUserApplication.test.ts b/packages/@sanity/cli/src/actions/deploy/__tests__/findUserApplication.test.ts index 35115c589..e17c5231b 100644 --- a/packages/@sanity/cli/src/actions/deploy/__tests__/findUserApplication.test.ts +++ b/packages/@sanity/cli/src/actions/deploy/__tests__/findUserApplication.test.ts @@ -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) @@ -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}) }) }) diff --git a/packages/@sanity/cli/src/actions/deploy/deployApp.ts b/packages/@sanity/cli/src/actions/deploy/deployApp.ts index c2fcb7567..877f6b04f 100644 --- a/packages/@sanity/cli/src/actions/deploy/deployApp.ts +++ b/packages/@sanity/cli/src/actions/deploy/deployApp.ts @@ -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'] @@ -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, @@ -121,13 +123,13 @@ 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, slug: workbench.slug, title: appTitle, }) } else if (deployApplication) { - application = await resolveAppApplication(options, {dryRun, reporter}) + ;({application, created: appCreated} = await resolveAppApplication(options, {dryRun, reporter})) } await checkBuild(reporter, { @@ -243,13 +245,21 @@ async function runAppDeployment( title: appTitle, version, }) - logAppDeployed({applicationId, cliConfig, organizationId, output, title: appTitle}) + logAppDeployed({ + applicationId, + cliConfig, + created: !appId, + organizationId, + output, + title: appTitle, + }) return { applicationType: 'coreApp', applicationVersion: version, ...(exposes.length > 0 ? {exposes} : {}), ...(workbench.isSingleton === undefined ? {} : {isSingleton: workbench.isSingleton}), target: { + action: appId ? 'update' : 'create', applicationId, // A redeploy ignores the slug, so only a create reports the one it used. ...(appId ? {} : {slug}), @@ -268,6 +278,7 @@ async function runAppDeployment( logAppDeployed({ applicationId: application.id, cliConfig, + created: appCreated, organizationId: application.organizationId, output, title: application.title, @@ -276,6 +287,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), @@ -290,7 +302,7 @@ async function runAppDeployment( async function resolveAppApplication( options: DeployAppOptions, {dryRun, reporter}: {dryRun: boolean; reporter: CheckReporter}, -): Promise { +): 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 @@ -298,7 +310,7 @@ async function resolveAppApplication( if (dryRun) { await checkAppTarget(reporter, {appId: getAppId(cliConfig), organizationId, title}) - return null + return {application: null, created: false} } let application = await findUserApplication({ @@ -314,9 +326,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. */ @@ -391,12 +404,14 @@ 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 @@ -404,10 +419,19 @@ export function logAppDeployed({ 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:'), ) diff --git a/packages/@sanity/cli/src/actions/deploy/deployChecks.ts b/packages/@sanity/cli/src/actions/deploy/deployChecks.ts index 95db5ab5c..ce6cfc426 100644 --- a/packages/@sanity/cli/src/actions/deploy/deployChecks.ts +++ b/packages/@sanity/cli/src/actions/deploy/deployChecks.ts @@ -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). */ @@ -261,7 +263,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': { @@ -285,7 +292,13 @@ export function describeAppTarget( return { message: `Would create a new application "${title}"${slug ? ` with slug "${slug}"` : ''}`, status: 'pass', - target: {applicationId: null, ...(slug ? {slug} : {}), title, url: null}, + target: { + action: 'create', + applicationId: null, + ...(slug ? {slug} : {}), + title, + url: null, + }, } } return { @@ -363,6 +376,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, @@ -398,7 +412,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}, } } } diff --git a/packages/@sanity/cli/src/actions/deploy/deployStudio.ts b/packages/@sanity/cli/src/actions/deploy/deployStudio.ts index 57fa47e67..52846ad92 100644 --- a/packages/@sanity/cli/src/actions/deploy/deployStudio.ts +++ b/packages/@sanity/cli/src/actions/deploy/deployStudio.ts @@ -58,6 +58,7 @@ async function runStudioDeployment( const isWorkbenchApp = workbench !== null const projectId = cliConfig.api?.projectId const organizationId = cliConfig.app?.organizationId + const appId = getAppId(cliConfig) const dryRun = !!flags['dry-run'] // A federated app deploys through Sanity's build/hosting pipeline, which @@ -95,6 +96,7 @@ async function runStudioDeployment( // Workbench studios deploy to Brett (which needs the org); plain studios // resolve/create on user-applications, unchanged. let application: UserApplication | null = null + let studioCreated = false let studioTarget: DeployTarget | null = null if (workbench && !isExternal) { reporter.report( @@ -109,13 +111,16 @@ async function runStudioDeployment( // Both modes, so a bad appId fails before the build; its resolved URL feeds // the deploy result. studioTarget = await checkStudioTarget(reporter, { - appId: getAppId(cliConfig), + appId, isWorkbenchApp: true, studioHost: cliConfig.studioHost, title: appTitle, }) } else { - application = await resolveStudioApplication(options, {dryRun, reporter}) + ;({application, created: studioCreated} = await resolveStudioApplication(options, { + dryRun, + reporter, + })) } await checkBuild(reporter, { @@ -152,7 +157,7 @@ async function runStudioDeployment( // Workbench studios deploy to Brett; plain studios use user-applications. if (workbench && !isExternal && organizationId) { const {applicationId} = await deployWorkbenchStudio({ - appId: getAppId(cliConfig), + appId, interfaces: buildExposes(workbench, { appName: workbench.name, appTitle, @@ -174,7 +179,12 @@ async function runStudioDeployment( applicationType: 'studio', applicationVersion: version, ...(exposes.length > 0 ? {exposes} : {}), - target: {applicationId, title: appTitle, url: studioTarget?.url ?? null}, + target: { + action: appId ? 'update' : 'create', + applicationId, + title: appTitle, + url: studioTarget?.url ?? null, + }, } } @@ -191,7 +201,12 @@ async function runStudioDeployment( return { applicationType: 'studio', applicationVersion: version, - target: {applicationId: application.id, title: application.title ?? null, url: location}, + target: { + action: studioCreated ? 'create' : 'update', + applicationId: application.id, + title: application.title ?? null, + url: location, + }, } } @@ -202,27 +217,29 @@ async function runStudioDeployment( async function resolveStudioApplication( options: DeployAppOptions, {dryRun, reporter}: {dryRun: boolean; reporter: CheckReporter}, -): Promise { +): Promise<{application: UserApplication | null; created: boolean}> { const {cliConfig, flags, output} = options const isExternal = !!flags.external + const appId = getAppId(cliConfig) // Sets the title on a newly registered studio; blank falls back to undefined const title = flags.title?.trim() || undefined if (dryRun) { await checkStudioTarget(reporter, { - appId: getAppId(cliConfig), + appId, isExternal, projectId: cliConfig.api?.projectId, studioHost: cliConfig.studioHost, title, urlFlag: flags.url, }) - return null + return {application: null, created: false} } const projectId = cliConfig.api?.projectId ?? '' - let application = await findUserApplicationForStudio({ - appId: getAppId(cliConfig), + // `created` is true when a configured-but-unregistered host was just registered. + const {application, created} = await findUserApplicationForStudio({ + appId, isExternal, output, projectId, @@ -242,16 +259,17 @@ async function resolveStudioApplication( output.log('you will need one. Please enter the subdomain you want to use.') } - application = await createStudioUserApplication({ + const registered = await createStudioUserApplication({ projectId, title, urlType: isExternal ? 'external' : 'internal', }) - deployDebug('Created user application', application) + deployDebug('Created user application', registered) + return {application: registered, created: true} } deployDebug('Found user application', application) - return application + return {application, created} } /** Extracts the studio schema and manifest and uploads them to the schema store. */ diff --git a/packages/@sanity/cli/src/actions/deploy/findUserApplication.ts b/packages/@sanity/cli/src/actions/deploy/findUserApplication.ts index 44e1f18e3..6dba5b8e1 100644 --- a/packages/@sanity/cli/src/actions/deploy/findUserApplication.ts +++ b/packages/@sanity/cli/src/actions/deploy/findUserApplication.ts @@ -90,7 +90,7 @@ interface FindUserApplicationForStudioOptions { export async function findUserApplicationForStudio( options: FindUserApplicationForStudioOptions, -): Promise { +): Promise<{application: UserApplication | null; created: boolean}> { const { appId, isExternal, @@ -118,31 +118,37 @@ export async function findUserApplicationForStudio( spin.fail() deployDebug('Error finding user application', error) output.error(`Failed to resolve deploy target: ${getErrorMessage(error)}`, {exit: 1}) - return null + return {application: null, created: false} } if (resolution.type === 'found') { spin.succeed() - return resolution.application + return {application: resolution.application, created: false} } - // The configured host isn't registered yet — a deploy registers it without prompting + // The configured host isn't registered yet — a deploy registers it without + // prompting, so this returns a newly created studio. if (resolution.type === 'would-create') { spin.succeed() - return createFromConfiguredHost({ + const application = await createFromConfiguredHost({ appHost: resolution.appHost, output, projectId, title, urlType, }) + return {application, created: application !== null} } if (resolution.type === 'needs-input' && !unattended) { spin.succeed() // Nothing to select from — the caller prompts for a brand new host - if (resolution.existing.length === 0) return null - return promptForExistingStudio({existing: resolution.existing, urlType}) + if (resolution.existing.length === 0) return {application: null, created: false} + // A selected existing studio is an update; "new" returns null and the caller registers it. + return { + application: await promptForExistingStudio({existing: resolution.existing, urlType}), + created: false, + } } spin.fail() @@ -150,10 +156,10 @@ export async function findUserApplicationForStudio( // needs an explicit exit here if (resolution.type === 'blocked') { output.error(resolution.message, {exit: 1}) - return null + return {application: null, created: false} } createFailFastReporter(output).report(describeStudioTarget(resolution, {isExternal})) - return null + return {application: null, created: false} } /** diff --git a/packages/@sanity/cli/test/integration/commands/deploy.app.test.ts b/packages/@sanity/cli/test/integration/commands/deploy.app.test.ts index 8e886d2ee..b7a2c6467 100644 --- a/packages/@sanity/cli/test/integration/commands/deploy.app.test.ts +++ b/packages/@sanity/cli/test/integration/commands/deploy.app.test.ts @@ -374,6 +374,7 @@ describe('#deploy app', () => { const plan = JSON.parse(stdout) expect(plan.isDeployable).toBe(true) expect(plan.target).toEqual({ + action: 'create', applicationId: null, slug: 'drop-desk-host', title: 'Workbench App',