From f5f1f047b2eb0d82062e9bce0591755bab57e877 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Wed, 24 Jul 2024 12:38:56 -0500 Subject: [PATCH 01/14] feat(cli): update CLI to use new deploy endpoint --- packages/@sanity/cli/src/types.ts | 2 + .../deploy/__tests__/deployAction.test.ts | 204 ++++++++++++ .../actions/deploy/__tests__/helpers.test.ts | 292 ++++++++++++++++++ .../deploy/__tests__/undeployAction.test.ts | 123 ++++++++ .../cli/actions/deploy/deployAction.ts | 146 +++------ .../_internal/cli/actions/deploy/helpers.ts | 234 ++++++++++++++ .../cli/actions/deploy/undeployAction.ts | 25 +- 7 files changed, 909 insertions(+), 117 deletions(-) create mode 100644 packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts create mode 100644 packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts create mode 100644 packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts create mode 100644 packages/sanity/src/_internal/cli/actions/deploy/helpers.ts 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/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..3d3e2e91a2f --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/deployAction.test.ts @@ -0,0 +1,204 @@ +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 {[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(), + externalAppHost: null, + isDefault: false, + 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)}, + output: { + 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) + 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('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) + 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`?') + }) +}) 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..00c98fceb4c --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts @@ -0,0 +1,292 @@ +import {type Stats} from 'node:fs' +import fs from 'node:fs/promises' +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, +} 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(), + getUrl: 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()} as unknown as CliCommandContext['prompt'] + +const mockFetch = jest.fn() +global.fetch = mockFetch + +// Mock the Gzip stream +class MockGzip { + constructor(private chunks: Buffer[]) {} + [Symbol.asyncIterator]() { + const chunks = this.chunks + return (async function* thing() { + for (const chunk of chunks) yield chunk + })() + } +} + +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', + isDefault: true, + }) + + const result = await getOrCreateUserApplication({client: mockClient, context}) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/user-applications', + query: {default: 'true'}, + }) + expect(result).toEqual({id: 'default-app', isDefault: true}) + }) + + it('gets an existing user application if a `studioHost` is provided in the config', async () => { + mockClientRequest.mockResolvedValueOnce({ + id: 'existing-app', + appHost: 'example.sanity.studio', + }) + + const result = await getOrCreateUserApplication({ + client: mockClient, + cliConfig: {studioHost: 'example.sanity.studio'}, + context, + }) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/user-applications', + query: {appHost: 'example.sanity.studio'}, + }) + expect(result).toEqual({id: 'existing-app', 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', isDefault: true} + mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app + mockClientRequest.mockResolvedValueOnce(newApp) + + const result = await getOrCreateUserApplication({ + client: mockClient, + cliConfig: {studioHost: 'newhost'}, + context, + }) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/user-applications', + method: 'POST', + body: {appHost: 'newhost', isDefault: true}, + }) + expect(result).toEqual(newApp) + }) + + it('creates a default user application by prompting the user for a name', async () => { + const newApp = {id: 'default-app', appHost: 'default.sanity.studio', isDefault: true} + mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app + ;(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, + }) + + expect(mockPrompt.single).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Studio hostname (.sanity.studio):', + }), + ) + expect(result).toEqual(newApp) + }) +}) + +describe('createDeployment', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(mockClient.config as jest.Mock).mockReturnValue({token: 'fake-token'}) + ;(mockClient.getUrl as jest.Mock).mockImplementation( + (uri) => `http://example.api.sanity.io${uri}`, + ) + }) + + it('sends the correct request to create a deployment and includes authorization header if token is present', async () => { + const chunks = [Buffer.from('first chunk'), Buffer.from('second chunk')] + const tarball = new MockGzip(chunks) as unknown as Gzip + const applicationId = 'test-app-id' + + mockFetch.mockResolvedValueOnce(new Response()) + + await createDeployment({ + client: mockClient, + applicationId, + version: '1.0.0', + isAutoUpdating: true, + tarball, + }) + + // Check URL and method + expect(mockClient.getUrl).toHaveBeenCalledWith( + `/user-applications/${applicationId}/deployments`, + ) + expect(mockFetch).toHaveBeenCalledTimes(1) + const url = mockFetch.mock.calls[0][0] as URL + expect(url.toString()).toBe( + 'http://example.api.sanity.io/user-applications/test-app-id/deployments', + ) + + // Extract and validate form data + const mockFetchCalls = mockFetch.mock.calls as Parameters[] + const formData = mockFetchCalls[0][1]?.body as FormData + expect(formData.get('version')).toBe('1.0.0') + expect(formData.get('isAutoUpdating')).toBe('true') + expect(formData.get('tarball')).toBeInstanceOf(Blob) + + // Check Authorization header + const headers = mockFetchCalls[0][1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer fake-token') + }) +}) + +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..02171720609 --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts @@ -0,0 +1,123 @@ +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(), + externalAppHost: null, + isDefault: false, + 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('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..3968c1e21ce 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -1,31 +1,46 @@ -import {promises as fs} from 'node:fs' 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, + dirIsEmptyOrNonExistent, + getInstalledSanityVersion, + getOrCreateUserApplication, +} 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() - if (destFolder === 'graphql') { + const client = apiClient({ + requireUser: true, + requireProject: true, + }).withConfig({apiVersion: 'vX'}) + + 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 +63,20 @@ 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), - }) - } + + const userApplication = await getOrCreateUserApplication({ + client, + context, + // ensures only v3 configs with `studioHost` are sent + ...(cliConfig && 'studioHost' in cliConfig && {cliConfig}), + }) // 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, @@ -104,89 +105,22 @@ 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, + 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)}`) + output.print( + `\nSuccess! Studio deployed to ${chalk.cyan(`https://${userApplication.appHost}.sanity.studio`)}`, + ) } catch (err) { spinner.fail() throw err } } - -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 -} - -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..b4992d9ea70 --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import {type Gzip} from 'node:zlib' + +import {type CliCommandContext, type CliConfig} from '@sanity/cli' +import {type SanityClient} from '@sanity/client' +import readPkgUp from 'read-pkg-up' + +// 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 + isDefault: boolean | null + appHost: string + createdAt: string + updatedAt: string + externalAppHost: string | null + type: 'studio' + activeDeployment?: ActiveDeployment | null +} + +export interface GetUserApplicationOptions { + client: SanityClient + 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 + throw e + } +} + +function createUserApplication( + client: SanityClient, + body: {title?: string; isDefault?: boolean; appHost: string}, +): Promise { + return client.request({uri: '/user-applications', method: 'POST', body}) +} + +export interface GetOrCreateUserApplicationOptions { + client: SanityClient + context: Pick + cliConfig?: Pick +} + +export async function getOrCreateUserApplication({ + client, + cliConfig, + context: {output, prompt}, +}: GetOrCreateUserApplicationOptions): Promise { + // if there is already an existing user-app, then just return it + const existingUserApplication = await getUserApplication({client, appHost: cliConfig?.studioHost}) + if (existingUserApplication) return existingUserApplication + + // otherwise, we need to create one. + // if a `studioHost` was provided in the CLI config, then use that + if (cliConfig?.studioHost) { + return await createUserApplication(client, { + appHost: cliConfig.studioHost, + isDefault: true, + }) + } + + // 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, isDefault: true}) + resolve(response) + return true + } catch (e) { + // if the name is taken, it should return a 409 so we relay to the user + if (e?.statusCode === 409) return e?.message || 'Conflict' // just in case + + // otherwise, it's a fatal error + throw e + } + }, + }) + + return await promise +} + +export interface CreateDeploymentOptions { + client: SanityClient + applicationId: string + version: string + isAutoUpdating: boolean + tarball: Gzip +} + +export async function createDeployment({ + client, + tarball, + applicationId, + ...options +}: CreateDeploymentOptions): Promise { + const config = client.config() + + const formData = new FormData() + for (const [key, value] of Object.entries(options)) { + formData.set(key, value.toString()) + } + + // convert the tarball into a blob and add it to the form data + const chunks: Buffer[] = [] + for await (const chunk of tarball) { + chunks.push(chunk) + } + const blob = new Blob([Buffer.concat(chunks)], {type: 'application/gzip'}) + formData.set('tarball', blob) + + const url = new URL(client.getUrl(`/user-applications/${applicationId}/deployments`)) + const headers = new Headers({ + ...(config.token && {Authorization: `Bearer ${config.token}`}), + }) + + await fetch(url, {method: 'POST', headers, body: formData}) +} + +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..ed5ecb560e7 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts @@ -1,23 +1,29 @@ import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' -export default async function undeployStudio( +import {deleteUserApplication, getUserApplication} from './helpers' + +export default async function undeployStudioAction( args: 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: 'vX'}) // 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) { + if (!userApplication) { output.print('Your project has not been assigned a studio hostname.') output.print('Nothing to undeploy.') return @@ -26,7 +32,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,12 +45,9 @@ 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() From e2b14937e006d038dd243fc3daec2c68582fbc83 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Wed, 24 Jul 2024 15:46:28 -0500 Subject: [PATCH 02/14] feat: restore streaming support --- packages/sanity/package.json | 1 + .../actions/deploy/__tests__/helpers.test.ts | 94 +++++++++++++++++-- .../_internal/cli/actions/deploy/helpers.ts | 33 ++++--- pnpm-lock.yaml | 3 + 4 files changed, 109 insertions(+), 22 deletions(-) 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__/helpers.test.ts b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/helpers.test.ts index 00c98fceb4c..9e255c58644 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 @@ -1,5 +1,6 @@ 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' @@ -43,11 +44,11 @@ global.fetch = mockFetch // Mock the Gzip stream class MockGzip { - constructor(private chunks: Buffer[]) {} + constructor(private chunks: AsyncIterable | Iterable) {} [Symbol.asyncIterator]() { const chunks = this.chunks return (async function* thing() { - for (const chunk of chunks) yield chunk + for await (const chunk of chunks) yield chunk })() } } @@ -148,16 +149,16 @@ describe('createDeployment', () => { }) it('sends the correct request to create a deployment and includes authorization header if token is present', async () => { - const chunks = [Buffer.from('first chunk'), Buffer.from('second chunk')] - const tarball = new MockGzip(chunks) as unknown as Gzip + const tarball = Readable.from([Buffer.from('example chunk', 'utf-8')]) as Gzip const applicationId = 'test-app-id' + const version = '1.0.0' mockFetch.mockResolvedValueOnce(new Response()) await createDeployment({ client: mockClient, applicationId, - version: '1.0.0', + version, isAutoUpdating: true, tarball, }) @@ -174,15 +175,90 @@ describe('createDeployment', () => { // Extract and validate form data const mockFetchCalls = mockFetch.mock.calls as Parameters[] - const formData = mockFetchCalls[0][1]?.body as FormData - expect(formData.get('version')).toBe('1.0.0') - expect(formData.get('isAutoUpdating')).toBe('true') - expect(formData.get('tarball')).toBeInstanceOf(Blob) + const formData = mockFetchCalls[0][1]?.body as unknown as Readable + + // dump the raw content of the form data into a string + let content = '' + for await (const chunk of formData) { + content += chunk + } + + expect(content).toContain('isAutoUpdating') + expect(content).toContain('true') + expect(content).toContain('version') + expect(content).toContain(version) + expect(content).toContain('example chunk') // Check Authorization header const headers = mockFetchCalls[0][1]?.headers as Headers expect(headers.get('Authorization')).toBe('Bearer fake-token') }) + + 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' + + mockFetch.mockResolvedValueOnce(new Response()) + + const version = '1.0.0' + + await createDeployment({ + client: mockClient, + applicationId, + version, + isAutoUpdating: true, + tarball: mockTarball, + }) + + // Check URL and method + expect(mockClient.getUrl).toHaveBeenCalledWith( + `/user-applications/${applicationId}/deployments`, + ) + expect(mockFetch).toHaveBeenCalledTimes(1) + const url = mockFetch.mock.calls[0][0] as URL + expect(url.toString()).toBe( + 'http://example.api.sanity.io/user-applications/test-app-id/deployments', + ) + + // Extract and validate form data + const mockFetchCalls = mockFetch.mock.calls as Parameters[] + const requestInit = mockFetchCalls[0][1] as any + + // this is required to enable streaming with the native fetch API + // https://github.com/nodejs/node/issues/46221 + expect(requestInit.duplex).toBe('half') + + const formData = requestInit.body as Readable + + let emissions = 0 + let content = '' + for await (const chunk of formData) { + 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', () => { diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index b4992d9ea70..73c31fac10d 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -1,9 +1,11 @@ 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 CliConfig} from '@sanity/cli' import {type SanityClient} from '@sanity/client' +import FormData from 'form-data' import readPkgUp from 'read-pkg-up' // TODO: replace with `Promise.withResolvers()` once it lands in node @@ -135,29 +137,34 @@ export async function createDeployment({ client, tarball, applicationId, - ...options + isAutoUpdating, + version, }: CreateDeploymentOptions): Promise { const config = client.config() const formData = new FormData() - for (const [key, value] of Object.entries(options)) { - formData.set(key, value.toString()) - } - - // convert the tarball into a blob and add it to the form data - const chunks: Buffer[] = [] - for await (const chunk of tarball) { - chunks.push(chunk) - } - const blob = new Blob([Buffer.concat(chunks)], {type: 'application/gzip'}) - formData.set('tarball', blob) + formData.append('isAutoUpdating', isAutoUpdating.toString()) + formData.append('version', version) + formData.append('tarball', tarball, {contentType: 'application/gzip'}) const url = new URL(client.getUrl(`/user-applications/${applicationId}/deployments`)) const headers = new Headers({ ...(config.token && {Authorization: `Bearer ${config.token}`}), }) - await fetch(url, {method: 'POST', headers, body: formData}) + await fetch(url, { + method: 'POST', + headers, + // NOTE: + // - the fetch API in node.js supports streams but it's not in the types + // - the PassThrough is required because `form-data` does not fully conform + // to the node.js stream API + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: formData.pipe(new PassThrough()) as any, + // @ts-expect-error the `duplex` param is required in order to send a stream + // https://github.com/nodejs/node/issues/46221#issuecomment-1383246036 + duplex: 'half', + }) } export interface DeleteUserApplicationOptions { 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) From 3f0a2d2d1494defa8fcadf48a092934ec5a4ee45 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Thu, 25 Jul 2024 11:08:19 -0500 Subject: [PATCH 03/14] refactor: use client to stream instead of fetch --- .../actions/deploy/__tests__/helpers.test.ts | 63 +++++-------------- .../_internal/cli/actions/deploy/helpers.ts | 21 +------ 2 files changed, 18 insertions(+), 66 deletions(-) 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 9e255c58644..502051e1520 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 @@ -26,7 +26,6 @@ const mockFsPromisesReaddir = mockFsPromises.readdir as unknown as jest.Mock< const mockClient = { request: jest.fn(), config: jest.fn(), - getUrl: jest.fn(), } as unknown as SanityClient const mockClientRequest = mockClient.request as jest.Mock @@ -67,7 +66,7 @@ describe('getOrCreateUserApplication', () => { }) const result = await getOrCreateUserApplication({client: mockClient, context}) - expect(mockClient.request).toHaveBeenCalledWith({ + expect(mockClientRequest).toHaveBeenCalledWith({ uri: '/user-applications', query: {default: 'true'}, }) @@ -86,7 +85,7 @@ describe('getOrCreateUserApplication', () => { context, }) - expect(mockClient.request).toHaveBeenCalledWith({ + expect(mockClientRequest).toHaveBeenCalledWith({ uri: '/user-applications', query: {appHost: 'example.sanity.studio'}, }) @@ -104,7 +103,7 @@ describe('getOrCreateUserApplication', () => { context, }) - expect(mockClient.request).toHaveBeenCalledWith({ + expect(mockClientRequest).toHaveBeenCalledWith({ uri: '/user-applications', method: 'POST', body: {appHost: 'newhost', isDefault: true}, @@ -142,10 +141,6 @@ describe('getOrCreateUserApplication', () => { describe('createDeployment', () => { beforeEach(() => { jest.clearAllMocks() - ;(mockClient.config as jest.Mock).mockReturnValue({token: 'fake-token'}) - ;(mockClient.getUrl as jest.Mock).mockImplementation( - (uri) => `http://example.api.sanity.io${uri}`, - ) }) it('sends the correct request to create a deployment and includes authorization header if token is present', async () => { @@ -153,8 +148,6 @@ describe('createDeployment', () => { const applicationId = 'test-app-id' const version = '1.0.0' - mockFetch.mockResolvedValueOnce(new Response()) - await createDeployment({ client: mockClient, applicationId, @@ -163,23 +156,18 @@ describe('createDeployment', () => { tarball, }) - // Check URL and method - expect(mockClient.getUrl).toHaveBeenCalledWith( - `/user-applications/${applicationId}/deployments`, - ) - expect(mockFetch).toHaveBeenCalledTimes(1) - const url = mockFetch.mock.calls[0][0] as URL - expect(url.toString()).toBe( - 'http://example.api.sanity.io/user-applications/test-app-id/deployments', - ) + expect(mockClientRequest).toHaveBeenCalledTimes(1) // Extract and validate form data - const mockFetchCalls = mockFetch.mock.calls as Parameters[] - const formData = mockFetchCalls[0][1]?.body as unknown as Readable + 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 formData) { + for await (const chunk of body) { content += chunk } @@ -188,10 +176,6 @@ describe('createDeployment', () => { expect(content).toContain('version') expect(content).toContain(version) expect(content).toContain('example chunk') - - // Check Authorization header - const headers = mockFetchCalls[0][1]?.headers as Headers - expect(headers.get('Authorization')).toBe('Bearer fake-token') }) it('streams the tarball contents', async () => { @@ -207,9 +191,6 @@ describe('createDeployment', () => { const mockTarball = Readable.from(createMockStream()) as Gzip const applicationId = 'test-app-id' - - mockFetch.mockResolvedValueOnce(new Response()) - const version = '1.0.0' await createDeployment({ @@ -220,29 +201,15 @@ describe('createDeployment', () => { tarball: mockTarball, }) - // Check URL and method - expect(mockClient.getUrl).toHaveBeenCalledWith( - `/user-applications/${applicationId}/deployments`, - ) - expect(mockFetch).toHaveBeenCalledTimes(1) - const url = mockFetch.mock.calls[0][0] as URL - expect(url.toString()).toBe( - 'http://example.api.sanity.io/user-applications/test-app-id/deployments', - ) - - // Extract and validate form data - const mockFetchCalls = mockFetch.mock.calls as Parameters[] - const requestInit = mockFetchCalls[0][1] as any - - // this is required to enable streaming with the native fetch API - // https://github.com/nodejs/node/issues/46221 - expect(requestInit.duplex).toBe('half') + expect(mockClientRequest).toHaveBeenCalledTimes(1) - const formData = requestInit.body as Readable + 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 formData) { + for await (const chunk of body) { content += chunk emissions++ } diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 73c31fac10d..0b538fda4fa 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -140,30 +140,15 @@ export async function createDeployment({ isAutoUpdating, version, }: CreateDeploymentOptions): Promise { - const config = client.config() - const formData = new FormData() formData.append('isAutoUpdating', isAutoUpdating.toString()) formData.append('version', version) - formData.append('tarball', tarball, {contentType: 'application/gzip'}) - - const url = new URL(client.getUrl(`/user-applications/${applicationId}/deployments`)) - const headers = new Headers({ - ...(config.token && {Authorization: `Bearer ${config.token}`}), - }) + formData.append('tarball', tarball, {contentType: 'application/gzip', filename: 'app.tar.gz'}) - await fetch(url, { + await client.request({ + uri: `/user-applications/${applicationId}/deployments`, method: 'POST', - headers, - // NOTE: - // - the fetch API in node.js supports streams but it's not in the types - // - the PassThrough is required because `form-data` does not fully conform - // to the node.js stream API - // eslint-disable-next-line @typescript-eslint/no-explicit-any body: formData.pipe(new PassThrough()) as any, - // @ts-expect-error the `duplex` param is required in order to send a stream - // https://github.com/nodejs/node/issues/46221#issuecomment-1383246036 - duplex: 'half', }) } From e88aa695142650bda17fa3fd2cf8a9bb4a930e66 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Mon, 29 Jul 2024 14:44:12 -0500 Subject: [PATCH 04/14] fix: use `urlType` for new API spec --- .../src/_internal/cli/actions/deploy/helpers.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 0b538fda4fa..6430d870143 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -36,9 +36,9 @@ export interface UserApplication { title: string | null isDefault: boolean | null appHost: string + urlType: 'internal' | 'external' createdAt: string updatedAt: string - externalAppHost: string | null type: 'studio' activeDeployment?: ActiveDeployment | null } @@ -65,7 +65,7 @@ export async function getUserApplication({ function createUserApplication( client: SanityClient, - body: {title?: string; isDefault?: boolean; appHost: string}, + body: {title?: string; isDefault?: boolean; appHost: string; urlType: 'internal' | 'external'}, ): Promise { return client.request({uri: '/user-applications', method: 'POST', body}) } @@ -90,7 +90,7 @@ export async function getOrCreateUserApplication({ if (cliConfig?.studioHost) { return await createUserApplication(client, { appHost: cliConfig.studioHost, - isDefault: true, + urlType: 'internal', }) } @@ -109,7 +109,11 @@ export async function getOrCreateUserApplication({ // the user to try again until this function returns true validate: async (appHost: string) => { try { - const response = await createUserApplication(client, {appHost, isDefault: true}) + const response = await createUserApplication(client, { + appHost, + isDefault: true, + urlType: 'internal', + }) resolve(response) return true } catch (e) { From 86b9ad4a5444e774bb69372fbd3010d1717fcff5 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Mon, 5 Aug 2024 16:19:38 -0400 Subject: [PATCH 05/14] fix(cli): fixes issues with deploying to new endpoint --- .../deploy/__tests__/deployAction.test.ts | 5 +- .../actions/deploy/__tests__/helpers.test.ts | 56 ++++++++++++------- .../deploy/__tests__/undeployAction.test.ts | 3 +- .../cli/actions/deploy/deployAction.ts | 39 +++++++++---- .../_internal/cli/actions/deploy/helpers.ts | 32 ++++++++--- .../cli/actions/deploy/undeployAction.ts | 8 ++- 6 files changed, 98 insertions(+), 45 deletions(-) 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 3d3e2e91a2f..b9b2462d513 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 @@ -35,8 +35,7 @@ describe('deployStudioAction', () => { appHost: 'app-host', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - externalAppHost: null, - isDefault: false, + urlType: 'internal', projectId: 'example', title: null, type: 'studio', @@ -73,6 +72,7 @@ describe('deployStudioAction', () => { 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') @@ -126,6 +126,7 @@ describe('deployStudioAction', () => { ).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') 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 502051e1520..7a225fe3955 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 @@ -6,6 +6,7 @@ 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 {type Ora} from 'ora' import { checkDir, @@ -37,21 +38,13 @@ const mockOutput = { spinner: jest.fn(), } as CliCommandContext['output'] const mockPrompt = {single: jest.fn()} as unknown as CliCommandContext['prompt'] +const mockSpinner = { + succeed: jest.fn() as jest.Mock, +} as unknown as Ora const mockFetch = jest.fn() global.fetch = mockFetch -// Mock the Gzip stream -class MockGzip { - constructor(private chunks: AsyncIterable | Iterable) {} - [Symbol.asyncIterator]() { - const chunks = this.chunks - return (async function* thing() { - for await (const chunk of chunks) yield chunk - })() - } -} - const context = {output: mockOutput, prompt: mockPrompt} describe('getOrCreateUserApplication', () => { @@ -62,25 +55,30 @@ describe('getOrCreateUserApplication', () => { it('gets the default user application if no `studioHost` is provided', async () => { mockClientRequest.mockResolvedValueOnce({ id: 'default-app', - isDefault: true, }) - const result = await getOrCreateUserApplication({client: mockClient, context}) + const result = await getOrCreateUserApplication({ + client: mockClient, + spinner: mockSpinner, + context, + }) expect(mockClientRequest).toHaveBeenCalledWith({ uri: '/user-applications', query: {default: 'true'}, }) - expect(result).toEqual({id: 'default-app', isDefault: true}) + expect(result).toEqual({id: 'default-app'}) }) 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 getOrCreateUserApplication({ client: mockClient, + spinner: mockSpinner, cliConfig: {studioHost: 'example.sanity.studio'}, context, }) @@ -89,30 +87,49 @@ describe('getOrCreateUserApplication', () => { uri: '/user-applications', query: {appHost: 'example.sanity.studio'}, }) - expect(result).toEqual({id: 'existing-app', appHost: 'example.sanity.studio'}) + 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', isDefault: true} + const newApp = { + id: 'new-app', + appHost: 'newhost.sanity.studio', + urlType: 'internal', + } mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app mockClientRequest.mockResolvedValueOnce(newApp) const result = await getOrCreateUserApplication({ client: mockClient, + spinner: mockSpinner, cliConfig: {studioHost: 'newhost'}, context, }) - expect(mockClientRequest).toHaveBeenCalledWith({ + 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', isDefault: true}, + body: {appHost: 'newhost', urlType: 'internal'}, }) expect(result).toEqual(newApp) }) it('creates a default user application by prompting the user for a name', async () => { - const newApp = {id: 'default-app', appHost: 'default.sanity.studio', isDefault: true} + const newApp = { + id: 'default-app', + appHost: 'default.sanity.studio', + urlType: 'internal', + isDefaultForDeployment: true, + } mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app ;(mockPrompt.single as jest.Mock).mockImplementationOnce( async ({validate}: Parameters[0]) => { @@ -127,6 +144,7 @@ describe('getOrCreateUserApplication', () => { const result = await getOrCreateUserApplication({ client: mockClient, context, + spinner: mockSpinner, }) expect(mockPrompt.single).toHaveBeenCalledWith( 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 index 02171720609..31a8e209dd1 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts @@ -24,8 +24,7 @@ describe('undeployStudioAction', () => { appHost: 'app-host', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - externalAppHost: null, - isDefault: false, + urlType: 'internal', projectId: 'example', title: null, type: 'studio', diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index 3968c1e21ce..faf84c7807f 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -1,9 +1,11 @@ +/* eslint-disable max-statements */ import path from 'node:path' import zlib from 'node:zlib' import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' import tar from 'tar-fs' +import {debug as debugIt} from '../../debug' import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction' import { checkDir, @@ -13,6 +15,8 @@ import { getOrCreateUserApplication, } from './helpers' +const debug = debugIt.extend('deploy') + export interface DeployStudioActionFlags extends BuildSanityStudioCommandFlags { build?: boolean } @@ -34,7 +38,7 @@ export default async function deployStudioAction( const client = apiClient({ requireUser: true, requireProject: true, - }).withConfig({apiVersion: 'vX'}) + }).withConfig({apiVersion: 'v2024-08-01'}) if (customSourceDir === 'graphql') { throw new Error('Did you mean `sanity graphql deploy`?') @@ -66,12 +70,25 @@ export default async function deployStudioAction( // Check that the project has a studio hostname let spinner = output.spinner('Checking project info').start() - const userApplication = await getOrCreateUserApplication({ - client, - context, - // ensures only v3 configs with `studioHost` are sent - ...(cliConfig && 'studioHost' in cliConfig && {cliConfig}), - }) + let userApplication + + try { + userApplication = await getOrCreateUserApplication({ + client, + context, + spinner, + // ensures only v3 configs with `studioHost` are sent + ...(cliConfig && 'studioHost' in cliConfig && {cliConfig}), + }) + } 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 @@ -95,6 +112,7 @@ export default async function deployStudioAction( spinner.succeed() } catch (err) { spinner.fail() + debug('Error checking directory', err) throw err } @@ -105,7 +123,7 @@ export default async function deployStudioAction( spinner = output.spinner('Deploying to Sanity.Studio').start() try { - await createDeployment({ + const {location} = await createDeployment({ client, applicationId: userApplication.id, version: installedSanityVersion, @@ -116,11 +134,10 @@ export default async function deployStudioAction( spinner.succeed() // And let the user know we're done - output.print( - `\nSuccess! Studio deployed to ${chalk.cyan(`https://${userApplication.appHost}.sanity.studio`)}`, - ) + output.print(`\nSuccess! Studio deployed to ${chalk.cyan(location)}`) } catch (err) { spinner.fail() + debug('Error deploying studio', err) throw err } } diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 6430d870143..2bef9c4582d 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 CliConfig} from '@sanity/cli' import {type SanityClient} from '@sanity/client' import FormData from 'form-data' +import {type Ora} from 'ora' import readPkgUp from 'read-pkg-up' // TODO: replace with `Promise.withResolvers()` once it lands in node @@ -34,7 +35,6 @@ export interface UserApplication { id: string projectId: string title: string | null - isDefault: boolean | null appHost: string urlType: 'internal' | 'external' createdAt: string @@ -65,7 +65,10 @@ export async function getUserApplication({ function createUserApplication( client: SanityClient, - body: {title?: string; isDefault?: boolean; appHost: string; urlType: 'internal' | 'external'}, + body: Pick & { + title?: string + isDefaultForDeployment?: boolean + }, ): Promise { return client.request({uri: '/user-applications', method: 'POST', body}) } @@ -73,22 +76,30 @@ function createUserApplication( export interface GetOrCreateUserApplicationOptions { client: SanityClient context: Pick + spinner: Ora cliConfig?: Pick } export async function getOrCreateUserApplication({ client, cliConfig, + spinner, context: {output, prompt}, }: GetOrCreateUserApplicationOptions): Promise { // if there is already an existing user-app, then just return it const existingUserApplication = await getUserApplication({client, appHost: cliConfig?.studioHost}) - if (existingUserApplication) return existingUserApplication + + // Complete the spinner so prompt can properly work + spinner.succeed() + + if (existingUserApplication) { + return existingUserApplication + } // otherwise, we need to create one. // if a `studioHost` was provided in the CLI config, then use that if (cliConfig?.studioHost) { - return await createUserApplication(client, { + return createUserApplication(client, { appHost: cliConfig.studioHost, urlType: 'internal', }) @@ -111,14 +122,16 @@ export async function getOrCreateUserApplication({ try { const response = await createUserApplication(client, { appHost, - isDefault: true, + isDefaultForDeployment: true, 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 (e?.statusCode === 409) return e?.message || 'Conflict' // just in case + if (e?.statusCode === 409) { + return e?.message || 'Conflict' // just in case + } // otherwise, it's a fatal error throw e @@ -143,16 +156,17 @@ export async function createDeployment({ applicationId, isAutoUpdating, version, -}: CreateDeploymentOptions): Promise { +}: 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'}) - await client.request({ + return client.request({ uri: `/user-applications/${applicationId}/deployments`, method: 'POST', - body: formData.pipe(new PassThrough()) as any, + headers: formData.getHeaders(), + body: formData.pipe(new PassThrough()), }) } diff --git a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts index ed5ecb560e7..185abc91579 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts @@ -1,9 +1,12 @@ import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' +import {debug as debugIt} from '../../debug' import {deleteUserApplication, getUserApplication} from './helpers' +const debug = debugIt.extend('undeploy') + export default async function undeployStudioAction( - args: CliCommandArguments>, + _: CliCommandArguments>, context: CliCommandContext, ): Promise { const {apiClient, chalk, output, prompt, cliConfig} = context @@ -11,7 +14,7 @@ export default async function undeployStudioAction( const client = apiClient({ requireUser: true, requireProject: true, - }).withConfig({apiVersion: 'vX'}) + }).withConfig({apiVersion: 'v2024-08-01'}) // Check that the project has a studio hostname let spinner = output.spinner('Checking project info').start() @@ -51,6 +54,7 @@ export default async function undeployStudioAction( spinner.succeed() } catch (err) { spinner.fail() + debug('Error undeploying studio', err) throw err } From 0e7b4e0a3ffeed5030bc2634636d0af5f02457aa Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Mon, 5 Aug 2024 16:25:34 -0400 Subject: [PATCH 06/14] fix(cli): fixes dependency issue --- packages/sanity/src/_internal/cli/actions/deploy/helpers.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 2bef9c4582d..889c9af1aed 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -3,10 +3,9 @@ import path from 'node:path' import {PassThrough} from 'node:stream' import {type Gzip} from 'node:zlib' -import {type CliCommandContext, type CliConfig} from '@sanity/cli' +import {type CliCommandContext, type CliConfig, type CliOutputter} from '@sanity/cli' import {type SanityClient} from '@sanity/client' import FormData from 'form-data' -import {type Ora} from 'ora' import readPkgUp from 'read-pkg-up' // TODO: replace with `Promise.withResolvers()` once it lands in node @@ -76,7 +75,7 @@ function createUserApplication( export interface GetOrCreateUserApplicationOptions { client: SanityClient context: Pick - spinner: Ora + spinner: ReturnType cliConfig?: Pick } From a88ea01f09446fd17dda0bb53a71fd6279f8b579 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Mon, 5 Aug 2024 16:30:11 -0400 Subject: [PATCH 07/14] fix(core): fixes issue with depedency in tests --- .../_internal/cli/actions/deploy/__tests__/helpers.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 7a225fe3955..893331c4da7 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 @@ -6,7 +6,6 @@ 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 {type Ora} from 'ora' import { checkDir, @@ -39,8 +38,8 @@ const mockOutput = { } as CliCommandContext['output'] const mockPrompt = {single: jest.fn()} as unknown as CliCommandContext['prompt'] const mockSpinner = { - succeed: jest.fn() as jest.Mock, -} as unknown as Ora + succeed: jest.fn(), +} as unknown as ReturnType const mockFetch = jest.fn() global.fetch = mockFetch From e0fcbecebbedaca44c2c38f3392340e35ad84d45 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Mon, 5 Aug 2024 23:42:07 -0400 Subject: [PATCH 08/14] feat(cli): handle error statusCode from API --- .../deploy/__tests__/deployAction.test.ts | 40 ++++++++++++++++++- .../_internal/cli/actions/deploy/helpers.ts | 4 +- 2 files changed, 41 insertions(+), 3 deletions(-) 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 b9b2462d513..fd0007831dc 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 @@ -55,8 +55,9 @@ describe('deployStudioAction', () => { withConfig: jest.fn().mockReturnThis(), }), workDir: '/fake/work/dir', - chalk: {cyan: jest.fn((str) => str)}, + 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), }, @@ -202,4 +203,41 @@ describe('deployStudioAction', () => { ), ).rejects.toThrow('Did you mean `sanity graphql deploy`?') }) + + it('returns an error if API responds with 402', async () => { + const mockSpinner = mockContext.output.spinner('') + + // 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/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 889c9af1aed..fca02fffc53 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -128,8 +128,8 @@ export async function getOrCreateUserApplication({ return true } catch (e) { // if the name is taken, it should return a 409 so we relay to the user - if (e?.statusCode === 409) { - return e?.message || 'Conflict' // just in case + if ([402, 409].includes(e?.statusCode)) { + return e?.message || 'Bad request' // just in case } // otherwise, it's a fatal error From 8b8c28db184c10ba0dbbac1a072caf21603a7640 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 14:05:22 -0400 Subject: [PATCH 09/14] fix(cli): don't create default apps on deploy --- .../src/_internal/cli/actions/deploy/__tests__/helpers.test.ts | 3 +-- packages/sanity/src/_internal/cli/actions/deploy/helpers.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) 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 893331c4da7..8ed47e3645b 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 @@ -122,12 +122,11 @@ describe('getOrCreateUserApplication', () => { expect(result).toEqual(newApp) }) - it('creates a default user application by prompting the user for a name', async () => { + it('creates a user application by prompting the user for a name', async () => { const newApp = { id: 'default-app', appHost: 'default.sanity.studio', urlType: 'internal', - isDefaultForDeployment: true, } mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app ;(mockPrompt.single as jest.Mock).mockImplementationOnce( diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index fca02fffc53..ac58cca914c 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -66,7 +66,6 @@ function createUserApplication( client: SanityClient, body: Pick & { title?: string - isDefaultForDeployment?: boolean }, ): Promise { return client.request({uri: '/user-applications', method: 'POST', body}) @@ -121,7 +120,6 @@ export async function getOrCreateUserApplication({ try { const response = await createUserApplication(client, { appHost, - isDefaultForDeployment: true, urlType: 'internal', }) resolve(response) From 707fc07d6e0f346842d11555d44ca360f109c0fa Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 18:01:15 -0400 Subject: [PATCH 10/14] feat(cli): add list of deployments on deploy --- .../cli/actions/deploy/deployAction.ts | 30 ++-- .../_internal/cli/actions/deploy/helpers.ts | 152 ++++++++++++++++-- 2 files changed, 157 insertions(+), 25 deletions(-) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index faf84c7807f..3ea3432184e 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -5,18 +5,18 @@ import zlib from 'node:zlib' import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' import tar from 'tar-fs' -import {debug as debugIt} from '../../debug' import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction' import { checkDir, createDeployment, + debug, dirIsEmptyOrNonExistent, getInstalledSanityVersion, getOrCreateUserApplication, + getOrCreateUserApplicationFromConfig, + type UserApplication, } from './helpers' -const debug = debugIt.extend('deploy') - export interface DeployStudioActionFlags extends BuildSanityStudioCommandFlags { build?: boolean } @@ -70,16 +70,24 @@ export default async function deployStudioAction( // Check that the project has a studio hostname let spinner = output.spinner('Checking project info').start() - let userApplication + let userApplication: UserApplication try { - userApplication = await getOrCreateUserApplication({ - client, - context, - spinner, - // ensures only v3 configs with `studioHost` are sent - ...(cliConfig && 'studioHost' in cliConfig && {cliConfig}), - }) + // If the user has provided a studioHost in the config, use that + if (cliConfig && 'studioHost' in cliConfig && cliConfig.studioHost) { + userApplication = await getOrCreateUserApplicationFromConfig({ + client, + context, + spinner, + appHost: cliConfig.studioHost, + }) + } else { + userApplication = await getOrCreateUserApplication({ + client, + context, + spinner, + }) + } } catch (err) { if (err.message) { output.error(chalk.red(err.message)) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index ac58cca914c..a2fd28eedc8 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -3,11 +3,15 @@ import path from 'node:path' import {PassThrough} from 'node:stream' import {type Gzip} from 'node:zlib' -import {type CliCommandContext, type CliConfig, type CliOutputter} from '@sanity/cli' +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 @@ -42,8 +46,11 @@ export interface UserApplication { activeDeployment?: ActiveDeployment | null } -export interface GetUserApplicationOptions { +export interface GetUserApplicationsOptions { client: SanityClient +} + +export interface GetUserApplicationOptions extends GetUserApplicationsOptions { appHost?: string } @@ -57,7 +64,28 @@ export async function getUserApplication({ query: appHost ? {appHost} : {default: 'true'}, }) } catch (e) { - if (e?.statusCode === 404) return null + 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 } } @@ -75,17 +103,46 @@ export interface GetOrCreateUserApplicationOptions { client: SanityClient context: Pick spinner: ReturnType - cliConfig?: Pick } +/** + * 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, - cliConfig, spinner, - context: {output, prompt}, + context, }: GetOrCreateUserApplicationOptions): Promise { + const {output, prompt} = context // if there is already an existing user-app, then just return it - const existingUserApplication = await getUserApplication({client, appHost: cliConfig?.studioHost}) + const existingUserApplication = await getUserApplication({client}) // Complete the spinner so prompt can properly work spinner.succeed() @@ -94,13 +151,28 @@ export async function getOrCreateUserApplication({ return existingUserApplication } - // otherwise, we need to create one. - // if a `studioHost` was provided in the CLI config, then use that - if (cliConfig?.studioHost) { - return createUserApplication(client, { - appHost: cliConfig.studioHost, - urlType: 'internal', + 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 @@ -127,9 +199,10 @@ export async function getOrCreateUserApplication({ } 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?.message || 'Bad request' // just in case + return e?.response?.body?.message || 'Bad request' // just in case } + debug('Error creating user application', e) // otherwise, it's a fatal error throw e } @@ -139,6 +212,57 @@ export async function getOrCreateUserApplication({ 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 From b923c0dcbc4f8ccaa69ff76de5920223eb65cf39 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 23:16:23 -0400 Subject: [PATCH 11/14] test: fix and add tests for new changes --- .../deploy/__tests__/deployAction.test.ts | 58 ++++++++- .../actions/deploy/__tests__/helpers.test.ts | 114 ++++++++++++------ 2 files changed, 132 insertions(+), 40 deletions(-) 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 fd0007831dc..493ad21f0cf 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 @@ -16,7 +16,7 @@ jest.mock('../helpers') jest.mock('../../build/buildAction') type Helpers = typeof _helpers -const helpers = _helpers as {[K in keyof Helpers]: jest.Mock} +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 @@ -118,6 +118,60 @@ describe('deployStudioAction', () => { 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('') @@ -205,8 +259,6 @@ describe('deployStudioAction', () => { }) it('returns an error if API responds with 402', async () => { - const mockSpinner = mockContext.output.spinner('') - // Mock utility functions helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true) helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX') 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 8ed47e3645b..c634816e96a 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 @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import {type Stats} from 'node:fs' import fs from 'node:fs/promises' import {Readable} from 'node:stream' @@ -13,6 +14,7 @@ import { deleteUserApplication, dirIsEmptyOrNonExistent, getOrCreateUserApplication, + getOrCreateUserApplicationFromConfig, } from '../helpers' jest.mock('node:fs/promises') @@ -36,8 +38,12 @@ const mockOutput = { warn: jest.fn(), spinner: jest.fn(), } as CliCommandContext['output'] -const mockPrompt = {single: jest.fn()} as unknown as CliCommandContext['prompt'] +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 @@ -68,6 +74,71 @@ describe('getOrCreateUserApplication', () => { 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', @@ -75,16 +146,16 @@ describe('getOrCreateUserApplication', () => { urlType: 'internal', }) - const result = await getOrCreateUserApplication({ + const result = await getOrCreateUserApplicationFromConfig({ client: mockClient, spinner: mockSpinner, - cliConfig: {studioHost: 'example.sanity.studio'}, context, + appHost: 'example', }) expect(mockClientRequest).toHaveBeenCalledWith({ uri: '/user-applications', - query: {appHost: 'example.sanity.studio'}, + query: {appHost: 'example'}, }) expect(result).toEqual({ id: 'existing-app', @@ -102,11 +173,11 @@ describe('getOrCreateUserApplication', () => { mockClientRequest.mockResolvedValueOnce(null) // Simulate no existing app mockClientRequest.mockResolvedValueOnce(newApp) - const result = await getOrCreateUserApplication({ + const result = await getOrCreateUserApplicationFromConfig({ client: mockClient, spinner: mockSpinner, - cliConfig: {studioHost: 'newhost'}, context, + appHost: 'newhost', }) expect(mockClientRequest).toHaveBeenCalledTimes(2) @@ -121,37 +192,6 @@ describe('getOrCreateUserApplication', () => { }) expect(result).toEqual(newApp) }) - - 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 - ;(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) - }) }) describe('createDeployment', () => { From 89ee33fa9cc67ce5244222b05c45cfdb4314584f Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Wed, 14 Aug 2024 12:27:16 -0400 Subject: [PATCH 12/14] fix: error handling and update copy --- .../cli/actions/deploy/__tests__/undeployAction.test.ts | 5 ++++- packages/sanity/src/_internal/cli/actions/deploy/helpers.ts | 2 +- .../src/_internal/cli/actions/deploy/undeployAction.ts | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) 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 index 31a8e209dd1..936c8dede0b 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/__tests__/undeployAction.test.ts @@ -61,7 +61,10 @@ describe('undeployStudioAction', () => { await undeployStudioAction({} as CliCommandArguments>, mockContext) expect(mockContext.output.print).toHaveBeenCalledWith( - 'Your project has not been assigned a studio hostname.', + '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.') }) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index a2fd28eedc8..6faf83d5aec 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -254,7 +254,7 @@ export async function getOrCreateUserApplicationFromConfig({ 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 + throw new Error(e?.response?.body?.message || 'Bad request') // just in case } debug('Error creating user application from config', e) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts index 185abc91579..376f53b06d4 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/undeployAction.ts @@ -27,7 +27,8 @@ export default async function undeployStudioAction( spinner.succeed() if (!userApplication) { - output.print('Your project has not been assigned a studio hostname.') + 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 } From 85cea12f3a428745897b38824b11cb2588c6533d Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Wed, 14 Aug 2024 13:40:03 -0400 Subject: [PATCH 13/14] chore(cli): update copy Co-authored-by: Carolina Gonzalez --- packages/sanity/src/_internal/cli/actions/deploy/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts index 6faf83d5aec..5bd56da3766 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/helpers.ts @@ -177,7 +177,7 @@ export async function getOrCreateUserApplication({ // 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('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() From 3b8936d2c573107e0910c2270d2c58c29e43e65e Mon Sep 17 00:00:00 2001 From: Carolina Gonzalez Date: Wed, 14 Aug 2024 13:53:51 -0400 Subject: [PATCH 14/14] feat(cli): add message about adding studioHost to cliConfig on deploys (#7349) Co-authored-by: Rune Botten --- .../src/_internal/cli/actions/deploy/deployAction.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index 3ea3432184e..5a773d52b71 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -34,6 +34,7 @@ export default async function deployStudioAction( (cliConfig && 'autoUpdates' in cliConfig && cliConfig.autoUpdates === true) || false const installedSanityVersion = await getInstalledSanityVersion() + const configStudioHost = cliConfig && 'studioHost' in cliConfig && cliConfig.studioHost const client = apiClient({ requireUser: true, @@ -74,12 +75,12 @@ export default async function deployStudioAction( try { // If the user has provided a studioHost in the config, use that - if (cliConfig && 'studioHost' in cliConfig && cliConfig.studioHost) { + if (configStudioHost) { userApplication = await getOrCreateUserApplicationFromConfig({ client, context, spinner, - appHost: cliConfig.studioHost, + appHost: configStudioHost, }) } else { userApplication = await getOrCreateUserApplication({ @@ -143,6 +144,12 @@ export default async function deployStudioAction( // And let the user know we're done output.print(`\nSuccess! 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.') + } } catch (err) { spinner.fail() debug('Error deploying studio', err)