From 046f56a4923066daaf98d88990aa9108624c069c Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 14:05:22 -0400 Subject: [PATCH 1/3] 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 f2fb29982cecacce977df0066100f6e7b94006b2 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 18:01:15 -0400 Subject: [PATCH 2/3] 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 812e1e393037e12bad90533b992ab4043396b21a Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Tue, 6 Aug 2024 23:16:23 -0400 Subject: [PATCH 3/3] 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', () => {