diff --git a/commands/project/upload.ts b/commands/project/upload.ts index 343e93d52..0e6432d09 100644 --- a/commands/project/upload.ts +++ b/commands/project/upload.ts @@ -48,7 +48,7 @@ exports.handler = async options => { }); try { - const result = await handleProjectUpload( + const { result, uploadError } = await handleProjectUpload( derivedAccountId, projectConfig, projectDir, @@ -56,9 +56,9 @@ exports.handler = async options => { message ); - if (result.uploadError) { + if (uploadError) { if ( - isSpecifiedError(result.uploadError, { + isSpecifiedError(uploadError, { subCategory: PROJECT_ERROR_TYPES.PROJECT_LOCKED, }) ) { @@ -67,7 +67,7 @@ exports.handler = async options => { logger.log(); } else { logError( - result.uploadError, + uploadError, new ApiErrorContext({ accountId: derivedAccountId, request: 'project upload', diff --git a/commands/project/watch.ts b/commands/project/watch.ts index ceac52615..db969c6fd 100644 --- a/commands/project/watch.ts +++ b/commands/project/watch.ts @@ -113,16 +113,16 @@ exports.handler = async options => { // Upload all files if no build exists for this project yet if (initialUpload || hasNoBuilds) { - const result = await handleProjectUpload( + const { uploadError } = await handleProjectUpload( derivedAccountId, projectConfig, projectDir, startWatching ); - if (result.uploadError) { + if (uploadError) { if ( - isSpecifiedError(result.uploadError, { + isSpecifiedError(uploadError, { subCategory: PROJECT_ERROR_TYPES.PROJECT_LOCKED, }) ) { @@ -131,7 +131,7 @@ exports.handler = async options => { logger.log(); } else { logError( - result.uploadError, + uploadError, new ApiErrorContext({ accountId: derivedAccountId, request: 'project upload', diff --git a/lang/en.lyaml b/lang/en.lyaml index db54c51ef..10925b95c 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -1075,6 +1075,7 @@ en: fileChangeError: "Failed to notify local dev server of file change: {{ message }}" localDev: confirmDefaultAccountIsTarget: + configError: "An error occurred while reading the default account from your config. Run {{ authCommand }} to re-auth this account" declineDefaultAccountExplanation: "To develop on a different account, run {{ useCommand }} to change your default account, then re-run {{ devCommand }}." checkIfDefaultAccountIsSupported: publicApp: "This project contains a public app. Local development of public apps is only supported on developer accounts and developer test accounts. Change your default account using {{ useCommand }}, or link a new account with {{ authCommand }}." @@ -1095,6 +1096,7 @@ en: createInitialBuildForNewProject: initialUploadMessage: "HubSpot Local Dev Server Startup" projectLockedError: "Your project is locked. This may mean that another user is running the {{#bold}}`hs project watch`{{/bold}} command for this project. If this is you, unlock the project in Projects UI." + genericError: "An error occurred while creating the initial build for this project. Run {{ uploadCommand }} to try again." checkIfParentAccountIsAuthed: notAuthedError: "To develop this project locally, run {{ authCommand }} to authenticate the App Developer Account {{ accountId }} associated with {{ accountIdentifier }}." projects: @@ -1421,6 +1423,7 @@ en: invalidUser: "Couldn't create {{#bold}}{{ accountName }}{{/bold}} because your account has been removed from {{#bold}}{{ parentAccountName }}{{/bold}} or your permission set doesn't allow you to create the sandbox. To update your permissions, contact a super admin in {{#bold}}{{ parentAccountName }}{{/bold}}." 403Gating: "Couldn't create {{#bold}}{{ accountName }}{{/bold}} because {{#bold}}{{ parentAccountName }}{{/bold}} does not have access to development sandboxes. To opt in to the CRM Development Beta and use development sandboxes, visit https://app.hubspot.com/l/product-updates/in-beta?update=13899236." usageLimitsFetch: "Unable to fetch sandbox usage limits. Please try again." + generic: "An error occurred while creating a new sandbox. Please try again." limit: developer: one: "{{#bold}}{{ accountName }}{{/bold}} reached the limit of {{ limit }} development sandbox. diff --git a/lib/LocalDevManager.ts b/lib/LocalDevManager.ts index 18af42151..6de661f95 100644 --- a/lib/LocalDevManager.ts +++ b/lib/LocalDevManager.ts @@ -1,40 +1,47 @@ -// @ts-nocheck -const path = require('path'); -const chokidar = require('chokidar'); -const chalk = require('chalk'); -const { i18n } = require('./lang'); -const { handleKeypress } = require('./process'); -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { - fetchAppInstallationData, -} = require('@hubspot/local-dev-lib/api/localDevAuth'); -const { +import path from 'path'; +import chokidar, { FSWatcher } from 'chokidar'; +import chalk from 'chalk'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { fetchAppInstallationData } from '@hubspot/local-dev-lib/api/localDevAuth'; +import { fetchPublicAppsForPortal, fetchPublicAppProductionInstallCounts, -} = require('@hubspot/local-dev-lib/api/appsDev'); -const { +} from '@hubspot/local-dev-lib/api/appsDev'; +import { getAccountId, getConfigDefaultAccount, -} = require('@hubspot/local-dev-lib/config'); -const { PROJECT_CONFIG_FILE } = require('./constants'); -const SpinniesManager = require('./ui/SpinniesManager'); -const DevServerManager = require('./DevServerManager'); -const { EXIT_CODES } = require('./enums/exitCodes'); -const { getProjectDetailUrl } = require('./projects/urls'); -const { getAccountHomeUrl } = require('./localDev'); -const { CONFIG_FILES, getAppCardConfigs } = require('./projects/structure'); -const { ComponentTypes } = require('../types/Projects'); -const { +} from '@hubspot/local-dev-lib/config'; +import { Build } from '@hubspot/local-dev-lib/types/Build'; +import { PublicApp } from '@hubspot/local-dev-lib/types/Apps'; +import { Environment } from '@hubspot/local-dev-lib/types/Config'; + +import { PROJECT_CONFIG_FILE } from './constants'; +import SpinniesManager from './ui/SpinniesManager'; +import DevServerManager from './DevServerManager'; +import { EXIT_CODES } from './enums/exitCodes'; +import { getProjectDetailUrl } from './projects/urls'; +import { getAccountHomeUrl } from './localDev'; +import { + componentIsApp, + componentIsPublicApp, + CONFIG_FILES, + getAppCardConfigs, + getComponentUid, +} from './projects/structure'; +import { Component, ComponentTypes, ProjectConfig } from '../types/Projects'; +import { UI_COLORS, uiCommandReference, uiAccountDescription, uiBetaTag, uiLink, uiLine, -} = require('./ui'); -const { logError } = require('./errorHandlers/index'); -const { installPublicAppPrompt } = require('./prompts/installPublicAppPrompt'); -const { confirmPrompt } = require('./prompts/promptUtils'); +} from './ui'; +import { logError } from './errorHandlers/index'; +import { installPublicAppPrompt } from './prompts/installPublicAppPrompt'; +import { confirmPrompt } from './prompts/promptUtils'; +import { i18n } from './lang'; +import { handleKeypress } from './process'; const WATCH_EVENTS = { add: 'add', @@ -45,11 +52,42 @@ const WATCH_EVENTS = { const i18nKey = 'lib.LocalDevManager'; +type LocalDevManagerConstructorOptions = { + targetAccountId: number; + parentAccountId: number; + projectConfig: ProjectConfig; + projectDir: string; + projectId: number; + debug?: boolean; + deployedBuild: Build; + isGithubLinked: boolean; + runnableComponents: Component[]; + env: Environment; +}; + class LocalDevManager { - constructor(options) { + targetAccountId: number; + targetProjectAccountId: number; + projectConfig: ProjectConfig; + projectDir: string; + projectId: number; + debug: boolean; + deployedBuild: Build; + isGithubLinked: boolean; + watcher: FSWatcher | null; + uploadWarnings: { [key: string]: boolean }; + runnableComponents: Component[]; + activeApp: Component | null; + activePublicAppData: PublicApp | null; + env: Environment; + publicAppActiveInstalls: number | null; + projectSourceDir: string; + mostRecentUploadWarning: string | null; + + constructor(options: LocalDevManagerConstructorOptions) { this.targetAccountId = options.targetAccountId; // The account that the project exists in. This is not always the targetAccountId - this.targetProjectAccountId = options.parentAccountId || options.accountId; + this.targetProjectAccountId = options.parentAccountId; this.projectConfig = options.projectConfig; this.projectDir = options.projectDir; @@ -64,6 +102,7 @@ class LocalDevManager { this.activePublicAppData = null; this.env = options.env; this.publicAppActiveInstalls = null; + this.mostRecentUploadWarning = null; this.projectSourceDir = path.join( this.projectDir, @@ -76,7 +115,7 @@ class LocalDevManager { } } - async setActiveApp(appUid) { + async setActiveApp(appUid?: string): Promise { if (!appUid) { logger.error( i18n(`${i18nKey}.missingUid`, { @@ -85,11 +124,12 @@ class LocalDevManager { ); process.exit(EXIT_CODES.ERROR); } - this.activeApp = this.runnableComponents.find(component => { - return component.config.uid === appUid; - }); + this.activeApp = + this.runnableComponents.find(component => { + return getComponentUid(component) === appUid; + }) || null; - if (this.activeApp.type === ComponentTypes.PublicApp) { + if (componentIsPublicApp(this.activeApp)) { try { await this.setActivePublicAppData(); await this.checkActivePublicAppInstalls(); @@ -100,7 +140,7 @@ class LocalDevManager { } } - async setActivePublicAppData() { + async setActivePublicAppData(): Promise { if (!this.activeApp) { return; } @@ -110,10 +150,13 @@ class LocalDevManager { } = await fetchPublicAppsForPortal(this.targetProjectAccountId); const activePublicAppData = portalPublicApps.find( - ({ sourceId }) => sourceId === this.activeApp.config.uid + ({ sourceId }) => sourceId === getComponentUid(this.activeApp) ); - // TODO: Update to account for new API with { data } + if (!activePublicAppData) { + return; + } + const { data: { uniquePortalInstallCount }, } = await fetchPublicAppProductionInstallCounts( @@ -125,7 +168,7 @@ class LocalDevManager { this.publicAppActiveInstalls = uniquePortalInstallCount; } - async checkActivePublicAppInstalls() { + async checkActivePublicAppInstalls(): Promise { if ( !this.activePublicAppData || !this.publicAppActiveInstalls || @@ -156,7 +199,7 @@ class LocalDevManager { } } - async start() { + async start(): Promise { SpinniesManager.stopAll(); SpinniesManager.init(); @@ -205,11 +248,11 @@ class LocalDevManager { getProjectDetailUrl( this.projectConfig.name, this.targetProjectAccountId - ) + ) || '' ) ); - if (this.activeApp.type === ComponentTypes.PublicApp) { + if (this.activeApp?.type === ComponentTypes.PublicApp) { logger.log( uiLink( i18n(`${i18nKey}.viewTestAccountLink`), @@ -237,7 +280,7 @@ class LocalDevManager { this.compareLocalProjectToDeployed(); } - async stop(showProgress = true) { + async stop(showProgress = true): Promise { if (showProgress) { SpinniesManager.add('cleanupMessage', { text: i18n(`${i18nKey}.exitingStart`), @@ -264,22 +307,20 @@ class LocalDevManager { process.exit(EXIT_CODES.SUCCESS); } - async getActiveAppInstallationData() { - const { data } = await fetchAppInstallationData( + async checkPublicAppInstallation(): Promise { + if (!componentIsPublicApp(this.activeApp) || !this.activePublicAppData) { + return; + } + + const { + data: { isInstalledWithScopeGroups, previouslyAuthorizedScopeGroups }, + } = await fetchAppInstallationData( this.targetAccountId, this.projectId, this.activeApp.config.uid, this.activeApp.config.auth.requiredScopes, this.activeApp.config.auth.optionalScopes ); - - return data; - } - - async checkPublicAppInstallation() { - const { isInstalledWithScopeGroups, previouslyAuthorizedScopeGroups } = - await this.getActiveAppInstallationData(); - const isReinstall = previouslyAuthorizedScopeGroups.length > 0; if (!isInstalledWithScopeGroups) { @@ -294,7 +335,7 @@ class LocalDevManager { } } - updateKeypressListeners() { + updateKeypressListeners(): void { handleKeypress(async key => { if ((key.ctrl && key.name === 'c') || key.name === 'q') { this.stop(); @@ -302,8 +343,8 @@ class LocalDevManager { }); } - getUploadCommand() { - const currentDefaultAccount = getConfigDefaultAccount(); + getUploadCommand(): string { + const currentDefaultAccount = getConfigDefaultAccount() || undefined; return this.targetProjectAccountId !== getAccountId(currentDefaultAccount) ? uiCommandReference( @@ -312,11 +353,15 @@ class LocalDevManager { : uiCommandReference('hs project upload'); } - logUploadWarning(reason) { - let warning = reason; - if (!reason) { + logUploadWarning(reason?: string): void { + let warning: string; + + if (reason) { + warning = reason; + } else { warning = - this.activeApp.type === ComponentTypes.PublicApp && + componentIsPublicApp(this.activeApp) && + this.publicAppActiveInstalls && this.publicAppActiveInstalls > 0 ? i18n(`${i18nKey}.uploadWarning.defaultPublicAppWarning`, { installCount: this.publicAppActiveInstalls, @@ -356,10 +401,29 @@ class LocalDevManager { } } - monitorConsoleOutput() { + monitorConsoleOutput(): void { const originalStdoutWrite = process.stdout.write.bind(process.stdout); - process.stdout.write = function (chunk, encoding, callback) { + type StdoutCallback = (err?: Error) => void; + + // Need to provide both overloads for process.stdout.write to satisfy TS + function customStdoutWrite( + this: LocalDevManager, + buffer: Uint8Array | string, + cb?: StdoutCallback + ): boolean; + function customStdoutWrite( + this: LocalDevManager, + str: Uint8Array | string, + encoding?: BufferEncoding, + cb?: StdoutCallback + ): boolean; + function customStdoutWrite( + this: LocalDevManager, + chunk: Uint8Array | string, + encoding?: BufferEncoding | StdoutCallback, + callback?: StdoutCallback + ) { // Reset the most recently logged warning if ( this.mostRecentUploadWarning && @@ -368,42 +432,51 @@ class LocalDevManager { delete this.uploadWarnings[this.mostRecentUploadWarning]; } + if (typeof encoding === 'function') { + return originalStdoutWrite(chunk, callback); + } return originalStdoutWrite(chunk, encoding, callback); - }.bind(this); + } + + customStdoutWrite.bind(this); + + process.stdout.write = customStdoutWrite; } - compareLocalProjectToDeployed() { + compareLocalProjectToDeployed(): void { const deployedComponentNames = this.deployedBuild.subbuildStatuses.map( subbuildStatus => subbuildStatus.buildName ); - const missingComponents = []; - - this.runnableComponents.forEach(({ type, config, path }) => { - if (Object.values(ComponentTypes).includes(type)) { - const cardConfigs = getAppCardConfigs(config, path); + const missingComponents: string[] = []; - if (!deployedComponentNames.includes(config.name)) { - missingComponents.push( - `${i18n(`${i18nKey}.uploadWarning.appLabel`)} ${config.name}` - ); - } + this.runnableComponents + .filter(componentIsApp) + .forEach(({ type, config, path }) => { + if (Object.values(ComponentTypes).includes(type)) { + const cardConfigs = getAppCardConfigs(config, path); - cardConfigs.forEach(cardConfig => { - if ( - cardConfig.data && - cardConfig.data.title && - !deployedComponentNames.includes(cardConfig.data.title) - ) { + if (!deployedComponentNames.includes(config.name)) { missingComponents.push( - `${i18n(`${i18nKey}.uploadWarning.uiExtensionLabel`)} ${ - cardConfig.data.title - }` + `${i18n(`${i18nKey}.uploadWarning.appLabel`)} ${config.name}` ); } - }); - } - }); + + cardConfigs.forEach(cardConfig => { + if ( + cardConfig.data && + cardConfig.data.title && + !deployedComponentNames.includes(cardConfig.data.title) + ) { + missingComponents.push( + `${i18n(`${i18nKey}.uploadWarning.uiExtensionLabel`)} ${ + cardConfig.data.title + }` + ); + } + }); + } + }); if (missingComponents.length) { this.logUploadWarning( @@ -414,7 +487,7 @@ class LocalDevManager { } } - startWatching() { + startWatching(): void { this.watcher = chokidar.watch(this.projectDir, { ignoreInitial: true, }); @@ -446,11 +519,15 @@ class LocalDevManager { }); } - async stopWatching() { - await this.watcher.close(); + async stopWatching(): Promise { + await this.watcher?.close(); } - handleWatchEvent(filePath, event, configPaths) { + handleWatchEvent( + filePath: string, + event: string, + configPaths: string[] + ): void { if (configPaths.includes(filePath)) { this.logUploadWarning(); } else { @@ -458,7 +535,7 @@ class LocalDevManager { } } - async devServerSetup() { + async devServerSetup(): Promise { try { await DevServerManager.setup({ components: this.runnableComponents, @@ -472,13 +549,15 @@ class LocalDevManager { logger.error(e); } logger.error( - i18n(`${i18nKey}.devServer.setupError`, { message: e.message }) + i18n(`${i18nKey}.devServer.setupError`, { + message: e instanceof Error ? e.message : '', + }) ); return false; } } - async devServerStart() { + async devServerStart(): Promise { try { await DevServerManager.start({ accountId: this.targetAccountId, @@ -489,13 +568,15 @@ class LocalDevManager { logger.error(e); } logger.error( - i18n(`${i18nKey}.devServer.startError`, { message: e.message }) + i18n(`${i18nKey}.devServer.startError`, { + message: e instanceof Error ? e.message : '', + }) ); process.exit(EXIT_CODES.ERROR); } } - devServerFileChange(filePath, event) { + devServerFileChange(filePath: string, event: string): void { try { DevServerManager.fileChange({ filePath, event }); } catch (e) { @@ -504,13 +585,13 @@ class LocalDevManager { } logger.error( i18n(`${i18nKey}.devServer.fileChangeError`, { - message: e.message, + message: e instanceof Error ? e.message : '', }) ); } } - async devServerCleanup() { + async devServerCleanup(): Promise { try { await DevServerManager.cleanup(); return true; @@ -519,11 +600,14 @@ class LocalDevManager { logger.error(e); } logger.error( - i18n(`${i18nKey}.devServer.cleanupError`, { message: e.message }) + i18n(`${i18nKey}.devServer.cleanupError`, { + message: e instanceof Error ? e.message : '', + }) ); return false; } } } +export default LocalDevManager; module.exports = LocalDevManager; diff --git a/lib/localDev.ts b/lib/localDev.ts index 886e185ca..76fe50088 100644 --- a/lib/localDev.ts +++ b/lib/localDev.ts @@ -1,66 +1,77 @@ -// @ts-nocheck -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { +import { logger } from '@hubspot/local-dev-lib/logger'; +import { HUBSPOT_ACCOUNT_TYPES, HUBSPOT_ACCOUNT_TYPE_STRINGS, -} = require('@hubspot/local-dev-lib/constants/config'); -const { +} from '@hubspot/local-dev-lib/constants/config'; +import { isMissingScopeError, isSpecifiedError, -} = require('@hubspot/local-dev-lib/errors/index'); -const { getHubSpotWebsiteOrigin } = require('@hubspot/local-dev-lib/urls'); -const { getAccountConfig, getEnv } = require('@hubspot/local-dev-lib/config'); -const { createProject } = require('@hubspot/local-dev-lib/api/projects'); -const { - ENVIRONMENTS, -} = require('@hubspot/local-dev-lib/constants/environments'); -const { +} from '@hubspot/local-dev-lib/errors/index'; +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { getAccountConfig, getEnv } from '@hubspot/local-dev-lib/config'; +import { createProject } from '@hubspot/local-dev-lib/api/projects'; +import { ENVIRONMENTS } from '@hubspot/local-dev-lib/constants/environments'; +import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '@hubspot/local-dev-lib/constants/auth'; +import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountIdentifier'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { Environment } from '@hubspot/local-dev-lib/types/Config'; +import { DeveloperTestAccount } from '@hubspot/local-dev-lib/types/developerTestAccounts'; +import { Project } from '@hubspot/local-dev-lib/types/Project'; + +import { confirmDefaultAccountPrompt, selectSandboxTargetAccountPrompt, selectDeveloperTestTargetAccountPrompt, confirmUseExistingDeveloperTestAccountPrompt, -} = require('./prompts/projectDevTargetAccountPrompt'); -const { confirmPrompt } = require('./prompts/promptUtils'); -const { - validateSandboxUsageLimits, - getAvailableSyncTypes, -} = require('./sandboxes'); -const { syncSandbox } = require('./sandboxSync'); -const { - validateDevTestAccountUsageLimits, -} = require('./developerTestAccounts'); -const { uiCommandReference, uiLine, uiAccountDescription } = require('./ui'); -const SpinniesManager = require('./ui/SpinniesManager'); -const { i18n } = require('./lang'); -const { EXIT_CODES } = require('./enums/exitCodes'); -const { trackCommandMetadataUsage } = require('./usageTracking'); -const { - isAppDeveloperAccount, - isDeveloperTestAccount, -} = require('./accountTypes'); -const { handleProjectUpload } = require('./projects/upload'); -const { pollProjectBuildAndDeploy } = require('./projects/buildAndDeploy'); -const { +} from './prompts/projectDevTargetAccountPrompt'; +import { confirmPrompt } from './prompts/promptUtils'; +import { validateSandboxUsageLimits, getAvailableSyncTypes } from './sandboxes'; +import { syncSandbox } from './sandboxSync'; +import { validateDevTestAccountUsageLimits } from './developerTestAccounts'; +import { uiCommandReference, uiLine, uiAccountDescription } from './ui'; +import SpinniesManager from './ui/SpinniesManager'; +import { i18n } from './lang'; +import { EXIT_CODES } from './enums/exitCodes'; +import { trackCommandMetadataUsage } from './usageTracking'; +import { isAppDeveloperAccount, isDeveloperTestAccount } from './accountTypes'; +import { handleProjectUpload } from './projects/upload'; +import { pollProjectBuildAndDeploy } from './projects/buildAndDeploy'; +import { PROJECT_ERROR_TYPES, PROJECT_BUILD_TEXT, PROJECT_DEPLOY_TEXT, -} = require('./constants'); -const { logError, ApiErrorContext } = require('./errorHandlers/index'); -const { - PERSONAL_ACCESS_KEY_AUTH_METHOD, -} = require('@hubspot/local-dev-lib/constants/auth'); -const { +} from './constants'; +import { logError, ApiErrorContext } from './errorHandlers/index'; +import { buildSandbox, buildDeveloperTestAccount, saveAccountToConfig, -} = require('./buildAccount'); -const { hubspotAccountNamePrompt } = require('./prompts/accountNamePrompt'); +} from './buildAccount'; +import { hubspotAccountNamePrompt } from './prompts/accountNamePrompt'; +import { + ProjectConfig, + ProjectPollResult, + ProjectSubtask, +} from '../types/Projects'; +import { FileResult } from 'tmp'; +import { Build } from '@hubspot/local-dev-lib/types/Build'; const i18nKey = 'lib.localDev'; // If the user passed in the --account flag, confirm they want to use that account as // their target account, otherwise exit -const confirmDefaultAccountIsTarget = async accountConfig => { +export async function confirmDefaultAccountIsTarget( + accountConfig: CLIAccount +): Promise { + if (!accountConfig.name || !accountConfig.accountType) { + logger.error( + i18n(`${i18nKey}.confirmDefaultAccountIsTarget.configError`, { + authCommand: uiCommandReference('hs auth'), + }) + ); + process.exit(EXIT_CODES.ERROR); + } + logger.log(); const useDefaultAccount = await confirmDefaultAccountPrompt( accountConfig.name, @@ -79,10 +90,13 @@ const confirmDefaultAccountIsTarget = async accountConfig => { ); process.exit(EXIT_CODES.SUCCESS); } -}; +} // Confirm the default account is supported for the type of apps being developed -const checkIfDefaultAccountIsSupported = (accountConfig, hasPublicApps) => { +export function checkIfDefaultAccountIsSupported( + accountConfig: CLIAccount, + hasPublicApps: boolean +): void { if ( hasPublicApps && !( @@ -106,14 +120,19 @@ const checkIfDefaultAccountIsSupported = (accountConfig, hasPublicApps) => { ); process.exit(EXIT_CODES.SUCCESS); } -}; +} -const checkIfParentAccountIsAuthed = accountConfig => { - if (!getAccountConfig(accountConfig.parentAccountId)) { +export function checkIfParentAccountIsAuthed(accountConfig: CLIAccount): void { + if ( + !accountConfig.parentAccountId || + !getAccountConfig(accountConfig.parentAccountId) + ) { logger.error( i18n(`${i18nKey}.checkIfParentAccountIsAuthed.notAuthedError`, { - accountId: accountConfig.parentAccountId, - accountIdentifier: uiAccountDescription(accountConfig.portalId), + accountId: accountConfig.parentAccountId || '', + accountIdentifier: uiAccountDescription( + getAccountIdentifier(accountConfig) + ), authCommand: uiCommandReference( `hs auth --account=${accountConfig.parentAccountId}` ), @@ -121,10 +140,13 @@ const checkIfParentAccountIsAuthed = accountConfig => { ); process.exit(EXIT_CODES.SUCCESS); } -}; +} // Confirm the default account is a developer account if developing public apps -const checkIfAccountFlagIsSupported = (accountConfig, hasPublicApps) => { +export function checkIfAccountFlagIsSupported( + accountConfig: CLIAccount, + hasPublicApps: boolean +): void { if (hasPublicApps) { if (!isDeveloperTestAccount(accountConfig)) { logger.error( @@ -144,14 +166,14 @@ const checkIfAccountFlagIsSupported = (accountConfig, hasPublicApps) => { ); process.exit(EXIT_CODES.SUCCESS); } -}; +} // If the user isn't using the recommended account type, prompt them to use or create one -const suggestRecommendedNestedAccount = async ( - accounts, - accountConfig, - hasPublicApps -) => { +export async function suggestRecommendedNestedAccount( + accounts: CLIAccount[], + accountConfig: CLIAccount, + hasPublicApps: boolean +) { logger.log(); uiLine(); if (hasPublicApps) { @@ -170,11 +192,15 @@ const suggestRecommendedNestedAccount = async ( ? selectDeveloperTestTargetAccountPrompt : selectSandboxTargetAccountPrompt; - return targetAccountPrompt(accounts, accountConfig, hasPublicApps); -}; + return targetAccountPrompt(accounts, accountConfig); +} // Create a new sandbox and return its accountId -const createSandboxForLocalDev = async (accountId, accountConfig, env) => { +export async function createSandboxForLocalDev( + accountId: number, + accountConfig: CLIAccount, + env: Environment +): Promise { try { await validateSandboxUsageLimits( accountConfig, @@ -222,6 +248,12 @@ const createSandboxForLocalDev = async (accountId, accountConfig, env) => { const targetAccountId = result.sandbox.sandboxHubId; const sandboxAccountConfig = getAccountConfig(result.sandbox.sandboxHubId); + + if (!sandboxAccountConfig) { + logger.error(i18n('lib.sandbox.create.failure.generic')); + process.exit(EXIT_CODES.ERROR); + } + const syncTasks = await getAvailableSyncTypes( accountConfig, sandboxAccountConfig @@ -239,14 +271,14 @@ const createSandboxForLocalDev = async (accountId, accountConfig, env) => { logError(err); process.exit(EXIT_CODES.ERROR); } -}; +} // Create a developer test account and return its accountId -const createDeveloperTestAccountForLocalDev = async ( - accountId, - accountConfig, - env -) => { +export async function createDeveloperTestAccountForLocalDev( + accountId: number, + accountConfig: CLIAccount, + env: Environment +): Promise { let currentPortalCount = 0; let maxTestPortals = 10; try { @@ -302,10 +334,13 @@ const createDeveloperTestAccountForLocalDev = async ( logError(err); process.exit(EXIT_CODES.ERROR); } -}; +} // Prompt user to confirm usage of an existing developer test account that is not currently in the config -const useExistingDevTestAccount = async (env, account) => { +export async function useExistingDevTestAccount( + env: Environment, + account: DeveloperTestAccount +): Promise { const useExistingDevTestAcct = await confirmUseExistingDeveloperTestAccountPrompt(account); if (!useExistingDevTestAcct) { @@ -333,15 +368,15 @@ const useExistingDevTestAccount = async (env, account) => { authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.name, }) ); -}; +} // Prompt the user to create a new project if one doesn't exist on their target account -const createNewProjectForLocalDev = async ( - projectConfig, - targetAccountId, - shouldCreateWithoutConfirmation, - hasPublicApps -) => { +export async function createNewProjectForLocalDev( + projectConfig: ProjectConfig, + targetAccountId: number, + shouldCreateWithoutConfirmation: boolean, + hasPublicApps: boolean +): Promise { // Create the project without prompting if this is a newly created sandbox let shouldCreateProject = shouldCreateWithoutConfirmation; @@ -404,26 +439,51 @@ const createNewProjectForLocalDev = async ( ); process.exit(EXIT_CODES.SUCCESS); } -}; +} + +function projectUploadCallback( + accountId: number, + projectConfig: ProjectConfig, + tempFile: FileResult, + buildId?: number +): Promise { + if (!buildId) { + logger.error( + i18n(`${i18nKey}.createInitialBuildForNewProject.initialUploadMessage`, { + uploadCommand: uiCommandReference('hs project upload'), + }) + ); + process.exit(EXIT_CODES.ERROR); + } -// Create an initial build if the project was newly created in the account -// Return the newly deployed build -const createInitialBuildForNewProject = async ( - projectConfig, - projectDir, - targetAccountId -) => { - const initialUploadResult = await handleProjectUpload( - targetAccountId, + return pollProjectBuildAndDeploy( + accountId, projectConfig, - projectDir, - (...args) => pollProjectBuildAndDeploy(...args, true), - i18n(`${i18nKey}.createInitialBuildForNewProject.initialUploadMessage`) + tempFile, + buildId, + true ); +} + +// Create an initial build if the project was newly created in the account +// Return the newly deployed build +export async function createInitialBuildForNewProject( + projectConfig: ProjectConfig, + projectDir: string, + targetAccountId: number +): Promise { + const { result: initialUploadResult, uploadError } = + await handleProjectUpload( + targetAccountId, + projectConfig, + projectDir, + projectUploadCallback, + i18n(`${i18nKey}.createInitialBuildForNewProject.initialUploadMessage`) + ); - if (initialUploadResult.uploadError) { + if (uploadError) { if ( - isSpecifiedError(initialUploadResult.uploadError, { + isSpecifiedError(uploadError, { subCategory: PROJECT_ERROR_TYPES.PROJECT_LOCKED, }) ) { @@ -434,7 +494,7 @@ const createInitialBuildForNewProject = async ( logger.log(); } else { logError( - initialUploadResult.uploadError, + uploadError, new ApiErrorContext({ accountId: targetAccountId, projectName: projectConfig.name, @@ -444,13 +504,13 @@ const createInitialBuildForNewProject = async ( process.exit(EXIT_CODES.ERROR); } - if (!initialUploadResult.succeeded) { - let subTasks = []; + if (!initialUploadResult?.succeeded) { + let subTasks: ProjectSubtask[] = []; - if (initialUploadResult.buildResult.status === 'FAILURE') { + if (initialUploadResult?.buildResult.status === 'FAILURE') { subTasks = initialUploadResult.buildResult[PROJECT_BUILD_TEXT.SUBTASK_KEY]; - } else if (initialUploadResult.deployResult.status === 'FAILURE') { + } else if (initialUploadResult?.deployResult?.status === 'FAILURE') { subTasks = initialUploadResult.deployResult[PROJECT_DEPLOY_TEXT.SUBTASK_KEY]; } @@ -467,25 +527,11 @@ const createInitialBuildForNewProject = async ( } return initialUploadResult.buildResult; -}; +} -const getAccountHomeUrl = accountId => { +export function getAccountHomeUrl(accountId: number): string { const baseUrl = getHubSpotWebsiteOrigin( getEnv(accountId) === 'qa' ? ENVIRONMENTS.QA : ENVIRONMENTS.PROD ); return `${baseUrl}/home?portalId=${accountId}`; -}; - -module.exports = { - confirmDefaultAccountIsTarget, - checkIfDefaultAccountIsSupported, - checkIfAccountFlagIsSupported, - suggestRecommendedNestedAccount, - createSandboxForLocalDev, - createDeveloperTestAccountForLocalDev, - useExistingDevTestAccount, - createNewProjectForLocalDev, - createInitialBuildForNewProject, - getAccountHomeUrl, - checkIfParentAccountIsAuthed, -}; +} diff --git a/lib/projects/buildAndDeploy.ts b/lib/projects/buildAndDeploy.ts index 1a1960f7f..83f2c5d63 100644 --- a/lib/projects/buildAndDeploy.ts +++ b/lib/projects/buildAndDeploy.ts @@ -36,6 +36,7 @@ import { ProjectTask, ProjectSubtask, ProjectPollStatusFunctionText, + ProjectPollResult, } from '../../types/Projects'; const i18nKey = 'lib.projectBuildAndDeploy'; @@ -453,13 +454,6 @@ export const pollDeployStatus = makePollTaskStatusFunc({ }, }); -type ProjectPollResult = { - succeeded: boolean; - buildId: number; - buildResult: Build; - deployResult: Deploy | null; -}; - export async function displayWarnLogs( accountId: number, projectName: string, diff --git a/lib/projects/structure.ts b/lib/projects/structure.ts index d6ad0a903..1f4b2f55c 100644 --- a/lib/projects/structure.ts +++ b/lib/projects/structure.ts @@ -149,3 +149,30 @@ export function getProjectComponentTypes(components: Array): { components.forEach(({ type }) => (projectContents[type] = true)); return projectContents; } + +export function getComponentUid(component?: Component | null): string | null { + if (!component) { + return null; + } else if ('uid' in component.config) { + return component.config.uid; + } else { + return component.config.data.uid; + } +} + +export function componentIsApp( + component?: Component | null +): component is Component< + PublicAppComponentConfig | PrivateAppComponentConfig +> { + return ( + component?.type === ComponentTypes.PublicApp || + component?.type === ComponentTypes.PrivateApp + ); +} + +export function componentIsPublicApp( + component?: Component | null +): component is Component { + return component?.type === ComponentTypes.PublicApp; +} diff --git a/lib/projects/upload.ts b/lib/projects/upload.ts index 98068259d..b3ff5ff1e 100644 --- a/lib/projects/upload.ts +++ b/lib/projects/upload.ts @@ -80,19 +80,20 @@ type ProjectUploadCallbackFunction = ( projectConfig: ProjectConfig, tempFile: FileResult, buildId?: number -) => Promise; +) => Promise; -type ProjectUploadDefaultResult = { +type ProjectUploadResult = { + result?: T; uploadError?: unknown; }; -export async function handleProjectUpload( +export async function handleProjectUpload( accountId: number, projectConfig: ProjectConfig, projectDir: string, callbackFunc: ProjectUploadCallbackFunction, uploadMessage: string -) { +): Promise> { const srcDir = path.resolve(projectDir, projectConfig.srcDir); const filenames = fs.readdirSync(srcDir); @@ -116,10 +117,8 @@ export async function handleProjectUpload( const output = fs.createWriteStream(tempFile.name); const archive = archiver('zip'); - const result = new Promise(resolve => + const result = new Promise>(resolve => output.on('close', async function () { - let uploadResult: ProjectUploadDefaultResult | T | undefined; - logger.debug( i18n(`${i18nKey}.handleProjectUpload.compressed`, { byteCount: archive.pointer(), @@ -136,16 +135,16 @@ export async function handleProjectUpload( if (error) { console.log(error); - uploadResult = { uploadError: error }; + resolve({ uploadError: error }); } else if (callbackFunc) { - uploadResult = await callbackFunc( + const uploadResult = await callbackFunc( accountId, projectConfig, tempFile, buildId ); + resolve({ result: uploadResult }); } - resolve(uploadResult || {}); }) ); diff --git a/lib/prompts/installPublicAppPrompt.ts b/lib/prompts/installPublicAppPrompt.ts index 56f6759af..33e5a527b 100644 --- a/lib/prompts/installPublicAppPrompt.ts +++ b/lib/prompts/installPublicAppPrompt.ts @@ -10,7 +10,7 @@ const i18nKey = 'lib.prompts.installPublicAppPrompt'; export async function installPublicAppPrompt( env: string, targetAccountId: number, - clientId: number, + clientId: string, scopes: string[], redirectUrls: string[], isReinstall = false diff --git a/lib/prompts/projectDevTargetAccountPrompt.ts b/lib/prompts/projectDevTargetAccountPrompt.ts index 8be678aa2..b89774159 100644 --- a/lib/prompts/projectDevTargetAccountPrompt.ts +++ b/lib/prompts/projectDevTargetAccountPrompt.ts @@ -11,7 +11,7 @@ import { import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountIdentifier'; import { logger } from '@hubspot/local-dev-lib/logger'; import { fetchDeveloperTestAccounts } from '@hubspot/local-dev-lib/api/developerTestAccounts'; -import { CLIAccount, AccountType } from '@hubspot/local-dev-lib/types/Accounts'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; import { Usage } from '@hubspot/local-dev-lib/types/Sandbox'; import { DeveloperTestAccount, @@ -214,7 +214,7 @@ async function selectTargetAccountPrompt( export async function confirmDefaultAccountPrompt( accountName: string, - accountType: AccountType + accountType: string ): Promise { const { useDefaultAccount } = await promptUser<{ useDefaultAccount: boolean; diff --git a/types/Projects.ts b/types/Projects.ts index 0d7b9ac25..3a3606efb 100644 --- a/types/Projects.ts +++ b/types/Projects.ts @@ -50,6 +50,13 @@ export type ProjectTemplateRepoConfig = { components?: ComponentTemplate[]; }; +export type ProjectPollResult = { + succeeded: boolean; + buildId: number; + buildResult: Build; + deployResult: Deploy | null; +}; + export type PrivateAppComponentConfig = { name: string; description: string; @@ -114,9 +121,9 @@ export enum ComponentTypes { HublTheme = 'hubl-theme', } -export type Component = { +export type Component = { type: ComponentTypes; - config: GenericComponentConfig; + config: T; runnable: boolean; path: string; };