From 193c5715637a84fe5df23013478db414ef3e4180 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 12 Mar 2025 09:16:50 -0700 Subject: [PATCH 01/36] wip --- commands/project/migrateApp.ts | 216 ++++++++++++++++++++++++++------- lib/usageTracking.ts | 1 + 2 files changed, 174 insertions(+), 43 deletions(-) diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index daade61fa..0043091e5 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,60 +1,88 @@ -// @ts-nocheck -const path = require('path'); -const { +import { inputPrompt, promptUser } from '../../lib/prompts/promptUtils'; + +import path from 'path'; + +import { addAccountOptions, addConfigOptions, addUseEnvironmentOptions, -} = require('../../lib/commonOpts'); -const { +} from '../../lib/commonOpts'; +import { 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 { +} from '../../lib/usageTracking'; +import { createProjectPrompt } from '../../lib/prompts/createProjectPrompt'; +import { i18n } from '../../lib/lang'; +import { selectPublicAppPrompt } from '../../lib/prompts/selectPublicAppPrompt'; +import { poll } from '../../lib/polling'; +import { + uiAccountDescription, uiBetaTag, + uiCommandReference, 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, +} from '../../lib/ui'; +import SpinniesManager from '../../lib/ui/SpinniesManager'; +import { ApiErrorContext, logError } from '../../lib/errorHandlers'; +import { EXIT_CODES } from '../../lib/enums/exitCodes'; +import { isAppDeveloperAccount } from '../../lib/accountTypes'; +import { ensureProjectExists } from '../../lib/projects'; +import { handleKeypress } from '../../lib/process'; +import { 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'); + downloadProject, + migrateApp, +} 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 { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { + AccountArgs, + CommonArgs, + ConfigArgs, + EnvironmentArgs, +} from '../../types/Yargs'; const i18nKey = 'commands.project.subcommands.migrateApp'; exports.command = 'migrate-app'; exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); -exports.handler = async options => { - const { derivedAccountId } = options; +interface MigrateAppOptions + extends CommonArgs, + AccountArgs, + EnvironmentArgs, + ConfigArgs { + name: string; + dest: string; + appId: number; + unified: boolean; +} + +exports.handler = async (options: ArgumentsCamelCase) => { + const { derivedAccountId, unified } = options; const accountConfig = getAccountConfig(derivedAccountId); const accountName = uiAccountDescription(derivedAccountId); + if (!accountConfig) { + throw new Error('Account is not configured'); + } + + if (unified) { + try { + await migrateToUnifiedApp(derivedAccountId, accountConfig, options); + process.exit(EXIT_CODES.SUCCESS); + } catch (error) { + logError(error); + process.exit(EXIT_CODES.ERROR); + } + } + trackCommandUsage('migrate-app', {}, derivedAccountId); logger.log(''); @@ -223,7 +251,7 @@ exports.handler = async options => { uiLink( i18n(`${i18nKey}.projectDetailsLink`), `${baseUrl}/developer-projects/${derivedAccountId}/project/${encodeURIComponent( - project.name + project!.name )}` ) ); @@ -239,8 +267,13 @@ exports.handler = async options => { text: i18n(`${i18nKey}.migrationStatus.failure`), failColor: 'white', }); - if (error.errors) { - error.errors.forEach(logError); + 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 })); } @@ -255,7 +288,7 @@ exports.handler = async options => { process.exit(EXIT_CODES.SUCCESS); }; -exports.builder = yargs => { +exports.builder = (yargs: Argv) => { yargs.options({ name: { describe: i18n(`${i18nKey}.options.name.describe`), @@ -269,6 +302,11 @@ exports.builder = yargs => { describe: i18n(`${i18nKey}.options.appId.describe`), type: 'number', }, + unified: { + type: 'boolean', + hidden: true, + default: false, + }, }); yargs.example([ @@ -281,3 +319,95 @@ exports.builder = yargs => { return yargs; }; + +export async function migrateToUnifiedApp( + derivedAccountId: number, + accountConfig: CLIAccount, + options: unknown +) { + console.log('der', derivedAccountId); + console.log('acc', accountConfig); + console.log('opt', options); + + // Check if the command is running within a project + const isProject = false; + let appId: number; + + if (isProject) { + // Use the current project and app details for the migration + appId = 111; + } else { + // Make the call to get the list of the non project apps eligible to migrate + // Prompt the user to select the app to migrate + // Prompt the user for a project name and destination + const projectName = await inputPrompt( + 'Enter the name of the app you want to migrate: ' + ); + console.log(projectName); + const projectDest = await inputPrompt( + 'Where do you want to save the project?: ' + ); + console.log(projectDest); + appId = 999; + } + + // Call the migration end points + const { migrationId, uidsRequired } = await beginMigration(appId); + + const uidMap: Record = {}; + + if (uidsRequired.length !== 0) { + for (const u of uidsRequired) { + uidMap[u] = await inputPrompt(`Give me a uid for ${u}: `); + } + } + + try { + const response = await finishMigration(migrationId, uidMap); + // Poll using the projectId and the build id? + console.log(response); + } catch (error) { + logError(error); + process.exit(EXIT_CODES.ERROR); + } +} + +interface MigrationStageOneResponse { + migrationId: number; + uidsRequired: string[]; +} +export async function beginMigration( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + appId: number +): Promise { + console.log(`migrating ${appId}`); + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + migrationId: 1234, + uidsRequired: ['App 1', 'App 2'], + }); + }, 150); + }); +} + +type MigrationFinishResponse = { + projectId: number; + buildId: number; +}; + +export async function finishMigration( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + migrationId: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + uidMap: Record +): Promise { + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + projectId: 1234, + buildId: 5555, + }); + }, 150); + }); +} diff --git a/lib/usageTracking.ts b/lib/usageTracking.ts index c1cbc735d..090a81bc9 100644 --- a/lib/usageTracking.ts +++ b/lib/usageTracking.ts @@ -22,6 +22,7 @@ type Meta = { type?: string | number; // "The upload type" file?: boolean; // "Whether or not the 'file' flag was used" successful?: boolean; // "Whether or not the CLI interaction was successful" + status?: string; // TODO: What is this? }; const EventClass = { From 307a833bcd2c598489e9d57ab53a2c253adaa9d5 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 18 Mar 2025 08:56:06 -0700 Subject: [PATCH 02/36] wip --- commands/app.ts | 15 ++ commands/app/migrate.ts | 113 +++++++++ commands/project/cloneApp.ts | 5 +- commands/project/migrateApp.ts | 416 ++------------------------------- lib/app/migrate.ts | 297 +++++++++++++++++++++++ lib/ui/index.ts | 14 +- types/Yargs.ts | 11 + 7 files changed, 463 insertions(+), 408 deletions(-) create mode 100644 commands/app.ts create mode 100644 commands/app/migrate.ts create mode 100644 lib/app/migrate.ts diff --git a/commands/app.ts b/commands/app.ts new file mode 100644 index 000000000..495ba235a --- /dev/null +++ b/commands/app.ts @@ -0,0 +1,15 @@ +import * as migrateCommand from './app/migrate'; +import { addGlobalOptions } from '../lib/commonOpts'; +import { Argv } from 'yargs'; + +export const command = ['app', 'apps']; +export const describe = null; + +export function builder(yargs: Argv) { + addGlobalOptions(yargs); + + // @ts-ignore + yargs.command(migrateCommand).demandCommand(1, ''); + + return yargs; +} diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts new file mode 100644 index 000000000..7d725d5b4 --- /dev/null +++ b/commands/app/migrate.ts @@ -0,0 +1,113 @@ +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 { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { migrateAppTo2023_2, migrateToUnifiedApp } from '../../lib/app/migrate'; + +// TODO: Move this somewhere else +const platformVersions = { + v2023_2: '2023.2', + v2025_2: '2025.2', + unstable: 'unstable', +}; + +const { v2023_2, v2025_2 } = platformVersions; +const supportedPlatformVersions = [v2023_2, v2025_2]; + +const i18nKey = 'commands.project.subcommands.migrateApp'; + +export const command = 'migrate'; +export const describe = null; // uiBetaTag(i18n(`${i18nKey}.describe`), false); + +export async function handler(options: ArgumentsCamelCase) { + const { derivedAccountId, platformVersion } = options; + await trackCommandUsage('migrate-app', {}, derivedAccountId); + const accountConfig = getAccountConfig(derivedAccountId); + + if (!accountConfig) { + throw new Error('Account is not configured'); + } + + if (!supportedPlatformVersions.includes(platformVersion)) { + throw new Error('Unsupported platform version'); + } + + try { + if (platformVersion === v2025_2) { + await migrateToUnifiedApp(derivedAccountId, accountConfig, options); + } else if (platformVersion === v2023_2) { + await migrateAppTo2023_2(accountConfig, options, derivedAccountId); + } + await trackCommandMetadataUsage( + 'migrate-app', + { status: 'SUCCESS' }, + derivedAccountId + ); + } 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', + { status: 'FAILURE' }, + derivedAccountId + ); + process.exit(EXIT_CODES.ERROR); + } + + await trackCommandMetadataUsage( + 'migrate-app', + { status: 'SUCCESS' }, + derivedAccountId + ); + process.exit(EXIT_CODES.SUCCESS); +} + +export function builder(yargs: Argv) { + yargs.options({ + name: { + describe: i18n(`${i18nKey}.options.name.describe`), + type: 'string', + }, + dest: { + describe: i18n(`${i18nKey}.options.dest.describe`), + type: 'string', + }, + 'app-id': { + describe: i18n(`${i18nKey}.options.appId.describe`), + type: 'number', + }, + 'platform-version': { + type: 'string', + choices: ['2023.2', '2025.2'], + hidden: true, + default: '2023.2', + }, + }); + + yargs.example([['$0 app migrate', i18n(`${i18nKey}.examples.default`)]]); + + addConfigOptions(yargs); + addAccountOptions(yargs); + addUseEnvironmentOptions(yargs); + + return yargs; +} diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index 707c22e9f..f2cde0635 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -1,4 +1,6 @@ // @ts-nocheck +import { uiDeprecatedTag } from '../../lib/ui'; + const path = require('path'); const fs = require('fs'); const { @@ -19,7 +21,6 @@ const { } = require('../../lib/prompts/createProjectPrompt'); const { poll } = require('../../lib/polling'); const { - uiBetaTag, uiLine, uiCommandReference, uiAccountDescription, @@ -43,7 +44,7 @@ const { extractZipArchive } = require('@hubspot/local-dev-lib/archive'); const i18nKey = 'commands.project.subcommands.cloneApp'; exports.command = 'clone-app'; -exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); +exports.describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); exports.handler = async options => { const { derivedAccountId } = options; diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index 0043091e5..659d6fa56 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,413 +1,23 @@ -import { inputPrompt, promptUser } from '../../lib/prompts/promptUtils'; - -import path from 'path'; - -import { - addAccountOptions, - addConfigOptions, - addUseEnvironmentOptions, -} from '../../lib/commonOpts'; -import { - trackCommandUsage, - trackCommandMetadataUsage, -} from '../../lib/usageTracking'; -import { createProjectPrompt } from '../../lib/prompts/createProjectPrompt'; import { i18n } from '../../lib/lang'; -import { selectPublicAppPrompt } from '../../lib/prompts/selectPublicAppPrompt'; -import { poll } from '../../lib/polling'; -import { - uiAccountDescription, - uiBetaTag, - uiCommandReference, - uiLine, - uiLink, -} from '../../lib/ui'; -import SpinniesManager from '../../lib/ui/SpinniesManager'; -import { ApiErrorContext, logError } from '../../lib/errorHandlers'; -import { EXIT_CODES } from '../../lib/enums/exitCodes'; -import { isAppDeveloperAccount } from '../../lib/accountTypes'; -import { ensureProjectExists } from '../../lib/projects'; -import { handleKeypress } from '../../lib/process'; -import { - checkMigrationStatus, - downloadProject, - migrateApp, -} from '@hubspot/local-dev-lib/api/projects'; -import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { uiDeprecatedTag } from '../../lib/ui'; +import { handler as migrateHandler } from '../app/migrate'; + +import { ArgumentsCamelCase } from 'yargs'; import { logger } from '@hubspot/local-dev-lib/logger'; -import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; -import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; -import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; -import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { ArgumentsCamelCase, Argv } from 'yargs'; -import { - AccountArgs, - CommonArgs, - ConfigArgs, - EnvironmentArgs, -} from '../../types/Yargs'; +import { MigrateAppOptions } from '../../types/Yargs'; const i18nKey = 'commands.project.subcommands.migrateApp'; -exports.command = 'migrate-app'; -exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); - -interface MigrateAppOptions - extends CommonArgs, - AccountArgs, - EnvironmentArgs, - ConfigArgs { - name: string; - dest: string; - appId: number; - unified: boolean; -} - -exports.handler = async (options: ArgumentsCamelCase) => { - const { derivedAccountId, unified } = options; - const accountConfig = getAccountConfig(derivedAccountId); - const accountName = uiAccountDescription(derivedAccountId); +export const command = 'migrate-app'; - if (!accountConfig) { - throw new Error('Account is not configured'); - } +// TODO: Leave this as deprecated and remove in the next major release +export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); - if (unified) { - try { - await migrateToUnifiedApp(derivedAccountId, accountConfig, options); - process.exit(EXIT_CODES.SUCCESS); - } catch (error) { - logError(error); - process.exit(EXIT_CODES.ERROR); - } - } - - 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' - ) +export async function handler(yargs: ArgumentsCamelCase) { + logger.warn( + "The 'hs project migrate-app' command is deprecated and will be removed. Use 'hs app migrate' going forward." ); - 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 - ); - - 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 && - typeof error === 'object' && - 'errors' in error && - Array.isArray(error.errors) - ) { - error.errors.forEach(err => logError(err)); - } else { - logError(error, new ApiErrorContext({ accountId: derivedAccountId })); - } - - process.exit(EXIT_CODES.ERROR); - } - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'SUCCESS' }, - derivedAccountId - ); - process.exit(EXIT_CODES.SUCCESS); -}; - -exports.builder = (yargs: Argv) => { - yargs.options({ - name: { - describe: i18n(`${i18nKey}.options.name.describe`), - type: 'string', - }, - dest: { - describe: i18n(`${i18nKey}.options.dest.describe`), - type: 'string', - }, - 'app-id': { - describe: i18n(`${i18nKey}.options.appId.describe`), - type: 'number', - }, - unified: { - type: 'boolean', - hidden: true, - default: false, - }, - }); - - yargs.example([ - ['$0 project migrate-app', i18n(`${i18nKey}.examples.default`)], - ]); - - addConfigOptions(yargs); - addAccountOptions(yargs); - addUseEnvironmentOptions(yargs); - - return yargs; -}; - -export async function migrateToUnifiedApp( - derivedAccountId: number, - accountConfig: CLIAccount, - options: unknown -) { - console.log('der', derivedAccountId); - console.log('acc', accountConfig); - console.log('opt', options); - - // Check if the command is running within a project - const isProject = false; - let appId: number; - - if (isProject) { - // Use the current project and app details for the migration - appId = 111; - } else { - // Make the call to get the list of the non project apps eligible to migrate - // Prompt the user to select the app to migrate - // Prompt the user for a project name and destination - const projectName = await inputPrompt( - 'Enter the name of the app you want to migrate: ' - ); - console.log(projectName); - const projectDest = await inputPrompt( - 'Where do you want to save the project?: ' - ); - console.log(projectDest); - appId = 999; - } - - // Call the migration end points - const { migrationId, uidsRequired } = await beginMigration(appId); - - const uidMap: Record = {}; - - if (uidsRequired.length !== 0) { - for (const u of uidsRequired) { - uidMap[u] = await inputPrompt(`Give me a uid for ${u}: `); - } - } - - try { - const response = await finishMigration(migrationId, uidMap); - // Poll using the projectId and the build id? - console.log(response); - } catch (error) { - logError(error); - process.exit(EXIT_CODES.ERROR); - } + await migrateHandler(yargs); } -interface MigrationStageOneResponse { - migrationId: number; - uidsRequired: string[]; -} -export async function beginMigration( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - appId: number -): Promise { - console.log(`migrating ${appId}`); - return new Promise(async resolve => { - setTimeout(() => { - resolve({ - migrationId: 1234, - uidsRequired: ['App 1', 'App 2'], - }); - }, 150); - }); -} - -type MigrationFinishResponse = { - projectId: number; - buildId: number; -}; - -export async function finishMigration( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - migrationId: number, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - uidMap: Record -): Promise { - return new Promise(async resolve => { - setTimeout(() => { - resolve({ - projectId: 1234, - buildId: 5555, - }); - }, 150); - }); -} +export { builder } from '../app/migrate'; diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts new file mode 100644 index 000000000..2d6269c45 --- /dev/null +++ b/lib/app/migrate.ts @@ -0,0 +1,297 @@ +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { inputPrompt, promptUser } from '../prompts/promptUtils'; +import { ApiErrorContext, logError } from '../errorHandlers'; +import { EXIT_CODES } from '../enums/exitCodes'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { + uiAccountDescription, + uiBetaTag, + uiCommandReference, + uiLine, + uiLink, +} from '../ui'; +import { i18n } from '../lang'; +import { isAppDeveloperAccount } from '../accountTypes'; +import { selectPublicAppPrompt } from '../prompts/selectPublicAppPrompt'; +import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; +import { createProjectPrompt } from '../prompts/createProjectPrompt'; +import { ensureProjectExists } from '../projects'; +import { trackCommandMetadataUsage } from '../usageTracking'; +import SpinniesManager from '../ui/SpinniesManager'; +import { handleKeypress } from '../process'; +import { + checkMigrationStatus, + downloadProject, + migrateApp, +} from '@hubspot/local-dev-lib/api/projects'; +import { poll } from '../polling'; +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'; + +export async function migrateToUnifiedApp( + derivedAccountId: number, + accountConfig: CLIAccount, + options: unknown +) { + console.log('der', derivedAccountId); + console.log('acc', accountConfig); + console.log('opt', options); + + // Check if the command is running within a project + const isProject = false; + let appId: number; + + if (isProject) { + // Use the current project and app details for the migration + appId = 111; + } else { + // Make the call to get the list of the non project apps eligible to migrate + // Prompt the user to select the app to migrate + // Prompt the user for a project name and destination + const projectName = await inputPrompt( + 'Enter the name of the app you want to migrate: ' + ); + console.log(projectName); + const projectDest = await inputPrompt( + 'Where do you want to save the project?: ' + ); + console.log(projectDest); + appId = 999; + } + + // Call the migration end points + const { migrationId, uidsRequired } = await beginMigration(appId); + + const uidMap: Record = {}; + + if (uidsRequired.length !== 0) { + for (const u of uidsRequired) { + uidMap[u] = await inputPrompt(`Give me a uid for ${u}: `); + } + } + + try { + const response = await finishMigration(migrationId, uidMap); + // Poll using the projectId and the build id? + console.log(response); + } catch (error) { + logError(error); + process.exit(EXIT_CODES.ERROR); + } +} + +interface MigrationStageOneResponse { + migrationId: number; + uidsRequired: string[]; +} +export async function beginMigration( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + appId: number +): Promise { + console.log(`migrating ${appId}`); + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + migrationId: 1234, + uidsRequired: ['App 1', 'App 2'], + }); + }, 150); + }); +} + +type MigrationFinishResponse = { + projectId: number; + buildId: number; +}; + +export async function finishMigration( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + migrationId: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + uidMap: Record +): Promise { + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + projectId: 1234, + buildId: 5555, + }); + }, 150); + }); +} + +export async function migrateAppTo2023_2( + accountConfig: CLIAccount, + options: any, + derivedAccountId: number +) { + const i18nKey = 'commands.project.subcommands.migrateApp'; + const accountName = uiAccountDescription(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); + } + + 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(`${i18nKey}.errors.projectAlreadyExists`, { + projectName, + }) + ); + } + + await trackCommandMetadataUsage( + 'migrate-app', + { status: 'STARTED' }, + derivedAccountId + ); + + logger.log(''); + uiLine(); + logger.warn(`${i18n(`${i18nKey}.warning.title`)}\n`); + logger.log(i18n(`${i18nKey}.warning.projectConversion`)); + logger.log(`${i18n(`${i18nKey}.warning.appConfig`)}\n`); + logger.log(`${i18n(`${i18nKey}.warning.buildAndDeploy`)}\n`); + logger.log(`${i18n(`${i18nKey}.warning.existingApps`)}\n`); + 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) { + SpinniesManager.fail('migrateApp', { + text: i18n(`${i18nKey}.migrationStatus.failure`), + failColor: 'white', + }); + throw error; + } +} diff --git a/lib/ui/index.ts b/lib/ui/index.ts index ed8c94482..494baa499 100644 --- a/lib/ui/index.ts +++ b/lib/ui/index.ts @@ -120,12 +120,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(); @@ -136,6 +138,12 @@ export function uiDeprecatedTag(message: string): void { } ${message}`; logger.log(result); + + if (log) { + logger.log(result); + return; + } + return result; } export function uiCommandDisabledBanner( diff --git a/types/Yargs.ts b/types/Yargs.ts index f995db212..75b5a1823 100644 --- a/types/Yargs.ts +++ b/types/Yargs.ts @@ -33,3 +33,14 @@ export type StringArgType = Options & { export type TestingArgs = { qa?: boolean; }; + +export interface MigrateAppOptions + extends CommonArgs, + AccountArgs, + EnvironmentArgs, + ConfigArgs { + name: string; + dest: string; + appId: number; + platformVersion: string; +} From 2d96fbb1b8a777bda742c9e9c6fbdea5e72c57ea Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 18 Mar 2025 16:10:43 -0700 Subject: [PATCH 03/36] Fake it --- bin/cli.js | 2 + commands/app/migrate.ts | 28 ++-- commands/customObject/schema/update.ts | 2 +- commands/project/cloneApp.ts | 106 +++++++------ commands/project/migrateApp.ts | 7 +- lang/en.lyaml | 1 + lib/app/migrate.ts | 206 +++++++++++++++++++------ lib/prompts/promptUtils.ts | 12 +- types/Prompts.ts | 6 +- 9 files changed, 245 insertions(+), 125 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index b7b33fd24..7d1af756c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -54,6 +54,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'); const notifier = updateNotifier({ pkg: { ...pkg, name: '@hubspot/cli' }, @@ -349,6 +350,7 @@ const argv = yargs .command(feedbackCommand) .command(doctorCommand) .command(completionCommand) + .command(appCommand) .help() .alias('h', 'help') .recommendCommands() diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 7d725d5b4..5a46be661 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -13,17 +13,11 @@ import { EXIT_CODES } from '../../lib/enums/exitCodes'; import { getAccountConfig } from '@hubspot/local-dev-lib/config'; import { ArgumentsCamelCase, Argv } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; -import { migrateAppTo2023_2, migrateToUnifiedApp } from '../../lib/app/migrate'; +import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; -// TODO: Move this somewhere else -const platformVersions = { - v2023_2: '2023.2', - v2025_2: '2025.2', - unstable: 'unstable', -}; - -const { v2023_2, v2025_2 } = platformVersions; -const supportedPlatformVersions = [v2023_2, v2025_2]; +const { v2023_2, v2025_2 } = PLATFORM_VERSIONS; +const validMigrationTargets = [v2023_2, v2025_2]; const i18nKey = 'commands.project.subcommands.migrateApp'; @@ -39,15 +33,15 @@ export async function handler(options: ArgumentsCamelCase) { throw new Error('Account is not configured'); } - if (!supportedPlatformVersions.includes(platformVersion)) { + if (!validMigrationTargets.includes(platformVersion)) { throw new Error('Unsupported platform version'); } try { if (platformVersion === v2025_2) { - await migrateToUnifiedApp(derivedAccountId, accountConfig, options); + await migrateApp2025_2(derivedAccountId, accountConfig, options); } else if (platformVersion === v2023_2) { - await migrateAppTo2023_2(accountConfig, options, derivedAccountId); + await migrateApp2023_2(accountConfig, options, derivedAccountId); } await trackCommandMetadataUsage( 'migrate-app', @@ -82,6 +76,10 @@ export async function handler(options: ArgumentsCamelCase) { } export function builder(yargs: Argv) { + addConfigOptions(yargs); + addAccountOptions(yargs); + addUseEnvironmentOptions(yargs); + yargs.options({ name: { describe: i18n(`${i18nKey}.options.name.describe`), @@ -105,9 +103,5 @@ export function builder(yargs: Argv) { yargs.example([['$0 app migrate', i18n(`${i18nKey}.examples.default`)]]); - addConfigOptions(yargs); - addAccountOptions(yargs); - addUseEnvironmentOptions(yargs); - return yargs; } diff --git a/commands/customObject/schema/update.ts b/commands/customObject/schema/update.ts index 5fdafad38..54e4edc37 100644 --- a/commands/customObject/schema/update.ts +++ b/commands/customObject/schema/update.ts @@ -66,7 +66,7 @@ export async function handler( name = providedName && typeof providedName === 'string' ? providedName - : await listPrompt(i18n(`${i18nKey}.selectSchema`), { + : await listPrompt(i18n(`${i18nKey}.selectSchema`), { choices: schemaNames, }); diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index f2cde0635..c66cfe38d 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -1,68 +1,71 @@ -// @ts-nocheck import { uiDeprecatedTag } from '../../lib/ui'; - -const path = require('path'); -const fs = require('fs'); -const { +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 { - 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 } from 'yargs'; +import { + AccountArgs, + CommonArgs, + ConfigArgs, + EnvironmentArgs, +} from '../../types/Yargs'; +import { logInvalidAccountError } from '../../lib/app/migrate'; const i18nKey = 'commands.project.subcommands.cloneApp'; exports.command = 'clone-app'; exports.describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); -exports.handler = async options => { +export interface CloneAppArgs + extends ConfigArgs, + EnvironmentArgs, + AccountArgs, + CommonArgs { + dest: string; + appId: number; +} + +exports.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('Account is not configured'); + } 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); } @@ -75,7 +78,6 @@ exports.handler = async options => { const appIdResponse = await selectPublicAppPrompt({ accountId: derivedAccountId, accountName, - options, isMigratingApp: false, }); appId = appIdResponse.appId; @@ -150,7 +152,9 @@ 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); } @@ -165,8 +169,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 })) ); @@ -183,7 +193,7 @@ exports.handler = async options => { process.exit(EXIT_CODES.SUCCESS); }; -exports.builder = yargs => { +exports.builder = (yargs: Argv) => { yargs.options({ dest: { describe: i18n(`${i18nKey}.options.dest.describe`), diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index 659d6fa56..b50e49dca 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,5 +1,5 @@ import { i18n } from '../../lib/lang'; -import { uiDeprecatedTag } from '../../lib/ui'; +import { uiCommandReference, uiDeprecatedTag } from '../../lib/ui'; import { handler as migrateHandler } from '../app/migrate'; import { ArgumentsCamelCase } from 'yargs'; @@ -15,7 +15,10 @@ export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); export async function handler(yargs: ArgumentsCamelCase) { logger.warn( - "The 'hs project migrate-app' command is deprecated and will be removed. Use 'hs app migrate' going forward." + i18n(`${i18nKey}.describe.deprecationWarning`, { + oldCommand: uiCommandReference('hs project migrate-app'), + newCommand: uiCommandReference('hs app migrate'), + }) ); await migrateHandler(yargs); } diff --git a/lang/en.lyaml b/lang/en.lyaml index fc6b69de0..c5ffe00d9 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -566,6 +566,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}}" diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 2d6269c45..edbc1d94f 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -1,5 +1,5 @@ import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { inputPrompt, promptUser } from '../prompts/promptUtils'; +import { inputPrompt, listPrompt, promptUser } from '../prompts/promptUtils'; import { ApiErrorContext, logError } from '../errorHandlers'; import { EXIT_CODES } from '../enums/exitCodes'; import { logger } from '@hubspot/local-dev-lib/logger'; @@ -29,40 +29,60 @@ 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 { MigrateAppOptions } from '../../types/Yargs'; -export async function migrateToUnifiedApp( +export async function migrateApp2025_2( derivedAccountId: number, accountConfig: CLIAccount, - options: unknown + options: ArgumentsCamelCase ) { - console.log('der', derivedAccountId); - console.log('acc', accountConfig); - console.log('opt', options); - - // Check if the command is running within a project - const isProject = false; - let appId: number; - - if (isProject) { - // Use the current project and app details for the migration - appId = 111; - } else { - // Make the call to get the list of the non project apps eligible to migrate - // Prompt the user to select the app to migrate - // Prompt the user for a project name and destination - const projectName = await inputPrompt( - 'Enter the name of the app you want to migrate: ' - ); - console.log(projectName); - const projectDest = await inputPrompt( - 'Where do you want to save the project?: ' - ); - console.log(projectDest); - appId = 999; + SpinniesManager.init(); + + console.log(accountConfig); + console.log(options); + // let appId: number; + + const { apps } = await getEligibleApps(derivedAccountId); + + if (apps.length === 0) { + logger.info('No apps available to migrate'); + process.exit(EXIT_CODES.SUCCESS); } + const appChoices = apps.map(app => ({ + name: app.name, + value: app, + disabled: app.projectName !== undefined ? 'Already migrated' : false, + })); + + const appToMigrate = await listPrompt( + 'Choose the app you want to migrate: ', + { + choices: appChoices, + } + ); + + // Make the call to get the list of the non project apps eligible to migrate + // Prompt the user to select the app to migrate + // Prompt the user for a project name and destination + const projectName = await inputPrompt('Enter the name for the project'); + const projectDest = await inputPrompt( + 'Where do you want to save the project?: ' + ); + + SpinniesManager.add('beginningMigration', { + text: 'Beginning migration', + }); + // Call the migration end points - const { migrationId, uidsRequired } = await beginMigration(appId); + const { migrationId, uidsRequired } = await beginMigration( + appToMigrate.appId + ); + + SpinniesManager.succeed('beginningMigration', { + text: 'Migration started', + }); const uidMap: Record = {}; @@ -72,36 +92,105 @@ export async function migrateToUnifiedApp( } } + let buildId: number; + let projectId: number; + try { - const response = await finishMigration(migrationId, uidMap); + SpinniesManager.add('finishingMigration', { + text: 'Finalizing migration', + }); + const migration = await finishMigration(migrationId, uidMap, projectName); + projectId = migration.projectId; + buildId = migration.buildId; // Poll using the projectId and the build id? - console.log(response); + SpinniesManager.succeed('finishingMigration', { + text: 'Migration Successful', + }); } catch (error) { logError(error); process.exit(EXIT_CODES.ERROR); } + + SpinniesManager.add('fetchingMigratedProject', { + text: 'Fetching migrated project', + }); + await fetchProjectSource(projectId, buildId); + SpinniesManager.succeed('fetchingMigratedProject', { + text: 'Migrated project fetched', + }); + + // TODO: Actually save it + logger.success(`Saved ${projectName} to ${projectDest}`); } interface MigrationStageOneResponse { migrationId: number; uidsRequired: string[]; } + +interface EligibleApp { + name: string; + appId: number; + projectName?: string; +} + +interface EligibleAppsResponse { + apps: EligibleApp[]; +} + +export async function getEligibleApps( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + appId: number +): Promise { + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + apps: [ + { + name: 'App 1', + appId: 1, + }, + { + name: 'App 2', + appId: 2, + projectName: 'Project 2', + }, + { + name: 'App 3 - No uids required ', + appId: 3, + }, + ], + }); + }, 150); + }); +} + export async function beginMigration( // eslint-disable-next-line @typescript-eslint/no-unused-vars appId: number ): Promise { - console.log(`migrating ${appId}`); return new Promise(async resolve => { setTimeout(() => { + if (appId === 1) { + return resolve({ + migrationId: 1234, + uidsRequired: [ + 'App 1', + 'Serverless function 1', + 'Serverless function 2', + ], + }); + } resolve({ migrationId: 1234, - uidsRequired: ['App 1', 'App 2'], + uidsRequired: [], }); - }, 150); + }, 1500); }); } type MigrationFinishResponse = { + projectName: string; projectId: number; buildId: number; }; @@ -110,21 +199,50 @@ export async function finishMigration( // eslint-disable-next-line @typescript-eslint/no-unused-vars migrationId: number, // eslint-disable-next-line @typescript-eslint/no-unused-vars - uidMap: Record + uidMap: Record, + projectName: string ): Promise { return new Promise(async resolve => { setTimeout(() => { resolve({ - projectId: 1234, - buildId: 5555, + projectName, + projectId: 8675309, + buildId: 1234, }); - }, 150); + }, 2000); }); } -export async function migrateAppTo2023_2( +export async function fetchProjectSource( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + __projectId: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + __buildId: number +) { + return new Promise(async resolve => { + setTimeout(() => { + resolve({ + source: 'console.log("Hello, World!");', + }); + }, 1500); + }); +} + +export function logInvalidAccountError(i18nKey: string) { + uiLine(); + logger.error(i18n(`${i18nKey}.errors.invalidAccountTypeTitle`)); + logger.log( + i18n(`${i18nKey}.errors.invalidAccountTypeDescription`, { + useCommand: uiCommandReference('hs accounts use'), + authCommand: uiCommandReference('hs auth'), + }) + ); + uiLine(); +} + +export async function migrateApp2023_2( accountConfig: CLIAccount, - options: any, + options: ArgumentsCamelCase, derivedAccountId: number ) { const i18nKey = 'commands.project.subcommands.migrateApp'; @@ -140,15 +258,7 @@ export async function migrateAppTo2023_2( 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(); + logInvalidAccountError(i18nKey); process.exit(EXIT_CODES.SUCCESS); } diff --git a/lib/prompts/promptUtils.ts b/lib/prompts/promptUtils.ts index c1b1d10c8..a8968c46d 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', 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; } >; From 503b2c88f8e0380e44f771b1eda56e5d4802a2c1 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 19 Mar 2025 12:41:29 -0700 Subject: [PATCH 04/36] wip --- commands/app/migrate.ts | 11 +++++++---- lib/app/migrate.ts | 32 +++++++++++++++----------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 5a46be661..e5decdb59 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -39,9 +39,9 @@ export async function handler(options: ArgumentsCamelCase) { try { if (platformVersion === v2025_2) { - await migrateApp2025_2(derivedAccountId, accountConfig, options); + await migrateApp2025_2(derivedAccountId, options); } else if (platformVersion === v2023_2) { - await migrateApp2023_2(accountConfig, options, derivedAccountId); + await migrateApp2023_2(derivedAccountId, options, accountConfig); } await trackCommandMetadataUsage( 'migrate-app', @@ -75,7 +75,7 @@ export async function handler(options: ArgumentsCamelCase) { process.exit(EXIT_CODES.SUCCESS); } -export function builder(yargs: Argv) { +export async function builder(yargs: Argv) { addConfigOptions(yargs); addAccountOptions(yargs); addUseEnvironmentOptions(yargs); @@ -101,7 +101,10 @@ export function builder(yargs: Argv) { }, }); - yargs.example([['$0 app migrate', i18n(`${i18nKey}.examples.default`)]]); + // This is a hack so we can use the same function for both the app migrate and project migrate-app commands + // and have the examples be correct. If we don't can about that we can remove this. + const { _ } = await yargs.argv; + yargs.example([[`$0 ${_.join(' ')}`, i18n(`${i18nKey}.examples.default`)]]); return yargs; } diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index edbc1d94f..5d22c1dcd 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -34,14 +34,11 @@ import { MigrateAppOptions } from '../../types/Yargs'; export async function migrateApp2025_2( derivedAccountId: number, - accountConfig: CLIAccount, options: ArgumentsCamelCase ) { - SpinniesManager.init(); + const { name, dest, appId } = options; - console.log(accountConfig); - console.log(options); - // let appId: number; + SpinniesManager.init(); const { apps } = await getEligibleApps(derivedAccountId); @@ -56,20 +53,21 @@ export async function migrateApp2025_2( disabled: app.projectName !== undefined ? 'Already migrated' : false, })); - const appToMigrate = await listPrompt( - 'Choose the app you want to migrate: ', - { - choices: appChoices, - } - ); + const appToMigrate = appId + ? { appId } + : await listPrompt('Choose the app you want to migrate: ', { + choices: appChoices, + }); // Make the call to get the list of the non project apps eligible to migrate // Prompt the user to select the app to migrate // Prompt the user for a project name and destination - const projectName = await inputPrompt('Enter the name for the project'); - const projectDest = await inputPrompt( - 'Where do you want to save the project?: ' - ); + const projectName = + name || (await inputPrompt('[--name] Enter the name for the project')); + + const projectDest = + dest || + (await inputPrompt('[--dest] Where do you want to save the project?: ')); SpinniesManager.add('beginningMigration', { text: 'Beginning migration', @@ -241,9 +239,9 @@ export function logInvalidAccountError(i18nKey: string) { } export async function migrateApp2023_2( - accountConfig: CLIAccount, + derivedAccountId: number, options: ArgumentsCamelCase, - derivedAccountId: number + accountConfig: CLIAccount ) { const i18nKey = 'commands.project.subcommands.migrateApp'; const accountName = uiAccountDescription(derivedAccountId); From 99f343ec4ac7e993224b473fc4b92e5e8fca8ab7 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Fri, 21 Mar 2025 12:06:41 -0700 Subject: [PATCH 05/36] Refactor --- lang/en.lyaml | 20 +++ lib/app/migrate.ts | 305 ++++++++++++++++++++++++--------------------- 2 files changed, 185 insertions(+), 140 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index c5ffe00d9..5a733310a 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -583,10 +583,30 @@ en: createAppPrompt: "Proceed with migrating this app to a project component (this process can't be aborted)?" projectDetailsLink: "View project details in your developer account" errors: + noApps: "No apps found in account {{ accountId }}" + noAppsEligible: "No apps in account {{ accountId }} are eligible for migration" 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." + prompt: + chooseApp: 'Choose the app you want to migrate: ' + inputName: '[--name] Enter the name for the project' + inputDest: '[--dest] Where do you want to save the project?' + uidForComponent: "Enter a UID for {{ componentName }}:" + spinners: + beginningMigration: "Beginning migration" + migrationStarted: "Migration started" + unableToStartMigration: "Unable to begin migration" + finishingMigration: "Finishing migration" + migrationComplete: "Migration complete" + migrationFailed: "Migration failed" + downloadingProjectContents: "Fetching migrated project" + downloadingProjectContentsComplete: "Fetching migrated project complete" + downloadingProjectContentsFailed: "Unable to fetch migrated project" + migrationFailureReasons: + upToDate: 'App is already up to date' + generic: "Unable to migrate app: {{ reasonCode }}" cloneApp: describe: "Clone a public app using the projects framework." examples: diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 5d22c1dcd..bde210a9d 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -22,7 +22,11 @@ import { handleKeypress } from '../process'; import { checkMigrationStatus, downloadProject, - migrateApp, + migrateNonProjectApp_v2023_2, + beginMigration, + finishMigration, + listAppsForMigration, + MigrationApp, } from '@hubspot/local-dev-lib/api/projects'; import { poll } from '../polling'; import path from 'path'; @@ -32,6 +36,22 @@ import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; import { ArgumentsCamelCase } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; +function getUnmigrateableReason(reasonCode: string) { + switch (reasonCode) { + case 'UP_TO_DATE': + return i18n( + 'commands.project.subcommands.migrateApp.migrationFailureReasons.upToDate' + ); + default: + return i18n( + 'commands.project.subcommands.migrateApp.migrationFailureReasons.generic', + { + reasonCode, + } + ); + } +} + export async function migrateApp2025_2( derivedAccountId: number, options: ArgumentsCamelCase @@ -40,190 +60,195 @@ export async function migrateApp2025_2( SpinniesManager.init(); - const { apps } = await getEligibleApps(derivedAccountId); + const { migratableApps, unmigratableApps } = + await listAppsForMigration(derivedAccountId); - if (apps.length === 0) { - logger.info('No apps available to migrate'); - process.exit(EXIT_CODES.SUCCESS); + const allApps = [...migratableApps, ...unmigratableApps]; + + if (allApps.length === 0) { + throw new Error( + i18n(`commands.project.subcommands.migrateApp.errors.noApps`, { + accountId: derivedAccountId, + }) + ); } - const appChoices = apps.map(app => ({ - name: app.name, + if (migratableApps.length === 0) { + const reasons = unmigratableApps.map( + app => `${app.appName}: ${getUnmigrateableReason(app.unmigratableReason)}` + ); + + logger.error( + `${i18n(`commands.project.subcommands.migrateApp.errors.noAppsEligible`, { + accountId: derivedAccountId, + })} \n\t${reasons.join('\n\t')}` + ); + + return process.exit(EXIT_CODES.SUCCESS); + } + + const appChoices = allApps.map(app => ({ + name: app.appName, value: app, - disabled: app.projectName !== undefined ? 'Already migrated' : false, + disabled: app.isMigratable + ? false + : getUnmigrateableReason(app.unmigratableReason), })); const appToMigrate = appId ? { appId } - : await listPrompt('Choose the app you want to migrate: ', { - choices: appChoices, - }); + : await listPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.chooseApp'), + { + choices: appChoices, + } + ); // Make the call to get the list of the non project apps eligible to migrate // Prompt the user to select the app to migrate // Prompt the user for a project name and destination const projectName = - name || (await inputPrompt('[--name] Enter the name for the project')); + name || + (await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.inputName') + )); + + const { projectExists } = await ensureProjectExists( + derivedAccountId, + projectName, + { forceCreate: false, allowCreate: false } + ); + + if (projectExists) { + throw new Error( + i18n( + 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists', + { + projectName, + } + ) + ); + } const projectDest = dest || - (await inputPrompt('[--dest] Where do you want to save the project?: ')); + (await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.inputDest') + )); SpinniesManager.add('beginningMigration', { - text: 'Beginning migration', + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.beginningMigration' + ), }); - // Call the migration end points - const { migrationId, uidsRequired } = await beginMigration( - appToMigrate.appId - ); + const uidMap: Record = {}; + let migrationId: number | undefined; - SpinniesManager.succeed('beginningMigration', { - text: 'Migration started', - }); + try { + // Call the migration end points + const { migrationId: mid, uidsRequired } = await beginMigration( + appToMigrate.appId + ); - const uidMap: Record = {}; + migrationId = mid; + SpinniesManager.succeed('beginningMigration', { + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.migrationStarted' + ), + }); - if (uidsRequired.length !== 0) { - for (const u of uidsRequired) { - uidMap[u] = await inputPrompt(`Give me a uid for ${u}: `); + if (uidsRequired.length !== 0) { + for (const u of uidsRequired) { + uidMap[u] = await inputPrompt( + i18n( + 'commands.project.subcommands.migrateApp.prompt.uidForComponent', + { componentName: u } + ) + ); + } } + } catch (e) { + SpinniesManager.fail('beginningMigration', { + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' + ), + }); + logError(e); + return process.exit(EXIT_CODES.ERROR); } let buildId: number; - let projectId: number; try { SpinniesManager.add('finishingMigration', { - text: 'Finalizing migration', + text: i18n( + `commands.project.subcommands.migrateApp.spinners.finishingMigration` + ), }); - const migration = await finishMigration(migrationId, uidMap, projectName); - projectId = migration.projectId; + const migration = await finishMigration( + derivedAccountId, + migrationId, + uidMap, + projectName + ); buildId = migration.buildId; // Poll using the projectId and the build id? SpinniesManager.succeed('finishingMigration', { - text: 'Migration Successful', + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationComplete` + ), }); } catch (error) { + SpinniesManager.fail('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationFailed` + ), + }); logError(error); process.exit(EXIT_CODES.ERROR); } - SpinniesManager.add('fetchingMigratedProject', { - text: 'Fetching migrated project', - }); - await fetchProjectSource(projectId, buildId); - SpinniesManager.succeed('fetchingMigratedProject', { - text: 'Migrated project fetched', - }); - - // TODO: Actually save it - logger.success(`Saved ${projectName} to ${projectDest}`); -} - -interface MigrationStageOneResponse { - migrationId: number; - uidsRequired: string[]; -} + try { + SpinniesManager.add('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContents` + ), + }); -interface EligibleApp { - name: string; - appId: number; - projectName?: string; -} + const { data: zippedProject } = await downloadProject( + derivedAccountId, + projectName, + buildId + ); -interface EligibleAppsResponse { - apps: EligibleApp[]; -} + const absoluteDestPath = dest ? path.resolve(getCwd(), dest) : getCwd(); -export async function getEligibleApps( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - appId: number -): Promise { - return new Promise(async resolve => { - setTimeout(() => { - resolve({ - apps: [ - { - name: 'App 1', - appId: 1, - }, - { - name: 'App 2', - appId: 2, - projectName: 'Project 2', - }, - { - name: 'App 3 - No uids required ', - appId: 3, - }, - ], - }); - }, 150); - }); -} + await extractZipArchive( + zippedProject, + sanitizeFileName(projectName), + path.resolve(absoluteDestPath), + { includesRootDir: false } + ); -export async function beginMigration( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - appId: number -): Promise { - return new Promise(async resolve => { - setTimeout(() => { - if (appId === 1) { - return resolve({ - migrationId: 1234, - uidsRequired: [ - 'App 1', - 'Serverless function 1', - 'Serverless function 2', - ], - }); - } - resolve({ - migrationId: 1234, - uidsRequired: [], - }); - }, 1500); - }); -} + SpinniesManager.succeed('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsComplete` + ), + }); -type MigrationFinishResponse = { - projectName: string; - projectId: number; - buildId: number; -}; - -export async function finishMigration( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - migrationId: number, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - uidMap: Record, - projectName: string -): Promise { - return new Promise(async resolve => { - setTimeout(() => { - resolve({ - projectName, - projectId: 8675309, - buildId: 1234, - }); - }, 2000); - }); -} + logger.success(`Saved ${projectName} to ${projectDest}`); + } catch (error) { + SpinniesManager.fail('fetchingMigratedProject', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed` + ), + }); + logError(error); + return process.exit(EXIT_CODES.ERROR); + } -export async function fetchProjectSource( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - __projectId: number, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - __buildId: number -) { - return new Promise(async resolve => { - setTimeout(() => { - resolve({ - source: 'console.log("Hello, World!");', - }); - }, 1500); - }); + process.exit(EXIT_CODES.SUCCESS); } export function logInvalidAccountError(i18nKey: string) { @@ -349,7 +374,7 @@ export async function migrateApp2023_2( } }); - const { data: migrateResponse } = await migrateApp( + const { data: migrateResponse } = await migrateNonProjectApp_v2023_2( derivedAccountId, appId, projectName From b9e16111b2683f3abe009b444516935c1477c90e Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Fri, 21 Mar 2025 15:59:27 -0700 Subject: [PATCH 06/36] Add additional reasons --- lang/en.lyaml | 2 ++ lib/app/migrate.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index 5a733310a..930aedf56 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -606,6 +606,8 @@ en: downloadingProjectContentsFailed: "Unable to fetch migrated project" migrationFailureReasons: upToDate: 'App is already up to date' + isPrivateApp: 'Private apps are not currently migratable' + listedInMarketplace: 'App is listed in marketplace' generic: "Unable to migrate app: {{ reasonCode }}" cloneApp: describe: "Clone a public app using the projects framework." diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index bde210a9d..64b7d0136 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -27,6 +27,7 @@ import { finishMigration, listAppsForMigration, MigrationApp, + UNMIGRATABLE_REASONS, } from '@hubspot/local-dev-lib/api/projects'; import { poll } from '../polling'; import path from 'path'; @@ -36,12 +37,20 @@ import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; import { ArgumentsCamelCase } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; -function getUnmigrateableReason(reasonCode: string) { +function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { - case 'UP_TO_DATE': + case UNMIGRATABLE_REASONS.UP_TO_DATE: return i18n( 'commands.project.subcommands.migrateApp.migrationFailureReasons.upToDate' ); + case UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP: + return i18n( + 'commands.project.subcommands.migrateApp.migrationFailureReasons.isPrivateApp' + ); + case UNMIGRATABLE_REASONS.LISTED_IN_MARKETPLACE: + return i18n( + 'commands.project.subcommands.migrateApp.migrationFailureReasons.listedInMarketplace' + ); default: return i18n( 'commands.project.subcommands.migrateApp.migrationFailureReasons.generic', @@ -75,7 +84,7 @@ export async function migrateApp2025_2( if (migratableApps.length === 0) { const reasons = unmigratableApps.map( - app => `${app.appName}: ${getUnmigrateableReason(app.unmigratableReason)}` + app => `${app.appName}: ${getUnmigratableReason(app.unmigratableReason)}` ); logger.error( @@ -92,7 +101,7 @@ export async function migrateApp2025_2( value: app, disabled: app.isMigratable ? false - : getUnmigrateableReason(app.unmigratableReason), + : getUnmigratableReason(app.unmigratableReason), })); const appToMigrate = appId From 175e36c922335dbd47109ccb764cbc0b8ce425dc Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 26 Mar 2025 15:17:33 -0700 Subject: [PATCH 07/36] Review copy with Jono --- lang/en.lyaml | 27 ++++++++++++---------- lib/app/migrate.ts | 57 ++++++++++++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index 930aedf56..26efd1c1e 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -584,30 +584,33 @@ en: projectDetailsLink: "View project details in your developer account" errors: noApps: "No apps found in account {{ accountId }}" - noAppsEligible: "No apps in account {{ accountId }} are eligible for migration" + noAppsEligible: "No apps in account {{ accountId }} are currently migratable" 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." prompt: - chooseApp: 'Choose the app you want to migrate: ' - inputName: '[--name] Enter the name for the project' - inputDest: '[--dest] Where do you want to save the project?' - uidForComponent: "Enter a UID for {{ componentName }}:" + 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 }}?' spinners: beginningMigration: "Beginning migration" migrationStarted: "Migration started" unableToStartMigration: "Unable to begin migration" - finishingMigration: "Finishing migration" - migrationComplete: "Migration complete" + finishingMigration: "Wrapping up migration" + migrationComplete: "Migration completed" migrationFailed: "Migration failed" - downloadingProjectContents: "Fetching migrated project" - downloadingProjectContentsComplete: "Fetching migrated project complete" - downloadingProjectContentsFailed: "Unable to fetch migrated project" - migrationFailureReasons: + 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" + migrationNotAllowedReasons: upToDate: 'App is already up to date' isPrivateApp: 'Private apps are not currently migratable' - listedInMarketplace: 'App is listed in marketplace' + listedInMarketplace: 'Listed apps are not currently migratable' generic: "Unable to migrate app: {{ reasonCode }}" cloneApp: describe: "Clone a public app using the projects framework." diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 64b7d0136..a54c37a47 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -36,24 +36,25 @@ import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; import { ArgumentsCamelCase } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; +import chalk from 'chalk'; function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { case UNMIGRATABLE_REASONS.UP_TO_DATE: return i18n( - 'commands.project.subcommands.migrateApp.migrationFailureReasons.upToDate' + 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.upToDate' ); case UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP: return i18n( - 'commands.project.subcommands.migrateApp.migrationFailureReasons.isPrivateApp' + 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.isPrivateApp' ); case UNMIGRATABLE_REASONS.LISTED_IN_MARKETPLACE: return i18n( - 'commands.project.subcommands.migrateApp.migrationFailureReasons.listedInMarketplace' + 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.listedInMarketplace' ); default: return i18n( - 'commands.project.subcommands.migrateApp.migrationFailureReasons.generic', + 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.generic', { reasonCode, } @@ -61,17 +62,13 @@ function getUnmigratableReason(reasonCode: string) { } } -export async function migrateApp2025_2( +async function handleMigrationSetup( derivedAccountId: number, options: ArgumentsCamelCase ) { const { name, dest, appId } = options; - - SpinniesManager.init(); - - const { migratableApps, unmigratableApps } = - await listAppsForMigration(derivedAccountId); - + const { data } = await listAppsForMigration(derivedAccountId); + const { migratableApps, unmigratableApps } = data; const allApps = [...migratableApps, ...unmigratableApps]; if (allApps.length === 0) { @@ -84,20 +81,23 @@ export async function migrateApp2025_2( if (migratableApps.length === 0) { const reasons = unmigratableApps.map( - app => `${app.appName}: ${getUnmigratableReason(app.unmigratableReason)}` + app => + `${chalk.bold(app.appName)}: ${getUnmigratableReason(app.unmigratableReason)}` ); logger.error( `${i18n(`commands.project.subcommands.migrateApp.errors.noAppsEligible`, { accountId: derivedAccountId, - })} \n\t${reasons.join('\n\t')}` + })} \n - ${reasons.join('\n - ')}` ); return process.exit(EXIT_CODES.SUCCESS); } const appChoices = allApps.map(app => ({ - name: app.appName, + name: app.isMigratable + ? app.appName + : `[${chalk.yellow('DISABLED')}] ${app.appName} `, value: app, disabled: app.isMigratable ? false @@ -113,9 +113,6 @@ export async function migrateApp2025_2( } ); - // Make the call to get the list of the non project apps eligible to migrate - // Prompt the user to select the app to migrate - // Prompt the user for a project name and destination const projectName = name || (await inputPrompt( @@ -145,6 +142,10 @@ export async function migrateApp2025_2( i18n('commands.project.subcommands.migrateApp.prompt.inputDest') )); + return { appToMigrate, projectName, projectDest }; +} + +async function handleMigrationProcess(appToMigrate: { appId: number }) { SpinniesManager.add('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.beginningMigration' @@ -155,7 +156,6 @@ export async function migrateApp2025_2( let migrationId: number | undefined; try { - // Call the migration end points const { migrationId: mid, uidsRequired } = await beginMigration( appToMigrate.appId ); @@ -187,6 +187,22 @@ export async function migrateApp2025_2( return process.exit(EXIT_CODES.ERROR); } + return { migrationId, uidMap }; +} + +export async function migrateApp2025_2( + derivedAccountId: number, + options: ArgumentsCamelCase +) { + SpinniesManager.init(); + + const { appToMigrate, projectName, projectDest } = await handleMigrationSetup( + derivedAccountId, + options + ); + + const { migrationId, uidMap } = await handleMigrationProcess(appToMigrate); + let buildId: number; try { @@ -202,7 +218,6 @@ export async function migrateApp2025_2( projectName ); buildId = migration.buildId; - // Poll using the projectId and the build id? SpinniesManager.succeed('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationComplete` @@ -231,7 +246,9 @@ export async function migrateApp2025_2( buildId ); - const absoluteDestPath = dest ? path.resolve(getCwd(), dest) : getCwd(); + const absoluteDestPath = projectDest + ? path.resolve(getCwd(), projectDest) + : getCwd(); await extractZipArchive( zippedProject, From e7350b46343b77931f24a5bcea22b1c7b1d076b7 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Thu, 27 Mar 2025 12:01:35 -0700 Subject: [PATCH 08/36] test --- lib/app/__tests__/migrate.test.ts | 362 ++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 lib/app/__tests__/migrate.test.ts diff --git a/lib/app/__tests__/migrate.test.ts b/lib/app/__tests__/migrate.test.ts new file mode 100644 index 000000000..825c77690 --- /dev/null +++ b/lib/app/__tests__/migrate.test.ts @@ -0,0 +1,362 @@ +import { migrateApp2025_2, migrateApp2023_2 } from '../migrate'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { promptUser, listPrompt, inputPrompt } from '../../prompts/promptUtils'; +import { + listAppsForMigration, + beginMigration, + finishMigration, + downloadProject, + migrateNonProjectApp_v2023_2, + UNMIGRATABLE_REASONS, +} from '@hubspot/local-dev-lib/api/projects'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; +import { poll } from '../../polling'; +import SpinniesManager from '../../ui/SpinniesManager'; +import { selectPublicAppPrompt } from '../../prompts/selectPublicAppPrompt'; +import { createProjectPrompt } from '../../prompts/createProjectPrompt'; +import { EXIT_CODES } from '../../enums/exitCodes'; +import { MigrateAppOptions } from '../../../types/Yargs'; +import { ArgumentsCamelCase } from 'yargs'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { ensureProjectExists } from '../../projects'; +import { i18n } from '../../lang'; +import { isAppDeveloperAccount } from '../../accountTypes'; +import { handleKeypress } from '../../process'; +import { trackCommandMetadataUsage } from '../../usageTracking'; +import { logError } from '../../errorHandlers'; +import { + uiAccountDescription, + uiBetaTag, + uiCommandReference, + uiLine, + uiLink, +} from '../../ui'; +import chalk from 'chalk'; + +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('../../prompts/promptUtils'); +jest.mock('../../projects'); +jest.mock('@hubspot/local-dev-lib/api/projects'); +jest.mock('@hubspot/local-dev-lib/archive'); +jest.mock('@hubspot/local-dev-lib/path'); +jest.mock('@hubspot/local-dev-lib/urls'); +jest.mock('@hubspot/local-dev-lib/api/appsDev'); +jest.mock('../../usageTracking'); +jest.mock('../../polling'); +jest.mock('../../ui/SpinniesManager'); +jest.mock('../../prompts/selectPublicAppPrompt'); +jest.mock('../../prompts/createProjectPrompt'); +jest.mock('../../lang'); +jest.mock('../../accountTypes'); +jest.mock('../../process'); +jest.mock('../../errorHandlers'); +jest.mock('../../ui'); +jest.mock('chalk', () => ({ + bold: jest.fn().mockReturnValue('Bold Text'), +})); + +describe('lib/app/migrate', () => { + const mockDerivedAccountId = 12345; + const mockOptions: ArgumentsCamelCase = { + name: 'test-project', + dest: 'test-dest', + appId: 67890, + platformVersion: '2025.2', + derivedAccountId: mockDerivedAccountId, + d: false, + debug: false, + _: [], + $0: 'test', + }; + + beforeEach(() => { + jest.clearAllMocks(); + const mockExit = jest.fn(); + (process.exit as unknown as jest.Mock) = mockExit; + (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: false }); + (logger.error as jest.Mock).mockImplementation(() => {}); + (logger.log as jest.Mock).mockImplementation(() => {}); + (logger.success as jest.Mock).mockImplementation(() => {}); + (logger.warn as jest.Mock).mockImplementation(() => {}); + (i18n as jest.Mock).mockImplementation((key) => key); + (listPrompt as jest.Mock).mockResolvedValue({ appId: 67890 }); + (inputPrompt as jest.Mock).mockImplementation((prompt) => { + if (prompt.includes('inputName')) return mockOptions.name; + if (prompt.includes('inputDest')) return mockOptions.dest; + return ''; + }); + (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); + (isAppDeveloperAccount as jest.Mock).mockReturnValue(true); + (handleKeypress as jest.Mock).mockImplementation(() => {}); + (trackCommandMetadataUsage as jest.Mock).mockResolvedValue(undefined); + (logError as jest.Mock).mockImplementation(() => {}); + (uiAccountDescription as jest.Mock).mockReturnValue('Test Account'); + (uiBetaTag as jest.Mock).mockReturnValue('BETA'); + (uiCommandReference as jest.Mock).mockReturnValue('hs command'); + (uiLine as jest.Mock).mockReturnValue('---'); + (uiLink as jest.Mock).mockReturnValue('Link'); + }); + + describe('migrateApp2025_2', () => { + const mockMigrationData = { + migratableApps: [ + { appId: 67890, appName: 'Test App', isMigratable: true }, + ], + unmigratableApps: [], + }; + + const mockMigrationResponse = { + migrationId: 123, + uidsRequired: [], + }; + + const mockFinishResponse = { + buildId: 456, + }; + + beforeEach(() => { + (listAppsForMigration as jest.Mock).mockResolvedValue({ + data: mockMigrationData, + }); + (beginMigration as jest.Mock).mockResolvedValue(mockMigrationResponse); + (finishMigration as jest.Mock).mockResolvedValue(mockFinishResponse); + (downloadProject as jest.Mock).mockResolvedValue({ + data: 'mock-zip-data', + }); + (getCwd as jest.Mock).mockReturnValue('/mock/cwd'); + (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: true }); + }); + + it('should successfully migrate an app', async () => { + await migrateApp2025_2(mockDerivedAccountId, mockOptions); + + expect(listAppsForMigration).toHaveBeenCalledWith(mockDerivedAccountId); + expect(beginMigration).toHaveBeenCalledWith(mockOptions.appId); + expect(finishMigration).toHaveBeenCalledWith( + mockDerivedAccountId, + mockMigrationResponse.migrationId, + {}, + mockOptions.name + ); + expect(downloadProject).toHaveBeenCalledWith( + mockDerivedAccountId, + mockOptions.name, + mockFinishResponse.buildId + ); + expect(extractZipArchive).toHaveBeenCalledWith( + 'mock-zip-data', + sanitizeFileName(mockOptions.name), + expect.any(String), + { includesRootDir: false } + ); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle no apps available for migration', async () => { + (listAppsForMigration as jest.Mock).mockResolvedValue({ + data: { migratableApps: [], unmigratableApps: [] }, + }); + + await expect(migrateApp2025_2(mockDerivedAccountId, mockOptions)).rejects.toThrow( + 'commands.project.subcommands.migrateApp.errors.noApps' + ); + }); + + it('should handle unmigratable apps', async () => { + (listAppsForMigration as jest.Mock).mockResolvedValue({ + data: { + migratableApps: [], + unmigratableApps: [ + { + appId: 67890, + appName: 'Test App', + isMigratable: false, + unmigratableReason: UNMIGRATABLE_REASONS.UP_TO_DATE, + }, + ], + }, + }); + (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: false }); + + await migrateApp2025_2(mockDerivedAccountId, mockOptions); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('commands.project.subcommands.migrateApp.errors.noAppsEligible')); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle migration failure', async () => { + (beginMigration as jest.Mock).mockRejectedValue(new Error('Migration failed')); + (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: true }); + + await migrateApp2025_2(mockDerivedAccountId, mockOptions); + expect(SpinniesManager.fail).toHaveBeenCalledWith('beginningMigration', { + text: 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration', + }); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + + it('should handle project already exists error', async () => { + (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: true }); + + await expect(migrateApp2025_2(mockDerivedAccountId, mockOptions)).rejects.toThrow( + 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists' + ); + }); + + it('should handle download failure', async () => { + (downloadProject as jest.Mock).mockRejectedValue(new Error('Download failed')); + + await migrateApp2025_2(mockDerivedAccountId, mockOptions); + expect(SpinniesManager.fail).toHaveBeenCalledWith('fetchingMigratedProject', { + text: 'commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed', + }); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + }); + + describe('migrateApp2023_2', () => { + const mockAccountConfig: CLIAccount = { + env: 'qa', + accountId: mockDerivedAccountId, + name: 'Test Account', + authType: 'personalaccesskey', + }; + + const mockAppMetadata = { + preventProjectMigrations: false, + listingInfo: null, + }; + + const mockMigrationResponse = { + id: 123, + }; + + const mockPollResponse = { + status: 'SUCCESS', + project: { + name: 'test-project', + }, + }; + + beforeEach(() => { + (selectPublicAppPrompt as jest.Mock).mockResolvedValue({ appId: 67890 }); + (fetchPublicAppMetadata as jest.Mock).mockResolvedValue({ + data: mockAppMetadata, + }); + (createProjectPrompt as jest.Mock).mockResolvedValue({ + name: 'test-project', + dest: 'test-dest', + }); + (migrateNonProjectApp_v2023_2 as jest.Mock).mockResolvedValue({ + data: mockMigrationResponse, + }); + (poll as jest.Mock).mockResolvedValue(mockPollResponse); + (downloadProject as jest.Mock).mockResolvedValue({ + data: 'mock-zip-data', + }); + (getHubSpotWebsiteOrigin as jest.Mock).mockReturnValue('https://test.hubspot.com'); + (getCwd as jest.Mock).mockReturnValue('/mock/cwd'); + }); + + it('should successfully migrate an app', async () => { + (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + + expect(fetchPublicAppMetadata).toHaveBeenCalledWith( + 67890, + mockDerivedAccountId + ); + expect(migrateNonProjectApp_v2023_2).toHaveBeenCalledWith( + mockDerivedAccountId, + 67890, + 'test-project' + ); + expect(poll).toHaveBeenCalled(); + expect(downloadProject).toHaveBeenCalled(); + expect(extractZipArchive).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle migration cancellation', async () => { + (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: false }); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle invalid app metadata', async () => { + (fetchPublicAppMetadata as jest.Mock).mockResolvedValue({ + data: { + preventProjectMigrations: true, + listingInfo: { someData: true }, + }, + }); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(logger.error).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.errors.invalidApp'); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + + it('should handle migration failure', async () => { + (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); + (migrateNonProjectApp_v2023_2 as jest.Mock).mockRejectedValue( + new Error('Migration failed') + ); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(SpinniesManager.fail).toHaveBeenCalledWith('migrateApp', { + text: 'commands.project.subcommands.migrateApp.migrationStatus.failure', + failColor: 'white', + }); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + + it('should handle non-developer account', async () => { + (isAppDeveloperAccount as jest.Mock).mockReturnValue(false); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(logger.error).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.errors.invalidAccountTypeTitle'); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle project already exists error', async () => { + (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: true }); + + await expect(migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig)).rejects.toThrow( + 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists' + ); + }); + + it('should handle migration interruption', async () => { + (handleKeypress as jest.Mock).mockImplementation((callback) => { + callback({ ctrl: true, name: 'c' }); + }); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(SpinniesManager.remove).toHaveBeenCalledWith('migrateApp'); + expect(logger.log).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.migrationInterrupted'); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should handle metadata fetch failure', async () => { + (fetchPublicAppMetadata as jest.Mock).mockRejectedValue(new Error('Metadata fetch failed')); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + + it('should handle download failure', async () => { + (downloadProject as jest.Mock).mockRejectedValue(new Error('Download failed')); + + await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); + expect(SpinniesManager.fail).toHaveBeenCalledWith('migrateApp', { + text: 'commands.project.subcommands.migrateApp.migrationStatus.failure', + failColor: 'white', + }); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + }); +}); From 0d202073a93732419350f3e740a6d4c6a9971805 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Fri, 28 Mar 2025 12:56:30 -0700 Subject: [PATCH 09/36] Integration tweaks --- commands/app/migrate.ts | 12 +- lib/app/__tests__/migrate.test.ts | 362 ------------------------------ lib/app/migrate.ts | 67 +++--- package.json | 2 +- scripts/get-all-commands.ts | 2 +- 5 files changed, 44 insertions(+), 401 deletions(-) delete mode 100644 lib/app/__tests__/migrate.test.ts diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index e5decdb59..6bd78e8cf 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -16,8 +16,8 @@ import { MigrateAppOptions } from '../../types/Yargs'; import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; -const { v2023_2, v2025_2 } = PLATFORM_VERSIONS; -const validMigrationTargets = [v2023_2, v2025_2]; +const { v2023_2, v2025_2, unstable } = PLATFORM_VERSIONS; +const validMigrationTargets = [v2023_2, v2025_2, unstable]; const i18nKey = 'commands.project.subcommands.migrateApp'; @@ -33,12 +33,8 @@ export async function handler(options: ArgumentsCamelCase) { throw new Error('Account is not configured'); } - if (!validMigrationTargets.includes(platformVersion)) { - throw new Error('Unsupported platform version'); - } - try { - if (platformVersion === v2025_2) { + if (platformVersion === v2025_2 || platformVersion === unstable) { await migrateApp2025_2(derivedAccountId, options); } else if (platformVersion === v2023_2) { await migrateApp2023_2(derivedAccountId, options, accountConfig); @@ -95,7 +91,7 @@ export async function builder(yargs: Argv) { }, 'platform-version': { type: 'string', - choices: ['2023.2', '2025.2'], + choices: validMigrationTargets, hidden: true, default: '2023.2', }, diff --git a/lib/app/__tests__/migrate.test.ts b/lib/app/__tests__/migrate.test.ts deleted file mode 100644 index 825c77690..000000000 --- a/lib/app/__tests__/migrate.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { migrateApp2025_2, migrateApp2023_2 } from '../migrate'; -import { logger } from '@hubspot/local-dev-lib/logger'; -import { promptUser, listPrompt, inputPrompt } from '../../prompts/promptUtils'; -import { - listAppsForMigration, - beginMigration, - finishMigration, - downloadProject, - migrateNonProjectApp_v2023_2, - UNMIGRATABLE_REASONS, -} from '@hubspot/local-dev-lib/api/projects'; -import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; -import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; -import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; -import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; -import { poll } from '../../polling'; -import SpinniesManager from '../../ui/SpinniesManager'; -import { selectPublicAppPrompt } from '../../prompts/selectPublicAppPrompt'; -import { createProjectPrompt } from '../../prompts/createProjectPrompt'; -import { EXIT_CODES } from '../../enums/exitCodes'; -import { MigrateAppOptions } from '../../../types/Yargs'; -import { ArgumentsCamelCase } from 'yargs'; -import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { ensureProjectExists } from '../../projects'; -import { i18n } from '../../lang'; -import { isAppDeveloperAccount } from '../../accountTypes'; -import { handleKeypress } from '../../process'; -import { trackCommandMetadataUsage } from '../../usageTracking'; -import { logError } from '../../errorHandlers'; -import { - uiAccountDescription, - uiBetaTag, - uiCommandReference, - uiLine, - uiLink, -} from '../../ui'; -import chalk from 'chalk'; - -jest.mock('@hubspot/local-dev-lib/logger'); -jest.mock('../../prompts/promptUtils'); -jest.mock('../../projects'); -jest.mock('@hubspot/local-dev-lib/api/projects'); -jest.mock('@hubspot/local-dev-lib/archive'); -jest.mock('@hubspot/local-dev-lib/path'); -jest.mock('@hubspot/local-dev-lib/urls'); -jest.mock('@hubspot/local-dev-lib/api/appsDev'); -jest.mock('../../usageTracking'); -jest.mock('../../polling'); -jest.mock('../../ui/SpinniesManager'); -jest.mock('../../prompts/selectPublicAppPrompt'); -jest.mock('../../prompts/createProjectPrompt'); -jest.mock('../../lang'); -jest.mock('../../accountTypes'); -jest.mock('../../process'); -jest.mock('../../errorHandlers'); -jest.mock('../../ui'); -jest.mock('chalk', () => ({ - bold: jest.fn().mockReturnValue('Bold Text'), -})); - -describe('lib/app/migrate', () => { - const mockDerivedAccountId = 12345; - const mockOptions: ArgumentsCamelCase = { - name: 'test-project', - dest: 'test-dest', - appId: 67890, - platformVersion: '2025.2', - derivedAccountId: mockDerivedAccountId, - d: false, - debug: false, - _: [], - $0: 'test', - }; - - beforeEach(() => { - jest.clearAllMocks(); - const mockExit = jest.fn(); - (process.exit as unknown as jest.Mock) = mockExit; - (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: false }); - (logger.error as jest.Mock).mockImplementation(() => {}); - (logger.log as jest.Mock).mockImplementation(() => {}); - (logger.success as jest.Mock).mockImplementation(() => {}); - (logger.warn as jest.Mock).mockImplementation(() => {}); - (i18n as jest.Mock).mockImplementation((key) => key); - (listPrompt as jest.Mock).mockResolvedValue({ appId: 67890 }); - (inputPrompt as jest.Mock).mockImplementation((prompt) => { - if (prompt.includes('inputName')) return mockOptions.name; - if (prompt.includes('inputDest')) return mockOptions.dest; - return ''; - }); - (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); - (isAppDeveloperAccount as jest.Mock).mockReturnValue(true); - (handleKeypress as jest.Mock).mockImplementation(() => {}); - (trackCommandMetadataUsage as jest.Mock).mockResolvedValue(undefined); - (logError as jest.Mock).mockImplementation(() => {}); - (uiAccountDescription as jest.Mock).mockReturnValue('Test Account'); - (uiBetaTag as jest.Mock).mockReturnValue('BETA'); - (uiCommandReference as jest.Mock).mockReturnValue('hs command'); - (uiLine as jest.Mock).mockReturnValue('---'); - (uiLink as jest.Mock).mockReturnValue('Link'); - }); - - describe('migrateApp2025_2', () => { - const mockMigrationData = { - migratableApps: [ - { appId: 67890, appName: 'Test App', isMigratable: true }, - ], - unmigratableApps: [], - }; - - const mockMigrationResponse = { - migrationId: 123, - uidsRequired: [], - }; - - const mockFinishResponse = { - buildId: 456, - }; - - beforeEach(() => { - (listAppsForMigration as jest.Mock).mockResolvedValue({ - data: mockMigrationData, - }); - (beginMigration as jest.Mock).mockResolvedValue(mockMigrationResponse); - (finishMigration as jest.Mock).mockResolvedValue(mockFinishResponse); - (downloadProject as jest.Mock).mockResolvedValue({ - data: 'mock-zip-data', - }); - (getCwd as jest.Mock).mockReturnValue('/mock/cwd'); - (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: true }); - }); - - it('should successfully migrate an app', async () => { - await migrateApp2025_2(mockDerivedAccountId, mockOptions); - - expect(listAppsForMigration).toHaveBeenCalledWith(mockDerivedAccountId); - expect(beginMigration).toHaveBeenCalledWith(mockOptions.appId); - expect(finishMigration).toHaveBeenCalledWith( - mockDerivedAccountId, - mockMigrationResponse.migrationId, - {}, - mockOptions.name - ); - expect(downloadProject).toHaveBeenCalledWith( - mockDerivedAccountId, - mockOptions.name, - mockFinishResponse.buildId - ); - expect(extractZipArchive).toHaveBeenCalledWith( - 'mock-zip-data', - sanitizeFileName(mockOptions.name), - expect.any(String), - { includesRootDir: false } - ); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle no apps available for migration', async () => { - (listAppsForMigration as jest.Mock).mockResolvedValue({ - data: { migratableApps: [], unmigratableApps: [] }, - }); - - await expect(migrateApp2025_2(mockDerivedAccountId, mockOptions)).rejects.toThrow( - 'commands.project.subcommands.migrateApp.errors.noApps' - ); - }); - - it('should handle unmigratable apps', async () => { - (listAppsForMigration as jest.Mock).mockResolvedValue({ - data: { - migratableApps: [], - unmigratableApps: [ - { - appId: 67890, - appName: 'Test App', - isMigratable: false, - unmigratableReason: UNMIGRATABLE_REASONS.UP_TO_DATE, - }, - ], - }, - }); - (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: false }); - - await migrateApp2025_2(mockDerivedAccountId, mockOptions); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('commands.project.subcommands.migrateApp.errors.noAppsEligible')); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle migration failure', async () => { - (beginMigration as jest.Mock).mockRejectedValue(new Error('Migration failed')); - (promptUser as jest.Mock).mockResolvedValue({ shouldProceed: true }); - - await migrateApp2025_2(mockDerivedAccountId, mockOptions); - expect(SpinniesManager.fail).toHaveBeenCalledWith('beginningMigration', { - text: 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration', - }); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - - it('should handle project already exists error', async () => { - (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: true }); - - await expect(migrateApp2025_2(mockDerivedAccountId, mockOptions)).rejects.toThrow( - 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists' - ); - }); - - it('should handle download failure', async () => { - (downloadProject as jest.Mock).mockRejectedValue(new Error('Download failed')); - - await migrateApp2025_2(mockDerivedAccountId, mockOptions); - expect(SpinniesManager.fail).toHaveBeenCalledWith('fetchingMigratedProject', { - text: 'commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed', - }); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - }); - - describe('migrateApp2023_2', () => { - const mockAccountConfig: CLIAccount = { - env: 'qa', - accountId: mockDerivedAccountId, - name: 'Test Account', - authType: 'personalaccesskey', - }; - - const mockAppMetadata = { - preventProjectMigrations: false, - listingInfo: null, - }; - - const mockMigrationResponse = { - id: 123, - }; - - const mockPollResponse = { - status: 'SUCCESS', - project: { - name: 'test-project', - }, - }; - - beforeEach(() => { - (selectPublicAppPrompt as jest.Mock).mockResolvedValue({ appId: 67890 }); - (fetchPublicAppMetadata as jest.Mock).mockResolvedValue({ - data: mockAppMetadata, - }); - (createProjectPrompt as jest.Mock).mockResolvedValue({ - name: 'test-project', - dest: 'test-dest', - }); - (migrateNonProjectApp_v2023_2 as jest.Mock).mockResolvedValue({ - data: mockMigrationResponse, - }); - (poll as jest.Mock).mockResolvedValue(mockPollResponse); - (downloadProject as jest.Mock).mockResolvedValue({ - data: 'mock-zip-data', - }); - (getHubSpotWebsiteOrigin as jest.Mock).mockReturnValue('https://test.hubspot.com'); - (getCwd as jest.Mock).mockReturnValue('/mock/cwd'); - }); - - it('should successfully migrate an app', async () => { - (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - - expect(fetchPublicAppMetadata).toHaveBeenCalledWith( - 67890, - mockDerivedAccountId - ); - expect(migrateNonProjectApp_v2023_2).toHaveBeenCalledWith( - mockDerivedAccountId, - 67890, - 'test-project' - ); - expect(poll).toHaveBeenCalled(); - expect(downloadProject).toHaveBeenCalled(); - expect(extractZipArchive).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle migration cancellation', async () => { - (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: false }); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle invalid app metadata', async () => { - (fetchPublicAppMetadata as jest.Mock).mockResolvedValue({ - data: { - preventProjectMigrations: true, - listingInfo: { someData: true }, - }, - }); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(logger.error).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.errors.invalidApp'); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - - it('should handle migration failure', async () => { - (promptUser as jest.Mock).mockResolvedValue({ shouldCreateApp: true }); - (migrateNonProjectApp_v2023_2 as jest.Mock).mockRejectedValue( - new Error('Migration failed') - ); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(SpinniesManager.fail).toHaveBeenCalledWith('migrateApp', { - text: 'commands.project.subcommands.migrateApp.migrationStatus.failure', - failColor: 'white', - }); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - - it('should handle non-developer account', async () => { - (isAppDeveloperAccount as jest.Mock).mockReturnValue(false); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(logger.error).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.errors.invalidAccountTypeTitle'); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle project already exists error', async () => { - (ensureProjectExists as jest.Mock).mockResolvedValue({ projectExists: true }); - - await expect(migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig)).rejects.toThrow( - 'commands.project.subcommands.migrateApp.errors.projectAlreadyExists' - ); - }); - - it('should handle migration interruption', async () => { - (handleKeypress as jest.Mock).mockImplementation((callback) => { - callback({ ctrl: true, name: 'c' }); - }); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(SpinniesManager.remove).toHaveBeenCalledWith('migrateApp'); - expect(logger.log).toHaveBeenCalledWith('commands.project.subcommands.migrateApp.migrationInterrupted'); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); - }); - - it('should handle metadata fetch failure', async () => { - (fetchPublicAppMetadata as jest.Mock).mockRejectedValue(new Error('Metadata fetch failed')); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - - it('should handle download failure', async () => { - (downloadProject as jest.Mock).mockRejectedValue(new Error('Download failed')); - - await migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig); - expect(SpinniesManager.fail).toHaveBeenCalledWith('migrateApp', { - text: 'commands.project.subcommands.migrateApp.migrationStatus.failure', - failColor: 'white', - }); - expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); - }); - }); -}); diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index a54c37a47..a6e01b3ac 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -68,6 +68,7 @@ async function handleMigrationSetup( ) { const { name, dest, appId } = options; const { data } = await listAppsForMigration(derivedAccountId); + const { migratableApps, unmigratableApps } = data; const allApps = [...migratableApps, ...unmigratableApps]; @@ -104,14 +105,16 @@ async function handleMigrationSetup( : getUnmigratableReason(app.unmigratableReason), })); - const appToMigrate = appId - ? { appId } - : await listPrompt( - i18n('commands.project.subcommands.migrateApp.prompt.chooseApp'), - { - choices: appChoices, - } - ); + let appIdToMigrate = appId; + if (!appIdToMigrate) { + const { appId: selectedAppId } = await listPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.chooseApp'), + { + choices: appChoices, + } + ); + appIdToMigrate = selectedAppId; + } const projectName = name || @@ -142,10 +145,10 @@ async function handleMigrationSetup( i18n('commands.project.subcommands.migrateApp.prompt.inputDest') )); - return { appToMigrate, projectName, projectDest }; + return { appIdToMigrate, projectName, projectDest }; } -async function handleMigrationProcess(appToMigrate: { appId: number }) { +async function handleMigrationProcess(derivedAccountId: number, appId: number) { SpinniesManager.add('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.beginningMigration' @@ -153,12 +156,11 @@ async function handleMigrationProcess(appToMigrate: { appId: number }) { }); const uidMap: Record = {}; - let migrationId: number | undefined; + let migrationId: number; try { - const { migrationId: mid, uidsRequired } = await beginMigration( - appToMigrate.appId - ); + const { data } = await beginMigration(derivedAccountId, appId); + const { migrationId: mid, componentsRequiringUids } = data; migrationId = mid; SpinniesManager.succeed('beginningMigration', { @@ -167,23 +169,27 @@ async function handleMigrationProcess(appToMigrate: { appId: number }) { ), }); - if (uidsRequired.length !== 0) { - for (const u of uidsRequired) { - uidMap[u] = await inputPrompt( + if (Object.values(componentsRequiringUids).length !== 0) { + for (const [componentId, component] of Object.entries( + componentsRequiringUids + )) { + uidMap[componentId] = await inputPrompt( i18n( 'commands.project.subcommands.migrateApp.prompt.uidForComponent', - { componentName: u } + { + componentName: component.componentHint || component.componentType, + } ) ); } } } catch (e) { + logError(e); SpinniesManager.fail('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' ), }); - logError(e); return process.exit(EXIT_CODES.ERROR); } @@ -196,13 +202,14 @@ export async function migrateApp2025_2( ) { SpinniesManager.init(); - const { appToMigrate, projectName, projectDest } = await handleMigrationSetup( + const { appIdToMigrate, projectName, projectDest } = + await handleMigrationSetup(derivedAccountId, options); + + const { migrationId, uidMap } = await handleMigrationProcess( derivedAccountId, - options + appIdToMigrate ); - const { migrationId, uidMap } = await handleMigrationProcess(appToMigrate); - let buildId: number; try { @@ -211,25 +218,27 @@ export async function migrateApp2025_2( `commands.project.subcommands.migrateApp.spinners.finishingMigration` ), }); - const migration = await finishMigration( + const { data } = await finishMigration( derivedAccountId, migrationId, uidMap, - projectName + projectName, + options.platformVersion ); - buildId = migration.buildId; + + buildId = data.buildId; SpinniesManager.succeed('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationComplete` ), }); } catch (error) { + logError(error); SpinniesManager.fail('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationFailed` ), }); - logError(error); process.exit(EXIT_CODES.ERROR); } @@ -254,7 +263,7 @@ export async function migrateApp2025_2( zippedProject, sanitizeFileName(projectName), path.resolve(absoluteDestPath), - { includesRootDir: false } + { includesRootDir: false, hideLogs: false } ); SpinniesManager.succeed('fetchingMigratedProject', { @@ -265,12 +274,12 @@ export async function migrateApp2025_2( logger.success(`Saved ${projectName} to ${projectDest}`); } catch (error) { + logError(error); SpinniesManager.fail('fetchingMigratedProject', { text: i18n( `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed` ), }); - logError(error); return process.exit(EXIT_CODES.ERROR); } diff --git a/package.json b/package.json index aef080141..70a8f406f 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.3.2", + "@hubspot/local-dev-lib": "0.2.6-experimental.0", "@hubspot/project-parsing-lib": "0.0.5", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", diff --git a/scripts/get-all-commands.ts b/scripts/get-all-commands.ts index 967231fb3..65b5fb6e3 100644 --- a/scripts/get-all-commands.ts +++ b/scripts/get-all-commands.ts @@ -43,7 +43,7 @@ async function extractCommands( return commands; } -(async function() { +(async function () { SpinniesManager.init(); SpinniesManager.add('extractingCommands', { text: 'Extracting commands' }); From 143ad4986190648bfed43a839b9cde79ab418a75 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Fri, 28 Mar 2025 12:56:50 -0700 Subject: [PATCH 10/36] v7.2.3-experimental.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 70a8f406f..ebaaf5c98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hubspot/cli", - "version": "7.1.1", + "version": "7.2.3-experimental.0", "description": "The official CLI for developing on HubSpot", "license": "Apache-2.0", "repository": "https://github.com/HubSpot/hubspot-cli", From ac26a4b7e8b0d397fa0b5b062b4901cb6d60bef4 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 31 Mar 2025 14:49:56 -0700 Subject: [PATCH 11/36] Add logs displaying what will be migrated, prompt to proceed --- commands/app/migrate.ts | 12 ++++++ commands/project/migrateApp.ts | 2 +- lang/en.lyaml | 5 +++ lib/app/migrate.ts | 72 ++++++++++++++++++++++++++++------ lib/ui/index.ts | 3 -- 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 6bd78e8cf..19d9c78db 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -15,6 +15,8 @@ import { ArgumentsCamelCase, Argv } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { uiBetaTag, uiLink } from '../../lib/ui'; const { v2023_2, v2025_2, unstable } = PLATFORM_VERSIONS; const validMigrationTargets = [v2023_2, v2025_2, unstable]; @@ -33,6 +35,16 @@ export async function handler(options: ArgumentsCamelCase) { throw new Error('Account is not configured'); } + 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(''); + try { if (platformVersion === v2025_2 || platformVersion === unstable) { await migrateApp2025_2(derivedAccountId, options); diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index b50e49dca..c40d31ef3 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -15,7 +15,7 @@ export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); export async function handler(yargs: ArgumentsCamelCase) { logger.warn( - i18n(`${i18nKey}.describe.deprecationWarning`, { + i18n(`${i18nKey}.deprecationWarning`, { oldCommand: uiCommandReference('hs project migrate-app'), newCommand: uiCommandReference('hs app migrate'), }) diff --git a/lang/en.lyaml b/lang/en.lyaml index 26efd1c1e..77780e3de 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -567,6 +567,7 @@ en: 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}}" @@ -582,6 +583,8 @@ 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: noApps: "No apps found in account {{ accountId }}" noAppsEligible: "No apps in account {{ accountId }} are currently migratable" @@ -589,11 +592,13 @@ en: 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 }} " 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" migrationStarted: "Migration started" diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index a6e01b3ac..e895084bf 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -1,11 +1,15 @@ import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { inputPrompt, listPrompt, promptUser } from '../prompts/promptUtils'; +import { + confirmPrompt, + inputPrompt, + listPrompt, + promptUser, +} from '../prompts/promptUtils'; import { ApiErrorContext, logError } from '../errorHandlers'; import { EXIT_CODES } from '../enums/exitCodes'; import { logger } from '@hubspot/local-dev-lib/logger'; import { uiAccountDescription, - uiBetaTag, uiCommandReference, uiLine, uiLink, @@ -95,6 +99,20 @@ async function handleMigrationSetup( return process.exit(EXIT_CODES.SUCCESS); } + if ( + appId && + !allApps.some(app => { + return app.appId === appId; + }) + ) { + logger.error( + i18n('commands.project.subcommands.migrateApp.prompt.chooseApp', { + appId, + }) + ); + return process.exit(EXIT_CODES.ERROR); + } + const appChoices = allApps.map(app => ({ name: app.isMigratable ? app.appName @@ -116,6 +134,47 @@ async function handleMigrationSetup( appIdToMigrate = selectedAppId; } + const selectedApp = allApps.find(app => app.appId === appIdToMigrate); + + const migratableComponents: string[] = ['Application']; + const unmigratableComponents: string[] = []; + + selectedApp?.migrationComponents.forEach(component => { + if (component.isSupported) { + migratableComponents.push(component.componentType); + } else { + unmigratableComponents.push(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) { + process.exit(EXIT_CODES.SUCCESS); + } + const projectName = name || (await inputPrompt( @@ -305,15 +364,6 @@ export async function migrateApp2023_2( ) { const i18nKey = 'commands.project.subcommands.migrateApp'; const accountName = uiAccountDescription(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)) { logInvalidAccountError(i18nKey); diff --git a/lib/ui/index.ts b/lib/ui/index.ts index 494baa499..07c6a783e 100644 --- a/lib/ui/index.ts +++ b/lib/ui/index.ts @@ -137,11 +137,8 @@ export function uiDeprecatedTag( terminalUISupport.color ? chalk.yellow(tag) : tag } ${message}`; - logger.log(result); - if (log) { logger.log(result); - return; } return result; } From 009fe740c6ec72fc3ee05c229fc9db91040b1229 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 1 Apr 2025 15:05:44 -0700 Subject: [PATCH 12/36] Remove duplicate tracking event, add shell for hs project migrate --- commands/app/migrate.ts | 5 -- commands/project/migrate.ts | 78 +++++++++++++++++++++++++++ lib/app/migrate.ts | 103 +++++++++++++++++++++++++----------- lib/prompts/promptUtils.ts | 3 ++ 4 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 commands/project/migrate.ts diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 19d9c78db..3d22d273b 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -51,11 +51,6 @@ export async function handler(options: ArgumentsCamelCase) { } else if (platformVersion === v2023_2) { await migrateApp2023_2(derivedAccountId, options, accountConfig); } - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'SUCCESS' }, - derivedAccountId - ); } catch (error) { if ( error && diff --git a/commands/project/migrate.ts b/commands/project/migrate.ts new file mode 100644 index 000000000..69363dfb9 --- /dev/null +++ b/commands/project/migrate.ts @@ -0,0 +1,78 @@ +import { + addAccountOptions, + addConfigOptions, + addUseEnvironmentOptions, +} from '../../lib/commonOpts'; +import { + trackCommandMetadataUsage, + trackCommandUsage, +} from '../../lib/usageTracking'; +import { ApiErrorContext, logError } from '../../lib/errorHandlers'; +import { EXIT_CODES } from '../../lib/enums/exitCodes'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { MigrateAppOptions } from '../../types/Yargs'; +import { migrateApp2025_2 } from '../../lib/app/migrate'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; +const { v2025_2, unstable } = PLATFORM_VERSIONS; +const validMigrationTargets = [v2025_2, unstable]; + +export const command = 'migrate'; +export const describe = null; // uiBetaTag(i18n(`${i18nKey}.describe`), false); + +export async function handler(options: ArgumentsCamelCase) { + const { derivedAccountId } = options; + await trackCommandUsage('project-migrate', {}, derivedAccountId); + const accountConfig = getAccountConfig(derivedAccountId); + + if (!accountConfig) { + throw new Error('Account is not configured'); + } + + try { + await migrateApp2025_2(derivedAccountId, options); + } 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( + 'project-migrate', + { status: 'FAILURE' }, + derivedAccountId + ); + process.exit(EXIT_CODES.ERROR); + } + + await trackCommandMetadataUsage( + 'project-migrate', + { status: 'SUCCESS' }, + derivedAccountId + ); + process.exit(EXIT_CODES.SUCCESS); +} + +export async function builder(yargs: Argv) { + addConfigOptions(yargs); + addAccountOptions(yargs); + addUseEnvironmentOptions(yargs); + + yargs.options({ + 'platform-version': { + type: 'string', + choices: validMigrationTargets, + hidden: true, + default: '2025.2', + }, + }); + + // yargs.example([[`$0 ${_.join(' ')}`, i18n(`${i18nKey}.examples.default`)]]); + + return yargs; +} diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index e895084bf..cec40970b 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -26,7 +26,7 @@ import { handleKeypress } from '../process'; import { checkMigrationStatus, downloadProject, - migrateNonProjectApp_v2023_2, + migrateApp as migrateNonProjectApp_v2023_2, beginMigration, finishMigration, listAppsForMigration, @@ -41,6 +41,7 @@ import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; import { ArgumentsCamelCase } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; import chalk from 'chalk'; +import { validateUid } from '@hubspot/project-parsing-lib'; function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { @@ -90,13 +91,11 @@ async function handleMigrationSetup( `${chalk.bold(app.appName)}: ${getUnmigratableReason(app.unmigratableReason)}` ); - logger.error( + throw new Error( `${i18n(`commands.project.subcommands.migrateApp.errors.noAppsEligible`, { accountId: derivedAccountId, })} \n - ${reasons.join('\n - ')}` ); - - return process.exit(EXIT_CODES.SUCCESS); } if ( @@ -105,12 +104,11 @@ async function handleMigrationSetup( return app.appId === appId; }) ) { - logger.error( + throw new Error( i18n('commands.project.subcommands.migrateApp.prompt.chooseApp', { appId, }) ); - return process.exit(EXIT_CODES.ERROR); } const appChoices = allApps.map(app => ({ @@ -172,7 +170,7 @@ async function handleMigrationSetup( ); if (!proceed) { - process.exit(EXIT_CODES.SUCCESS); + return {}; } const projectName = @@ -184,7 +182,7 @@ async function handleMigrationSetup( const { projectExists } = await ensureProjectExists( derivedAccountId, projectName, - { forceCreate: false, allowCreate: false } + { forceCreate: false, allowCreate: false, noLogs: true } ); if (projectExists) { @@ -232,45 +230,43 @@ async function handleMigrationProcess(derivedAccountId: number, appId: number) { for (const [componentId, component] of Object.entries( componentsRequiringUids )) { + // TODO: We need to validate the UID here uidMap[componentId] = await inputPrompt( i18n( 'commands.project.subcommands.migrateApp.prompt.uidForComponent', { componentName: component.componentHint || component.componentType, } - ) + ), + { + validate: (uid: string) => { + const result = validateUid(uid); + return result === undefined ? true : result; + }, + } ); } } } catch (e) { - logError(e); SpinniesManager.fail('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' ), }); - return process.exit(EXIT_CODES.ERROR); + throw e; } return { migrationId, uidMap }; } -export async function migrateApp2025_2( +async function finalizeMigration( derivedAccountId: number, - options: ArgumentsCamelCase + migrationId: number, + uidMap: Record, + projectName: string, + platformVersion: string ) { - SpinniesManager.init(); - - const { appIdToMigrate, projectName, projectDest } = - await handleMigrationSetup(derivedAccountId, options); - - const { migrationId, uidMap } = await handleMigrationProcess( - derivedAccountId, - appIdToMigrate - ); - let buildId: number; - try { SpinniesManager.add('finishingMigration', { text: i18n( @@ -282,25 +278,41 @@ export async function migrateApp2025_2( migrationId, uidMap, projectName, - options.platformVersion + platformVersion ); buildId = data.buildId; + + // const pollResponse = await poll(() => + // checkMigrationStatus(derivedAccountId, migrationId, platformVersion) + // ); + // const { status } = pollResponse; + // if (status === 'SUCCESS') { + SpinniesManager.succeed('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationComplete` ), }); + return buildId; + + // } } catch (error) { - logError(error); SpinniesManager.fail('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationFailed` ), }); - process.exit(EXIT_CODES.ERROR); + throw error; } +} +export async function downloadProjectFiles( + derivedAccountId: number, + projectName: string, + buildId: number, + projectDest: string +) { try { SpinniesManager.add('fetchingMigratedProject', { text: i18n( @@ -333,16 +345,47 @@ export async function migrateApp2025_2( logger.success(`Saved ${projectName} to ${projectDest}`); } catch (error) { - logError(error); SpinniesManager.fail('fetchingMigratedProject', { text: i18n( `commands.project.subcommands.migrateApp.spinners.downloadingProjectContentsFailed` ), }); - return process.exit(EXIT_CODES.ERROR); + throw error; + } +} + +export async function migrateApp2025_2( + derivedAccountId: number, + options: ArgumentsCamelCase +) { + SpinniesManager.init(); + + const { appIdToMigrate, projectName, projectDest } = + await handleMigrationSetup(derivedAccountId, options); + + if (!appIdToMigrate) { + return; } - process.exit(EXIT_CODES.SUCCESS); + const { migrationId, uidMap } = await handleMigrationProcess( + derivedAccountId, + appIdToMigrate + ); + + const buildId = await finalizeMigration( + derivedAccountId, + migrationId, + uidMap, + projectName, + options.platformVersion + ); + + await downloadProjectFiles( + derivedAccountId, + projectName, + buildId, + projectDest + ); } export function logInvalidAccountError(i18nKey: string) { diff --git a/lib/prompts/promptUtils.ts b/lib/prompts/promptUtils.ts index a8968c46d..6fa5f0ce8 100644 --- a/lib/prompts/promptUtils.ts +++ b/lib/prompts/promptUtils.ts @@ -69,8 +69,10 @@ export async function inputPrompt( message: string, { when, + validate, }: { when?: boolean | (() => boolean); + validate?: (input: string) => boolean | string; } = {} ): Promise { const { input } = await promptUser([ @@ -79,6 +81,7 @@ export async function inputPrompt( type: 'input', message, when, + validate, }, ]); return input; From 3f556b3cb90aee309f7654f19fdf0728438b39db Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Thu, 3 Apr 2025 11:05:29 -0700 Subject: [PATCH 13/36] Clean up --- commands/app.ts | 21 ++++++--- commands/app/migrate.ts | 56 +++++++++++++++++------- commands/project/migrate.ts | 78 ---------------------------------- commands/project/migrateApp.ts | 24 ++++++++--- lang/en.lyaml | 2 +- lib/app/migrate.ts | 2 +- lib/usageTracking.ts | 2 +- types/Yargs.ts | 19 ++++----- 8 files changed, 84 insertions(+), 120 deletions(-) delete mode 100644 commands/project/migrate.ts diff --git a/commands/app.ts b/commands/app.ts index 495ba235a..f73ad6f94 100644 --- a/commands/app.ts +++ b/commands/app.ts @@ -1,15 +1,22 @@ -import * as migrateCommand from './app/migrate'; +import migrateCommand from './app/migrate'; import { addGlobalOptions } from '../lib/commonOpts'; -import { Argv } from 'yargs'; +import { Argv, CommandModule } from 'yargs'; export const command = ['app', 'apps']; -export const describe = null; + +// Keep the command hidden for now +export const describe = undefined; export function builder(yargs: Argv) { addGlobalOptions(yargs); - // @ts-ignore - yargs.command(migrateCommand).demandCommand(1, ''); - - return yargs; + return yargs.command(migrateCommand).demandCommand(1, ''); } + +const appCommand: CommandModule = { + command, + describe, + builder, + handler: () => {}, +}; +export default appCommand; diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 3d22d273b..2c7f004db 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -11,7 +11,7 @@ import { i18n } from '../../lib/lang'; import { ApiErrorContext, logError } from '../../lib/errorHandlers'; import { EXIT_CODES } from '../../lib/enums/exitCodes'; import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { ArgumentsCamelCase, Argv } from 'yargs'; +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; @@ -21,10 +21,8 @@ import { uiBetaTag, uiLink } from '../../lib/ui'; const { v2023_2, v2025_2, unstable } = PLATFORM_VERSIONS; const validMigrationTargets = [v2023_2, v2025_2, unstable]; -const i18nKey = 'commands.project.subcommands.migrateApp'; - -export const command = 'migrate'; -export const describe = null; // uiBetaTag(i18n(`${i18nKey}.describe`), false); +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; @@ -32,14 +30,22 @@ export async function handler(options: ArgumentsCamelCase) { const accountConfig = getAccountConfig(derivedAccountId); if (!accountConfig) { - throw new Error('Account is not configured'); + logger.error( + i18n(`commands.project.subcommands.migrateApp.errors.noAccountConfig`) + ); + return process.exit(EXIT_CODES.ERROR); } logger.log(''); - logger.log(uiBetaTag(i18n(`${i18nKey}.header.text`), false)); + logger.log( + uiBetaTag( + i18n(`commands.project.subcommands.migrateApp.header.text`), + false + ) + ); logger.log( uiLink( - i18n(`${i18nKey}.header.link`), + i18n(`commands.project.subcommands.migrateApp.header.link`), 'https://developers.hubspot.com/docs/platform/migrate-a-public-app-to-projects' ) ); @@ -48,7 +54,7 @@ export async function handler(options: ArgumentsCamelCase) { try { if (platformVersion === v2025_2 || platformVersion === unstable) { await migrateApp2025_2(derivedAccountId, options); - } else if (platformVersion === v2023_2) { + } else { await migrateApp2023_2(derivedAccountId, options, accountConfig); } } catch (error) { @@ -78,22 +84,28 @@ export async function handler(options: ArgumentsCamelCase) { process.exit(EXIT_CODES.SUCCESS); } -export async function builder(yargs: Argv) { +export async function builder(yargs: Argv): Promise> { addConfigOptions(yargs); addAccountOptions(yargs); addUseEnvironmentOptions(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': { @@ -107,7 +119,21 @@ export async function builder(yargs: Argv) { // This is a hack so we can use the same function for both the app migrate and project migrate-app commands // and have the examples be correct. If we don't can about that we can remove this. const { _ } = await yargs.argv; - yargs.example([[`$0 ${_.join(' ')}`, i18n(`${i18nKey}.examples.default`)]]); + yargs.example([ + [ + `$0 ${_.join(' ')}`, + i18n(`commands.project.subcommands.migrateApp.examples.default`), + ], + ]); - return yargs; + return yargs as Argv; } + +const migrateCommand: CommandModule = { + command, + describe, + handler, + builder, +}; + +export default migrateCommand; diff --git a/commands/project/migrate.ts b/commands/project/migrate.ts deleted file mode 100644 index 69363dfb9..000000000 --- a/commands/project/migrate.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - addAccountOptions, - addConfigOptions, - addUseEnvironmentOptions, -} from '../../lib/commonOpts'; -import { - trackCommandMetadataUsage, - trackCommandUsage, -} from '../../lib/usageTracking'; -import { ApiErrorContext, logError } from '../../lib/errorHandlers'; -import { EXIT_CODES } from '../../lib/enums/exitCodes'; -import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { ArgumentsCamelCase, Argv } from 'yargs'; -import { MigrateAppOptions } from '../../types/Yargs'; -import { migrateApp2025_2 } from '../../lib/app/migrate'; -import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; -const { v2025_2, unstable } = PLATFORM_VERSIONS; -const validMigrationTargets = [v2025_2, unstable]; - -export const command = 'migrate'; -export const describe = null; // uiBetaTag(i18n(`${i18nKey}.describe`), false); - -export async function handler(options: ArgumentsCamelCase) { - const { derivedAccountId } = options; - await trackCommandUsage('project-migrate', {}, derivedAccountId); - const accountConfig = getAccountConfig(derivedAccountId); - - if (!accountConfig) { - throw new Error('Account is not configured'); - } - - try { - await migrateApp2025_2(derivedAccountId, options); - } 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( - 'project-migrate', - { status: 'FAILURE' }, - derivedAccountId - ); - process.exit(EXIT_CODES.ERROR); - } - - await trackCommandMetadataUsage( - 'project-migrate', - { status: 'SUCCESS' }, - derivedAccountId - ); - process.exit(EXIT_CODES.SUCCESS); -} - -export async function builder(yargs: Argv) { - addConfigOptions(yargs); - addAccountOptions(yargs); - addUseEnvironmentOptions(yargs); - - yargs.options({ - 'platform-version': { - type: 'string', - choices: validMigrationTargets, - hidden: true, - default: '2025.2', - }, - }); - - // yargs.example([[`$0 ${_.join(' ')}`, i18n(`${i18nKey}.examples.default`)]]); - - return yargs; -} diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index c40d31ef3..f0f5074a8 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,21 +1,23 @@ import { i18n } from '../../lib/lang'; import { uiCommandReference, uiDeprecatedTag } from '../../lib/ui'; -import { handler as migrateHandler } from '../app/migrate'; +import { handler as migrateHandler, builder } from '../app/migrate'; -import { ArgumentsCamelCase } from 'yargs'; +import { ArgumentsCamelCase, CommandModule } from 'yargs'; import { logger } from '@hubspot/local-dev-lib/logger'; import { MigrateAppOptions } from '../../types/Yargs'; -const i18nKey = 'commands.project.subcommands.migrateApp'; - export const command = 'migrate-app'; // TODO: Leave this as deprecated and remove in the next major release -export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); +export const describe = uiDeprecatedTag( + i18n(`commands.project.subcommands.migrateApp.describe`), + false +); +export const deprecated = true; export async function handler(yargs: ArgumentsCamelCase) { logger.warn( - i18n(`${i18nKey}.deprecationWarning`, { + i18n(`commands.project.subcommands.migrateApp.deprecationWarning`, { oldCommand: uiCommandReference('hs project migrate-app'), newCommand: uiCommandReference('hs app migrate'), }) @@ -23,4 +25,12 @@ export async function handler(yargs: ArgumentsCamelCase) { await migrateHandler(yargs); } -export { builder } from '../app/migrate'; +const migrateAppCommand: CommandModule = { + command, + describe, + deprecated, + handler, + builder, +}; + +export default migrateAppCommand; diff --git a/lang/en.lyaml b/lang/en.lyaml index 7840ce024..40322ad55 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -571,7 +571,6 @@ en: 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}}" @@ -592,6 +591,7 @@ en: errors: noApps: "No apps found in account {{ accountId }}" 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." diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index cec40970b..b1d4b2772 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -30,7 +30,6 @@ import { beginMigration, finishMigration, listAppsForMigration, - MigrationApp, UNMIGRATABLE_REASONS, } from '@hubspot/local-dev-lib/api/projects'; import { poll } from '../polling'; @@ -42,6 +41,7 @@ import { ArgumentsCamelCase } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; import chalk from 'chalk'; import { validateUid } from '@hubspot/project-parsing-lib'; +import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { diff --git a/lib/usageTracking.ts b/lib/usageTracking.ts index 2a4cdb3a0..424ae7404 100644 --- a/lib/usageTracking.ts +++ b/lib/usageTracking.ts @@ -22,7 +22,7 @@ type Meta = { type?: string | number; // "The upload type" file?: boolean; // "Whether or not the 'file' flag was used" successful?: boolean; // "Whether or not the CLI interaction was successful" - status?: string; // TODO: What is this? + status?: string; // Is the action started, successful, or failed }; const EventClass = { diff --git a/types/Yargs.ts b/types/Yargs.ts index 6297392be..d451d42e7 100644 --- a/types/Yargs.ts +++ b/types/Yargs.ts @@ -36,13 +36,12 @@ export type TestingArgs = { qa?: boolean; }; -export interface MigrateAppOptions - extends CommonArgs, - AccountArgs, - EnvironmentArgs, - ConfigArgs { - name: string; - dest: string; - appId: number; - platformVersion: string; -} +export type MigrateAppOptions = CommonArgs & + AccountArgs & + EnvironmentArgs & + ConfigArgs & { + name: string; + dest: string; + appId: number; + platformVersion: string; + }; From 78cb580c27dd5f51feb6bfd8b619e5d29fad5367 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Thu, 3 Apr 2025 12:35:08 -0700 Subject: [PATCH 14/36] Clean up, add tests --- commands/__tests__/project.test.ts | 5 +- commands/app/__tests__/migrate.test.ts | 135 +++++++++++++++++++++++++ commands/app/migrate.ts | 2 +- commands/project/cloneApp.ts | 37 +++---- types/Yargs.ts | 8 ++ 5 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 commands/app/__tests__/migrate.test.ts 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/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts new file mode 100644 index 000000000..cd96f99e6 --- /dev/null +++ b/commands/app/__tests__/migrate.test.ts @@ -0,0 +1,135 @@ +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { handler, builder } from '../migrate'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { migrateApp2023_2, migrateApp2025_2 } from '../../../lib/app/migrate'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { EXIT_CODES } from '../../../lib/enums/exitCodes'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; +import { MigrateAppOptions } from '../../../types/Yargs'; + +jest.mock('@hubspot/local-dev-lib/config'); +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('../../../lib/app/migrate'); +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.any(Object), + dest: expect.any(Object), + 'app-id': expect.any(Object), + 'platform-version': expect.any(Object), + }) + ); + }); + + 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 index 2c7f004db..1ef44b001 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -81,7 +81,7 @@ export async function handler(options: ArgumentsCamelCase) { { status: 'SUCCESS' }, derivedAccountId ); - process.exit(EXIT_CODES.SUCCESS); + return process.exit(EXIT_CODES.SUCCESS); } export async function builder(yargs: Argv): Promise> { diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index c66cfe38d..b2c753236 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -30,30 +30,16 @@ 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 } from 'yargs'; -import { - AccountArgs, - CommonArgs, - ConfigArgs, - EnvironmentArgs, -} from '../../types/Yargs'; +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 = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); - -export interface CloneAppArgs - extends ConfigArgs, - EnvironmentArgs, - AccountArgs, - CommonArgs { - dest: string; - appId: number; -} +export const command = 'clone-app'; +export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); -exports.handler = async (options: ArgumentsCamelCase) => { +export const handler = async (options: ArgumentsCamelCase) => { const { derivedAccountId } = options; await trackCommandUsage('clone-app', {}, derivedAccountId); @@ -193,7 +179,7 @@ exports.handler = async (options: ArgumentsCamelCase) => { process.exit(EXIT_CODES.SUCCESS); }; -exports.builder = (yargs: Argv) => { +export const builder = (yargs: Argv) => { yargs.options({ dest: { describe: i18n(`${i18nKey}.options.dest.describe`), @@ -213,5 +199,14 @@ exports.builder = (yargs: Argv) => { addAccountOptions(yargs); addUseEnvironmentOptions(yargs); - return yargs; + return yargs as Argv; }; + +const cloneAppCommand: CommandModule = { + command, + describe, + handler, + builder, +}; + +export default cloneAppCommand; diff --git a/types/Yargs.ts b/types/Yargs.ts index d451d42e7..eed5b406a 100644 --- a/types/Yargs.ts +++ b/types/Yargs.ts @@ -45,3 +45,11 @@ export type MigrateAppOptions = CommonArgs & appId: number; platformVersion: string; }; + +export type CloneAppArgs = ConfigArgs & + EnvironmentArgs & + AccountArgs & + CommonArgs & { + dest: string; + appId: number; + }; From 79ee7c8a4233744207f8d538049d19395b931136 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Thu, 3 Apr 2025 14:33:46 -0700 Subject: [PATCH 15/36] Implement polling --- lib/app/migrate.ts | 49 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index b1d4b2772..0220782fa 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -259,13 +259,26 @@ async function handleMigrationProcess(derivedAccountId: number, appId: number) { return { migrationId, uidMap }; } +async function pollMigrationStatus( + derivedAccountId: number, + migrationId: number, + platformVersion: string +) { + const pollResponse = await poll(() => + checkMigrationStatus(derivedAccountId, migrationId, platformVersion) + ); + + const { status } = pollResponse; + return status === 'SUCCESS'; +} + async function finalizeMigration( derivedAccountId: number, migrationId: number, uidMap: Record, projectName: string, platformVersion: string -) { +): Promise { let buildId: number; try { SpinniesManager.add('finishingMigration', { @@ -283,20 +296,26 @@ async function finalizeMigration( buildId = data.buildId; - // const pollResponse = await poll(() => - // checkMigrationStatus(derivedAccountId, migrationId, platformVersion) - // ); - // const { status } = pollResponse; - // if (status === 'SUCCESS') { + const success = await pollMigrationStatus( + derivedAccountId, + migrationId, + platformVersion + ); - SpinniesManager.succeed('finishingMigration', { - text: i18n( - `commands.project.subcommands.migrateApp.spinners.migrationComplete` - ), - }); + if (success) { + SpinniesManager.succeed('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationComplete` + ), + }); + } else { + SpinniesManager.fail('finishingMigration', { + text: i18n( + `commands.project.subcommands.migrateApp.spinners.migrationFailed` + ), + }); + } return buildId; - - // } } catch (error) { SpinniesManager.fail('finishingMigration', { text: i18n( @@ -380,6 +399,10 @@ export async function migrateApp2025_2( options.platformVersion ); + if (!buildId) { + throw new Error('Migration Failed'); + } + await downloadProjectFiles( derivedAccountId, projectName, From 80e30014cccb1876db19a6e6c40d0c7a9581ad7e Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 10:55:27 -0700 Subject: [PATCH 16/36] Udpate after LDL release --- commands/app/__tests__/migrate.test.ts | 2 +- commands/app/migrate.ts | 2 +- lib/app/migrate.ts | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/commands/app/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts index cd96f99e6..f4f837634 100644 --- a/commands/app/__tests__/migrate.test.ts +++ b/commands/app/__tests__/migrate.test.ts @@ -4,8 +4,8 @@ import { getAccountConfig } from '@hubspot/local-dev-lib/config'; import { migrateApp2023_2, migrateApp2025_2 } from '../../../lib/app/migrate'; import { logger } from '@hubspot/local-dev-lib/logger'; import { EXIT_CODES } from '../../../lib/enums/exitCodes'; -import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; 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'); diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 1ef44b001..ec44effc2 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -14,7 +14,7 @@ import { getAccountConfig } from '@hubspot/local-dev-lib/config'; import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; -import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/platformVersion'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; import { logger } from '@hubspot/local-dev-lib/logger'; import { uiBetaTag, uiLink } from '../../lib/ui'; diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 0220782fa..ffe484b12 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -30,7 +30,6 @@ import { beginMigration, finishMigration, listAppsForMigration, - UNMIGRATABLE_REASONS, } from '@hubspot/local-dev-lib/api/projects'; import { poll } from '../polling'; import path from 'path'; @@ -42,6 +41,7 @@ import { MigrateAppOptions } from '../../types/Yargs'; import chalk from 'chalk'; import { validateUid } from '@hubspot/project-parsing-lib'; import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; +import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { diff --git a/package.json b/package.json index 8200a568a..2348c5361 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.0-beta.0", "@hubspot/project-parsing-lib": "0.1.4", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", From 87fd9d55b13a40b1448e6379a2e9d8329e4508c0 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 11:39:51 -0700 Subject: [PATCH 17/36] Add defaultAnswer for dest --- lib/app/migrate.ts | 13 ++++++++----- lib/prompts/promptUtils.ts | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index ffe484b12..984b6784c 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -42,6 +42,7 @@ import chalk from 'chalk'; import { validateUid } from '@hubspot/project-parsing-lib'; import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; +import { mapToUserFacingType } from '@hubspot/project-parsing-lib/src/lib/transform'; function getUnmigratableReason(reasonCode: string) { switch (reasonCode) { @@ -134,14 +135,14 @@ async function handleMigrationSetup( const selectedApp = allApps.find(app => app.appId === appIdToMigrate); - const migratableComponents: string[] = ['Application']; + const migratableComponents: string[] = []; const unmigratableComponents: string[] = []; selectedApp?.migrationComponents.forEach(component => { if (component.isSupported) { - migratableComponents.push(component.componentType); + migratableComponents.push(mapToUserFacingType(component.componentType)); } else { - unmigratableComponents.push(component.componentType); + unmigratableComponents.push(mapToUserFacingType(component.componentType)); } }); @@ -199,7 +200,10 @@ async function handleMigrationSetup( const projectDest = dest || (await inputPrompt( - i18n('commands.project.subcommands.migrateApp.prompt.inputDest') + i18n('commands.project.subcommands.migrateApp.prompt.inputDest'), + { + defaultAnswer: path.resolve(getCwd(), projectName), + } )); return { appIdToMigrate, projectName, projectDest }; @@ -230,7 +234,6 @@ async function handleMigrationProcess(derivedAccountId: number, appId: number) { for (const [componentId, component] of Object.entries( componentsRequiringUids )) { - // TODO: We need to validate the UID here uidMap[componentId] = await inputPrompt( i18n( 'commands.project.subcommands.migrateApp.prompt.uidForComponent', diff --git a/lib/prompts/promptUtils.ts b/lib/prompts/promptUtils.ts index 6fa5f0ce8..346d9fdbe 100644 --- a/lib/prompts/promptUtils.ts +++ b/lib/prompts/promptUtils.ts @@ -70,15 +70,18 @@ export async function inputPrompt( { 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, From 39393cfe2288b5330831bbee7d7f96cb17a326b6 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 11:45:14 -0700 Subject: [PATCH 18/36] clean up --- commands/project/cloneApp.ts | 6 +++++- lang/en.lyaml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index b2c753236..90aeaed90 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -38,6 +38,7 @@ const i18nKey = 'commands.project.subcommands.cloneApp'; export const command = 'clone-app'; export const describe = uiDeprecatedTag(i18n(`${i18nKey}.describe`), false); +export const deprecated = true; export const handler = async (options: ArgumentsCamelCase) => { const { derivedAccountId } = options; @@ -47,7 +48,9 @@ export const handler = async (options: ArgumentsCamelCase) => { const accountName = uiAccountDescription(derivedAccountId); if (!accountConfig) { - throw new Error('Account is not configured'); + throw new Error( + i18n(`commands.projects.subcommands.cloneApp.errors.noAccountConfig`) + ); } if (!isAppDeveloperAccount(accountConfig)) { @@ -207,6 +210,7 @@ const cloneAppCommand: CommandModule = { describe, handler, builder, + deprecated, }; export default cloneAppCommand; diff --git a/lang/en.lyaml b/lang/en.lyaml index 2d16dff3d..e6102b2d7 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -639,6 +639,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: From 505df3d9cf61e49c8001312f6aa9700a9ed8d8e0 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 11:58:27 -0700 Subject: [PATCH 19/36] Update LDL version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2348c5361..0b22b1b49 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.5.0-beta.0", + "@hubspot/local-dev-lib": "3.5.0", "@hubspot/project-parsing-lib": "0.1.4", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", From bbbed834af007bdf99b4904816c20ae2e30db031 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 12:07:23 -0700 Subject: [PATCH 20/36] Fix wierd flag behavior --- commands/app/migrate.ts | 7 ++--- commands/project/migrateApp.ts | 54 ++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index ec44effc2..26a9a19cb 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -19,7 +19,7 @@ import { logger } from '@hubspot/local-dev-lib/logger'; import { uiBetaTag, uiLink } from '../../lib/ui'; const { v2023_2, v2025_2, unstable } = PLATFORM_VERSIONS; -const validMigrationTargets = [v2023_2, v2025_2, unstable]; +export const validMigrationTargets = [v2023_2, v2025_2, unstable]; const command = 'migrate'; const describe = undefined; // uiBetaTag(i18n(`commands.project.subcommands.migrateApp.header.text.describe`), false); @@ -116,12 +116,9 @@ export async function builder(yargs: Argv): Promise> { }, }); - // This is a hack so we can use the same function for both the app migrate and project migrate-app commands - // and have the examples be correct. If we don't can about that we can remove this. - const { _ } = await yargs.argv; yargs.example([ [ - `$0 ${_.join(' ')}`, + `$0 app migrate`, i18n(`commands.project.subcommands.migrateApp.examples.default`), ], ]); diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index f0f5074a8..0ddcf5070 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -1,10 +1,18 @@ import { i18n } from '../../lib/lang'; import { uiCommandReference, uiDeprecatedTag } from '../../lib/ui'; -import { handler as migrateHandler, builder } from '../app/migrate'; +import { + handler as migrateHandler, + validMigrationTargets, +} from '../app/migrate'; -import { ArgumentsCamelCase, CommandModule } from 'yargs'; +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { logger } from '@hubspot/local-dev-lib/logger'; import { MigrateAppOptions } from '../../types/Yargs'; +import { + addAccountOptions, + addConfigOptions, + addUseEnvironmentOptions, +} from '../../lib/commonOpts'; export const command = 'migrate-app'; @@ -25,6 +33,48 @@ export async function handler(yargs: ArgumentsCamelCase) { await migrateHandler(yargs); } +export function builder(yargs: Argv): Promise> { + 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 project migrate-app`, + i18n(`commands.project.subcommands.migrateApp.examples.default`), + ], + ]); + + return yargs as Argv; +} + const migrateAppCommand: CommandModule = { command, describe, From 4907c784924877e7be7aa889cb0fc77f24fbc7e4 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 12:14:18 -0700 Subject: [PATCH 21/36] Remove async and promise wrapper --- commands/app/migrate.ts | 2 +- commands/project/migrateApp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 26a9a19cb..56a1f1b88 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -84,7 +84,7 @@ export async function handler(options: ArgumentsCamelCase) { return process.exit(EXIT_CODES.SUCCESS); } -export async function builder(yargs: Argv): Promise> { +export function builder(yargs: Argv): Argv { addConfigOptions(yargs); addAccountOptions(yargs); addUseEnvironmentOptions(yargs); diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index 0ddcf5070..db11d6ad4 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -33,7 +33,7 @@ export async function handler(yargs: ArgumentsCamelCase) { await migrateHandler(yargs); } -export function builder(yargs: Argv): Promise> { +export function builder(yargs: Argv): Argv { addConfigOptions(yargs); addAccountOptions(yargs); addUseEnvironmentOptions(yargs); From bfafd1845a68a431ecfd0a78ead0f64ccba20883 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 13:02:25 -0700 Subject: [PATCH 22/36] Feedback --- commands/app/__tests__/migrate.test.ts | 21 ++++++++++--- commands/app/migrate.ts | 4 +-- commands/project/cloneApp.ts | 10 ++---- lang/en.lyaml | 2 +- lib/app/migrate.ts | 42 +++++++++++++++----------- lib/usageTracking.ts | 1 - 6 files changed, 47 insertions(+), 33 deletions(-) diff --git a/commands/app/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts index f4f837634..c5048aa34 100644 --- a/commands/app/__tests__/migrate.test.ts +++ b/commands/app/__tests__/migrate.test.ts @@ -104,10 +104,23 @@ describe('commands/app/migrate', () => { expect(mockYargs.options).toHaveBeenCalledWith( expect.objectContaining({ - name: expect.any(Object), - dest: expect.any(Object), - 'app-id': expect.any(Object), - 'platform-version': expect.any(Object), + 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, + }), }) ); }); diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 56a1f1b88..431b9d77a 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -70,7 +70,7 @@ export async function handler(options: ArgumentsCamelCase) { } await trackCommandMetadataUsage( 'migrate-app', - { status: 'FAILURE' }, + { successful: false }, derivedAccountId ); process.exit(EXIT_CODES.ERROR); @@ -78,7 +78,7 @@ export async function handler(options: ArgumentsCamelCase) { await trackCommandMetadataUsage( 'migrate-app', - { status: 'SUCCESS' }, + { successful: true }, derivedAccountId ); return process.exit(EXIT_CODES.SUCCESS); diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index 90aeaed90..8e328379b 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -80,11 +80,7 @@ export const handler = async (options: ArgumentsCamelCase) => { process.exit(EXIT_CODES.ERROR); } - await trackCommandMetadataUsage( - 'clone-app', - { status: 'STARTED' }, - derivedAccountId - ); + await trackCommandMetadataUsage('clone-app', {}, derivedAccountId); try { SpinniesManager.init(); @@ -150,7 +146,7 @@ export const handler = async (options: ArgumentsCamelCase) => { } catch (error) { await trackCommandMetadataUsage( 'clone-app', - { status: 'FAILURE' }, + { successful: false }, derivedAccountId ); @@ -176,7 +172,7 @@ export const handler = async (options: ArgumentsCamelCase) => { await trackCommandMetadataUsage( 'clone-app', - { status: 'SUCCESS' }, + { successful: true }, derivedAccountId ); process.exit(EXIT_CODES.SUCCESS); diff --git a/lang/en.lyaml b/lang/en.lyaml index e6102b2d7..c4232ce5f 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -616,7 +616,7 @@ en: copyingProjectFiles: "Copying migrated project files" copyingProjectFilesComplete: "Migrated project files copied" copyingProjectFilesFailed: "Unable to copy migrated project files" - migrationNotAllowedReasons: + unmigratableReasons: upToDate: 'App is already up to date' isPrivateApp: 'Private apps are not currently migratable' listedInMarketplace: 'Listed apps are not currently migratable' diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 984b6784c..6a63e9df6 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -44,23 +44,23 @@ import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; import { mapToUserFacingType } from '@hubspot/project-parsing-lib/src/lib/transform'; -function getUnmigratableReason(reasonCode: string) { +function getUnmigratableReason(reasonCode: string): string { switch (reasonCode) { case UNMIGRATABLE_REASONS.UP_TO_DATE: return i18n( - 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.upToDate' + 'commands.project.subcommands.migrateApp.unmigratableReasons.upToDate' ); case UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP: return i18n( - 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.isPrivateApp' + 'commands.project.subcommands.migrateApp.unmigratableReasons.isPrivateApp' ); case UNMIGRATABLE_REASONS.LISTED_IN_MARKETPLACE: return i18n( - 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.listedInMarketplace' + 'commands.project.subcommands.migrateApp.unmigratableReasons.listedInMarketplace' ); default: return i18n( - 'commands.project.subcommands.migrateApp.migrationNotAllowedReasons.generic', + 'commands.project.subcommands.migrateApp.unmigratableReasons.generic', { reasonCode, } @@ -71,7 +71,11 @@ function getUnmigratableReason(reasonCode: string) { async function handleMigrationSetup( derivedAccountId: number, options: ArgumentsCamelCase -) { +): Promise<{ + appIdToMigrate?: number | undefined; + projectName?: string; + projectDest?: string; +}> { const { name, dest, appId } = options; const { data } = await listAppsForMigration(derivedAccountId); @@ -202,14 +206,20 @@ async function handleMigrationSetup( (await inputPrompt( i18n('commands.project.subcommands.migrateApp.prompt.inputDest'), { - defaultAnswer: path.resolve(getCwd(), projectName), + defaultAnswer: path.resolve(getCwd(), sanitizeFileName(projectName)), } )); return { appIdToMigrate, projectName, projectDest }; } -async function handleMigrationProcess(derivedAccountId: number, appId: number) { +async function handleMigrationProcess( + derivedAccountId: number, + appId: number +): Promise<{ + migrationId: number; + uidMap: Record; +}> { SpinniesManager.add('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.beginningMigration' @@ -334,7 +344,7 @@ export async function downloadProjectFiles( projectName: string, buildId: number, projectDest: string -) { +): Promise { try { SpinniesManager.add('fetchingMigratedProject', { text: i18n( @@ -379,13 +389,13 @@ export async function downloadProjectFiles( export async function migrateApp2025_2( derivedAccountId: number, options: ArgumentsCamelCase -) { +): Promise { SpinniesManager.init(); const { appIdToMigrate, projectName, projectDest } = await handleMigrationSetup(derivedAccountId, options); - if (!appIdToMigrate) { + if (!appIdToMigrate || !projectName || !projectDest) { return; } @@ -414,7 +424,7 @@ export async function migrateApp2025_2( ); } -export function logInvalidAccountError(i18nKey: string) { +export function logInvalidAccountError(i18nKey: string): void { uiLine(); logger.error(i18n(`${i18nKey}.errors.invalidAccountTypeTitle`)); logger.log( @@ -430,7 +440,7 @@ export async function migrateApp2023_2( derivedAccountId: number, options: ArgumentsCamelCase, accountConfig: CLIAccount -) { +): Promise { const i18nKey = 'commands.project.subcommands.migrateApp'; const accountName = uiAccountDescription(derivedAccountId); @@ -486,11 +496,7 @@ export async function migrateApp2023_2( ); } - await trackCommandMetadataUsage( - 'migrate-app', - { status: 'STARTED' }, - derivedAccountId - ); + await trackCommandMetadataUsage('migrate-app', undefined, derivedAccountId); logger.log(''); uiLine(); diff --git a/lib/usageTracking.ts b/lib/usageTracking.ts index 424ae7404..ae3bf7be6 100644 --- a/lib/usageTracking.ts +++ b/lib/usageTracking.ts @@ -22,7 +22,6 @@ type Meta = { type?: string | number; // "The upload type" file?: boolean; // "Whether or not the 'file' flag was used" successful?: boolean; // "Whether or not the CLI interaction was successful" - status?: string; // Is the action started, successful, or failed }; const EventClass = { From 9ce527328621a72a7fa056e1369eb598d41ceb29 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Mon, 7 Apr 2025 13:06:02 -0700 Subject: [PATCH 23/36] feedback --- commands/project/cloneApp.ts | 6 +++++- lib/app/migrate.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index 8e328379b..f2636c3e8 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -80,7 +80,11 @@ export const handler = async (options: ArgumentsCamelCase) => { process.exit(EXIT_CODES.ERROR); } - await trackCommandMetadataUsage('clone-app', {}, derivedAccountId); + await trackCommandMetadataUsage( + 'clone-app', + { step: 'STARTED' }, + derivedAccountId + ); try { SpinniesManager.init(); diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 6a63e9df6..c6883c51b 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -496,7 +496,11 @@ export async function migrateApp2023_2( ); } - await trackCommandMetadataUsage('migrate-app', undefined, derivedAccountId); + await trackCommandMetadataUsage( + 'migrate-app', + { step: 'STARTED' }, + derivedAccountId + ); logger.log(''); uiLine(); From adfcc01eacac472df13da5740509f26bc4e7dd2b Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 13:55:48 -0700 Subject: [PATCH 24/36] Get it working with new endpoints --- lib/app/migrate.ts | 158 +++++++++++++++++++++++++-------------------- lib/polling.ts | 2 +- 2 files changed, 88 insertions(+), 72 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index c6883c51b..cc5326e28 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -25,13 +25,14 @@ import SpinniesManager from '../ui/SpinniesManager'; import { handleKeypress } from '../process'; import { checkMigrationStatus, + checkMigrationStatusV2, downloadProject, migrateApp as migrateNonProjectApp_v2023_2, - beginMigration, - finishMigration, + initializeMigration, + continueMigration, listAppsForMigration, } from '@hubspot/local-dev-lib/api/projects'; -import { poll } from '../polling'; +import { DEFAULT_POLLING_STATUS_LOOKUP, poll } from '../polling'; import path from 'path'; import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; @@ -43,6 +44,7 @@ import { validateUid } from '@hubspot/project-parsing-lib'; import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; import { mapToUserFacingType } from '@hubspot/project-parsing-lib/src/lib/transform'; +import { MigrationStatus } from '@hubspot/local-dev-lib/types/Migration'; function getUnmigratableReason(reasonCode: string): string { switch (reasonCode) { @@ -80,7 +82,9 @@ async function handleMigrationSetup( const { data } = await listAppsForMigration(derivedAccountId); const { migratableApps, unmigratableApps } = data; - const allApps = [...migratableApps, ...unmigratableApps]; + const allApps = [...migratableApps, ...unmigratableApps].filter( + app => !app.projectName + ); if (allApps.length === 0) { throw new Error( @@ -213,13 +217,17 @@ async function handleMigrationSetup( return { appIdToMigrate, projectName, projectDest }; } -async function handleMigrationProcess( +async function beginMigration( derivedAccountId: number, - appId: number -): Promise<{ - migrationId: number; - uidMap: Record; -}> { + appId: number, + platformVersion: string +): Promise< + | { + migrationId: number; + uidMap: Record; + } + | undefined +> { SpinniesManager.add('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.beginningMigration' @@ -227,46 +235,55 @@ async function handleMigrationProcess( }); const uidMap: Record = {}; - let migrationId: number; - try { - const { data } = await beginMigration(derivedAccountId, appId); - const { migrationId: mid, componentsRequiringUids } = data; + const { data } = await initializeMigration( + derivedAccountId, + appId, + platformVersion + ); + const { migrationId } = data; - migrationId = mid; - SpinniesManager.succeed('beginningMigration', { - text: i18n( - 'commands.project.subcommands.migrateApp.spinners.migrationStarted' - ), - }); + const pollResponse = await pollMigrationStatus( + derivedAccountId, + migrationId, + ['INPUT_REQUIRED'] + ); - if (Object.values(componentsRequiringUids).length !== 0) { - for (const [componentId, component] of Object.entries( - componentsRequiringUids - )) { - uidMap[componentId] = await inputPrompt( - i18n( - 'commands.project.subcommands.migrateApp.prompt.uidForComponent', - { - componentName: component.componentHint || component.componentType, - } - ), - { - validate: (uid: string) => { - const result = validateUid(uid); - return result === undefined ? true : result; - }, - } - ); - } - } - } catch (e) { + if (pollResponse.status !== 'INPUT_REQUIRED') { SpinniesManager.fail('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' ), }); - throw e; + return; + } + + console.log(pollResponse); + const { componentsRequiringUids } = pollResponse; + + SpinniesManager.succeed('beginningMigration', { + text: i18n( + 'commands.project.subcommands.migrateApp.spinners.migrationStarted' + ), + }); + + if (Object.values(componentsRequiringUids).length !== 0) { + for (const [componentId, component] of Object.entries( + componentsRequiringUids + )) { + console.log(component); + uidMap[componentId] = await inputPrompt( + i18n('commands.project.subcommands.migrateApp.prompt.uidForComponent', { + componentName: component.componentHint || component.componentType, + }), + { + validate: (uid: string) => { + const result = validateUid(uid); + return result === undefined ? true : result; + }, + } + ); + } } return { migrationId, uidMap }; @@ -275,47 +292,37 @@ async function handleMigrationProcess( async function pollMigrationStatus( derivedAccountId: number, migrationId: number, - platformVersion: string -) { - const pollResponse = await poll(() => - checkMigrationStatus(derivedAccountId, migrationId, platformVersion) - ); - - const { status } = pollResponse; - return status === 'SUCCESS'; + 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, - platformVersion: string + projectName: string ): Promise { - let buildId: number; + let buildId: number | undefined; try { SpinniesManager.add('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.finishingMigration` ), }); - const { data } = await finishMigration( - derivedAccountId, - migrationId, - uidMap, - projectName, - platformVersion - ); - - buildId = data.buildId; + await continueMigration(derivedAccountId, migrationId, uidMap, projectName); - const success = await pollMigrationStatus( + const pollResponse = await pollMigrationStatus( derivedAccountId, migrationId, - platformVersion + ['SUCCESS'] ); - if (success) { + if (pollResponse.status === 'SUCCESS') { + buildId = pollResponse.buildId; SpinniesManager.succeed('finishingMigration', { text: i18n( `commands.project.subcommands.migrateApp.spinners.migrationComplete` @@ -328,6 +335,7 @@ async function finalizeMigration( ), }); } + return buildId; } catch (error) { SpinniesManager.fail('finishingMigration', { @@ -365,8 +373,11 @@ export async function downloadProjectFiles( await extractZipArchive( zippedProject, sanitizeFileName(projectName), - path.resolve(absoluteDestPath), - { includesRootDir: false, hideLogs: false } + absoluteDestPath, + { + includesRootDir: true, + hideLogs: false, + } ); SpinniesManager.succeed('fetchingMigratedProject', { @@ -399,17 +410,22 @@ export async function migrateApp2025_2( return; } - const { migrationId, uidMap } = await handleMigrationProcess( + const migrationInProgress = await beginMigration( derivedAccountId, - appIdToMigrate + appIdToMigrate, + options.platformVersion ); + if (!migrationInProgress) { + return; + } + + const { migrationId, uidMap } = migrationInProgress; const buildId = await finalizeMigration( derivedAccountId, migrationId, uidMap, - projectName, - options.platformVersion + projectName ); if (!buildId) { 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, From 91b557ee03e1d462f84abe0780a06edf86d71903 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 13:58:04 -0700 Subject: [PATCH 25/36] Use experimental release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f463edb6d..601541d8b 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.5.0", + "@hubspot/local-dev-lib": "0.3.0-experimental.0", "@hubspot/project-parsing-lib": "0.1.4", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", From cd6fe011aa2fedec4fc57d9e5b18deecab4698e8 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 14:22:51 -0700 Subject: [PATCH 26/36] Remove console.logs --- lib/app/migrate.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index cc5326e28..235baddd1 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -258,7 +258,6 @@ async function beginMigration( return; } - console.log(pollResponse); const { componentsRequiringUids } = pollResponse; SpinniesManager.succeed('beginningMigration', { @@ -271,7 +270,6 @@ async function beginMigration( for (const [componentId, component] of Object.entries( componentsRequiringUids )) { - console.log(component); uidMap[componentId] = await inputPrompt( i18n('commands.project.subcommands.migrateApp.prompt.uidForComponent', { componentName: component.componentHint || component.componentType, From 2fcbaa90a267a1fd89486b330216fddb2501344e Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 15:12:53 -0700 Subject: [PATCH 27/36] Fixes --- lib/app/migrate.ts | 2 +- lib/errorHandlers/index.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 235baddd1..3143cd29a 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -191,7 +191,7 @@ async function handleMigrationSetup( const { projectExists } = await ensureProjectExists( derivedAccountId, projectName, - { forceCreate: false, allowCreate: false, noLogs: true } + { allowCreate: false, noLogs: true } ); if (projectExists) { diff --git a/lib/errorHandlers/index.ts b/lib/errorHandlers/index.ts index 3419d5af9..2dc3a5ea3 100644 --- a/lib/errorHandlers/index.ts +++ b/lib/errorHandlers/index.ts @@ -1,8 +1,5 @@ import { logger } from '@hubspot/local-dev-lib/logger'; -import { - isHubSpotHttpError, - isValidationError, -} from '@hubspot/local-dev-lib/errors/index'; +import { isHubSpotHttpError } from '@hubspot/local-dev-lib/errors/index'; import { getConfig } from '@hubspot/local-dev-lib/config'; import { shouldSuppressError } from './suppressError'; @@ -28,7 +25,7 @@ export function logError(error: unknown, context?: ApiErrorContext): void { error.updateContext(context); } - if (isHubSpotHttpError(error) && isValidationError(error)) { + if (isHubSpotHttpError(error)) { logger.error(error.formattedValidationErrors()); } else if (isErrorWithMessageOrReason(error)) { const message: string[] = []; From d1b3243b8d7dda96cbb4cf4e3437a1591a5a275f Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 15:20:35 -0700 Subject: [PATCH 28/36] Undo --- lib/errorHandlers/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/errorHandlers/index.ts b/lib/errorHandlers/index.ts index 2dc3a5ea3..3419d5af9 100644 --- a/lib/errorHandlers/index.ts +++ b/lib/errorHandlers/index.ts @@ -1,5 +1,8 @@ import { logger } from '@hubspot/local-dev-lib/logger'; -import { isHubSpotHttpError } from '@hubspot/local-dev-lib/errors/index'; +import { + isHubSpotHttpError, + isValidationError, +} from '@hubspot/local-dev-lib/errors/index'; import { getConfig } from '@hubspot/local-dev-lib/config'; import { shouldSuppressError } from './suppressError'; @@ -25,7 +28,7 @@ export function logError(error: unknown, context?: ApiErrorContext): void { error.updateContext(context); } - if (isHubSpotHttpError(error)) { + if (isHubSpotHttpError(error) && isValidationError(error)) { logger.error(error.formattedValidationErrors()); } else if (isErrorWithMessageOrReason(error)) { const message: string[] = []; From 110baf17da1fbf64d1221ed429e0f90d0967fd4e Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Tue, 8 Apr 2025 15:33:00 -0700 Subject: [PATCH 29/36] Inline i18nkey --- lib/app/migrate.ts | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 3143cd29a..4d67bb9b3 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -455,11 +455,10 @@ export async function migrateApp2023_2( options: ArgumentsCamelCase, accountConfig: CLIAccount ): Promise { - const i18nKey = 'commands.project.subcommands.migrateApp'; const accountName = uiAccountDescription(derivedAccountId); if (!isAppDeveloperAccount(accountConfig)) { - logInvalidAccountError(i18nKey); + logInvalidAccountError('commands.project.subcommands.migrateApp'); process.exit(EXIT_CODES.SUCCESS); } @@ -482,7 +481,7 @@ export async function migrateApp2023_2( const preventProjectMigrations = selectedApp.preventProjectMigrations; const listingInfo = selectedApp.listingInfo; if (preventProjectMigrations && listingInfo) { - logger.error(i18n(`${i18nKey}.errors.invalidApp`, { appId })); + logger.error(i18n(`commands.project.subcommands.migrateApp.errors.invalidApp`, { appId })); process.exit(EXIT_CODES.ERROR); } } catch (error) { @@ -504,7 +503,7 @@ export async function migrateApp2023_2( if (projectExists) { throw new Error( - i18n(`${i18nKey}.errors.projectAlreadyExists`, { + i18n(`commands.project.subcommands.migrateApp.errors.projectAlreadyExists`, { projectName, }) ); @@ -518,18 +517,18 @@ export async function migrateApp2023_2( logger.log(''); uiLine(); - logger.warn(`${i18n(`${i18nKey}.warning.title`)}\n`); - logger.log(i18n(`${i18nKey}.warning.projectConversion`)); - logger.log(`${i18n(`${i18nKey}.warning.appConfig`)}\n`); - logger.log(`${i18n(`${i18nKey}.warning.buildAndDeploy`)}\n`); - logger.log(`${i18n(`${i18nKey}.warning.existingApps`)}\n`); - logger.log(i18n(`${i18nKey}.warning.copyApp`)); + 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(`${i18nKey}.createAppPrompt`), + message: i18n(`commands.project.subcommands.migrateApp.createAppPrompt`), }); process.stdin.resume(); @@ -541,13 +540,13 @@ export async function migrateApp2023_2( SpinniesManager.init(); SpinniesManager.add('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.inProgress`), + 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(`${i18nKey}.migrationInterrupted`)); + logger.log(i18n(`commands.project.subcommands.migrateApp.migrationInterrupted`)); process.exit(EXIT_CODES.SUCCESS); } }); @@ -581,16 +580,16 @@ export async function migrateApp2023_2( ); SpinniesManager.succeed('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.done`), + text: i18n(`commands.project.subcommands.migrateApp.migrationStatus.done`), succeedColor: 'white', }); logger.log(''); uiLine(); - logger.success(i18n(`${i18nKey}.migrationStatus.success`)); + logger.success(i18n(`commands.project.subcommands.migrateApp.migrationStatus.success`)); logger.log(''); logger.log( uiLink( - i18n(`${i18nKey}.projectDetailsLink`), + i18n(`commands.project.subcommands.migrateApp.projectDetailsLink`), `${baseUrl}/developer-projects/${derivedAccountId}/project/${encodeURIComponent( project!.name )}` @@ -600,7 +599,7 @@ export async function migrateApp2023_2( } } catch (error) { SpinniesManager.fail('migrateApp', { - text: i18n(`${i18nKey}.migrationStatus.failure`), + text: i18n(`commands.project.subcommands.migrateApp.migrationStatus.failure`), failColor: 'white', }); throw error; From 210cb5f930c8b5565409c82c8c3870306aedd4c5 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 09:38:36 -0700 Subject: [PATCH 30/36] clean up --- lang/en.lyaml | 1 + lib/app/migrate.ts | 121 ++++++++++++++++++++++++++++----------------- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index 4c3865177..3be850099 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -597,6 +597,7 @@ en: 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?' diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 4d67bb9b3..83df5ec44 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -44,7 +44,10 @@ import { validateUid } from '@hubspot/project-parsing-lib'; import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; import { mapToUserFacingType } from '@hubspot/project-parsing-lib/src/lib/transform'; -import { MigrationStatus } from '@hubspot/local-dev-lib/types/Migration'; +import { + MIGRATION_STATUS, + MigrationStatus, +} from '@hubspot/local-dev-lib/types/Migration'; function getUnmigratableReason(reasonCode: string): string { switch (reasonCode) { @@ -246,10 +249,10 @@ async function beginMigration( const pollResponse = await pollMigrationStatus( derivedAccountId, migrationId, - ['INPUT_REQUIRED'] + [MIGRATION_STATUS.INPUT_REQUIRED] ); - if (pollResponse.status !== 'INPUT_REQUIRED') { + if (pollResponse.status !== MIGRATION_STATUS.INPUT_REQUIRED) { SpinniesManager.fail('beginningMigration', { text: i18n( 'commands.project.subcommands.migrateApp.spinners.unableToStartMigration' @@ -303,8 +306,8 @@ async function finalizeMigration( migrationId: number, uidMap: Record, projectName: string -): Promise { - let buildId: number | undefined; +): Promise { + let pollResponse: MigrationStatus; try { SpinniesManager.add('finishingMigration', { text: i18n( @@ -313,28 +316,9 @@ async function finalizeMigration( }); await continueMigration(derivedAccountId, migrationId, uidMap, projectName); - const pollResponse = await pollMigrationStatus( - derivedAccountId, - migrationId, - ['SUCCESS'] - ); - - if (pollResponse.status === 'SUCCESS') { - buildId = pollResponse.buildId; - SpinniesManager.succeed('finishingMigration', { - text: i18n( - `commands.project.subcommands.migrateApp.spinners.migrationComplete` - ), - }); - } else { - SpinniesManager.fail('finishingMigration', { - text: i18n( - `commands.project.subcommands.migrateApp.spinners.migrationFailed` - ), - }); - } - - return buildId; + pollResponse = await pollMigrationStatus(derivedAccountId, migrationId, [ + MIGRATION_STATUS.SUCCESS, + ]); } catch (error) { SpinniesManager.fail('finishingMigration', { text: i18n( @@ -343,6 +327,30 @@ async function finalizeMigration( }); 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( @@ -426,10 +434,6 @@ export async function migrateApp2025_2( projectName ); - if (!buildId) { - throw new Error('Migration Failed'); - } - await downloadProjectFiles( derivedAccountId, projectName, @@ -481,7 +485,11 @@ export async function migrateApp2023_2( const preventProjectMigrations = selectedApp.preventProjectMigrations; const listingInfo = selectedApp.listingInfo; if (preventProjectMigrations && listingInfo) { - logger.error(i18n(`commands.project.subcommands.migrateApp.errors.invalidApp`, { appId })); + logger.error( + i18n(`commands.project.subcommands.migrateApp.errors.invalidApp`, { + appId, + }) + ); process.exit(EXIT_CODES.ERROR); } } catch (error) { @@ -503,9 +511,12 @@ export async function migrateApp2023_2( if (projectExists) { throw new Error( - i18n(`commands.project.subcommands.migrateApp.errors.projectAlreadyExists`, { - projectName, - }) + i18n( + `commands.project.subcommands.migrateApp.errors.projectAlreadyExists`, + { + projectName, + } + ) ); } @@ -517,11 +528,21 @@ export async function migrateApp2023_2( 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.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(); @@ -540,13 +561,17 @@ export async function migrateApp2023_2( SpinniesManager.init(); SpinniesManager.add('migrateApp', { - text: i18n(`commands.project.subcommands.migrateApp.migrationStatus.inProgress`), + 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`)); + logger.log( + i18n(`commands.project.subcommands.migrateApp.migrationInterrupted`) + ); process.exit(EXIT_CODES.SUCCESS); } }); @@ -580,12 +605,16 @@ export async function migrateApp2023_2( ); SpinniesManager.succeed('migrateApp', { - text: i18n(`commands.project.subcommands.migrateApp.migrationStatus.done`), + text: i18n( + `commands.project.subcommands.migrateApp.migrationStatus.done` + ), succeedColor: 'white', }); logger.log(''); uiLine(); - logger.success(i18n(`commands.project.subcommands.migrateApp.migrationStatus.success`)); + logger.success( + i18n(`commands.project.subcommands.migrateApp.migrationStatus.success`) + ); logger.log(''); logger.log( uiLink( @@ -599,7 +628,9 @@ export async function migrateApp2023_2( } } catch (error) { SpinniesManager.fail('migrateApp', { - text: i18n(`commands.project.subcommands.migrateApp.migrationStatus.failure`), + text: i18n( + `commands.project.subcommands.migrateApp.migrationStatus.failure` + ), failColor: 'white', }); throw error; From 223f554b711233132d49f6ee541451d371eb2d18 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 10:56:34 -0700 Subject: [PATCH 31/36] PR feedback, move stuff around, add API calls to CLI --- api/__tests__/migrate.test.ts | 190 ++++++++++++++++++++ api/migrate.ts | 137 ++++++++++++++ commands/app/__tests__/migrate.test.ts | 4 +- commands/app/migrate.ts | 12 +- jest.config.js | 2 +- lib/app/migrate.ts | 238 ++----------------------- lib/app/migrate_legacy.ts | 211 ++++++++++++++++++++++ tsconfig.json | 2 +- 8 files changed, 566 insertions(+), 230 deletions(-) create mode 100644 api/__tests__/migrate.test.ts create mode 100644 api/migrate.ts create mode 100644 lib/app/migrate_legacy.ts 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..d9d4d9071 --- /dev/null +++ b/api/migrate.ts @@ -0,0 +1,137 @@ +import { HubSpotPromise } from '../../hubspot-local-dev-lib/dist/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/commands/app/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts index c5048aa34..52b9a797e 100644 --- a/commands/app/__tests__/migrate.test.ts +++ b/commands/app/__tests__/migrate.test.ts @@ -1,7 +1,8 @@ import { ArgumentsCamelCase, Argv } from 'yargs'; import { handler, builder } from '../migrate'; import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { migrateApp2023_2, migrateApp2025_2 } from '../../../lib/app/migrate'; +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'; @@ -10,6 +11,7 @@ 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; diff --git a/commands/app/migrate.ts b/commands/app/migrate.ts index 431b9d77a..104c18a7a 100644 --- a/commands/app/migrate.ts +++ b/commands/app/migrate.ts @@ -1,3 +1,8 @@ +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, @@ -10,13 +15,10 @@ import { import { i18n } from '../../lib/lang'; import { ApiErrorContext, logError } from '../../lib/errorHandlers'; import { EXIT_CODES } from '../../lib/enums/exitCodes'; -import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { MigrateAppOptions } from '../../types/Yargs'; -import { migrateApp2023_2, migrateApp2025_2 } from '../../lib/app/migrate'; -import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; -import { logger } from '@hubspot/local-dev-lib/logger'; +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]; 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/lib/app/migrate.ts b/lib/app/migrate.ts index 83df5ec44..5c3b8435d 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -1,53 +1,30 @@ -import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { - confirmPrompt, - inputPrompt, - listPrompt, - promptUser, -} from '../prompts/promptUtils'; -import { ApiErrorContext, logError } from '../errorHandlers'; -import { EXIT_CODES } from '../enums/exitCodes'; import { logger } from '@hubspot/local-dev-lib/logger'; -import { - uiAccountDescription, - uiCommandReference, - uiLine, - uiLink, -} from '../ui'; -import { i18n } from '../lang'; -import { isAppDeveloperAccount } from '../accountTypes'; -import { selectPublicAppPrompt } from '../prompts/selectPublicAppPrompt'; -import { fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; -import { createProjectPrompt } from '../prompts/createProjectPrompt'; -import { ensureProjectExists } from '../projects'; -import { trackCommandMetadataUsage } from '../usageTracking'; -import SpinniesManager from '../ui/SpinniesManager'; -import { handleKeypress } from '../process'; -import { - checkMigrationStatus, - checkMigrationStatusV2, - downloadProject, - migrateApp as migrateNonProjectApp_v2023_2, - initializeMigration, - continueMigration, - listAppsForMigration, -} from '@hubspot/local-dev-lib/api/projects'; -import { DEFAULT_POLLING_STATUS_LOOKUP, poll } from '../polling'; 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 { MigrateAppOptions } from '../../types/Yargs'; import chalk from 'chalk'; import { validateUid } from '@hubspot/project-parsing-lib'; -import { MigrationApp } from '@hubspot/local-dev-lib/types/Project'; 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 { 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 { - MIGRATION_STATUS, + checkMigrationStatusV2, + continueMigration, + initializeMigration, + listAppsForMigration, + MigrationApp, MigrationStatus, -} from '@hubspot/local-dev-lib/types/Migration'; +} from '../../api/migrate'; function getUnmigratableReason(reasonCode: string): string { switch (reasonCode) { @@ -453,186 +430,3 @@ export function logInvalidAccountError(i18nKey: string): void { ); uiLine(); } - -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/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/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__/*"] } From 6daa0dfd956c63ea2165cc5e4647ace286192459 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 11:16:34 -0700 Subject: [PATCH 32/36] Fix bad import --- api/migrate.ts | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/migrate.ts b/api/migrate.ts index d9d4d9071..d5534afe5 100644 --- a/api/migrate.ts +++ b/api/migrate.ts @@ -1,10 +1,11 @@ -import { HubSpotPromise } from '../../hubspot-local-dev-lib/dist/types/Http'; +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 { diff --git a/package.json b/package.json index 91c37d043..0f3e0726a 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": "0.3.0-experimental.0", + "@hubspot/local-dev-lib": "3.5.1-beta.0", "@hubspot/project-parsing-lib": "0.1.5", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", From 8ac0831b8dcfc525701e4e1b2c3b1fd3a6b0a9b0 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 13:30:08 -0700 Subject: [PATCH 33/36] Remove migration spinner --- lang/en.lyaml | 1 - lib/app/migrate.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index 3be850099..392f7a7f7 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -606,7 +606,6 @@ en: proceed: 'Would you like to proceed?' spinners: beginningMigration: "Beginning migration" - migrationStarted: "Migration started" unableToStartMigration: "Unable to begin migration" finishingMigration: "Wrapping up migration" migrationComplete: "Migration completed" diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 5c3b8435d..4f8db4044 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -240,11 +240,7 @@ async function beginMigration( const { componentsRequiringUids } = pollResponse; - SpinniesManager.succeed('beginningMigration', { - text: i18n( - 'commands.project.subcommands.migrateApp.spinners.migrationStarted' - ), - }); + SpinniesManager.remove('beginningMigration'); if (Object.values(componentsRequiringUids).length !== 0) { for (const [componentId, component] of Object.entries( From 9d60f24028b56992cb3478d8b56c96d677c608c9 Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 14:59:46 -0700 Subject: [PATCH 34/36] Feedback from demo --- lang/en.lyaml | 1 - lib/app/migrate.ts | 17 ++++++++--------- package.json | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lang/en.lyaml b/lang/en.lyaml index 392f7a7f7..27d42a1b7 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -589,7 +589,6 @@ en: 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: - noApps: "No apps found in account {{ accountId }}" 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}}" diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 4f8db4044..c60940666 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -62,19 +62,12 @@ async function handleMigrationSetup( const { data } = await listAppsForMigration(derivedAccountId); const { migratableApps, unmigratableApps } = data; + const allApps = [...migratableApps, ...unmigratableApps].filter( app => !app.projectName ); if (allApps.length === 0) { - throw new Error( - i18n(`commands.project.subcommands.migrateApp.errors.noApps`, { - accountId: derivedAccountId, - }) - ); - } - - if (migratableApps.length === 0) { const reasons = unmigratableApps.map( app => `${chalk.bold(app.appName)}: ${getUnmigratableReason(app.unmigratableReason)}` @@ -246,15 +239,21 @@ async function beginMigration( for (const [componentId, component] of Object.entries( componentsRequiringUids )) { + const { componentHint, componentType } = component; uidMap[componentId] = await inputPrompt( i18n('commands.project.subcommands.migrateApp.prompt.uidForComponent', { - componentName: component.componentHint || component.componentType, + componentName: componentHint + ? `${componentHint} [${componentType}]` + : componentType, }), { validate: (uid: string) => { const result = validateUid(uid); return result === undefined ? true : result; }, + defaultAnswer: (componentHint || '') + .toLowerCase() + .replace(/[^a-z0-9_]/g, ''), } ); } diff --git a/package.json b/package.json index 28b2cf974..8036be731 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.5.1-beta.0", + "@hubspot/local-dev-lib": "3.5.1", "@hubspot/project-parsing-lib": "0.1.5", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", From 04e54f6355ea3663c8b055045f4ba7334533381b Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Wed, 9 Apr 2025 16:46:23 -0700 Subject: [PATCH 35/36] fix bug with filtering apps --- lib/app/migrate.ts | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index c60940666..2dfb10cf3 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -11,7 +11,7 @@ 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 { uiCommandReference, uiLine } from '../ui'; +import { uiAccountDescription, uiCommandReference, uiLine } from '../ui'; import { i18n } from '../lang'; import { ensureProjectExists } from '../projects'; import SpinniesManager from '../ui/SpinniesManager'; @@ -59,30 +59,39 @@ async function handleMigrationSetup( projectDest?: string; }> { const { name, dest, appId } = options; - const { data } = await listAppsForMigration(derivedAccountId); + const { + data: { migratableApps, unmigratableApps }, + } = await listAppsForMigration(derivedAccountId); - const { migratableApps, unmigratableApps } = data; + const migratableAppsWithoutProject = migratableApps.filter( + (app: MigrationApp) => !app.projectName + ); - const allApps = [...migratableApps, ...unmigratableApps].filter( - app => !app.projectName + const unmigratableAppsWithoutProject = unmigratableApps.filter( + (app: MigrationApp) => !app.projectName ); - if (allApps.length === 0) { - const reasons = unmigratableApps.map( + 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: derivedAccountId, - })} \n - ${reasons.join('\n - ')}` + accountId: uiAccountDescription(derivedAccountId), + })}${reasons.length ? `\n - ${reasons.join('\n - ')}` : ''}` ); } if ( appId && - !allApps.some(app => { + !allAppsWithoutProject.some(app => { return app.appId === appId; }) ) { @@ -93,7 +102,7 @@ async function handleMigrationSetup( ); } - const appChoices = allApps.map(app => ({ + const appChoices = allAppsWithoutProject.map(app => ({ name: app.isMigratable ? app.appName : `[${chalk.yellow('DISABLED')}] ${app.appName} `, @@ -114,7 +123,9 @@ async function handleMigrationSetup( appIdToMigrate = selectedAppId; } - const selectedApp = allApps.find(app => app.appId === appIdToMigrate); + const selectedApp = allAppsWithoutProject.find( + app => app.appId === appIdToMigrate + ); const migratableComponents: string[] = []; const unmigratableComponents: string[] = []; @@ -243,8 +254,8 @@ async function beginMigration( uidMap[componentId] = await inputPrompt( i18n('commands.project.subcommands.migrateApp.prompt.uidForComponent', { componentName: componentHint - ? `${componentHint} [${componentType}]` - : componentType, + ? `${componentHint} [${mapToUserFacingType(componentType)}]` + : mapToUserFacingType(componentType), }), { validate: (uid: string) => { From 9c02037cad25453b2a299b34bdc1bbfcfef4292c Mon Sep 17 00:00:00 2001 From: Joe Yeager Date: Thu, 10 Apr 2025 14:44:41 -0700 Subject: [PATCH 36/36] Hide zip extraction logs --- lib/app/migrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index 2dfb10cf3..e7766a26a 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -365,7 +365,7 @@ export async function downloadProjectFiles( absoluteDestPath, { includesRootDir: true, - hideLogs: false, + hideLogs: true, } );