diff --git a/packages/@sanity/cli/src/types.ts b/packages/@sanity/cli/src/types.ts index f22defc74ec..ac85b2d5666 100644 --- a/packages/@sanity/cli/src/types.ts +++ b/packages/@sanity/cli/src/types.ts @@ -312,6 +312,8 @@ export interface CliConfig { vite?: UserViteConfig autoUpdates?: boolean + + studioHost?: string } export type UserViteConfig = diff --git a/packages/sanity/package.json b/packages/sanity/package.json index c9fe5ca964c..3706f19ef6f 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -207,6 +207,7 @@ "esbuild-register": "^3.5.0", "execa": "^2.0.0", "exif-component": "^1.0.1", + "form-data": "^4.0.0", "framer-motion": "11.0.8", "get-it": "^8.6.4", "get-random-values-esm": "1.0.2", 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 new file mode 100644 index 00000000000..493ad21f0cf --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts @@ -0,0 +1,295 @@ +import zlib from 'node:zlib' + +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' +import tar from 'tar-fs' + +import buildSanityStudio from '../../build/buildAction' +import deployStudioAction, {type DeployStudioActionFlags} from '../deployAction' +import * as _helpers from '../helpers' +import {type UserApplication} from '../helpers' + +// Mock dependencies +jest.mock('tar-fs') +jest.mock('node:zlib') +jest.mock('../helpers') +jest.mock('../../build/buildAction') + +type Helpers = typeof _helpers +const helpers = _helpers as unknown as {[K in keyof Helpers]: jest.Mock} +const buildSanityStudioMock = buildSanityStudio as jest.Mock +const tarPackMock = tar.pack as jest.Mock +const zlibCreateGzipMock = zlib.createGzip as jest.Mock +type SpinnerInstance = { + start: jest.Mock<() => SpinnerInstance> + succeed: jest.Mock<() => SpinnerInstance> + fail: jest.Mock<() => SpinnerInstance> +} + +describe('deployStudioAction', () => { + let mockContext: CliCommandContext + let spinnerInstance: SpinnerInstance + + const mockApplication: UserApplication = { + id: 'app-id', + appHost: 'app-host', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + urlType: 'internal', + projectId: 'example', + title: null, + type: 'studio', + } + + beforeEach(() => { + jest.clearAllMocks() + + spinnerInstance = { + start: jest.fn(() => spinnerInstance), + succeed: jest.fn(() => spinnerInstance), + fail: jest.fn(() => spinnerInstance), + } + + mockContext = { + apiClient: jest.fn().mockReturnValue({ + withConfig: jest.fn().mockReturnThis(), + }), + workDir: '/fake/work/dir', + chalk: {cyan: jest.fn((str) => str), red: jest.fn((str) => str)}, + output: { + error: jest.fn((str) => str), + print: jest.fn(), + spinner: jest.fn().mockReturnValue(spinnerInstance), + }, + prompt: {single: jest.fn()}, + cliConfig: {}, + } as unknown as CliCommandContext + }) + + it('builds and deploys the studio if the directory is empty', async () => { + const mockSpinner = mockContext.output.spinner('') + + // Mock utility functions + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) + helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') + helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication) + helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'}) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')}) + zlibCreateGzipMock.mockReturnValue('gzipped') + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + // Check that buildSanityStudio was called + expect(buildSanityStudioMock).toHaveBeenCalledWith( + expect.objectContaining({ + extOptions: {build: true}, + argsWithoutOptions: ['customSourceDir'], + }), + mockContext, + {basePath: '/'}, + ) + expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( + expect.stringContaining('customSourceDir'), + ) + expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith( + expect.objectContaining({ + client: expect.anything(), + context: expect.anything(), + }), + ) + expect(helpers.createDeployment).toHaveBeenCalledWith({ + client: expect.anything(), + applicationId: 'app-id', + version: 'vX', + isAutoUpdating: false, + tarball: 'tarball', + }) + + expect(mockContext.output.print).toHaveBeenCalledWith( + '\nSuccess! Studio deployed to https://app-host.sanity.studio', + ) + expect(mockSpinner.succeed).toHaveBeenCalled() + }) + + it('builds and deploys the studio if the directory is empty and hostname in config', async () => { + const mockSpinner = mockContext.output.spinner('') + mockContext.cliConfig = {studioHost: 'app-host'} + + // Mock utility functions + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) + helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') + helpers.getOrCreateUserApplicationFromConfig.mockResolvedValueOnce(mockApplication) + helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'}) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')}) + zlibCreateGzipMock.mockReturnValue('gzipped') + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + // Check that buildSanityStudio was called + expect(buildSanityStudioMock).toHaveBeenCalledWith( + expect.objectContaining({ + extOptions: {build: true}, + argsWithoutOptions: ['customSourceDir'], + }), + mockContext, + {basePath: '/'}, + ) + expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( + expect.stringContaining('customSourceDir'), + ) + expect(helpers.getOrCreateUserApplicationFromConfig).toHaveBeenCalledWith( + expect.objectContaining({ + client: expect.anything(), + context: expect.anything(), + appHost: 'app-host', + }), + ) + expect(helpers.createDeployment).toHaveBeenCalledWith({ + client: expect.anything(), + applicationId: 'app-id', + version: 'vX', + isAutoUpdating: false, + tarball: 'tarball', + }) + + expect(mockContext.output.print).toHaveBeenCalledWith( + '\nSuccess! Studio deployed to https://app-host.sanity.studio', + ) + expect(mockSpinner.succeed).toHaveBeenCalled() + }) + + it('prompts the user if the directory is not empty', async () => { + const mockSpinner = mockContext.output.spinner('') + + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(false) + ;( + mockContext.prompt.single as jest.Mock + ).mockResolvedValueOnce(true) // User confirms to proceed + helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') + helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication) + helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'}) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')}) + zlibCreateGzipMock.mockReturnValue('gzipped') + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( + expect.stringContaining('customSourceDir'), + ) + expect(mockContext.prompt.single).toHaveBeenCalledWith({ + type: 'confirm', + message: expect.stringContaining('is not empty, do you want to proceed?'), + default: false, + }) + expect(buildSanityStudioMock).toHaveBeenCalled() + expect(mockSpinner.start).toHaveBeenCalled() + expect(mockSpinner.succeed).toHaveBeenCalled() + }) + + it('does not proceed if build fails', async () => { + const mockSpinner = mockContext.output.spinner('') + + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: false}) + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + expect(buildSanityStudioMock).toHaveBeenCalled() + expect(helpers.createDeployment).not.toHaveBeenCalled() + expect(mockSpinner.fail).not.toHaveBeenCalled() + }) + + it('fails if the directory does not exist', async () => { + const mockSpinner = mockContext.output.spinner('') + + helpers.checkDir.mockRejectedValueOnce(new Error('Example error')) + helpers.dirIsEmptyOrNonExistent.mockResolvedValue(true) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + + await expect( + deployStudioAction( + { + argsWithoutOptions: ['nonexistentDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ), + ).rejects.toThrow('Example error') + + expect(mockSpinner.fail).toHaveBeenCalled() + }) + + it('throws an error if "graphql" is passed as a source directory', async () => { + await expect( + deployStudioAction( + { + argsWithoutOptions: ['graphql'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ), + ).rejects.toThrow('Did you mean `sanity graphql deploy`?') + }) + + it('returns an error if API responds with 402', async () => { + // Mock utility functions + helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) + helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') + helpers.getOrCreateUserApplication.mockRejectedValueOnce({ + statusCode: 402, + message: 'Application limit reached', + error: 'Payment Required', + }) + buildSanityStudioMock.mockResolvedValueOnce({didCompile: true}) + tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')}) + zlibCreateGzipMock.mockReturnValue('gzipped') + + await deployStudioAction( + { + argsWithoutOptions: ['customSourceDir'], + extOptions: {}, + } as CliCommandArguments, + mockContext, + ) + + expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith( + expect.stringContaining('customSourceDir'), + ) + expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith( + expect.objectContaining({ + client: expect.anything(), + context: expect.anything(), + spinner: expect.anything(), + }), + ) + + expect(mockContext.output.error).toHaveBeenCalledWith('Application limit reached') + }) +}) 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 new file mode 100644 index 00000000000..c634816e96a --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts @@ -0,0 +1,391 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {type Stats} from 'node:fs' +import fs from 'node:fs/promises' +import {Readable} from 'node:stream' +import {type Gzip} from 'node:zlib' + +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {type CliCommandContext} from '@sanity/cli' +import {type SanityClient} from '@sanity/client' + +import { + checkDir, + createDeployment, + deleteUserApplication, + dirIsEmptyOrNonExistent, + getOrCreateUserApplication, + getOrCreateUserApplicationFromConfig, +} from '../helpers' + +jest.mock('node:fs/promises') + +const mockFsPromises = fs as jest.Mocked +const mockFsPromisesStat = mockFsPromises.stat as jest.Mock +const mockFsPromisesReaddir = mockFsPromises.readdir as unknown as jest.Mock< + () => Promise +> + +const mockClient = { + request: jest.fn(), + config: jest.fn(), +} as unknown as SanityClient +const mockClientRequest = mockClient.request as jest.Mock + +const mockOutput = { + print: jest.fn(), + clear: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + spinner: jest.fn(), +} as CliCommandContext['output'] +const mockPrompt = { + single: jest.fn(), + Separator: jest.fn(), +} as unknown as CliCommandContext['prompt'] +const mockSpinner = { + start: jest.fn(), + succeed: jest.fn(), +} as unknown as ReturnType + +const mockFetch = jest.fn() +global.fetch = mockFetch + +const context = {output: mockOutput, prompt: mockPrompt} + +describe('getOrCreateUserApplication', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('gets the default user application if no `studioHost` is provided', async () => { + mockClientRequest.mockResolvedValueOnce({ + id: 'default-app', + }) + + const result = await getOrCreateUserApplication({ + client: mockClient, + spinner: mockSpinner, + context, + }) + expect(mockClientRequest).toHaveBeenCalledWith({ + uri: '/user-applications', + query: {default: 'true'}, + }) + expect(result).toEqual({id: 'default-app'}) + }) + + it('creates a user application by prompting the user for a name', async () => { + const newApp = { + id: 'default-app', + appHost: 'default.sanity.studio', + urlType: 'internal', + } + mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app + mockClientRequest.mockResolvedValueOnce([]) // Simulate no list of deployments + ;(mockPrompt.single as jest.Mock).mockImplementationOnce( + async ({validate}: Parameters[0]) => { + // Simulate user input and validation + const appHost = 'default.sanity.studio' + await validate!(appHost) + return appHost + }, + ) + mockClientRequest.mockResolvedValueOnce(newApp) + + const result = await getOrCreateUserApplication({ + client: mockClient, + context, + spinner: mockSpinner, + }) + + expect(mockPrompt.single).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Studio hostname (.sanity.studio):', + }), + ) + expect(result).toEqual(newApp) + }) + + it('allows user to select a user application from a list', async () => { + const existingApp = { + id: 'default-app', + appHost: 'default.sanity.studio', + urlType: 'internal', + } + mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app + mockClientRequest.mockResolvedValueOnce([existingApp]) // Simulate no list of deployments + ;(mockPrompt.single as jest.Mock).mockImplementationOnce(async ({choices}: any) => { + // Simulate user input + return Promise.resolve(choices[2].value) + }) + + const result = await getOrCreateUserApplication({ + client: mockClient, + context, + spinner: mockSpinner, + }) + + expect(mockPrompt.single).toHaveBeenCalledWith( + expect.objectContaining({ + choices: expect.arrayContaining([expect.objectContaining({name: 'default.sanity.studio'})]), + }), + ) + expect(result).toEqual(existingApp) + }) +}) + +describe('getOrCreateUserApplicationFromConfig', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('gets an existing user application if a `studioHost` is provided in the config', async () => { + mockClientRequest.mockResolvedValueOnce({ + id: 'existing-app', + appHost: 'example.sanity.studio', + urlType: 'internal', + }) + + const result = await getOrCreateUserApplicationFromConfig({ + client: mockClient, + spinner: mockSpinner, + context, + appHost: 'example', + }) + + expect(mockClientRequest).toHaveBeenCalledWith({ + uri: '/user-applications', + query: {appHost: 'example'}, + }) + expect(result).toEqual({ + id: 'existing-app', + urlType: 'internal', + appHost: 'example.sanity.studio', + }) + }) + + it('creates a user application using `studioHost` if provided in the config', async () => { + const newApp = { + id: 'new-app', + appHost: 'newhost.sanity.studio', + urlType: 'internal', + } + mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app + mockClientRequest.mockResolvedValueOnce(newApp) + + const result = await getOrCreateUserApplicationFromConfig({ + client: mockClient, + spinner: mockSpinner, + context, + appHost: 'newhost', + }) + + expect(mockClientRequest).toHaveBeenCalledTimes(2) + expect(mockClientRequest).toHaveBeenNthCalledWith(1, { + uri: '/user-applications', + query: {appHost: 'newhost'}, + }) + expect(mockClientRequest).toHaveBeenNthCalledWith(2, { + uri: '/user-applications', + method: 'POST', + body: {appHost: 'newhost', urlType: 'internal'}, + }) + expect(result).toEqual(newApp) + }) +}) + +describe('createDeployment', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('sends the correct request to create a deployment and includes authorization header if token is present', async () => { + const tarball = Readable.from([Buffer.from('example chunk', 'utf-8')]) as Gzip + const applicationId = 'test-app-id' + const version = '1.0.0' + + await createDeployment({ + client: mockClient, + applicationId, + version, + isAutoUpdating: true, + tarball, + }) + + expect(mockClientRequest).toHaveBeenCalledTimes(1) + + // Extract and validate form data + const mockRequestCalls = mockClientRequest.mock.calls as Parameters[] + const {uri, method, body} = mockRequestCalls[0][0] + + expect(uri).toBe(`/user-applications/${applicationId}/deployments`) + expect(method).toBe('POST') + + // dump the raw content of the form data into a string + let content = '' + for await (const chunk of body) { + content += chunk + } + + expect(content).toContain('isAutoUpdating') + expect(content).toContain('true') + expect(content).toContain('version') + expect(content).toContain(version) + expect(content).toContain('example chunk') + }) + + it('streams the tarball contents', async () => { + const firstEmission = 'first emission\n' + const secondEmission = 'second emission\n' + + async function* createMockStream() { + await new Promise((resolve) => setTimeout(resolve, 0)) + yield Buffer.from(firstEmission, 'utf-8') + await new Promise((resolve) => setTimeout(resolve, 0)) + yield Buffer.from(secondEmission, 'utf-8') + } + + const mockTarball = Readable.from(createMockStream()) as Gzip + const applicationId = 'test-app-id' + const version = '1.0.0' + + await createDeployment({ + client: mockClient, + applicationId, + version, + isAutoUpdating: true, + tarball: mockTarball, + }) + + expect(mockClientRequest).toHaveBeenCalledTimes(1) + + const mockRequestCalls = mockClientRequest.mock.calls as Parameters[] + const {body} = mockRequestCalls[0][0] + + // Extract and validate form data + let emissions = 0 + let content = '' + for await (const chunk of body) { + content += chunk + emissions++ + } + + expect(emissions).toBeGreaterThan(1) + + expect(content).toContain('isAutoUpdating') + expect(content).toContain('true') + + expect(content).toContain('version') + expect(content).toContain(version) + + expect(content).toContain(firstEmission) + expect(content).toContain(secondEmission) + expect(content).toContain('application/gzip') + }) +}) + +describe('deleteUserApplication', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('sends the correct request to delete the user application', async () => { + await deleteUserApplication({ + client: mockClient, + applicationId: 'app-id', + }) + + expect(mockClientRequest).toHaveBeenCalledWith({ + uri: '/user-applications/app-id', + method: 'DELETE', + }) + }) + + it('handles errors when deleting the user application', async () => { + const errorMessage = 'Deletion error' + mockClientRequest.mockRejectedValueOnce(new Error(errorMessage)) + + await expect( + deleteUserApplication({client: mockClient, applicationId: 'app-id'}), + ).rejects.toThrow(errorMessage) + + expect(mockClientRequest).toHaveBeenCalledWith({ + uri: '/user-applications/app-id', + method: 'DELETE', + }) + }) +}) + +describe('dirIsEmptyOrNonExistent', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns true if the directory does not exist', async () => { + mockFsPromisesStat.mockRejectedValueOnce({code: 'ENOENT'}) + + const result = await dirIsEmptyOrNonExistent('nonexistentDir') + expect(result).toBe(true) + }) + + it('returns true if the directory is empty', async () => { + mockFsPromisesStat.mockResolvedValueOnce({isDirectory: () => true} as Stats) + mockFsPromisesReaddir.mockResolvedValueOnce([]) + + const result = await dirIsEmptyOrNonExistent('emptyDir') + expect(result).toBe(true) + }) + + it('returns false if the directory is not empty', async () => { + mockFsPromisesStat.mockResolvedValueOnce({isDirectory: () => true} as Stats) + mockFsPromisesReaddir.mockResolvedValueOnce(['file1', 'file2']) + + const result = await dirIsEmptyOrNonExistent('notEmptyDir') + expect(result).toBe(false) + }) + + it('throws an error if the path is not a directory', async () => { + mockFsPromisesStat.mockResolvedValueOnce({isDirectory: () => false} as Stats) + + await expect(dirIsEmptyOrNonExistent('notADir')).rejects.toThrow( + 'Directory notADir is not a directory', + ) + }) +}) + +describe('checkDir', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does nothing if the directory and index.html exist', async () => { + mockFsPromisesStat.mockResolvedValue({isDirectory: () => true} as Stats) + + await checkDir('validDir') + + expect(mockFsPromisesStat).toHaveBeenCalledWith('validDir') + expect(mockFsPromisesStat).toHaveBeenCalledWith('validDir/index.html') + }) + + it('throws an error if the directory does not exist', async () => { + mockFsPromisesStat.mockRejectedValueOnce({code: 'ENOENT'}) + + await expect(checkDir('missingDir')).rejects.toThrow('Directory "missingDir" does not exist') + }) + + it('throws an error if the path is not a directory', async () => { + mockFsPromisesStat.mockResolvedValueOnce({isDirectory: () => false} as Stats) + + await expect(checkDir('notADir')).rejects.toThrow('Directory notADir is not a directory') + }) + + it('throws an error if index.html does not exist', async () => { + mockFsPromisesStat + .mockResolvedValueOnce({isDirectory: () => true} as Stats) + .mockRejectedValueOnce({code: 'ENOENT'}) + + await expect(checkDir('missingIndex')).rejects.toThrow( + '"missingIndex/index.html" does not exist - [SOURCE_DIR] must be a directory containing a Sanity studio built using "sanity build"', + ) + }) +}) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts new file mode 100644 index 00000000000..936c8dede0b --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts @@ -0,0 +1,125 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' + +import {type UserApplication} from '../helpers' +import * as _helpers from '../helpers' +import undeployStudioAction from '../undeployAction' + +// Mock dependencies +jest.mock('../helpers') + +type Helpers = typeof _helpers +const helpers = _helpers as {[K in keyof Helpers]: jest.Mock} +type SpinnerInstance = { + start: jest.Mock<() => SpinnerInstance> + succeed: jest.Mock<() => SpinnerInstance> + fail: jest.Mock<() => SpinnerInstance> +} + +describe('undeployStudioAction', () => { + let mockContext: CliCommandContext + + const mockApplication: UserApplication = { + id: 'app-id', + appHost: 'app-host', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + urlType: 'internal', + projectId: 'example', + title: null, + type: 'studio', + } + + let spinnerInstance: SpinnerInstance + + beforeEach(() => { + jest.clearAllMocks() + + spinnerInstance = { + start: jest.fn(() => spinnerInstance), + succeed: jest.fn(() => spinnerInstance), + fail: jest.fn(() => spinnerInstance), + } + + mockContext = { + apiClient: jest.fn().mockReturnValue({ + withConfig: jest.fn().mockReturnThis(), + }), + chalk: {yellow: jest.fn((str) => str), red: jest.fn((str) => str)}, + output: { + print: jest.fn(), + spinner: jest.fn().mockReturnValue(spinnerInstance), + }, + prompt: {single: jest.fn()}, + cliConfig: {}, + } as unknown as CliCommandContext + }) + + it('does nothing if there is no user application', async () => { + helpers.getUserApplication.mockResolvedValueOnce(null) + + await undeployStudioAction({} as CliCommandArguments>, mockContext) + + expect(mockContext.output.print).toHaveBeenCalledWith( + 'Your project has not been assigned a studio hostname', + ) + expect(mockContext.output.print).toHaveBeenCalledWith( + 'or you do not have studioHost set in sanity.cli.js or sanity.cli.ts.', + ) + expect(mockContext.output.print).toHaveBeenCalledWith('Nothing to undeploy.') + }) + + it('prompts the user for confirmation and undeploys if confirmed', async () => { + helpers.getUserApplication.mockResolvedValueOnce(mockApplication) + helpers.deleteUserApplication.mockResolvedValueOnce(undefined) + ;( + mockContext.prompt.single as jest.Mock + ).mockResolvedValueOnce(true) // User confirms + + await undeployStudioAction({} as CliCommandArguments>, mockContext) + + expect(mockContext.prompt.single).toHaveBeenCalledWith({ + type: 'confirm', + default: false, + message: expect.stringContaining('undeploy'), + }) + expect(helpers.deleteUserApplication).toHaveBeenCalledWith({ + client: expect.anything(), + applicationId: 'app-id', + }) + expect(mockContext.output.print).toHaveBeenCalledWith( + expect.stringContaining('Studio undeploy scheduled.'), + ) + }) + + it('does not undeploy if the user cancels the prompt', async () => { + helpers.getUserApplication.mockResolvedValueOnce(mockApplication) + ;( + mockContext.prompt.single as jest.Mock + ).mockResolvedValueOnce(false) // User cancels + + await undeployStudioAction({} as CliCommandArguments>, mockContext) + + expect(mockContext.prompt.single).toHaveBeenCalledWith({ + type: 'confirm', + default: false, + message: expect.stringContaining('undeploy'), + }) + expect(helpers.deleteUserApplication).not.toHaveBeenCalled() + }) + + it('handles errors during the undeploy process', async () => { + const errorMessage = 'Example error' + helpers.getUserApplication.mockResolvedValueOnce(mockApplication) + helpers.deleteUserApplication.mockRejectedValueOnce(new Error(errorMessage)) + ;( + mockContext.prompt.single as jest.Mock + ).mockResolvedValueOnce(true) // User confirms + + await expect( + undeployStudioAction({} as CliCommandArguments>, mockContext), + ).rejects.toThrow(errorMessage) + + expect(mockContext.output.spinner('').fail).toHaveBeenCalled() + }) +}) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index fae251a7f72..5a773d52b71 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -1,31 +1,51 @@ -import {promises as fs} from 'node:fs' +/* eslint-disable max-statements */ import path from 'node:path' import zlib from 'node:zlib' import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' -import {type SanityClient} from '@sanity/client' import tar from 'tar-fs' import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction' +import { + checkDir, + createDeployment, + debug, + dirIsEmptyOrNonExistent, + getInstalledSanityVersion, + getOrCreateUserApplication, + getOrCreateUserApplicationFromConfig, + type UserApplication, +} from './helpers' export interface DeployStudioActionFlags extends BuildSanityStudioCommandFlags { build?: boolean } -export default async function deployStudio( +export default async function deployStudioAction( args: CliCommandArguments, context: CliCommandContext, ): Promise { - const {apiClient, workDir, chalk, output, prompt} = context + const {apiClient, workDir, chalk, output, prompt, cliConfig} = context const flags = {build: true, ...args.extOptions} - const destFolder = args.argsWithoutOptions[0] - const sourceDir = path.resolve(process.cwd(), destFolder || path.join(workDir, 'dist')) + const customSourceDir = args.argsWithoutOptions[0] + const sourceDir = path.resolve(process.cwd(), customSourceDir || path.join(workDir, 'dist')) + const isAutoUpdating = + flags['auto-updates'] || + (cliConfig && 'autoUpdates' in cliConfig && cliConfig.autoUpdates === true) || + false + const installedSanityVersion = await getInstalledSanityVersion() + const configStudioHost = cliConfig && 'studioHost' in cliConfig && cliConfig.studioHost - if (destFolder === 'graphql') { + const client = apiClient({ + requireUser: true, + requireProject: true, + }).withConfig({apiVersion: 'v2024-08-01'}) + + if (customSourceDir === 'graphql') { throw new Error('Did you mean `sanity graphql deploy`?') } - if (destFolder) { + if (customSourceDir) { let relativeOutput = path.relative(process.cwd(), sourceDir) if (relativeOutput[0] !== '.') { relativeOutput = `./${relativeOutput}` @@ -48,34 +68,41 @@ export default async function deployStudio( output.print(`Building to ${relativeOutput}\n`) } - const client = apiClient({ - requireUser: true, - requireProject: true, - }) - // Check that the project has a studio hostname let spinner = output.spinner('Checking project info').start() - const project = await client.projects.getById(client.config().projectId as string) - let studioHostname = project && project.studioHost - spinner.succeed() - - if (!studioHostname) { - output.print('Your project has not been assigned a studio hostname.') - output.print('To deploy your Sanity Studio to our hosted Sanity.Studio service,') - output.print('you will need one. Please enter the part you want to use.') - - studioHostname = await prompt.single({ - type: 'input', - filter: (inp: string) => inp.replace(/\.sanity\.studio$/i, ''), - message: 'Studio hostname (.sanity.studio):', - validate: (name: string) => validateHostname(name, client), - }) + + 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, + }) + } else { + userApplication = await getOrCreateUserApplication({ + client, + context, + spinner, + }) + } + } catch (err) { + if (err.message) { + output.error(chalk.red(err.message)) + return + } + + debug('Error creating user application', err) + throw err } // Always build the project, unless --no-build is passed const shouldBuild = flags.build if (shouldBuild) { - const buildArgs = [destFolder].filter(Boolean) + const buildArgs = [customSourceDir].filter(Boolean) const {didCompile} = await buildSanityStudio( {...args, extOptions: flags, argsWithoutOptions: buildArgs}, context, @@ -94,6 +121,7 @@ export default async function deployStudio( spinner.succeed() } catch (err) { spinner.fail() + debug('Error checking directory', err) throw err } @@ -104,89 +132,27 @@ export default async function deployStudio( spinner = output.spinner('Deploying to Sanity.Studio').start() try { - const response = await client.request({ - method: 'POST', - url: '/deploy', - body: tarball, - maxRedirects: 0, + const {location} = await createDeployment({ + client, + applicationId: userApplication.id, + version: installedSanityVersion, + isAutoUpdating, + tarball, }) spinner.succeed() // And let the user know we're done - output.print(`\nSuccess! Studio deployed to ${chalk.cyan(response.location)}`) - } catch (err) { - spinner.fail() - throw err - } -} + output.print(`\nSuccess! Studio deployed to ${chalk.cyan(location)}`) -async function dirIsEmptyOrNonExistent(sourceDir: string): Promise { - try { - const stats = await fs.stat(sourceDir) - if (!stats.isDirectory()) { - throw new Error(`Directory ${sourceDir} is not a directory`) + 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.') } } catch (err) { - if (err.code === 'ENOENT') { - return true - } - + spinner.fail() + debug('Error deploying studio', err) throw err } - - const content = await fs.readdir(sourceDir) - return content.length === 0 -} - -async function checkDir(sourceDir: string) { - try { - const stats = await fs.stat(sourceDir) - if (!stats.isDirectory()) { - throw new Error(`Directory ${sourceDir} is not a directory`) - } - } catch (err) { - const error = err.code === 'ENOENT' ? new Error(`Directory "${sourceDir}" does not exist`) : err - - throw error - } - - try { - await fs.stat(path.join(sourceDir, 'index.html')) - } catch (err) { - const error = - err.code === 'ENOENT' - ? new Error( - [ - `"${sourceDir}/index.html" does not exist -`, - '[SOURCE_DIR] must be a directory containing', - 'a Sanity studio built using "sanity build"', - ].join(' '), - ) - : err - - throw error - } -} - -async function validateHostname(value: string, client: SanityClient): Promise { - const projectId = client.config().projectId - const uri = `/projects/${projectId}` - const studioHost = value || '' - - // Check that it matches allowed character range - if (!/^[a-z0-9_-]+$/i.test(studioHost)) { - return 'Hostname can contain only A-Z, 0-9, _ and -' - } - - // Check that the hostname is not already taken - try { - await client.request({uri, method: 'PATCH', body: {studioHost}}) - return true - } catch (error) { - if (error?.response?.body?.message) { - return error.response.body.message - } - throw error - } } diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts new file mode 100644 index 00000000000..5bd56da3766 --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -0,0 +1,365 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import {PassThrough} from 'node:stream' +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 readPkgUp from 'read-pkg-up' + +import {debug as debugIt} from '../../debug' + +export const debug = debugIt.extend('deploy') + +// TODO: replace with `Promise.withResolvers()` once it lands in node +function promiseWithResolvers() { + let resolve!: (t: T) => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return {promise, resolve, reject} +} + +export interface ActiveDeployment { + deployedAt: string + deployedBy: string + isActiveDeployment: boolean + isAutoUpdating: boolean | null + size: string | null + createdAt: string + updatedAt: string + version: string +} + +export interface UserApplication { + id: string + projectId: string + title: string | null + appHost: string + urlType: 'internal' | 'external' + createdAt: string + updatedAt: string + type: 'studio' + activeDeployment?: ActiveDeployment | null +} + +export interface GetUserApplicationsOptions { + client: SanityClient +} + +export interface GetUserApplicationOptions extends GetUserApplicationsOptions { + appHost?: string +} + +export async function getUserApplication({ + client, + appHost, +}: GetUserApplicationOptions): Promise { + try { + return await client.request({ + uri: '/user-applications', + query: appHost ? {appHost} : {default: 'true'}, + }) + } catch (e) { + if (e?.statusCode === 404) { + return null + } + + debug('Error getting user application', e) + throw e + } +} + +export async function getUserApplications({ + client, +}: GetUserApplicationsOptions): Promise { + try { + return await client.request({ + uri: '/user-applications', + }) + } catch (e) { + if (e?.statusCode === 404) { + return null + } + + debug('Error getting user application', e) + throw e + } +} + +function createUserApplication( + client: SanityClient, + body: Pick & { + title?: string + }, +): Promise { + return client.request({uri: '/user-applications', method: 'POST', body}) +} + +export interface GetOrCreateUserApplicationOptions { + client: SanityClient + context: Pick + spinner: ReturnType +} + +/** + * This function handles the logic for managing user applications when + * studioHost is not provided in the CLI config. + * + * @internal + * + * +-------------------------------+ + * | Fetch Existing user-app? | + * +---------+--------------------+ + * | + * +-----+-----+ + * | | + * v v + * +---------+ +-------------------------+ + * | Return | | Fetch all user apps | + * | user-app| +-------------------------+ + * +---------+ | + * v + * +---------------------------+ + * | User apps found? | + * +-----------+---------------+ + * | + * +------v------+ + * | | + * v v + * +--------------------+ +------------------------+ + * | Show list and | | Prompt for hostname | + * | prompt selection | | and create new app | + * +--------------------+ +------------------------+ + */ +export async function getOrCreateUserApplication({ + client, + spinner, + context, +}: GetOrCreateUserApplicationOptions): Promise { + const {output, prompt} = context + // if there is already an existing user-app, then just return it + const existingUserApplication = await getUserApplication({client}) + + // Complete the spinner so prompt can properly work + spinner.succeed() + + if (existingUserApplication) { + 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, + ], + }) + + // if the user selected an existing app, return it + if (selected !== 'new') { + return userApplications.find((app) => app.appHost === selected)! + } + } + + // otherwise, prompt the user for a hostname + output.print('Your project has not been assigned a studio hostname.') + output.print('To deploy your Sanity Studio to our hosted sanity.studio service,') + output.print('you will need one. Please enter the part you want to use.') + + const {promise, resolve} = promiseWithResolvers() + + await prompt.single({ + type: 'input', + filter: (inp: string) => inp.replace(/\.sanity\.studio$/i, ''), + message: 'Studio hostname (.sanity.studio):', + // if a string is returned here, it is relayed to the user and prompt allows + // the user to try again until this function returns true + validate: async (appHost: string) => { + try { + const response = await createUserApplication(client, { + appHost, + urlType: 'internal', + }) + resolve(response) + return true + } catch (e) { + // if the name is taken, it should return a 409 so we relay to the user + if ([402, 409].includes(e?.statusCode)) { + return e?.response?.body?.message || 'Bad request' // just in case + } + + debug('Error creating user application', e) + // otherwise, it's a fatal error + throw e + } + }, + }) + + return await promise +} + +/** + * This function handles the logic for managing user applications when + * studioHost is provided in the CLI config. + * + * @internal + */ +export async function getOrCreateUserApplicationFromConfig({ + client, + context, + spinner, + appHost, +}: GetOrCreateUserApplicationOptions & { + appHost: string +}): Promise { + const {output} = context + // if there is already an existing user-app, then just return it + const existingUserApplication = await getUserApplication({client, appHost}) + + // Complete the spinner so prompt can properly work + spinner.succeed() + + if (existingUserApplication) { + return existingUserApplication + } + + output.print('Your project has not been assigned a studio hostname.') + output.print(`Creating https://${appHost}.sanity.studio`) + output.print('') + spinner.start('Creating studio hostname') + + try { + const response = await createUserApplication(client, { + appHost, + urlType: 'internal', + }) + spinner.succeed() + + return response + } catch (e) { + spinner.fail() + // if the name is taken, it should return a 409 so we relay to the user + 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 + } +} + +export interface CreateDeploymentOptions { + client: SanityClient + applicationId: string + version: string + isAutoUpdating: boolean + tarball: Gzip +} + +export async function createDeployment({ + client, + tarball, + applicationId, + isAutoUpdating, + version, +}: CreateDeploymentOptions): Promise<{location: string}> { + const formData = new FormData() + formData.append('isAutoUpdating', isAutoUpdating.toString()) + formData.append('version', version) + formData.append('tarball', tarball, {contentType: 'application/gzip', filename: 'app.tar.gz'}) + + return client.request({ + uri: `/user-applications/${applicationId}/deployments`, + method: 'POST', + headers: formData.getHeaders(), + body: formData.pipe(new PassThrough()), + }) +} + +export interface DeleteUserApplicationOptions { + client: SanityClient + applicationId: string +} + +export async function deleteUserApplication({ + applicationId, + client, +}: DeleteUserApplicationOptions): Promise { + await client.request({uri: `/user-applications/${applicationId}`, method: 'DELETE'}) +} + +export async function getInstalledSanityVersion(): Promise { + const sanityPkgPath = (await readPkgUp({cwd: __dirname}))?.path + if (!sanityPkgPath) { + throw new Error('Unable to resolve `sanity` module root') + } + + const pkg = JSON.parse(await fs.readFile(sanityPkgPath, 'utf-8')) + if (typeof pkg?.version !== 'string') { + throw new Error('Unable to find version of `sanity` module') + } + return pkg.version +} + +export async function dirIsEmptyOrNonExistent(sourceDir: string): Promise { + try { + const stats = await fs.stat(sourceDir) + if (!stats.isDirectory()) { + throw new Error(`Directory ${sourceDir} is not a directory`) + } + } catch (err) { + if (err.code === 'ENOENT') { + return true + } + + throw err + } + + const content = await fs.readdir(sourceDir) + return content.length === 0 +} + +export async function checkDir(sourceDir: string): Promise { + try { + const stats = await fs.stat(sourceDir) + if (!stats.isDirectory()) { + throw new Error(`Directory ${sourceDir} is not a directory`) + } + } catch (err) { + const error = err.code === 'ENOENT' ? new Error(`Directory "${sourceDir}" does not exist`) : err + + throw error + } + + try { + await fs.stat(path.join(sourceDir, 'index.html')) + } catch (err) { + const error = + err.code === 'ENOENT' + ? new Error( + [ + `"${sourceDir}/index.html" does not exist -`, + '[SOURCE_DIR] must be a directory containing', + 'a Sanity studio built using "sanity build"', + ].join(' '), + ) + : err + + throw error + } +} diff --git a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts index 30115192143..376f53b06d4 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts @@ -1,24 +1,34 @@ import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' -export default async function undeployStudio( - args: CliCommandArguments>, +import {debug as debugIt} from '../../debug' +import {deleteUserApplication, getUserApplication} from './helpers' + +const debug = debugIt.extend('undeploy') + +export default async function undeployStudioAction( + _: CliCommandArguments>, context: CliCommandContext, ): Promise { - const {apiClient, chalk, output, prompt} = context + const {apiClient, chalk, output, prompt, cliConfig} = context const client = apiClient({ requireUser: true, requireProject: true, - }) + }).withConfig({apiVersion: 'v2024-08-01'}) // Check that the project has a studio hostname let spinner = output.spinner('Checking project info').start() - const project = await client.projects.getById(client.config().projectId as string) - const studioHost = project && project.studioHost + + const userApplication = await getUserApplication({ + client, + appHost: cliConfig && 'studioHost' in cliConfig ? cliConfig.studioHost : undefined, + }) + spinner.succeed() - if (!studioHost) { - output.print('Your project has not been assigned a studio hostname.') + if (!userApplication) { + output.print('Your project has not been assigned a studio hostname') + output.print('or you do not have studioHost set in sanity.cli.js or sanity.cli.ts.') output.print('Nothing to undeploy.') return } @@ -26,7 +36,7 @@ export default async function undeployStudio( // Double-check output.print('') - const url = `https://${chalk.yellow(studioHost)}.sanity.studio` + const url = `https://${chalk.yellow(userApplication.appHost)}.sanity.studio` const shouldUndeploy = await prompt.single({ type: 'confirm', default: false, @@ -39,15 +49,13 @@ export default async function undeployStudio( return } - const projectId = client.config().projectId - const uri = `/projects/${projectId}` - spinner = output.spinner('Undeploying studio').start() try { - await client.request({uri, method: 'PATCH', body: {studioHost: null}}) + await deleteUserApplication({client, applicationId: userApplication.id}) spinner.succeed() } catch (err) { spinner.fail() + debug('Error undeploying studio', err) throw err } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd692325a2a..38002511bf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1524,6 +1524,9 @@ importers: exif-component: specifier: ^1.0.1 version: 1.0.1 + form-data: + specifier: ^4.0.0 + version: 4.0.0 framer-motion: specifier: 11.0.8 version: 11.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)