diff --git a/api/__tests__/migrate.test.ts b/api/__tests__/migrate.test.ts new file mode 100644 index 000000000..49e88f18c --- /dev/null +++ b/api/__tests__/migrate.test.ts @@ -0,0 +1,190 @@ +import { http } from '@hubspot/local-dev-lib/http'; +import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration'; +import { + listAppsForMigration, + initializeMigration, + continueMigration, + checkMigrationStatusV2, + ListAppsResponse, + MigrationStatus, +} from '../migrate'; + +jest.mock('@hubspot/local-dev-lib/http'); + +const httpMock = http as jest.Mocked; + +describe('api/migrate', () => { + const mockAccountId = 12345; + const mockPortalId = 12345; + const mockAppId = 67890; + const mockMigrationId = 54321; + const mockPlatformVersion = '2025.2'; + const mockProjectName = 'Test Project'; + const mockComponentUids = { 'component-1': 'uid-1', 'component-2': 'uid-2' }; + + describe('listAppsForMigration', () => { + it('should call http.get with correct parameters', async () => { + const mockResponse: ListAppsResponse = { + migratableApps: [ + { + appId: 1, + appName: 'App 1', + isMigratable: true, + migrationComponents: [ + { id: 'comp1', componentType: 'type1', isSupported: true }, + ], + }, + ], + unmigratableApps: [ + { + appId: 2, + appName: 'App 2', + isMigratable: false, + unmigratableReason: 'UP_TO_DATE', + migrationComponents: [ + { id: 'comp2', componentType: 'type2', isSupported: false }, + ], + }, + ], + }; + + // @ts-expect-error Mock + httpMock.get.mockResolvedValue(mockResponse); + + const result = await listAppsForMigration(mockAccountId); + + expect(http.get).toHaveBeenCalledWith(mockAccountId, { + url: 'dfs/migrations/v2/list-apps', + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('initializeMigration', () => { + it('should call http.post with correct parameters', async () => { + const mockResponse = { migrationId: mockMigrationId }; + // @ts-expect-error Mock + httpMock.post.mockResolvedValue(mockResponse); + + const result = await initializeMigration( + mockAccountId, + mockAppId, + mockPlatformVersion + ); + + expect(http.post).toHaveBeenCalledWith(mockAccountId, { + url: 'dfs/migrations/v2/migrations', + data: { + applicationId: mockAppId, + platformVersion: 'V2025_2', + }, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('continueMigration', () => { + it('should call http.post with correct parameters', async () => { + const mockResponse = { migrationId: mockMigrationId }; + // @ts-expect-error Mock + httpMock.post.mockResolvedValue(mockResponse); + + const result = await continueMigration( + mockPortalId, + mockMigrationId, + mockComponentUids, + mockProjectName + ); + + expect(http.post).toHaveBeenCalledWith(mockPortalId, { + url: 'dfs/migrations/v2/migrations/continue', + data: { + migrationId: mockMigrationId, + projectName: mockProjectName, + componentUids: mockComponentUids, + }, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('checkMigrationStatusV2', () => { + it('should call http.get with correct parameters for in-progress status', async () => { + const mockResponse: MigrationStatus = { + id: mockMigrationId, + status: MIGRATION_STATUS.IN_PROGRESS, + }; + // @ts-expect-error Mock + httpMock.get.mockResolvedValue(mockResponse); + + const result = await checkMigrationStatusV2( + mockAccountId, + mockMigrationId + ); + + expect(http.get).toHaveBeenCalledWith(mockAccountId, { + url: `dfs/migrations/v2/migrations/${mockMigrationId}/status`, + }); + expect(result).toEqual(mockResponse); + }); + + it('should handle input required status', async () => { + const mockResponse: MigrationStatus = { + id: mockMigrationId, + status: MIGRATION_STATUS.INPUT_REQUIRED, + componentsRequiringUids: { + 'component-1': { + componentType: 'type1', + componentHint: 'hint1', + }, + }, + }; + // @ts-expect-error Mock + httpMock.get.mockResolvedValue(mockResponse); + + const result = await checkMigrationStatusV2( + mockAccountId, + mockMigrationId + ); + + expect(result).toEqual(mockResponse); + }); + + it('should handle success status', async () => { + const mockResponse: MigrationStatus = { + id: mockMigrationId, + status: MIGRATION_STATUS.SUCCESS, + buildId: 98765, + }; + // @ts-expect-error Mock + httpMock.get.mockResolvedValue(mockResponse); + + const result = await checkMigrationStatusV2( + mockAccountId, + mockMigrationId + ); + + expect(result).toEqual(mockResponse); + }); + + it('should handle failure status', async () => { + const mockResponse: MigrationStatus = { + id: mockMigrationId, + status: MIGRATION_STATUS.FAILURE, + projectErrorsDetail: 'Error details', + componentErrorDetails: { + 'component-1': 'Component error', + }, + }; + // @ts-expect-error Mock + httpMock.get.mockResolvedValue(mockResponse); + + const result = await checkMigrationStatusV2( + mockAccountId, + mockMigrationId + ); + + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/api/migrate.ts b/api/migrate.ts new file mode 100644 index 000000000..d5534afe5 --- /dev/null +++ b/api/migrate.ts @@ -0,0 +1,138 @@ +import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; +import { + PLATFORM_VERSIONS, + UNMIGRATABLE_REASONS, +} from '@hubspot/local-dev-lib/constants/projects'; +import { http } from '@hubspot/local-dev-lib/http'; +import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration'; + +const MIGRATIONS_API_PATH_V2 = 'dfs/migrations/v2'; + +interface BaseMigrationApp { + appId: number; + appName: string; + isMigratable: boolean; + migrationComponents: ListAppsMigrationComponent[]; + projectName?: string; +} + +export interface MigratableApp extends BaseMigrationApp { + isMigratable: true; +} + +export interface UnmigratableApp extends BaseMigrationApp { + isMigratable: false; + unmigratableReason: keyof typeof UNMIGRATABLE_REASONS; +} + +export type MigrationApp = MigratableApp | UnmigratableApp; + +export interface ListAppsResponse { + migratableApps: MigratableApp[]; + unmigratableApps: UnmigratableApp[]; +} + +export interface InitializeMigrationResponse { + migrationId: number; +} + +export interface ListAppsMigrationComponent { + id: string; + componentType: string; + isSupported: boolean; +} + +export type ContinueMigrationResponse = { + migrationId: number; +}; + +export interface MigrationBaseStatus { + id: number; +} + +export interface MigrationInProgress extends MigrationBaseStatus { + status: typeof MIGRATION_STATUS.IN_PROGRESS; +} + +export interface MigrationInputRequired extends MigrationBaseStatus { + status: typeof MIGRATION_STATUS.INPUT_REQUIRED; + componentsRequiringUids: Record< + string, + { + componentType: string; + componentHint: string; + } + >; +} + +export interface MigrationSuccess extends MigrationBaseStatus { + status: typeof MIGRATION_STATUS.SUCCESS; + buildId: number; +} + +export interface MigrationFailed extends MigrationBaseStatus { + status: typeof MIGRATION_STATUS.FAILURE; + projectErrorsDetail?: string; + componentErrorDetails: Record; +} + +export type MigrationStatus = + | MigrationInProgress + | MigrationInputRequired + | MigrationSuccess + | MigrationFailed; + +export async function listAppsForMigration( + accountId: number +): HubSpotPromise { + return http.get(accountId, { + url: `${MIGRATIONS_API_PATH_V2}/list-apps`, + }); +} + +function mapPlatformVersionToEnum(platformVersion: string): string { + if (platformVersion === PLATFORM_VERSIONS.unstable) { + return PLATFORM_VERSIONS.unstable.toUpperCase(); + } + + return `V${platformVersion.replace('.', '_')}`; +} + +export async function initializeMigration( + accountId: number, + applicationId: number, + platformVersion: string +): HubSpotPromise { + return http.post(accountId, { + url: `${MIGRATIONS_API_PATH_V2}/migrations`, + data: { + applicationId, + platformVersion: mapPlatformVersionToEnum(platformVersion), + }, + }); +} + +export async function continueMigration( + portalId: number, + migrationId: number, + componentUids: Record, + projectName: string +): HubSpotPromise { + return http.post(portalId, { + url: `${MIGRATIONS_API_PATH_V2}/migrations/continue`, + data: { + migrationId, + projectName, + componentUids, + }, + }); +} + +export function checkMigrationStatusV2( + accountId: number, + id: number +): HubSpotPromise { + return http.get(accountId, { + url: `${MIGRATIONS_API_PATH_V2}/migrations/${id}/status`, + }); +} diff --git a/bin/cli.js b/bin/cli.js index a0c4010ee..796481a04 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -51,6 +51,7 @@ const cmsCommand = require('../commands/cms'); const feedbackCommand = require('../commands/feedback'); const doctorCommand = require('../commands/doctor'); const completionCommand = require('../commands/completion'); +const appCommand = require('../commands/app'); notifyAboutUpdates(); @@ -134,6 +135,7 @@ const argv = yargs .command(feedbackCommand) .command(doctorCommand) .command(completionCommand) + .command(appCommand) .help() .alias('h', 'help') .recommendCommands() diff --git a/commands/__tests__/project.test.ts b/commands/__tests__/project.test.ts index ee2074a86..9a88b0a06 100644 --- a/commands/__tests__/project.test.ts +++ b/commands/__tests__/project.test.ts @@ -25,10 +25,11 @@ jest.mock('../project/download'); jest.mock('../project/open'); jest.mock('../project/dev'); jest.mock('../project/add'); -jest.mock('../project/migrateApp'); -jest.mock('../project/cloneApp'); +jest.mock('../project/migrateApp', () => ({})); +jest.mock('../project/cloneApp', () => ({})); jest.mock('../project/installDeps'); jest.mock('../../lib/commonOpts'); + yargs.command.mockReturnValue(yargs); yargs.demandCommand.mockReturnValue(yargs); diff --git a/commands/app.ts b/commands/app.ts new file mode 100644 index 000000000..f73ad6f94 --- /dev/null +++ b/commands/app.ts @@ -0,0 +1,22 @@ +import migrateCommand from './app/migrate'; +import { addGlobalOptions } from '../lib/commonOpts'; +import { Argv, CommandModule } from 'yargs'; + +export const command = ['app', 'apps']; + +// Keep the command hidden for now +export const describe = undefined; + +export function builder(yargs: Argv) { + addGlobalOptions(yargs); + + return yargs.command(migrateCommand).demandCommand(1, ''); +} + +const appCommand: CommandModule = { + command, + describe, + builder, + handler: () => {}, +}; +export default appCommand; diff --git a/commands/app/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts new file mode 100644 index 000000000..52b9a797e --- /dev/null +++ b/commands/app/__tests__/migrate.test.ts @@ -0,0 +1,150 @@ +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { handler, builder } from '../migrate'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { migrateApp2025_2 } from '../../../lib/app/migrate'; +import { migrateApp2023_2 } from '../../../lib/app/migrate_legacy'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { EXIT_CODES } from '../../../lib/enums/exitCodes'; +import { MigrateAppOptions } from '../../../types/Yargs'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; + +jest.mock('@hubspot/local-dev-lib/config'); +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('../../../lib/app/migrate'); +jest.mock('../../../lib/app/migrate_legacy'); +jest.mock('yargs'); + +const mockedGetAccountConfig = getAccountConfig as jest.Mock; +const mockedMigrateApp2023_2 = migrateApp2023_2 as jest.Mock; +const mockedMigrateApp2025_2 = migrateApp2025_2 as jest.Mock; +const mockedLogger = logger as jest.Mocked; + +describe('commands/app/migrate', () => { + const mockAccountId = 123; + const mockAccountConfig = { + name: 'Test Account', + env: 'prod', + }; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation(); + mockedGetAccountConfig.mockReturnValue(mockAccountConfig); + }); + + describe('handler', () => { + it('should exit with error when no account config is found', async () => { + mockedGetAccountConfig.mockReturnValue(null); + + await handler({ + derivedAccountId: mockAccountId, + } as ArgumentsCamelCase); + + expect(mockedLogger.error).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.ERROR); + exitSpy.mockRestore(); + }); + + it('should call migrateApp2025_2 for platform version 2025.2', async () => { + await handler({ + derivedAccountId: mockAccountId, + platformVersion: PLATFORM_VERSIONS.v2025_2, + } as ArgumentsCamelCase); + + expect(mockedMigrateApp2025_2).toHaveBeenCalledWith( + mockAccountId, + expect.any(Object) + ); + expect(mockedMigrateApp2023_2).not.toHaveBeenCalled(); + }); + + it('should call migrateApp2023_2 for platform version 2023.2', async () => { + await handler({ + derivedAccountId: mockAccountId, + platformVersion: PLATFORM_VERSIONS.v2023_2, + } as ArgumentsCamelCase); + + expect(mockedMigrateApp2023_2).toHaveBeenCalledWith( + mockAccountId, + expect.any(Object), + mockAccountConfig + ); + expect(mockedMigrateApp2025_2).not.toHaveBeenCalled(); + }); + + it('should handle errors during migration', async () => { + const mockError = new Error('Migration failed'); + mockedMigrateApp2023_2.mockRejectedValue(mockError); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(); + + await handler({ + derivedAccountId: mockAccountId, + platformVersion: PLATFORM_VERSIONS.v2023_2, + } as ArgumentsCamelCase); + + expect(mockedLogger.error).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.ERROR); + exitSpy.mockRestore(); + }); + }); + + describe('builder', () => { + let mockYargs: Argv; + + beforeEach(() => { + mockYargs = { + options: jest.fn().mockReturnThis(), + option: jest.fn().mockReturnThis(), + example: jest.fn().mockReturnThis(), + conflicts: jest.fn().mockReturnThis(), + argv: { _: ['app', 'migrate'] }, + } as unknown as Argv; + }); + + it('should add required options', async () => { + await builder(mockYargs); + + expect(mockYargs.options).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.objectContaining({ + type: 'string', + describe: expect.any(String), + }), + dest: expect.objectContaining({ + type: 'string', + describe: expect.any(String), + }), + 'app-id': expect.objectContaining({ + type: 'number', + describe: expect.any(String), + }), + 'platform-version': expect.objectContaining({ + type: 'string', + default: '2023.2', + hidden: true, + }), + }) + ); + }); + + it('should set default platform version to 2023.2', async () => { + await builder(mockYargs); + + expect(mockYargs.options).toHaveBeenCalledWith( + expect.objectContaining({ + 'platform-version': expect.objectContaining({ + default: '2023.2', + }), + }) + ); + }); + + it('should add example command', async () => { + await builder(mockYargs); + + expect(mockYargs.example).toHaveBeenCalledWith([ + ['$0 app migrate', expect.any(String)], + ]); + }); + }); +}); diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts new file mode 100644 index 000000000..104c18a7a --- /dev/null +++ b/commands/app/migrate.ts @@ -0,0 +1,138 @@ +import { logger } from '@hubspot/local-dev-lib/logger'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; + +import { + addAccountOptions, + addConfigOptions, + addUseEnvironmentOptions, +} from '../../lib/commonOpts'; +import { + trackCommandMetadataUsage, + trackCommandUsage, +} from '../../lib/usageTracking'; +import { i18n } from '../../lib/lang'; +import { ApiErrorContext, logError } from '../../lib/errorHandlers'; +import { EXIT_CODES } from '../../lib/enums/exitCodes'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { migrateApp2025_2 } from '../../lib/app/migrate'; +import { uiBetaTag, uiLink } from '../../lib/ui'; +import { migrateApp2023_2 } from '../../lib/app/migrate_legacy'; + +const { v2023_2, v2025_2, unstable } = PLATFORM_VERSIONS; +export const validMigrationTargets = [v2023_2, v2025_2, unstable]; + +const command = 'migrate'; +const describe = undefined; // uiBetaTag(i18n(`commands.project.subcommands.migrateApp.header.text.describe`), false); + +export async function handler(options: ArgumentsCamelCase) { + const { derivedAccountId, platformVersion } = options; + await trackCommandUsage('migrate-app', {}, derivedAccountId); + const accountConfig = getAccountConfig(derivedAccountId); + + if (!accountConfig) { + logger.error( + i18n(`commands.project.subcommands.migrateApp.errors.noAccountConfig`) + ); + return process.exit(EXIT_CODES.ERROR); + } + + logger.log(''); + logger.log( + uiBetaTag( + i18n(`commands.project.subcommands.migrateApp.header.text`), + false + ) + ); + logger.log( + uiLink( + i18n(`commands.project.subcommands.migrateApp.header.link`), + 'https://developers.hubspot.com/docs/platform/migrate-a-public-app-to-projects' + ) + ); + logger.log(''); + + try { + if (platformVersion === v2025_2 || platformVersion === unstable) { + await migrateApp2025_2(derivedAccountId, options); + } else { + await migrateApp2023_2(derivedAccountId, options, accountConfig); + } + } catch (error) { + if ( + error && + typeof error === 'object' && + 'errors' in error && + Array.isArray(error.errors) + ) { + error.errors.forEach(err => logError(err)); + } else { + logError(error, new ApiErrorContext({ accountId: derivedAccountId })); + } + await trackCommandMetadataUsage( + 'migrate-app', + { successful: false }, + derivedAccountId + ); + process.exit(EXIT_CODES.ERROR); + } + + await trackCommandMetadataUsage( + 'migrate-app', + { successful: true }, + derivedAccountId + ); + return process.exit(EXIT_CODES.SUCCESS); +} + +export function builder(yargs: Argv): Argv { + addConfigOptions(yargs); + addAccountOptions(yargs); + addUseEnvironmentOptions(yargs); + + yargs.options({ + name: { + describe: i18n( + `commands.project.subcommands.migrateApp.options.name.describe` + ), + type: 'string', + }, + dest: { + describe: i18n( + `commands.project.subcommands.migrateApp.options.dest.describe` + ), + type: 'string', + }, + 'app-id': { + describe: i18n( + `commands.project.subcommands.migrateApp.options.appId.describe` + ), + type: 'number', + }, + 'platform-version': { + type: 'string', + choices: validMigrationTargets, + hidden: true, + default: '2023.2', + }, + }); + + yargs.example([ + [ + `$0 app migrate`, + i18n(`commands.project.subcommands.migrateApp.examples.default`), + ], + ]); + + return yargs as Argv; +} + +const migrateCommand: CommandModule = { + command, + describe, + handler, + builder, +}; + +export default migrateCommand; diff --git a/commands/customObject/schema/update.ts b/commands/customObject/schema/update.ts index ab22e4204..70e8d93b9 100644 --- a/commands/customObject/schema/update.ts +++ b/commands/customObject/schema/update.ts @@ -65,7 +65,7 @@ export async function handler( name = providedName && typeof providedName === 'string' ? providedName - : await listPrompt(i18n(`commands.customObject.subcommands.schema.subcommands.update.selectSchema`), { + : await listPrompt(i18n(`commands.customObject.subcommands.schema.subcommands.update.selectSchema`), { choices: schemaNames, }); diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index 707c22e9f..f2636c3e8 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -1,67 +1,60 @@ -// @ts-nocheck -const path = require('path'); -const fs = require('fs'); -const { +import { uiDeprecatedTag } from '../../lib/ui'; +import path from 'path'; +import fs from 'fs'; +import { addAccountOptions, addConfigOptions, addUseEnvironmentOptions, -} = require('../../lib/commonOpts'); -const { +} from '../../lib/commonOpts'; +import { trackCommandUsage, trackCommandMetadataUsage, -} = require('../../lib/usageTracking'); -const { i18n } = require('../../lib/lang'); -const { - selectPublicAppPrompt, -} = require('../../lib/prompts/selectPublicAppPrompt'); -const { - createProjectPrompt, -} = require('../../lib/prompts/createProjectPrompt'); -const { poll } = require('../../lib/polling'); -const { - uiBetaTag, - uiLine, - uiCommandReference, - uiAccountDescription, -} = require('../../lib/ui'); -const SpinniesManager = require('../../lib/ui/SpinniesManager'); -const { logError, ApiErrorContext } = require('../../lib/errorHandlers/index'); -const { EXIT_CODES } = require('../../lib/enums/exitCodes'); -const { isAppDeveloperAccount } = require('../../lib/accountTypes'); -const { writeProjectConfig } = require('../../lib/projects'); -const { PROJECT_CONFIG_FILE } = require('../../lib/constants'); -const { +} from '../../lib/usageTracking'; +import { i18n } from '../../lib/lang'; +import { selectPublicAppPrompt } from '../../lib/prompts/selectPublicAppPrompt'; +import { createProjectPrompt } from '../../lib/prompts/createProjectPrompt'; +import { poll } from '../../lib/polling'; +import { uiLine, uiAccountDescription } from '../../lib/ui'; +import { logError, ApiErrorContext } from '../../lib/errorHandlers'; +import { EXIT_CODES } from '../../lib/enums/exitCodes'; +import { isAppDeveloperAccount } from '../../lib/accountTypes'; +import { writeProjectConfig } from '../../lib/projects'; +import { PROJECT_CONFIG_FILE } from '../../lib/constants'; +import { cloneApp, checkCloneStatus, downloadClonedProject, -} = require('@hubspot/local-dev-lib/api/projects'); -const { getCwd, sanitizeFileName } = require('@hubspot/local-dev-lib/path'); -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { getAccountConfig } = require('@hubspot/local-dev-lib/config'); -const { extractZipArchive } = require('@hubspot/local-dev-lib/archive'); +} from '@hubspot/local-dev-lib/api/projects'; +import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import SpinniesManager from '../../lib/ui/SpinniesManager'; +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { CloneAppArgs } from '../../types/Yargs'; +import { logInvalidAccountError } from '../../lib/app/migrate'; const i18nKey = 'commands.project.subcommands.cloneApp'; -exports.command = 'clone-app'; -exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); +export const command = 'clone-app'; +export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); +export const deprecated = true; -exports.handler = async options => { +export const handler = async (options: ArgumentsCamelCase) => { const { derivedAccountId } = options; + await trackCommandUsage('clone-app', {}, derivedAccountId); + const accountConfig = getAccountConfig(derivedAccountId); const accountName = uiAccountDescription(derivedAccountId); - trackCommandUsage('clone-app', {}, derivedAccountId); + if (!accountConfig) { + throw new Error( + i18n(`commands.projects.subcommands.cloneApp.errors.noAccountConfig`) + ); + } if (!isAppDeveloperAccount(accountConfig)) { - uiLine(); - logger.error(i18n(`${i18nKey}.errors.invalidAccountTypeTitle`)); - logger.log( - i18n(`${i18nKey}.errors.invalidAccountTypeDescription`, { - useCommand: uiCommandReference('hs accounts use'), - authCommand: uiCommandReference('hs auth'), - }) - ); - uiLine(); + logInvalidAccountError(i18nKey); process.exit(EXIT_CODES.SUCCESS); } @@ -74,7 +67,6 @@ exports.handler = async options => { const appIdResponse = await selectPublicAppPrompt({ accountId: derivedAccountId, accountName, - options, isMigratingApp: false, }); appId = appIdResponse.appId; @@ -90,7 +82,7 @@ exports.handler = async options => { await trackCommandMetadataUsage( 'clone-app', - { status: 'STARTED' }, + { step: 'STARTED' }, derivedAccountId ); @@ -149,14 +141,16 @@ exports.handler = async options => { } logger.log(''); uiLine(); - logger.success(i18n(`${i18nKey}.cloneStatus.success`, { dest })); + logger.success( + i18n(`${i18nKey}.cloneStatus.success`, { dest: projectDest }) + ); logger.log(''); process.exit(EXIT_CODES.SUCCESS); } } catch (error) { await trackCommandMetadataUsage( 'clone-app', - { status: 'FAILURE' }, + { successful: false }, derivedAccountId ); @@ -164,8 +158,14 @@ exports.handler = async options => { text: i18n(`${i18nKey}.cloneStatus.failure`), failColor: 'white', }); + // Migrations endpoints return a response object with an errors property. The errors property contains an array of errors. - if (error.errors && Array.isArray(error.errors)) { + if ( + error && + typeof error === 'object' && + 'errors' in error && + Array.isArray(error.errors) + ) { error.errors.forEach(e => logError(e, new ApiErrorContext({ accountId: derivedAccountId })) ); @@ -176,13 +176,13 @@ exports.handler = async options => { await trackCommandMetadataUsage( 'clone-app', - { status: 'SUCCESS' }, + { successful: true }, derivedAccountId ); process.exit(EXIT_CODES.SUCCESS); }; -exports.builder = yargs => { +export const builder = (yargs: Argv) => { yargs.options({ dest: { describe: i18n(`${i18nKey}.options.dest.describe`), @@ -202,5 +202,15 @@ exports.builder = yargs => { addAccountOptions(yargs); addUseEnvironmentOptions(yargs); - return yargs; + return yargs as Argv; +}; + +const cloneAppCommand: CommandModule = { + command, + describe, + handler, + builder, + deprecated, }; + +export default cloneAppCommand; diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index daade61fa..db11d6ad4 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,283 +1,86 @@ -// @ts-nocheck -const path = require('path'); -const { +import { i18n } from '../../lib/lang'; +import { uiCommandReference, uiDeprecatedTag } from '../../lib/ui'; +import { + handler as migrateHandler, + validMigrationTargets, +} from '../app/migrate'; + +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { addAccountOptions, addConfigOptions, addUseEnvironmentOptions, -} = require('../../lib/commonOpts'); -const { - trackCommandUsage, - trackCommandMetadataUsage, -} = require('../../lib/usageTracking'); -const { - createProjectPrompt, -} = require('../../lib/prompts/createProjectPrompt'); -const { i18n } = require('../../lib/lang'); -const { - selectPublicAppPrompt, -} = require('../../lib/prompts/selectPublicAppPrompt'); -const { poll } = require('../../lib/polling'); -const { - uiBetaTag, - uiLine, - uiLink, - uiCommandReference, - uiAccountDescription, -} = require('../../lib/ui'); -const SpinniesManager = require('../../lib/ui/SpinniesManager'); -const { logError, ApiErrorContext } = require('../../lib/errorHandlers/index'); -const { EXIT_CODES } = require('../../lib/enums/exitCodes'); -const { promptUser } = require('../../lib/prompts/promptUtils'); -const { isAppDeveloperAccount } = require('../../lib/accountTypes'); -const { ensureProjectExists } = require('../../lib/projects'); -const { handleKeypress } = require('../../lib/process'); -const { - migrateApp, - checkMigrationStatus, -} = require('@hubspot/local-dev-lib/api/projects'); -const { getCwd, sanitizeFileName } = require('@hubspot/local-dev-lib/path'); -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { getAccountConfig } = require('@hubspot/local-dev-lib/config'); -const { downloadProject } = require('@hubspot/local-dev-lib/api/projects'); -const { extractZipArchive } = require('@hubspot/local-dev-lib/archive'); -const { getHubSpotWebsiteOrigin } = require('@hubspot/local-dev-lib/urls'); -const { - fetchPublicAppMetadata, -} = require('@hubspot/local-dev-lib/api/appsDev'); - -const i18nKey = 'commands.project.subcommands.migrateApp'; - -exports.command = 'migrate-app'; -exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); - -exports.handler = async options => { - const { derivedAccountId } = options; - const accountConfig = getAccountConfig(derivedAccountId); - const accountName = uiAccountDescription(derivedAccountId); - - trackCommandUsage('migrate-app', {}, derivedAccountId); - - logger.log(''); - logger.log(uiBetaTag(i18n(`${i18nKey}.header.text`), false)); - logger.log( - uiLink( - i18n(`${i18nKey}.header.link`), - 'https://developers.hubspot.com/docs/platform/migrate-a-public-app-to-projects' - ) - ); - logger.log(''); - - if (!isAppDeveloperAccount(accountConfig)) { - uiLine(); - logger.error(i18n(`${i18nKey}.errors.invalidAccountTypeTitle`)); - logger.log( - i18n(`${i18nKey}.errors.invalidAccountTypeDescription`, { - useCommand: uiCommandReference('hs accounts use'), - authCommand: uiCommandReference('hs auth'), - }) - ); - uiLine(); - process.exit(EXIT_CODES.SUCCESS); - } - - const { appId } = - 'appId' in options - ? options - : await selectPublicAppPrompt({ - accountId: derivedAccountId, - accountName, - isMigratingApp: true, - }); - - try { - const { data: selectedApp } = await fetchPublicAppMetadata( - appId, - derivedAccountId - ); - // preventProjectMigrations returns true if we have not added app to allowlist config. - // listingInfo will only exist for marketplace apps - const preventProjectMigrations = selectedApp.preventProjectMigrations; - const listingInfo = selectedApp.listingInfo; - if (preventProjectMigrations && listingInfo) { - logger.error(i18n(`${i18nKey}.errors.invalidApp`, { appId })); - process.exit(EXIT_CODES.ERROR); - } - } catch (error) { - logError(error, new ApiErrorContext({ accountId: derivedAccountId })); - process.exit(EXIT_CODES.ERROR); - } - - let projectName; - let projectDest; - try { - const createProjectPromptResponse = await createProjectPrompt(options); - - projectName = createProjectPromptResponse.name; - projectDest = createProjectPromptResponse.dest; - - const { projectExists } = await ensureProjectExists( - derivedAccountId, - projectName, - { - allowCreate: false, - noLogs: true, - } - ); - - if (projectExists) { - logger.error( - i18n(`${i18nKey}.errors.projectAlreadyExists`, { - projectName, - }) - ); - process.exit(EXIT_CODES.ERROR); - } - } catch (error) { - logError(error, new ApiErrorContext({ accountId: derivedAccountId })); - process.exit(EXIT_CODES.ERROR); - } - - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'STARTED' }, - derivedAccountId +} from '../../lib/commonOpts'; + +export const command = 'migrate-app'; + +// TODO: Leave this as deprecated and remove in the next major release +export const describe = uiDeprecatedTag( + i18n(`commands.project.subcommands.migrateApp.describe`), + false +); +export const deprecated = true; + +export async function handler(yargs: ArgumentsCamelCase) { + logger.warn( + i18n(`commands.project.subcommands.migrateApp.deprecationWarning`, { + oldCommand: uiCommandReference('hs project migrate-app'), + newCommand: uiCommandReference('hs app migrate'), + }) ); + await migrateHandler(yargs); +} - logger.log(''); - uiLine(); - logger.warn(i18n(`${i18nKey}.warning.title`)); - logger.log(''); - logger.log(i18n(`${i18nKey}.warning.projectConversion`)); - logger.log(i18n(`${i18nKey}.warning.appConfig`)); - logger.log(''); - logger.log(i18n(`${i18nKey}.warning.buildAndDeploy`)); - logger.log(''); - logger.log(i18n(`${i18nKey}.warning.existingApps`)); - logger.log(''); - logger.log(i18n(`${i18nKey}.warning.copyApp`)); - uiLine(); - - const { shouldCreateApp } = await promptUser({ - name: 'shouldCreateApp', - type: 'confirm', - message: i18n(`${i18nKey}.createAppPrompt`), - }); - process.stdin.resume(); - - if (!shouldCreateApp) { - process.exit(EXIT_CODES.SUCCESS); - } - - try { - SpinniesManager.init(); - - SpinniesManager.add('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.inProgress`), - }); - - handleKeypress(async key => { - if ((key.ctrl && key.name === 'c') || key.name === 'q') { - SpinniesManager.remove('migrateApp'); - logger.log(i18n(`${i18nKey}.migrationInterrupted`)); - process.exit(EXIT_CODES.SUCCESS); - } - }); - - const { data: migrateResponse } = await migrateApp( - derivedAccountId, - appId, - projectName - ); - const { id } = migrateResponse; - const pollResponse = await poll(() => - checkMigrationStatus(derivedAccountId, id) - ); - const { status, project } = pollResponse; - if (status === 'SUCCESS') { - const absoluteDestPath = path.resolve(getCwd(), projectDest); - const { env } = accountConfig; - const baseUrl = getHubSpotWebsiteOrigin(env); - - const { data: zippedProject } = await downloadProject( - derivedAccountId, - projectName, - 1 - ); - - await extractZipArchive( - zippedProject, - sanitizeFileName(projectName), - path.resolve(absoluteDestPath), - { includesRootDir: true, hideLogs: true } - ); - - SpinniesManager.succeed('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.done`), - succeedColor: 'white', - }); - logger.log(''); - uiLine(); - logger.success(i18n(`${i18nKey}.migrationStatus.success`)); - logger.log(''); - logger.log( - uiLink( - i18n(`${i18nKey}.projectDetailsLink`), - `${baseUrl}/developer-projects/${derivedAccountId}/project/${encodeURIComponent( - project.name - )}` - ) - ); - process.exit(EXIT_CODES.SUCCESS); - } - } catch (error) { - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'FAILURE' }, - derivedAccountId - ); - SpinniesManager.fail('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.failure`), - failColor: 'white', - }); - if (error.errors) { - error.errors.forEach(logError); - } else { - logError(error, new ApiErrorContext({ accountId: derivedAccountId })); - } - - process.exit(EXIT_CODES.ERROR); - } - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'SUCCESS' }, - derivedAccountId - ); - process.exit(EXIT_CODES.SUCCESS); -}; +export function builder(yargs: Argv): Argv { + addConfigOptions(yargs); + addAccountOptions(yargs); + addUseEnvironmentOptions(yargs); -exports.builder = yargs => { yargs.options({ name: { - describe: i18n(`${i18nKey}.options.name.describe`), + describe: i18n( + `commands.project.subcommands.migrateApp.options.name.describe` + ), type: 'string', }, dest: { - describe: i18n(`${i18nKey}.options.dest.describe`), + describe: i18n( + `commands.project.subcommands.migrateApp.options.dest.describe` + ), type: 'string', }, 'app-id': { - describe: i18n(`${i18nKey}.options.appId.describe`), + describe: i18n( + `commands.project.subcommands.migrateApp.options.appId.describe` + ), type: 'number', }, + 'platform-version': { + type: 'string', + choices: validMigrationTargets, + hidden: true, + default: '2023.2', + }, }); yargs.example([ - ['$0 project migrate-app', i18n(`${i18nKey}.examples.default`)], + [ + `$0 project migrate-app`, + i18n(`commands.project.subcommands.migrateApp.examples.default`), + ], ]); - addConfigOptions(yargs); - addAccountOptions(yargs); - addUseEnvironmentOptions(yargs); + return yargs as Argv; +} - return yargs; +const migrateAppCommand: CommandModule = { + command, + describe, + deprecated, + handler, + builder, }; + +export default migrateAppCommand; diff --git a/jest.config.js b/jest.config.js index 8df76fc4c..70af950a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { testEnvironment: 'node', preset: 'ts-jest', - roots: ['commands', 'lib'], + roots: ['commands', 'lib', 'api'], collectCoverage: true, clearMocks: true, }; diff --git a/lang/en.lyaml b/lang/en.lyaml index 90ddecf2c..3848fe56f 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -569,6 +569,7 @@ en: header: text: "Migrate an app to the projects framework" link: "Learn more about migrating apps to the projects framework" + deprecationWarning: "The {{ oldCommand }} command is deprecated and will be removed. Use {{ newCommand }} going forward." migrationStatus: inProgress: "Converting app configuration to {{#bold}}public-app.json{{/bold}} component definition ..." success: "{{#bold}}Your app was converted and build #1 is deployed{{/bold}}" @@ -584,11 +585,40 @@ en: migrationInterrupted: "\nThe command is terminated, but app migration is still in progress. Please check your account to ensure that the project and associated app have been created successfully." createAppPrompt: "Proceed with migrating this app to a project component (this process can't be aborted)?" projectDetailsLink: "View project details in your developer account" + componentsToBeMigrated: "The following component types will be migrated: {{ components }}" + componentsThatWillNotBeMigrated: "[NOTE] These component types are not yet supported for migration but will be available later: {{ components }}" errors: + noAppsEligible: "No apps in account {{ accountId }} are currently migratable" + noAccountConfig: "There is no account associated with {{ accountId }} in the config file. Please choose another account and try again, or authenticate {{ accountId }} using {{ authCommand }}." invalidAccountTypeTitle: "{{#bold}}Developer account not targeted{{/bold}}" invalidAccountTypeDescription: "Only public apps created in a developer account can be converted to a project component. Select a connected developer account with {{useCommand}} or {{authCommand}} and try again." projectAlreadyExists: "A project with name {{ projectName }} already exists. Please choose another name." invalidApp: "Could not migrate appId {{ appId }}. This app cannot be migrated at this time. Please choose another public app." + appWithAppIdNotFound: "Could not find an app with the id {{ appId }} " + migrationFailed: 'Migration Failed' + prompt: + chooseApp: 'Which app would you like to migrate?' + inputName: '[--name] What would you like to name the project?' + inputDest: '[--dest] Where would you like to save the project?' + uidForComponent: 'What UID would you like to use for {{ componentName }}?' + proceed: 'Would you like to proceed?' + spinners: + beginningMigration: "Beginning migration" + unableToStartMigration: "Unable to begin migration" + finishingMigration: "Wrapping up migration" + migrationComplete: "Migration completed" + migrationFailed: "Migration failed" + downloadingProjectContents: "Downloading migrated project files" + downloadingProjectContentsComplete: "Migrated project files downloaded" + downloadingProjectContentsFailed: "Unable to download migrated project files" + copyingProjectFiles: "Copying migrated project files" + copyingProjectFilesComplete: "Migrated project files copied" + copyingProjectFilesFailed: "Unable to copy migrated project files" + unmigratableReasons: + upToDate: 'App is already up to date' + isPrivateApp: 'Private apps are not currently migratable' + listedInMarketplace: 'Listed apps are not currently migratable' + generic: "Unable to migrate app: {{ reasonCode }}" cloneApp: describe: "Clone a public app using the projects framework." examples: @@ -607,6 +637,7 @@ en: invalidAccountTypeTitle: "{{#bold}}Developer account not targeted{{/bold}}" invalidAccountTypeDescription: "Only public apps created in a developer account can be converted to a project component. Select a connected developer account with {{useCommand}} or {{authCommand}} and try again." couldNotWriteConfigPath: "Failed to write project config at {{ configPath }}" + noAccountConfig: "There is no account associated with {{ accountId }} in the config file. Please choose another account and try again, or authenticate {{ accountId }} using {{ authCommand }}." add: describe: "Create a new component within a project." options: diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts new file mode 100644 index 000000000..e7766a26a --- /dev/null +++ b/lib/app/migrate.ts @@ -0,0 +1,438 @@ +import { logger } from '@hubspot/local-dev-lib/logger'; +import path from 'path'; +import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { ArgumentsCamelCase } from 'yargs'; +import chalk from 'chalk'; +import { validateUid } from '@hubspot/project-parsing-lib'; +import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; +import { mapToUserFacingType } from '@hubspot/project-parsing-lib/src/lib/transform'; +import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration'; +import { downloadProject } from '@hubspot/local-dev-lib/api/projects'; + +import { confirmPrompt, inputPrompt, listPrompt } from '../prompts/promptUtils'; +import { uiAccountDescription, uiCommandReference, uiLine } from '../ui'; +import { i18n } from '../lang'; +import { ensureProjectExists } from '../projects'; +import SpinniesManager from '../ui/SpinniesManager'; +import { DEFAULT_POLLING_STATUS_LOOKUP, poll } from '../polling'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { + checkMigrationStatusV2, + continueMigration, + initializeMigration, + listAppsForMigration, + MigrationApp, + MigrationStatus, +} from '../../api/migrate'; + +function getUnmigratableReason(reasonCode: string): string { + switch (reasonCode) { + case UNMIGRATABLE_REASONS.UP_TO_DATE: + return i18n( + 'commands.project.subcommands.migrateApp.unmigratableReasons.upToDate' + ); + case UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP: + return i18n( + 'commands.project.subcommands.migrateApp.unmigratableReasons.isPrivateApp' + ); + case UNMIGRATABLE_REASONS.LISTED_IN_MARKETPLACE: + return i18n( + 'commands.project.subcommands.migrateApp.unmigratableReasons.listedInMarketplace' + ); + default: + return i18n( + 'commands.project.subcommands.migrateApp.unmigratableReasons.generic', + { + reasonCode, + } + ); + } +} + +async function handleMigrationSetup( + derivedAccountId: number, + options: ArgumentsCamelCase +): Promise<{ + appIdToMigrate?: number | undefined; + projectName?: string; + projectDest?: string; +}> { + const { name, dest, appId } = options; + const { + data: { migratableApps, unmigratableApps }, + } = await listAppsForMigration(derivedAccountId); + + const migratableAppsWithoutProject = migratableApps.filter( + (app: MigrationApp) => !app.projectName + ); + + const unmigratableAppsWithoutProject = unmigratableApps.filter( + (app: MigrationApp) => !app.projectName + ); + + const allAppsWithoutProject = [ + ...migratableAppsWithoutProject, + ...unmigratableAppsWithoutProject, + ]; + + if (allAppsWithoutProject.length === 0) { + const reasons = unmigratableAppsWithoutProject.map( + app => + `${chalk.bold(app.appName)}: ${getUnmigratableReason(app.unmigratableReason)}` + ); + + throw new Error( + `${i18n(`commands.project.subcommands.migrateApp.errors.noAppsEligible`, { + accountId: uiAccountDescription(derivedAccountId), + })}${reasons.length ? `\n - ${reasons.join('\n - ')}` : ''}` + ); + } + + if ( + appId && + !allAppsWithoutProject.some(app => { + return app.appId === appId; + }) + ) { + throw new Error( + i18n('commands.project.subcommands.migrateApp.prompt.chooseApp', { + appId, + }) + ); + } + + const appChoices = allAppsWithoutProject.map(app => ({ + name: app.isMigratable + ? app.appName + : `[${chalk.yellow('DISABLED')}] ${app.appName} `, + value: app, + disabled: app.isMigratable + ? false + : getUnmigratableReason(app.unmigratableReason), + })); + + let appIdToMigrate = appId; + if (!appIdToMigrate) { + const { appId: selectedAppId } = await listPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.chooseApp'), + { + choices: appChoices, + } + ); + appIdToMigrate = selectedAppId; + } + + const selectedApp = allAppsWithoutProject.find( + app => app.appId === appIdToMigrate + ); + + const migratableComponents: string[] = []; + const unmigratableComponents: string[] = []; + + selectedApp?.migrationComponents.forEach(component => { + if (component.isSupported) { + migratableComponents.push(mapToUserFacingType(component.componentType)); + } else { + unmigratableComponents.push(mapToUserFacingType(component.componentType)); + } + }); + + if (migratableComponents.length !== 0) { + logger.log( + i18n('commands.project.subcommands.migrateApp.componentsToBeMigrated', { + components: `\n - ${migratableComponents.join('\n - ')}`, + }) + ); + } + + if (unmigratableComponents.length !== 0) { + logger.log( + i18n( + 'commands.project.subcommands.migrateApp.componentsThatWillNotBeMigrated', + { + components: `\n - ${unmigratableComponents.join('\n - ')}`, + } + ) + ); + } + + logger.log(); + const proceed = await confirmPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.proceed') + ); + + if (!proceed) { + return {}; + } + + const projectName = + name || + (await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.inputName') + )); + + const { projectExists } = await ensureProjectExists( + derivedAccountId, + projectName, + { allowCreate: false, noLogs: true } + ); + + if (projectExists) { + throw new Error( + i18n( + 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists', + { + projectName, + } + ) + ); + } + + const projectDest = + dest || + (await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.inputDest'), + { + defaultAnswer: path.resolve(getCwd(), sanitizeFileName(projectName)), + } + )); + + return { appIdToMigrate, projectName, projectDest }; +} + +async function beginMigration( + derivedAccountId: number, + appId: number, + platformVersion: string +): Promise< + | { + migrationId: number; + uidMap: Record; + } + | undefined +> { + SpinniesManager.add('beginningMigration', { + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.beginningMigration' + ), + }); + + const uidMap: Record = {}; + + const { data } = await initializeMigration( + derivedAccountId, + appId, + platformVersion + ); + const { migrationId } = data; + + const pollResponse = await pollMigrationStatus( + derivedAccountId, + migrationId, + [MIGRATION_STATUS.INPUT_REQUIRED] + ); + + if (pollResponse.status !== MIGRATION_STATUS.INPUT_REQUIRED) { + SpinniesManager.fail('beginningMigration', { + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' + ), + }); + return; + } + + const { componentsRequiringUids } = pollResponse; + + SpinniesManager.remove('beginningMigration'); + + if (Object.values(componentsRequiringUids).length !== 0) { + for (const [componentId, component] of Object.entries( + componentsRequiringUids + )) { + const { componentHint, componentType } = component; + uidMap[componentId] = await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.uidForComponent', { + componentName: componentHint + ? `${componentHint} [${mapToUserFacingType(componentType)}]` + : mapToUserFacingType(componentType), + }), + { + validate: (uid: string) => { + const result = validateUid(uid); + return result === undefined ? true : result; + }, + defaultAnswer: (componentHint || '') + .toLowerCase() + .replace(/[^a-z0-9_]/g, ''), + } + ); + } + } + + return { migrationId, uidMap }; +} + +async function pollMigrationStatus( + derivedAccountId: number, + migrationId: number, + successStates: string[] = [] +): Promise { + return poll(() => checkMigrationStatusV2(derivedAccountId, migrationId), { + successStates: [...successStates], + errorStates: [...DEFAULT_POLLING_STATUS_LOOKUP.errorStates], + }); +} + +async function finalizeMigration( + derivedAccountId: number, + migrationId: number, + uidMap: Record, + projectName: string +): Promise { + let pollResponse: MigrationStatus; + try { + SpinniesManager.add('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.finishingMigration` + ), + }); + await continueMigration(derivedAccountId, migrationId, uidMap, projectName); + + pollResponse = await pollMigrationStatus(derivedAccountId, migrationId, [ + MIGRATION_STATUS.SUCCESS, + ]); + } catch (error) { + SpinniesManager.fail('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationFailed` + ), + }); + throw error; + } + + if (pollResponse.status === MIGRATION_STATUS.SUCCESS) { + SpinniesManager.succeed('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationComplete` + ), + }); + + return pollResponse.buildId; + } else { + SpinniesManager.fail('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationFailed` + ), + }); + if (pollResponse.status === MIGRATION_STATUS.FAILURE) { + logger.error(pollResponse.componentErrorDetails); + throw new Error(pollResponse.projectErrorsDetail); + } + + throw new Error( + i18n('commands.project.subcommands.migrateApp.errors.migrationFailed') + ); + } +} + +export async function downloadProjectFiles( + derivedAccountId: number, + projectName: string, + buildId: number, + projectDest: string +): Promise { + try { + SpinniesManager.add('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContents` + ), + }); + + const { data: zippedProject } = await downloadProject( + derivedAccountId, + projectName, + buildId + ); + + const absoluteDestPath = projectDest + ? path.resolve(getCwd(), projectDest) + : getCwd(); + + await extractZipArchive( + zippedProject, + sanitizeFileName(projectName), + absoluteDestPath, + { + includesRootDir: true, + hideLogs: true, + } + ); + + SpinniesManager.succeed('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsComplete` + ), + }); + + logger.success(`Saved ${projectName} to ${projectDest}`); + } catch (error) { + SpinniesManager.fail('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed` + ), + }); + throw error; + } +} + +export async function migrateApp2025_2( + derivedAccountId: number, + options: ArgumentsCamelCase +): Promise { + SpinniesManager.init(); + + const { appIdToMigrate, projectName, projectDest } = + await handleMigrationSetup(derivedAccountId, options); + + if (!appIdToMigrate || !projectName || !projectDest) { + return; + } + + const migrationInProgress = await beginMigration( + derivedAccountId, + appIdToMigrate, + options.platformVersion + ); + + if (!migrationInProgress) { + return; + } + + const { migrationId, uidMap } = migrationInProgress; + const buildId = await finalizeMigration( + derivedAccountId, + migrationId, + uidMap, + projectName + ); + + await downloadProjectFiles( + derivedAccountId, + projectName, + buildId, + projectDest + ); +} + +export function logInvalidAccountError(i18nKey: string): void { + uiLine(); + logger.error(i18n(`${i18nKey}.errors.invalidAccountTypeTitle`)); + logger.log( + i18n(`${i18nKey}.errors.invalidAccountTypeDescription`, { + useCommand: uiCommandReference('hs accounts use'), + authCommand: uiCommandReference('hs auth'), + }) + ); + uiLine(); +} diff --git a/lib/app/migrate_legacy.ts b/lib/app/migrate_legacy.ts new file mode 100644 index 000000000..fc149ed0a --- /dev/null +++ b/lib/app/migrate_legacy.ts @@ -0,0 +1,211 @@ +import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { + checkMigrationStatus, + downloadProject, + migrateApp as migrateNonProjectApp_v2023_2, +} from '@hubspot/local-dev-lib/api/projects'; +import path from 'path'; +import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { ArgumentsCamelCase } from 'yargs'; +import { promptUser } from '../prompts/promptUtils'; +import { ApiErrorContext, logError } from '../errorHandlers'; +import { EXIT_CODES } from '../enums/exitCodes'; +import { uiAccountDescription, uiLine, uiLink } from '../ui'; +import { i18n } from '../lang'; +import { isAppDeveloperAccount } from '../accountTypes'; +import { selectPublicAppPrompt } from '../prompts/selectPublicAppPrompt'; +import { createProjectPrompt } from '../prompts/createProjectPrompt'; +import { ensureProjectExists } from '../projects'; +import { trackCommandMetadataUsage } from '../usageTracking'; +import SpinniesManager from '../ui/SpinniesManager'; +import { handleKeypress } from '../process'; +import { poll } from '../polling'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { logInvalidAccountError } from './migrate'; + +export async function migrateApp2023_2( + derivedAccountId: number, + options: ArgumentsCamelCase, + accountConfig: CLIAccount +): Promise { + const accountName = uiAccountDescription(derivedAccountId); + + if (!isAppDeveloperAccount(accountConfig)) { + logInvalidAccountError('commands.project.subcommands.migrateApp'); + process.exit(EXIT_CODES.SUCCESS); + } + + const { appId } = + 'appId' in options + ? options + : await selectPublicAppPrompt({ + accountId: derivedAccountId, + accountName, + isMigratingApp: true, + }); + + try { + const { data: selectedApp } = await fetchPublicAppMetadata( + appId, + derivedAccountId + ); + // preventProjectMigrations returns true if we have not added app to allowlist config. + // listingInfo will only exist for marketplace apps + const preventProjectMigrations = selectedApp.preventProjectMigrations; + const listingInfo = selectedApp.listingInfo; + if (preventProjectMigrations && listingInfo) { + logger.error( + i18n(`commands.project.subcommands.migrateApp.errors.invalidApp`, { + appId, + }) + ); + process.exit(EXIT_CODES.ERROR); + } + } catch (error) { + logError(error, new ApiErrorContext({ accountId: derivedAccountId })); + process.exit(EXIT_CODES.ERROR); + } + + const createProjectPromptResponse = await createProjectPrompt(options); + const { name: projectName, dest: projectDest } = createProjectPromptResponse; + + const { projectExists } = await ensureProjectExists( + derivedAccountId, + projectName, + { + allowCreate: false, + noLogs: true, + } + ); + + if (projectExists) { + throw new Error( + i18n( + `commands.project.subcommands.migrateApp.errors.projectAlreadyExists`, + { + projectName, + } + ) + ); + } + + await trackCommandMetadataUsage( + 'migrate-app', + { step: 'STARTED' }, + derivedAccountId + ); + + logger.log(''); + uiLine(); + logger.warn( + `${i18n(`commands.project.subcommands.migrateApp.warning.title`)}\n` + ); + logger.log( + i18n(`commands.project.subcommands.migrateApp.warning.projectConversion`) + ); + logger.log( + `${i18n(`commands.project.subcommands.migrateApp.warning.appConfig`)}\n` + ); + logger.log( + `${i18n(`commands.project.subcommands.migrateApp.warning.buildAndDeploy`)}\n` + ); + logger.log( + `${i18n(`commands.project.subcommands.migrateApp.warning.existingApps`)}\n` + ); + logger.log(i18n(`commands.project.subcommands.migrateApp.warning.copyApp`)); + uiLine(); + + const { shouldCreateApp } = await promptUser({ + name: 'shouldCreateApp', + type: 'confirm', + message: i18n(`commands.project.subcommands.migrateApp.createAppPrompt`), + }); + process.stdin.resume(); + + if (!shouldCreateApp) { + process.exit(EXIT_CODES.SUCCESS); + } + + try { + SpinniesManager.init(); + + SpinniesManager.add('migrateApp', { + text: i18n( + `commands.project.subcommands.migrateApp.migrationStatus.inProgress` + ), + }); + + handleKeypress(async key => { + if ((key.ctrl && key.name === 'c') || key.name === 'q') { + SpinniesManager.remove('migrateApp'); + logger.log( + i18n(`commands.project.subcommands.migrateApp.migrationInterrupted`) + ); + process.exit(EXIT_CODES.SUCCESS); + } + }); + + const { data: migrateResponse } = await migrateNonProjectApp_v2023_2( + derivedAccountId, + appId, + projectName + ); + const { id } = migrateResponse; + const pollResponse = await poll(() => + checkMigrationStatus(derivedAccountId, id) + ); + const { status, project } = pollResponse; + if (status === 'SUCCESS') { + const absoluteDestPath = path.resolve(getCwd(), projectDest); + const { env } = accountConfig; + const baseUrl = getHubSpotWebsiteOrigin(env); + + const { data: zippedProject } = await downloadProject( + derivedAccountId, + projectName, + 1 + ); + + await extractZipArchive( + zippedProject, + sanitizeFileName(projectName), + path.resolve(absoluteDestPath), + { includesRootDir: true, hideLogs: true } + ); + + SpinniesManager.succeed('migrateApp', { + text: i18n( + `commands.project.subcommands.migrateApp.migrationStatus.done` + ), + succeedColor: 'white', + }); + logger.log(''); + uiLine(); + logger.success( + i18n(`commands.project.subcommands.migrateApp.migrationStatus.success`) + ); + logger.log(''); + logger.log( + uiLink( + i18n(`commands.project.subcommands.migrateApp.projectDetailsLink`), + `${baseUrl}/developer-projects/${derivedAccountId}/project/${encodeURIComponent( + project!.name + )}` + ) + ); + process.exit(EXIT_CODES.SUCCESS); + } + } catch (error) { + SpinniesManager.fail('migrateApp', { + text: i18n( + `commands.project.subcommands.migrateApp.migrationStatus.failure` + ), + failColor: 'white', + }); + throw error; + } +} diff --git a/lib/polling.ts b/lib/polling.ts index 3c3a91d78..386675b54 100644 --- a/lib/polling.ts +++ b/lib/polling.ts @@ -9,7 +9,7 @@ export const DEFAULT_POLLING_STATES = { FAILURE: 'FAILURE', } as const; -const DEFAULT_POLLING_STATUS_LOOKUP = { +export const DEFAULT_POLLING_STATUS_LOOKUP = { successStates: [DEFAULT_POLLING_STATES.SUCCESS], errorStates: [ DEFAULT_POLLING_STATES.ERROR, diff --git a/lib/prompts/promptUtils.ts b/lib/prompts/promptUtils.ts index c1b1d10c8..346d9fdbe 100644 --- a/lib/prompts/promptUtils.ts +++ b/lib/prompts/promptUtils.ts @@ -39,21 +39,21 @@ export async function confirmPrompt( return choice; } -type ListPromptResponse = { - choice: string; +type ListPromptResponse = { + choice: T; }; -export async function listPrompt( +export async function listPrompt( message: string, { choices, when, }: { - choices: PromptChoices; + choices: PromptChoices; when?: PromptWhen; } -): Promise { - const { choice } = await promptUser([ +): Promise { + const { choice } = await promptUser>([ { name: 'choice', type: 'list', @@ -69,16 +69,22 @@ export async function inputPrompt( message: string, { when, + validate, + defaultAnswer, }: { when?: boolean | (() => boolean); + validate?: (input: string) => boolean | string; + defaultAnswer?: string; } = {} ): Promise { const { input } = await promptUser([ { name: 'input', type: 'input', + default: defaultAnswer, message, when, + validate, }, ]); return input; diff --git a/lib/ui/index.ts b/lib/ui/index.ts index 8c0f65ece..abd173563 100644 --- a/lib/ui/index.ts +++ b/lib/ui/index.ts @@ -122,12 +122,14 @@ export function uiBetaTag(message: string, log = true): string | undefined { if (log) { logger.log(result); return; - } else { - return result; } + return result; } -export function uiDeprecatedTag(message: string): void { +export function uiDeprecatedTag( + message: string, + log = true +): string | undefined { const i18nKey = 'lib.ui'; const terminalUISupport = getTerminalUISupport(); @@ -137,7 +139,10 @@ export function uiDeprecatedTag(message: string): void { terminalUISupport.color ? chalk.yellow(tag) : tag } ${message}`; - logger.log(result); + if (log) { + logger.log(result); + } + return result; } export function uiCommandDisabledBanner( diff --git a/package.json b/package.json index 3ed93e3bf..a9b11f443 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "repository": "https://github.com/HubSpot/hubspot-cli", "dependencies": { - "@hubspot/local-dev-lib": "3.4.1", + "@hubspot/local-dev-lib": "3.5.1", "@hubspot/project-parsing-lib": "0.1.6", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", diff --git a/tsconfig.json b/tsconfig.json index 889abc669..c2570f300 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "outDir": "dist", "noErrorTruncation": true }, - "include": ["bin", "commands", "lib"], + "include": ["bin", "commands", "lib", "api"], "exclude": ["**/__tests__/*"] } diff --git a/types/Prompts.ts b/types/Prompts.ts index d9b87b6bb..0b73e0ecd 100644 --- a/types/Prompts.ts +++ b/types/Prompts.ts @@ -14,12 +14,12 @@ type PromptType = | 'number' | 'rawlist'; -export type PromptChoices = Array< +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PromptChoices = Array< | string | { name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value?: any; + value?: T; disabled?: string | boolean; } >; diff --git a/types/Yargs.ts b/types/Yargs.ts index 7f012aec9..eed5b406a 100644 --- a/types/Yargs.ts +++ b/types/Yargs.ts @@ -35,3 +35,21 @@ export type ProjectDevArgs = CommonArgs & ConfigArgs & EnvironmentArgs; export type TestingArgs = { qa?: boolean; }; + +export type MigrateAppOptions = CommonArgs & + AccountArgs & + EnvironmentArgs & + ConfigArgs & { + name: string; + dest: string; + appId: number; + platformVersion: string; + }; + +export type CloneAppArgs = ConfigArgs & + EnvironmentArgs & + AccountArgs & + CommonArgs & { + dest: string; + appId: number; + };