Skip to content

Commit 61fbd15

Browse files
committed
refactor(deploy): unify core app and studio deploy on a shared runner
Both flows now drive a single runDeploy skeleton (fail-fast reporter + one normalized error handler) and read as the same top-to-bottom sequence of reported steps, with the ship/title/schema work pulled into named helpers. Removes the drift between the two deploy paths and their error handling.
1 parent 109ecde commit 61fbd15

3 files changed

Lines changed: 263 additions & 195 deletions

File tree

packages/@sanity/cli/src/actions/deploy/deployApp.ts

Lines changed: 112 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {basename, dirname} from 'node:path'
22
import {styleText} from 'node:util'
33
import {createGzip} from 'node:zlib'
44

5-
import {CLIError} from '@oclif/core/errors'
65
import {exitCodes} from '@sanity/cli-core'
76
import {spinner} from '@sanity/cli-core/ux'
87
import {getWorkbench} from '@sanity/workbench-cli/deploy'
@@ -25,72 +24,42 @@ import {
2524
checkBuild,
2625
checkPackageVersion,
2726
type CheckReporter,
28-
createFailFastReporter,
2927
verifyOutputDir,
3028
} from './deployChecks.js'
3129
import {deployDebug} from './deployDebug.js'
30+
import {runDeploy} from './deployRunner.js'
3231
import {findUserApplication} from './findUserApplication.js'
3332
import {type DeployAppOptions} from './types.js'
3433

35-
type Workbench = ReturnType<typeof getWorkbench>
34+
export function deployApp(options: DeployAppOptions): Promise<void> {
35+
return runDeploy(options, {run: runAppDeployment, type: 'coreApp'})
36+
}
3637

37-
export async function deployApp(options: DeployAppOptions): Promise<void> {
38-
const {cliConfig, output} = options
38+
/**
39+
* Validates the deploy, syncs the title from the manifest, and ships the build.
40+
* Every step reports through `reporter`, and the first failure exits — so
41+
* reaching any later step means the earlier ones passed.
42+
*/
43+
async function runAppDeployment(options: DeployAppOptions, reporter: CheckReporter): Promise<void> {
44+
const {cliConfig, flags, output, sourceDir} = options
45+
const workDir = options.projectRoot.directory
46+
const organizationId = cliConfig.app?.organizationId
3947
const workbench = getWorkbench(cliConfig)
4048

4149
// A federated app with no entry, view or service would ship a remote with
42-
// nothing to load — fail before any prompts or API calls.
50+
// nothing to load — reported first so it fails before any prompt or API call.
4351
if (workbench) {
4452
try {
4553
workbench.assertDeployable()
4654
} catch (err) {
47-
output.error(getErrorMessage(err), {exit: exitCodes.USAGE_ERROR})
48-
return
49-
}
50-
}
51-
52-
try {
53-
const reporter = createFailFastReporter(output)
54-
await createAppDeployment(options, reporter, workbench)
55-
} catch (error) {
56-
// Don't throw a generic error when the user cancels a prompt
57-
if (error.name === 'ExitPromptError') {
58-
output.error('Deployment cancelled by user', {exit: 1})
59-
return
60-
}
61-
if (error instanceof CLIError) {
62-
const {message, ...errorOptions} = error
63-
output.error(message, {...errorOptions, exit: 1})
64-
return
55+
reporter.report({
56+
exitCode: exitCodes.USAGE_ERROR,
57+
message: getErrorMessage(err),
58+
name: 'deployable',
59+
status: 'fail',
60+
})
6561
}
66-
deployDebug('Error deploying application', error)
67-
output.error(`Error deploying application: ${error}`, {exit: 1})
6862
}
69-
}
70-
71-
interface AppDeployment {
72-
application: UserApplication | null
73-
isAutoUpdating: boolean
74-
manifest: CoreAppManifest | undefined
75-
version: string | null
76-
}
77-
78-
/**
79-
* Validates the deploy, syncs the title from the manifest, and ships the build.
80-
*
81-
* Every step reports through `reporter`. In a real deploy a failed check exits
82-
* immediately, so reaching any later step means the earlier ones passed. A dry
83-
* run collects the failures instead and returns before shipping (the guard
84-
* below), so the steps read as one straight sequence in both modes.
85-
*/
86-
async function createAppDeployment(
87-
options: DeployAppOptions,
88-
reporter: CheckReporter,
89-
workbench: Workbench,
90-
): Promise<AppDeployment> {
91-
const {cliConfig, flags, output, projectRoot, sourceDir} = options
92-
const workDir = projectRoot.directory
93-
const organizationId = cliConfig.app?.organizationId
9463

9564
const isAutoUpdating = checkAutoUpdates(reporter, {cliConfig, flags})
9665

@@ -145,39 +114,84 @@ async function createAppDeployment(
145114
})
146115
}
147116

148-
// Sync the application title from the manifest when it has changed
149-
const titleUpdate = application ? resolveTitleUpdate(manifest, application) : null
150-
if (application && titleUpdate) {
151-
deployDebug('Updating application title from manifest', titleUpdate)
152-
output.log(
153-
titleUpdate.from
154-
? `Updating title from "${titleUpdate.from}" to "${titleUpdate.to}"`
155-
: `Setting application title to "${titleUpdate.to}"`,
156-
)
157-
const spin = spinner('Updating application title').start()
158-
try {
159-
application = await updateUserApplication({
160-
applicationId: application.id,
161-
appType: 'coreApp',
162-
body: {title: titleUpdate.to},
163-
})
164-
spin.succeed()
165-
} catch (err) {
166-
spin.fail()
167-
const message = getErrorMessage(err)
168-
deployDebug('Error updating application title', {message})
169-
output.warn(`Error updating application title: ${message}`)
170-
}
117+
// A real deploy has already exited if anything failed; landing here without a
118+
// resolved application or version means the deploy target was never resolved.
119+
if (!application || !version) return
120+
121+
application = await syncApplicationTitle({application, manifest, output})
122+
await shipAppDeployment({application, isAutoUpdating, manifest, sourceDir, version})
123+
124+
logAppDeployed({application, cliConfig, output})
125+
}
126+
127+
/** Finds the application a deploy targets, creating one when none is configured. */
128+
async function resolveAppApplication(options: DeployAppOptions): Promise<UserApplication | null> {
129+
const {cliConfig, output} = options
130+
const organizationId = cliConfig.app?.organizationId ?? ''
131+
132+
let application = await findUserApplication({cliConfig, organizationId, output})
133+
deployDebug('User application found', application)
134+
135+
if (!application) {
136+
deployDebug('No user application found. Creating a new one')
137+
application = await createUserApplication(organizationId)
138+
deployDebug('User application created', application)
171139
}
172140

173-
// A real deploy has already exited if anything failed; landing here without a
174-
// resolved application or version means a dry run collected problems — stop
175-
// before shipping.
176-
if (!application || !version) return {application, isAutoUpdating, manifest, version}
141+
return application
142+
}
177143

178-
const parentDir = dirname(sourceDir)
179-
const base = basename(sourceDir)
180-
const tarball = pack(parentDir, {entries: [base]}).pipe(createGzip())
144+
/** Syncs the application title from the manifest when it has changed. */
145+
async function syncApplicationTitle({
146+
application,
147+
manifest,
148+
output,
149+
}: {
150+
application: UserApplication
151+
manifest: CoreAppManifest | undefined
152+
output: DeployAppOptions['output']
153+
}): Promise<UserApplication> {
154+
const titleUpdate = resolveTitleUpdate(manifest, application)
155+
if (!titleUpdate) return application
156+
157+
deployDebug('Updating application title from manifest', titleUpdate)
158+
output.log(
159+
titleUpdate.from
160+
? `Updating title from "${titleUpdate.from}" to "${titleUpdate.to}"`
161+
: `Setting application title to "${titleUpdate.to}"`,
162+
)
163+
const spin = spinner('Updating application title').start()
164+
try {
165+
const updated = await updateUserApplication({
166+
applicationId: application.id,
167+
appType: 'coreApp',
168+
body: {title: titleUpdate.to},
169+
})
170+
spin.succeed()
171+
return updated
172+
} catch (err) {
173+
spin.fail()
174+
const message = getErrorMessage(err)
175+
deployDebug('Error updating application title', {message})
176+
output.warn(`Error updating application title: ${message}`)
177+
return application
178+
}
179+
}
180+
181+
async function shipAppDeployment({
182+
application,
183+
isAutoUpdating,
184+
manifest,
185+
sourceDir,
186+
version,
187+
}: {
188+
application: UserApplication
189+
isAutoUpdating: boolean
190+
manifest: CoreAppManifest | undefined
191+
sourceDir: string
192+
version: string
193+
}): Promise<void> {
194+
const tarball = pack(dirname(sourceDir), {entries: [basename(sourceDir)]}).pipe(createGzip())
181195

182196
const spin = spinner('Deploying...').start()
183197
try {
@@ -194,15 +208,26 @@ async function createAppDeployment(
194208
throw error
195209
}
196210
spin.succeed()
211+
}
197212

213+
function logAppDeployed({
214+
application,
215+
cliConfig,
216+
output,
217+
}: {
218+
application: UserApplication
219+
cliConfig: DeployAppOptions['cliConfig']
220+
output: DeployAppOptions['output']
221+
}): void {
198222
output.log(`\n🚀 ${styleText('bold', 'Success!')} Application deployed`)
199223

200-
if (!getAppId(cliConfig)) {
201-
output.log(`\n════ ${styleText('bold', 'Next step:')} ════`)
202-
output.log(
203-
styleText('bold', '\nAdd the deployment.appId to your sanity.cli.js or sanity.cli.ts file:'),
204-
)
205-
output.log(`
224+
if (getAppId(cliConfig)) return
225+
226+
output.log(`\n════ ${styleText('bold', 'Next step:')} ════`)
227+
output.log(
228+
styleText('bold', '\nAdd the deployment.appId to your sanity.cli.js or sanity.cli.ts file:'),
229+
)
230+
output.log(`
206231
${styleText(
207232
'dim',
208233
`app: {
@@ -215,22 +240,4 @@ ${styleText(
215240
appId: '${application.id}',
216241
}\n`,
217242
)}`)
218-
}
219-
220-
return {application, isAutoUpdating, manifest, version}
221-
}
222-
223-
async function resolveAppApplication(options: DeployAppOptions): Promise<UserApplication | null> {
224-
const {cliConfig, output} = options
225-
const organizationId = cliConfig.app?.organizationId ?? ''
226-
let application = await findUserApplication({cliConfig, organizationId, output})
227-
deployDebug('User application found', application)
228-
229-
if (!application) {
230-
deployDebug('No user application found. Creating a new one')
231-
application = await createUserApplication(organizationId)
232-
deployDebug('User application created', application)
233-
}
234-
235-
return application
236243
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {CLIError} from '@oclif/core/errors'
2+
import {type Output} from '@sanity/cli-core'
3+
4+
import {type CheckReporter, createFailFastReporter} from './deployChecks.js'
5+
import {deployDebug} from './deployDebug.js'
6+
import {type DeployAppOptions} from './types.js'
7+
8+
/**
9+
* A deploy flow, split into the parts that differ between core apps and studios.
10+
* Everything the two share — reporter setup and error handling — lives in
11+
* `runDeploy`, so both types read as the same sequence.
12+
*/
13+
export interface DeploySpec {
14+
/** The step sequence; every step reports through `reporter`. */
15+
run: (options: DeployAppOptions, reporter: CheckReporter) => Promise<void>
16+
type: 'coreApp' | 'studio'
17+
}
18+
19+
/**
20+
* Runs a deploy flow: the steps report through a fail-fast reporter — the first
21+
* failure prints and exits — and any escaping error is normalized to an exit
22+
* code.
23+
*/
24+
export async function runDeploy(options: DeployAppOptions, spec: DeploySpec): Promise<void> {
25+
const {output} = options
26+
27+
try {
28+
await spec.run(options, createFailFastReporter(output))
29+
} catch (error) {
30+
normalizeDeployError(error, output, spec.type)
31+
}
32+
}
33+
34+
function normalizeDeployError(error: unknown, output: Output, type: 'coreApp' | 'studio'): void {
35+
const noun = type === 'coreApp' ? 'application' : 'studio'
36+
37+
// Ctrl+C on an interactive prompt isn't a real failure
38+
if (error instanceof Error && error.name === 'ExitPromptError') {
39+
output.error('Deployment cancelled by user', {exit: 1})
40+
return
41+
}
42+
// A failed check already carries its own exit code; keep it
43+
if (error instanceof CLIError) {
44+
output.error(error.message, {exit: error.oclif?.exit ?? 1})
45+
return
46+
}
47+
deployDebug(`Error deploying ${noun}`, error)
48+
output.error(`Error deploying ${noun}: ${error}`, {exit: 1})
49+
}

0 commit comments

Comments
 (0)