From d315921fca822ab866bfdb225c4effc38bdc3344 Mon Sep 17 00:00:00 2001 From: Carolina Gonzalez Date: Tue, 18 Feb 2025 11:34:19 -0500 Subject: [PATCH] feat(cli): add deploy command for non-studio applications (#8592) --- .../src/actions/init-project/initProject.ts | 12 +- packages/@sanity/cli/src/cli.ts | 18 +- packages/@sanity/cli/src/types.ts | 2 + .../@sanity/cli/src/util/resolveRootDir.ts | 17 +- .../deploy/__tests__/deployAction.test.ts | 67 +++- .../actions/deploy/__tests__/helpers.test.ts | 229 +++++++++++++- .../cli/actions/deploy/deployAction.ts | 81 +++-- .../_internal/cli/actions/deploy/helpers.ts | 289 +++++++++++++++--- .../cli/commands/app/deployCommand.ts | 37 +++ .../cli/commands/deploy/deployCommand.ts | 2 +- .../src/_internal/cli/commands/index.ts | 2 + 11 files changed, 655 insertions(+), 101 deletions(-) create mode 100644 packages/sanity/src/_internal/cli/commands/app/deployCommand.ts diff --git a/packages/@sanity/cli/src/actions/init-project/initProject.ts b/packages/@sanity/cli/src/actions/init-project/initProject.ts index 6419e01e6f8..de44f6af259 100644 --- a/packages/@sanity/cli/src/actions/init-project/initProject.ts +++ b/packages/@sanity/cli/src/actions/init-project/initProject.ts @@ -266,16 +266,20 @@ export default async function initSanity( await getOrCreateUser() } + // skip project / dataset prompting + const isCoreAppTemplate = cliFlags.template ? determineCoreAppTemplate(cliFlags.template) : false // Default to false + let introMessage = 'Fetching existing projects' if (cliFlags.quickstart) { introMessage = "Eject your existing project's Sanity configuration" } - success(introMessage) - print('') + + if (!isCoreAppTemplate) { + success(introMessage) + print('') + } const flags = await prepareFlags() - // skip project / dataset prompting - const isCoreAppTemplate = cliFlags.template ? determineCoreAppTemplate(cliFlags.template) : false // Default to false // We're authenticated, now lets select or create a project (for studios) or org (for core apps) const {projectId, displayName, isFirstProject, datasetName, schemaUrl, organizationId} = diff --git a/packages/@sanity/cli/src/cli.ts b/packages/@sanity/cli/src/cli.ts index 67bc4f130d0..6273f19d7d9 100755 --- a/packages/@sanity/cli/src/cli.ts +++ b/packages/@sanity/cli/src/cli.ts @@ -47,16 +47,17 @@ export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string} const args = parseArguments() const isInit = args.groupOrCommand === 'init' && args.argsWithoutOptions[0] !== 'plugin' + const isCoreApp = args.groupOrCommand === 'app' const cwd = getCurrentWorkingDirectory() let workDir: string | undefined try { - workDir = isInit ? process.cwd() : resolveRootDir(cwd) + workDir = isInit ? process.cwd() : resolveRootDir(cwd, isCoreApp) } catch (err) { console.error(chalk.red(err.message)) process.exit(1) } - loadAndSetEnvFromDotEnvFiles({workDir, cmd: args.groupOrCommand}) + loadAndSetEnvFromDotEnvFiles({workDir, cmd: args.groupOrCommand, isCoreApp}) maybeFixMissingWindowsEnvVar() // Check if there are updates available for the CLI, and notify if there is @@ -99,6 +100,7 @@ export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string} corePath: await getCoreModulePath(workDir, cliConfig), cliConfig, telemetry, + isCoreApp, } warnOnNonProductionEnvironment() @@ -274,7 +276,15 @@ function warnOnNonProductionEnvironment(): void { ) } -function loadAndSetEnvFromDotEnvFiles({workDir, cmd}: {workDir: string; cmd: string}) { +function loadAndSetEnvFromDotEnvFiles({ + workDir, + cmd, + isCoreApp, +}: { + workDir: string + cmd: string + isCoreApp: boolean +}) { /* eslint-disable no-process-env */ // Do a cheap lookup for a sanity.json file. If there is one, assume it is a v2 project, @@ -309,7 +319,7 @@ function loadAndSetEnvFromDotEnvFiles({workDir, cmd}: {workDir: string; cmd: str debug('Loading environment files using %s mode', mode) - const studioEnv = loadEnv(mode, workDir, ['SANITY_STUDIO_']) + const studioEnv = loadEnv(mode, workDir, isCoreApp ? ['VITE_'] : ['SANITY_STUDIO_']) process.env = {...process.env, ...studioEnv} /* eslint-disable no-process-env */ } diff --git a/packages/@sanity/cli/src/types.ts b/packages/@sanity/cli/src/types.ts index 9493f27902a..76e8a5426b3 100644 --- a/packages/@sanity/cli/src/types.ts +++ b/packages/@sanity/cli/src/types.ts @@ -147,6 +147,7 @@ export interface CommandRunnerOptions { workDir: string corePath: string | undefined telemetry: TelemetryLogger + isCoreApp: boolean } export interface CliOutputter { @@ -354,6 +355,7 @@ export interface CliConfig { __experimental_coreAppConfiguration?: { organizationId?: string appLocation?: string + appId?: string } } diff --git a/packages/@sanity/cli/src/util/resolveRootDir.ts b/packages/@sanity/cli/src/util/resolveRootDir.ts index f752d2c682a..774c3a940ab 100644 --- a/packages/@sanity/cli/src/util/resolveRootDir.ts +++ b/packages/@sanity/cli/src/util/resolveRootDir.ts @@ -7,26 +7,27 @@ import {debug} from '../debug' /** * Resolve project root directory, falling back to cwd if it cannot be found */ -export function resolveRootDir(cwd: string): string { +export function resolveRootDir(cwd: string, isCoreApp = false): string { try { - return resolveProjectRoot(cwd) || cwd + return resolveProjectRoot(cwd, 0, isCoreApp) || cwd } catch (err) { throw new Error(`Error occurred trying to resolve project root:\n${err.message}`) } } -function hasStudioConfig(basePath: string): boolean { +function hasSanityConfig(basePath: string, configName: string): boolean { const buildConfigs = [ - fileExists(path.join(basePath, 'sanity.config.js')), - fileExists(path.join(basePath, 'sanity.config.ts')), + fileExists(path.join(basePath, `${configName}.js`)), + fileExists(path.join(basePath, `${configName}.ts`)), isSanityV2StudioRoot(basePath), ] return buildConfigs.some(Boolean) } -function resolveProjectRoot(basePath: string, iterations = 0): string | false { - if (hasStudioConfig(basePath)) { +function resolveProjectRoot(basePath: string, iterations = 0, isCoreApp = false): string | false { + const configName = isCoreApp ? 'sanity.cli' : 'sanity.config' + if (hasSanityConfig(basePath, configName)) { return basePath } @@ -36,7 +37,7 @@ function resolveProjectRoot(basePath: string, iterations = 0): string | false { return false } - return resolveProjectRoot(parentDir, iterations + 1) + return resolveProjectRoot(parentDir, iterations + 1, isCoreApp) } function isSanityV2StudioRoot(basePath: string): boolean { diff --git a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts index c93c71238b1..42afee786b8 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts @@ -1,6 +1,6 @@ import zlib from 'node:zlib' -import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' +import {type CliCommandArguments, type CliCommandContext, type CliConfig} from '@sanity/cli' import tar from 'tar-fs' import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest' @@ -36,6 +36,7 @@ describe('deployStudioAction', () => { updatedAt: new Date().toISOString(), urlType: 'internal', projectId: 'example', + organizationId: null, title: null, type: 'studio', } @@ -71,7 +72,7 @@ describe('deployStudioAction', () => { // Mock utility functions helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') - helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication) + helpers.getOrCreateStudio.mockResolvedValueOnce(mockApplication) helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'}) buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) tarPackMock.mockReturnValue({pipe: vi.fn(() => 'tarball')} as unknown as ReturnType< @@ -99,7 +100,7 @@ describe('deployStudioAction', () => { expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( expect.stringContaining('customSourceDir'), ) - expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith( + expect(helpers.getOrCreateStudio).toHaveBeenCalledWith( expect.objectContaining({ client: expect.anything(), context: expect.anything(), @@ -111,6 +112,7 @@ describe('deployStudioAction', () => { version: 'vX', isAutoUpdating: false, tarball: 'tarball', + isCoreApp: false, }) expect(mockContext.output.print).toHaveBeenCalledWith( @@ -167,6 +169,7 @@ describe('deployStudioAction', () => { version: 'vX', isAutoUpdating: false, tarball: 'tarball', + isCoreApp: false, }) expect(mockContext.output.print).toHaveBeenCalledWith( @@ -183,7 +186,7 @@ describe('deployStudioAction', () => { true, ) // User confirms to proceed helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') - helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication) + helpers.getOrCreateStudio.mockResolvedValueOnce(mockApplication) helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'}) buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) tarPackMock.mockReturnValue({pipe: vi.fn(() => 'tarball')} as unknown as ReturnType< @@ -267,7 +270,7 @@ describe('deployStudioAction', () => { // Mock utility functions helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') - helpers.getOrCreateUserApplication.mockRejectedValueOnce({ + helpers.getOrCreateStudio.mockRejectedValueOnce({ statusCode: 402, message: 'Application limit reached', error: 'Payment Required', @@ -289,7 +292,7 @@ describe('deployStudioAction', () => { expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( expect.stringContaining('customSourceDir'), ) - expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith( + expect(helpers.getOrCreateStudio).toHaveBeenCalledWith( expect.objectContaining({ client: expect.anything(), context: expect.anything(), @@ -299,4 +302,56 @@ describe('deployStudioAction', () => { expect(mockContext.output.error).toHaveBeenCalledWith('Application limit reached') }) + + it('handles core app deployment correctly', async () => { + // Create a mock application with all required properties + const mockCoreApp: UserApplication = { + id: 'core-app-id', + appHost: 'core-app-host', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + urlType: 'internal', + projectId: null, + title: null, + type: 'coreApp', + organizationId: 'org-id', + } + + mockContext = { + ...mockContext, + cliConfig: { + // eslint-disable-next-line camelcase + __experimental_coreAppConfiguration: { + appId: 'core-app-id', + organizationId: 'org-id', + }, + } as CliConfig, + } + + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) + helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') + helpers.getOrCreateUserApplicationFromConfig.mockResolvedValueOnce(mockCoreApp) + helpers.createDeployment.mockResolvedValueOnce({location: 'https://core-app-host'}) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + tarPackMock.mockReturnValue({pipe: vi.fn(() => 'tarball')} as unknown as ReturnType< + typeof tar.pack + >) + zlibCreateGzipMock.mockReturnValue('gzipped' as unknown as ReturnType) + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + expect(helpers.getOrCreateUserApplicationFromConfig).toHaveBeenCalled() + expect(helpers.createDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + isCoreApp: true, + }), + ) + expect(mockContext.output.print).toHaveBeenCalledWith('\nSuccess! Application deployed') + }) }) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts index 10b2d5deac7..d0970b785d9 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts @@ -13,7 +13,8 @@ import { createDeployment, deleteUserApplication, dirIsEmptyOrNonExistent, - getOrCreateUserApplication, + getOrCreateCoreApplication, + getOrCreateStudio, getOrCreateUserApplicationFromConfig, } from '../helpers' @@ -35,6 +36,7 @@ const mockOutput = { error: vi.fn(), warn: vi.fn(), spinner: vi.fn(), + success: vi.fn(), } as CliCommandContext['output'] const mockPrompt = { single: vi.fn(), @@ -50,7 +52,7 @@ global.fetch = mockFetch const context = {output: mockOutput, prompt: mockPrompt} -describe('getOrCreateUserApplication', () => { +describe('getOrCreateStudio', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -60,7 +62,7 @@ describe('getOrCreateUserApplication', () => { id: 'default-app', }) - const result = await getOrCreateUserApplication({ + const result = await getOrCreateStudio({ client: mockClient, spinner: mockSpinner, context, @@ -90,7 +92,7 @@ describe('getOrCreateUserApplication', () => { ) mockClientRequest.mockResolvedValueOnce(newApp) - const result = await getOrCreateUserApplication({ + const result = await getOrCreateStudio({ client: mockClient, context, spinner: mockSpinner, @@ -117,7 +119,7 @@ describe('getOrCreateUserApplication', () => { return Promise.resolve(choices[2].value) }) - const result = await getOrCreateUserApplication({ + const result = await getOrCreateStudio({ client: mockClient, context, spinner: mockSpinner, @@ -186,7 +188,8 @@ describe('getOrCreateUserApplicationFromConfig', () => { expect(mockClientRequest).toHaveBeenNthCalledWith(2, { uri: '/user-applications', method: 'POST', - body: {appHost: 'newhost', urlType: 'internal'}, + body: {appHost: 'newhost', urlType: 'internal', type: 'studio'}, + query: {appType: 'studio'}, }) expect(result).toEqual(newApp) }) @@ -390,3 +393,217 @@ describe('checkDir', () => { ) }) }) + +describe('getOrCreateCoreApplication', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const mockContext = { + output: mockOutput, + prompt: mockPrompt, + cliConfig: { + // eslint-disable-next-line camelcase + __experimental_coreAppConfiguration: { + organizationId: 'test-org', + }, + }, + } + + it('returns an existing core application when selected from the list', async () => { + const existingApp = { + id: 'core-app-1', + appHost: 'test-org-abc123', + title: 'Existing Core App', + type: 'coreApp', + urlType: 'internal', + } + + mockClientRequest.mockResolvedValueOnce([existingApp]) // getUserApplications response + ;(mockPrompt.single as Mock).mockImplementationOnce(async ({choices}: any) => { + // Simulate selecting the existing app + return Promise.resolve(choices[2].value) + }) + + const result = await getOrCreateCoreApplication({ + client: mockClient, + context: mockContext, + spinner: mockSpinner, + }) + + expect(mockClientRequest).toHaveBeenCalledWith({ + uri: '/user-applications', + query: {organizationId: 'test-org', appType: 'coreApp'}, + }) + expect(mockPrompt.single).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Select an existing deployed application', + choices: expect.arrayContaining([ + expect.objectContaining({name: 'Create new deployed application'}), + expect.anything(), // Separator + expect.objectContaining({name: 'Existing Core App'}), + ]), + }), + ) + expect(result).toEqual(existingApp) + }) + + it('creates a new core application when no existing apps are found', async () => { + const newApp = { + id: 'new-core-app', + appHost: 'test-org-xyz789', + title: 'New Core App', + type: 'coreApp', + urlType: 'internal', + } + + mockClientRequest.mockResolvedValueOnce([]) // getUserApplications returns empty array + + // Mock the title prompt + ;(mockPrompt.single as Mock).mockImplementationOnce(async () => { + return Promise.resolve('New Core App') + }) + + // Mock the creation request + mockClientRequest.mockImplementationOnce(async ({body, query}) => { + expect(query).toEqual({organizationId: 'test-org', appType: 'coreApp'}) + expect(body).toMatchObject({ + title: 'New Core App', + type: 'coreApp', + urlType: 'internal', + }) + expect(body.appHost).toMatch(/^[a-z0-9]{12}$/) // nanoid format + return newApp + }) + + const result = await getOrCreateCoreApplication({ + client: mockClient, + context: mockContext, + spinner: mockSpinner, + }) + + expect(mockPrompt.single).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Enter a title for your application:', + validate: expect.any(Function), + }), + ) + expect(result).toEqual(newApp) + }) + + it('creates a new core application when selected from the list', async () => { + const existingApp = { + id: 'core-app-1', + appHost: 'test-org-abc123', + title: 'Existing Core App', + type: 'coreApp', + urlType: 'internal', + } + const newApp = { + id: 'new-core-app', + appHost: 'test-org-xyz789', + title: 'New Core App', + type: 'coreApp', + urlType: 'internal', + } + + mockClientRequest.mockResolvedValueOnce([existingApp]) // getUserApplications response + + // Mock selecting "Create new" + ;(mockPrompt.single as Mock).mockImplementationOnce(async ({choices}: any) => { + return Promise.resolve('new') + }) + + // Mock the title prompt + ;(mockPrompt.single as Mock).mockImplementationOnce(async () => { + return Promise.resolve('New Core App') + }) + + // Mock the creation request + mockClientRequest.mockImplementationOnce(async ({body, query}) => { + expect(query).toEqual({organizationId: 'test-org', appType: 'coreApp'}) + expect(body).toMatchObject({ + title: 'New Core App', + type: 'coreApp', + urlType: 'internal', + }) + expect(body.appHost).toMatch(/^[a-z0-9]{12}$/) // nanoid format + return newApp + }) + + const result = await getOrCreateCoreApplication({ + client: mockClient, + context: mockContext, + spinner: mockSpinner, + }) + + expect(result).toEqual(newApp) + }) + + it('retries with a new appHost if creation fails with 409', async () => { + const newApp = { + id: 'new-core-app', + appHost: 'test-org-xyz789', + title: 'New Core App', + type: 'coreApp', + urlType: 'internal', + } + + mockClientRequest.mockResolvedValueOnce([]) // getUserApplications returns empty array + + // Mock the title prompt + ;(mockPrompt.single as Mock).mockImplementationOnce(async () => { + return Promise.resolve('New Core App') + }) + + // Mock first creation attempt failing + mockClientRequest.mockRejectedValueOnce({ + statusCode: 409, + response: {body: {message: 'App host already exists'}}, + }) + + // Mock second creation attempt succeeding + mockClientRequest.mockResolvedValueOnce(newApp) + + const result = await getOrCreateCoreApplication({ + client: mockClient, + context: mockContext, + spinner: mockSpinner, + }) + + expect(mockClientRequest).toHaveBeenCalledTimes(3) // getUserApplications + 2 creation attempts + expect(result).toEqual(newApp) + }) + + it('validates that the title is not empty', async () => { + mockClientRequest.mockResolvedValueOnce([]) // getUserApplications returns empty array + + // Mock the title prompt with validation + const mockValidate = vi.fn() + ;(mockPrompt.single as Mock).mockImplementationOnce(async ({validate}: any) => { + mockValidate.mockImplementation(validate) + expect(mockValidate('')).toBe('Title is required') + expect(mockValidate('Valid Title')).toBe(true) + return Promise.resolve('Valid Title') + }) + + // Mock the creation request + mockClientRequest.mockImplementationOnce(() => + Promise.resolve({ + id: 'new-core-app', + appHost: 'test-org-xyz789', + title: 'Valid Title', + type: 'coreApp', + urlType: 'internal', + }), + ) + + await getOrCreateCoreApplication({ + client: mockClient, + context: mockContext, + spinner: mockSpinner, + }) + + expect(mockValidate).toHaveBeenCalled() + }) +}) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index 042fb803190..8b020daf29c 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -9,12 +9,14 @@ import {shouldAutoUpdate} from '../../util/shouldAutoUpdate' import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction' import {extractManifestSafe} from '../manifest/extractManifestAction' import { + type BaseConfigOptions, checkDir, createDeployment, debug, dirIsEmptyOrNonExistent, getInstalledSanityVersion, - getOrCreateUserApplication, + getOrCreateCoreApplication, + getOrCreateStudio, getOrCreateUserApplicationFromConfig, type UserApplication, } from './helpers' @@ -32,13 +34,18 @@ export default async function deployStudioAction( const customSourceDir = args.argsWithoutOptions[0] const sourceDir = path.resolve(process.cwd(), customSourceDir || path.join(workDir, 'dist')) const isAutoUpdating = shouldAutoUpdate({flags, cliConfig}) + const isCoreApp = cliConfig && '__experimental_coreAppConfiguration' in cliConfig const installedSanityVersion = await getInstalledSanityVersion() const configStudioHost = cliConfig && 'studioHost' in cliConfig && cliConfig.studioHost + const appId = + cliConfig && + '__experimental_coreAppConfiguration' in cliConfig && + cliConfig.__experimental_coreAppConfiguration?.appId const client = apiClient({ requireUser: true, - requireProject: true, + requireProject: !isCoreApp, // core apps are not project-specific }).withConfig({apiVersion: 'v2024-08-01'}) if (customSourceDir === 'graphql') { @@ -74,20 +81,27 @@ export default async function deployStudioAction( let userApplication: UserApplication try { - // If the user has provided a studioHost in the config, use that - if (configStudioHost) { - userApplication = await getOrCreateUserApplicationFromConfig({ - client, - context, - spinner, - appHost: configStudioHost, - }) + const configParams: BaseConfigOptions & { + appHost?: string + appId?: string + } = { + client, + context, + spinner, + } + + if (isCoreApp && appId) { + configParams.appId = appId + } else if (configStudioHost) { + configParams.appHost = configStudioHost + } + // If the user has provided a studioHost / appId in the config, use that + if (configStudioHost || appId) { + userApplication = await getOrCreateUserApplicationFromConfig(configParams) } else { - userApplication = await getOrCreateUserApplication({ - client, - context, - spinner, - }) + userApplication = isCoreApp + ? await getOrCreateCoreApplication({client, context, spinner}) + : await getOrCreateStudio({client, context, spinner}) } } catch (err) { if (err.message) { @@ -113,14 +127,16 @@ export default async function deployStudioAction( return } - await extractManifestSafe( - { - ...buildArgs, - extOptions: {}, - extraArguments: [], - }, - context, - ) + if (!isCoreApp) { + await extractManifestSafe( + { + ...buildArgs, + extOptions: {}, + extraArguments: [], + }, + context, + ) + } } // Ensure that the directory exists, is a directory and seems to have valid content @@ -139,7 +155,7 @@ export default async function deployStudioAction( const base = path.basename(sourceDir) const tarball = tar.pack(parentDir, {entries: [base]}).pipe(zlib.createGzip()) - spinner = output.spinner('Deploying to Sanity.Studio').start() + spinner = output.spinner(`Deploying to ${isCoreApp ? 'CORE' : 'Sanity.Studio'}`).start() try { const {location} = await createDeployment({ client, @@ -147,17 +163,24 @@ export default async function deployStudioAction( version: installedSanityVersion, isAutoUpdating, tarball, + isCoreApp, }) spinner.succeed() // And let the user know we're done - output.print(`\nSuccess! Studio deployed to ${chalk.cyan(location)}`) + output.print( + `\nSuccess! ${isCoreApp ? 'Application deployed' : `Studio deployed to ${chalk.cyan(location)}`}`, + ) - if (!configStudioHost) { - output.print(`\nAdd ${chalk.cyan(`studioHost: '${userApplication.appHost}'`)}`) - output.print('to defineCliConfig root properties in sanity.cli.js or sanity.cli.ts') - output.print('to avoid prompting for hostname on next deploy.') + if ((isCoreApp && !appId) || (!isCoreApp && !configStudioHost)) { + output.print( + `\nAdd ${chalk.cyan(isCoreApp ? `appId: '${userApplication.id}'` : `studioHost: '${userApplication.appHost}'`)}`, + ) + output.print( + `to ${isCoreApp ? '__experimental_coreAppConfiguration' : 'defineCliConfig root properties'} in sanity.cli.js or sanity.cli.ts`, + ) + output.print(`to avoid prompting ${isCoreApp ? '' : 'for hostname'} on next deploy.`) } } catch (err) { spinner.fail() diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 5bd56da3766..9a43fa552d5 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -6,6 +6,7 @@ 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' @@ -36,33 +37,43 @@ export interface ActiveDeployment { export interface UserApplication { id: string - projectId: string + projectId: string | null + organizationId: string | null title: string | null appHost: string urlType: 'internal' | 'external' createdAt: string updatedAt: string - type: 'studio' + 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 { + 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: '/user-applications', - query: appHost ? {appHost} : {default: 'true'}, - }) + return await client.request({uri, query}) } catch (e) { if (e?.statusCode === 404) { return null @@ -72,41 +83,92 @@ export async function getUserApplication({ throw e } } - export async function getUserApplications({ client, + organizationId, }: GetUserApplicationsOptions): Promise { + const query: Record = 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 application', e) + debug('Error getting user applications', e) throw e } } function createUserApplication( client: SanityClient, - body: Pick & { + body: Pick & { title?: string }, + organizationId?: string, ): Promise { - return client.request({uri: '/user-applications', method: 'POST', body}) + const query: Record = 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 { + 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 + context: Pick spinner: ReturnType } /** - * This function handles the logic for managing user applications when + * These functions handle the logic for managing user applications when * studioHost is not provided in the CLI config. * * @internal @@ -135,7 +197,7 @@ export interface GetOrCreateUserApplicationOptions { * | prompt selection | | and create new app | * +--------------------+ +------------------------+ */ -export async function getOrCreateUserApplication({ +export async function getOrCreateStudio({ client, spinner, context, @@ -151,28 +213,15 @@ export async function getOrCreateUserApplication({ return existingUserApplication } - const userApplications = await getUserApplications({client}) - - if (userApplications?.length) { - const choices = userApplications.map((app) => ({ - value: app.appHost, - name: app.appHost, - })) - - const selected = await prompt.single({ - message: 'Select existing studio hostname', - type: 'list', - choices: [ - {value: 'new', name: 'Create new studio hostname'}, - new prompt.Separator(), - ...choices, - ], - }) + const selectedApp = await selectExistingApplication({ + client, + prompt, + message: 'Select existing studio hostname', + createNewLabel: 'Create new studio hostname', + }) - // if the user selected an existing app, return it - if (selected !== 'new') { - return userApplications.find((app) => app.appHost === selected)! - } + if (selectedApp) { + return selectedApp } // otherwise, prompt the user for a hostname @@ -193,6 +242,7 @@ export async function getOrCreateUserApplication({ const response = await createUserApplication(client, { appHost, urlType: 'internal', + type: 'studio', }) resolve(response) return true @@ -213,19 +263,114 @@ export async function getOrCreateUserApplication({ } /** - * This function handles the logic for managing user applications when - * studioHost is provided in the CLI config. + * Creates a core application with an auto-generated hostname * * @internal */ -export async function getOrCreateUserApplicationFromConfig({ +export async function getOrCreateCoreApplication({ client, context, spinner, - appHost, -}: GetOrCreateUserApplicationOptions & { +}: GetOrCreateUserApplicationOptions): Promise { + const {prompt, cliConfig} = context + const organizationId = + cliConfig && + '__experimental_coreAppConfiguration' in cliConfig && + cliConfig.__experimental_coreAppConfiguration?.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() + + // 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 + spinner: ReturnType +} + +interface StudioConfigOptions extends BaseConfigOptions { appHost: string -}): Promise { +} + +interface CoreAppConfigOptions extends BaseConfigOptions { + appId?: string +} + +async function getOrCreateStudioFromConfig({ + client, + context, + spinner, + appHost, +}: StudioConfigOptions): Promise { const {output} = context // if there is already an existing user-app, then just return it const existingUserApplication = await getUserApplication({client, appHost}) @@ -246,9 +391,9 @@ export async function getOrCreateUserApplicationFromConfig({ const response = await createUserApplication(client, { appHost, urlType: 'internal', + type: 'studio', }) spinner.succeed() - return response } catch (e) { spinner.fail() @@ -256,19 +401,75 @@ export async function getOrCreateUserApplicationFromConfig({ 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 getOrCreateCoreAppFromConfig({ + client, + context, + spinner, + appId, +}: CoreAppConfigOptions): Promise { + const {output, cliConfig} = context + const organizationId = + cliConfig && + '__experimental_coreAppConfiguration' in cliConfig && + cliConfig.__experimental_coreAppConfiguration?.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 appId provided in your configuration is not recognized.') + output.print('Checking existing applications...') + return getOrCreateCoreApplication({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 { + const {context} = options + const isCoreApp = context.cliConfig && '__experimental_coreAppConfiguration' in context.cliConfig + + if (isCoreApp) { + return getOrCreateCoreAppFromConfig(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 + isCoreApp?: boolean } export async function createDeployment({ @@ -277,6 +478,7 @@ export async function createDeployment({ applicationId, isAutoUpdating, version, + isCoreApp, }: CreateDeploymentOptions): Promise<{location: string}> { const formData = new FormData() formData.append('isAutoUpdating', isAutoUpdating.toString()) @@ -288,6 +490,7 @@ export async function createDeployment({ method: 'POST', headers: formData.getHeaders(), body: formData.pipe(new PassThrough()), + query: isCoreApp ? {appType: 'coreApp'} : {appType: 'studio'}, }) } diff --git a/packages/sanity/src/_internal/cli/commands/app/deployCommand.ts b/packages/sanity/src/_internal/cli/commands/app/deployCommand.ts new file mode 100644 index 00000000000..640a25481fa --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/app/deployCommand.ts @@ -0,0 +1,37 @@ +import { + type CliCommandArguments, + type CliCommandContext, + type CliCommandDefinition, +} from '@sanity/cli' + +import {type DeployStudioActionFlags} from '../../actions/deploy/deployAction' + +const helpText = ` +Options + --source-maps Enable source maps for built bundles (increases size of bundle) + --no-minify Skip minifying built JavaScript (speeds up build, increases size of bundle) + --no-build Don't build the application prior to deploy, instead deploying the version currently in \`dist/\` + -y, --yes Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults + +Examples + sanity deploy + sanity deploy --no-minify --source-maps +` + +const appDeployCommand: CliCommandDefinition = { + name: 'deploy', + group: 'app', + signature: '[SOURCE_DIR] [--no-build] [--source-maps] [--no-minify]', + description: 'Builds and deploys Sanity application to Sanity hosting', + action: async ( + args: CliCommandArguments, + context: CliCommandContext, + ) => { + const mod = await import('../../actions/deploy/deployAction') + + return mod.default(args, context) + }, + helpText, +} + +export default appDeployCommand diff --git a/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts b/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts index dd5f88407ac..d997c7f8a8a 100644 --- a/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts @@ -21,7 +21,7 @@ Examples const deployCommand: CliCommandDefinition = { name: 'deploy', - signature: '[SOURCE_DIR] [--no-build] [--source-maps] [--no-minify]', + signature: '[SOURCE_DIR] [--no-build] [--source-maps] [--no-minify]', description: 'Builds and deploys Sanity Studio to Sanity hosting', action: async ( args: CliCommandArguments, diff --git a/packages/sanity/src/_internal/cli/commands/index.ts b/packages/sanity/src/_internal/cli/commands/index.ts index 68f4fd4af29..31f76f491d9 100644 --- a/packages/sanity/src/_internal/cli/commands/index.ts +++ b/packages/sanity/src/_internal/cli/commands/index.ts @@ -2,6 +2,7 @@ import {type CliCommandDefinition, type CliCommandGroupDefinition} from '@sanity import appGroup from './app/appGroup' import appBuildCommand from './app/buildCommand' +import appDeployCommand from './app/deployCommand' import appDevCommand from './app/devCommand' import appStartCommand from './app/startCommand' import backupGroup from './backup/backupGroup' @@ -60,6 +61,7 @@ import usersGroup from './users/usersGroup' const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [ appGroup, + appDeployCommand, appDevCommand, appBuildCommand, appStartCommand,