diff --git a/commands/project/dev/unifiedFlow.ts b/commands/project/dev/unifiedFlow.ts index 381156379..276c75062 100644 --- a/commands/project/dev/unifiedFlow.ts +++ b/commands/project/dev/unifiedFlow.ts @@ -20,8 +20,9 @@ import { } from '../../../lib/projects/localDev/helpers'; import { selectDeveloperTestTargetAccountPrompt } from '../../../lib/prompts/projectDevTargetAccountPrompt'; import SpinniesManager from '../../../lib/ui/SpinniesManager'; -import LocalDevManagerV2 from '../../../lib/projects/localDev/LocalDevManagerV2'; -import { handleExit } from '../../../lib/process'; +import LocalDevProcess from '../../../lib/projects/localDev/LocalDevProcess'; +import LocalDevWatcher from '../../../lib/projects/localDev/LocalDevWatcher'; +import { handleExit, handleKeypress } from '../../../lib/process'; import { isAppDeveloperAccount, isStandardAccount, @@ -154,8 +155,9 @@ export async function unifiedProjectDevFlow( ); } - const LocalDev = new LocalDevManagerV2({ - projectNodes, + // End setup, start local dev process + const localDevProcess = new LocalDevProcess({ + initialProjectNodes: projectNodes, debug: args.debug, deployedBuild, isGithubLinked, @@ -167,7 +169,16 @@ export async function unifiedProjectDevFlow( env, }); - await LocalDev.start(); + await localDevProcess.start(); - handleExit(({ isSIGHUP }) => LocalDev.stop(!isSIGHUP)); + const watcher = new LocalDevWatcher(localDevProcess); + watcher.start(); + + handleKeypress(async key => { + if ((key.ctrl && key.name === 'c') || key.name === 'q') { + await Promise.all([localDevProcess.stop(), watcher.stop()]); + } + }); + + handleExit(({ isSIGHUP }) => localDevProcess.stop(!isSIGHUP)); } diff --git a/lang/en.ts b/lang/en.ts index acc45fda5..eac0dbcf9 100644 --- a/lang/en.ts +++ b/lang/en.ts @@ -5,7 +5,11 @@ import { uiCommandReference, uiLink, } from '../lib/ui'; -import { getProjectSettingsUrl } from '../lib/projects/urls'; +import { + getProjectDetailUrl, + getProjectSettingsUrl, +} from '../lib/projects/urls'; +import { UI_COLORS } from '../lib/ui'; type LangFunction = (...args: never[]) => string; @@ -2689,11 +2693,20 @@ export const lib = { `Your project ${chalk.bold(projectName)} exists in ${accountIdentifier}, but has no deployed build. Projects must be successfully deployed to be developed locally. Address any build and deploy errors your project may have, then run ${uploadCommand} to upload and deploy your project.`, noComponents: 'There are no components in this project.', betaMessage: 'HubSpot projects local development', - learnMoreLocalDevServer: 'Learn more about the projects local dev server', + learnMoreLocalDevServer: uiLink( + 'Learn more about the projects local dev server', + 'https://developers.hubspot.com/docs/platform/project-cli-commands#start-a-local-development-server' + ), running: (projectName: string, accountIdentifier: string) => - `Running ${chalk.bold(projectName)} locally on ${accountIdentifier}, waiting for changes ...`, + chalk.hex(UI_COLORS.SORBET)( + `Running ${chalk.bold(projectName)} locally on ${accountIdentifier}, waiting for changes ...` + ), quitHelper: `Press ${chalk.bold('q')} to stop the local dev server`, - viewProjectLink: 'View project in HubSpot', + viewProjectLink: (name: string, accountId: number) => + uiLink( + 'View project in HubSpot', + getProjectDetailUrl(name, accountId) || '' + ), viewTestAccountLink: 'View developer test account in HubSpot', exitingStart: 'Stopping local dev server ...', exitingSucceed: 'Successfully exited', @@ -2711,6 +2724,7 @@ export const lib = { `${chalk.bold('Changing project configuration requires a new project build.')}\n\nThis will affect your public app's ${chalk.bold(`${installCount} existing ${installText}`)}. If your app has users in production, we strongly recommend creating a copy of this app to test your changes before proceding.`, header: (warning: string) => `${warning} To reflect these changes and continue testing:`, + instructionsHeader: 'To reflect these changes and continue testing:', stopDev: ` * Stop ${uiCommandReference('hs project dev')}`, runUpload: (command: string) => ` * Run ${command}`, restartDev: ` * Re-run ${uiCommandReference('hs project dev')}`, @@ -2722,12 +2736,8 @@ export const lib = { `${chalk.bold('Changing project configuration requires creating a new project build.')}\n\nYour marketplace app is currently installed in ${chalk.bold(`${installCount} ${accountText}`)}. Any uploaded changes will impact your app's users. We strongly recommend creating a copy of this app to test your changes before proceding.`, }, activeInstallWarning: { - installCount: ( - appName: string, - installCount: number, - installText: string - ) => - `${chalk.bold(`The app ${appName} has ${installCount} production ${installText}`)}`, + installCount: (appName: string, installCount: number) => + `${chalk.bold(`The app ${appName} is installed in ${installCount} production ${installCount === 1 ? 'account' : 'accounts'}`)}`, explanation: 'Some changes made during local development may need to be synced to HubSpot, which will impact those existing installs. We strongly recommend creating a copy of this app to use instead.', confirmation: `You will always be asked to confirm any permanent changes to your app's configuration before uploading them.`, @@ -2744,6 +2754,10 @@ export const lib = { `Failed to notify local dev server of file change: ${message}`, }, }, + AppDevModeInterface: { + defaultMarketplaceAppWarning: (installCount: number) => + `\n\nYour marketplace app is currently installed in ${chalk.bold(`${installCount} ${installCount === 1 ? 'account' : 'accounts'}`)}. Any uploaded changes will impact your app's users. We strongly recommend creating a copy of this app to test your changes before proceding.`, + }, localDevHelpers: { confirmDefaultAccountIsTarget: { configError: `An error occurred while reading the default account from your config. Run ${uiCommandReference('hs auth')} to re-auth this account`, diff --git a/lib/projects/localDev/AppDevModeInterface.ts b/lib/projects/localDev/AppDevModeInterface.ts new file mode 100644 index 000000000..02959d408 --- /dev/null +++ b/lib/projects/localDev/AppDevModeInterface.ts @@ -0,0 +1,191 @@ +import { fetchAppInstallationData } from '@hubspot/local-dev-lib/api/localDevAuth'; +import { + fetchPublicAppsForPortal, + fetchPublicAppProductionInstallCounts, +} from '@hubspot/local-dev-lib/api/appsDev'; +import { PublicApp } from '@hubspot/local-dev-lib/types/Apps'; +import { DevModeUnifiedInterface as UIEDevModeInterface } from '@hubspot/ui-extensions-dev-server'; +import { requestPorts } from '@hubspot/local-dev-lib/portManager'; + +import { APP_DISTRIBUTION_TYPES } from '../../constants'; +import { EXIT_CODES } from '../../enums/exitCodes'; +import { isAppIRNode } from '../../projects/structure'; +import { uiLine } from '../../ui'; +import { logError } from '../../errorHandlers/index'; +import { installPublicAppPrompt } from '../../prompts/installPublicAppPrompt'; +import { confirmPrompt } from '../../prompts/promptUtils'; +import { AppIRNode } from '../../../types/ProjectComponents'; +import { lib } from '../../../lang/en'; +import { uiLogger } from '../../ui/logger'; +import { LocalDevState } from '../../../types/LocalDev'; +import LocalDevLogger from './LocalDevLogger'; + +type AppDevModeInterfaceConstructorOptions = { + localDevState: LocalDevState; + localDevLogger: LocalDevLogger; +}; + +class AppDevModeInterface { + localDevState: LocalDevState; + localDevLogger: LocalDevLogger; + _app?: AppIRNode | null; + marketplaceAppData?: PublicApp; + marketplaceAppInstalls?: number; + + constructor(options: AppDevModeInterfaceConstructorOptions) { + this.localDevState = options.localDevState; + this.localDevLogger = options.localDevLogger; + + if ( + !this.localDevState.targetProjectAccountId || + !this.localDevState.projectConfig || + !this.localDevState.projectDir + ) { + uiLogger.log(lib.LocalDevManager.failedToInitialize); + process.exit(EXIT_CODES.ERROR); + } + } + + // Assumes only one app per project + private get app(): AppIRNode | null { + if (this._app === undefined) { + this._app = + Object.values(this.localDevState.projectNodes).find(isAppIRNode) || + null; + } + return this._app; + } + + private async fetchMarketplaceAppData(): Promise { + const { + data: { results: portalMarketplaceApps }, + } = await fetchPublicAppsForPortal( + this.localDevState.targetProjectAccountId + ); + + const marketplaceAppData = portalMarketplaceApps.find( + ({ sourceId }) => sourceId === this.app?.uid + ); + + if (!marketplaceAppData) { + return; + } + + const { + data: { uniquePortalInstallCount }, + } = await fetchPublicAppProductionInstallCounts( + marketplaceAppData.id, + this.localDevState.targetProjectAccountId + ); + + this.marketplaceAppData = marketplaceAppData; + this.marketplaceAppInstalls = uniquePortalInstallCount; + } + + private async checkMarketplaceAppInstalls(): Promise { + if (!this.marketplaceAppData || !this.marketplaceAppInstalls) { + return; + } + uiLine(); + + uiLogger.warn( + lib.LocalDevManager.activeInstallWarning.installCount( + this.marketplaceAppData.name, + this.marketplaceAppInstalls + ) + ); + uiLogger.log(lib.LocalDevManager.activeInstallWarning.explanation); + uiLine(); + + const proceed = await confirmPrompt( + lib.LocalDevManager.activeInstallWarning.confirmationPrompt, + { defaultAnswer: false } + ); + + if (!proceed) { + process.exit(EXIT_CODES.SUCCESS); + } + + this.localDevLogger.addUploadWarning( + lib.AppDevModeInterface.defaultMarketplaceAppWarning( + this.marketplaceAppInstalls + ) + ); + } + + private async checkMarketplaceAppInstallation(): Promise { + if (!this.app || !this.marketplaceAppData) { + return; + } + + const { + data: { isInstalledWithScopeGroups, previouslyAuthorizedScopeGroups }, + } = await fetchAppInstallationData( + this.localDevState.targetTestingAccountId, + this.localDevState.projectId, + this.app.uid, + this.app.config.auth.requiredScopes, + this.app.config.auth.optionalScopes + ); + const isReinstall = previouslyAuthorizedScopeGroups.length > 0; + + if (!isInstalledWithScopeGroups) { + await installPublicAppPrompt( + this.localDevState.env, + this.localDevState.targetTestingAccountId, + this.marketplaceAppData.clientId, + this.app.config.auth.requiredScopes, + this.app.config.auth.redirectUrls, + isReinstall + ); + } + } + + // @ts-expect-error TODO: reconcile types between CLI and UIE Dev Server + // In the future, update UIE Dev Server to use LocalDevState + async setup(args): Promise { + if (!this.app) { + return; + } + + if (this.app?.config.distribution === APP_DISTRIBUTION_TYPES.MARKETPLACE) { + try { + await this.fetchMarketplaceAppData(); + await this.checkMarketplaceAppInstalls(); + await this.checkMarketplaceAppInstallation(); + } catch (e) { + logError(e); + } + } + return UIEDevModeInterface.setup(args); + } + + async start() { + if (!this.app) { + return; + } + + return UIEDevModeInterface.start({ + accountId: this.localDevState.targetTestingAccountId, + // @ts-expect-error TODO: reconcile types between CLI and UIE Dev Server + projectConfig: this.localDevState.projectConfig, + requestPorts, + }); + } + async fileChange(filePath: string, event: string) { + if (!this.app) { + return; + } + + return UIEDevModeInterface.fileChange(filePath, event); + } + async cleanup() { + if (!this.app) { + return; + } + + return UIEDevModeInterface.cleanup(); + } +} + +export default AppDevModeInterface; diff --git a/lib/projects/localDev/DevServerManagerV2.ts b/lib/projects/localDev/DevServerManagerV2.ts index 95d3863a5..d79453360 100644 --- a/lib/projects/localDev/DevServerManagerV2.ts +++ b/lib/projects/localDev/DevServerManagerV2.ts @@ -1,72 +1,77 @@ import { Environment } from '@hubspot/local-dev-lib/types/Config'; import { logger } from '@hubspot/local-dev-lib/logger'; import { promptUser } from '../../prompts/promptUtils'; -import { DevModeUnifiedInterface as UIEDevModeInterface } from '@hubspot/ui-extensions-dev-server'; import { startPortManagerServer, stopPortManagerServer, - requestPorts, } from '@hubspot/local-dev-lib/portManager'; import { getHubSpotApiOrigin, getHubSpotWebsiteOrigin, } from '@hubspot/local-dev-lib/urls'; import { getAccountConfig } from '@hubspot/local-dev-lib/config'; -import { ProjectConfig } from '../../../types/Projects'; -import { IntermediateRepresentationNodeLocalDev } from '@hubspot/project-parsing-lib/src/lib/types'; +import AppDevModeInterface from './AppDevModeInterface'; import { lib } from '../../../lang/en'; +import { LocalDevState } from '../../../types/LocalDev'; +import LocalDevLogger from './LocalDevLogger'; type DevServerInterface = { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type setup?: Function; - start?: (options: object) => Promise; + start?: () => Promise; fileChange?: (filePath: string, event: string) => Promise; cleanup?: () => Promise; }; +type DevServerManagerV2ConstructorOptions = { + localDevState: LocalDevState; + logger: LocalDevLogger; +}; + class DevServerManagerV2 { private initialized: boolean; private started: boolean; private devServers: DevServerInterface[]; + private localDevState: LocalDevState; - constructor() { + constructor(options: DevServerManagerV2ConstructorOptions) { this.initialized = false; this.started = false; - this.devServers = [UIEDevModeInterface]; + this.localDevState = options.localDevState; + + const AppsDevServer = new AppDevModeInterface({ + localDevState: options.localDevState, + localDevLogger: options.logger, + }); + this.devServers = [AppsDevServer]; } - async iterateDevServers( + private async iterateDevServers( callback: (serverInterface: DevServerInterface) => Promise ): Promise { await Promise.all(this.devServers.map(devServer => callback(devServer))); } - async setup({ - projectNodes, - accountId, - setActiveApp, - }: { - projectNodes: { [key: string]: IntermediateRepresentationNodeLocalDev }; - accountId: number; - setActiveApp: (appUid: string | undefined) => Promise; - }): Promise { + async setup(): Promise { let env: Environment; - const accountConfig = getAccountConfig(accountId); + const accountConfig = getAccountConfig( + this.localDevState.targetTestingAccountId + ); if (accountConfig) { env = accountConfig.env; } await startPortManagerServer(); await this.iterateDevServers(async serverInterface => { if (serverInterface.setup) { + // @TODO: In the future, update UIE Dev Server to use LocalDevState await serverInterface.setup({ - components: projectNodes, + components: this.localDevState.projectNodes, promptUser, logger, urls: { api: getHubSpotApiOrigin(env), web: getHubSpotWebsiteOrigin(env), }, - setActiveApp, }); } }); @@ -74,21 +79,11 @@ class DevServerManagerV2 { this.initialized = true; } - async start({ - accountId, - projectConfig, - }: { - accountId: number; - projectConfig: ProjectConfig; - }): Promise { + async start(): Promise { if (this.initialized) { await this.iterateDevServers(async serverInterface => { if (serverInterface.start) { - await serverInterface.start({ - accountId, - projectConfig, - requestPorts, - }); + await serverInterface.start(); } }); } else { @@ -127,6 +122,4 @@ class DevServerManagerV2 { } } -const Manager = new DevServerManagerV2(); - -export default Manager; +export default DevServerManagerV2; diff --git a/lib/projects/localDev/LocalDevLogger.ts b/lib/projects/localDev/LocalDevLogger.ts new file mode 100644 index 000000000..98a9220e2 --- /dev/null +++ b/lib/projects/localDev/LocalDevLogger.ts @@ -0,0 +1,216 @@ +import { getAccountId } from '@hubspot/local-dev-lib/config'; +import { getConfigDefaultAccount } from '@hubspot/local-dev-lib/config'; +import { logger } from '@hubspot/local-dev-lib/logger'; + +import { uiLogger } from '../../ui/logger'; +import { + uiBetaTag, + uiLine, + uiAccountDescription, + uiCommandReference, +} from '../../ui'; +import { lib } from '../../../lang/en'; +import { LocalDevState } from '../../../types/LocalDev'; +import SpinniesManager from '../../ui/SpinniesManager'; + +class LocalDevLogger { + private state: LocalDevState; + private mostRecentUploadWarning: string | null; + private uploadWarnings: Set; + + constructor(state: LocalDevState) { + this.state = state; + this.mostRecentUploadWarning = null; + this.uploadWarnings = new Set(); + } + + private logUploadInstructions(): void { + uiLogger.log(''); + uiLogger.log(lib.LocalDevManager.uploadWarning.instructionsHeader); + + uiLogger.log(lib.LocalDevManager.uploadWarning.stopDev); + if (this.state.isGithubLinked) { + uiLogger.log(lib.LocalDevManager.uploadWarning.pushToGithub); + } else { + uiLogger.log( + lib.LocalDevManager.uploadWarning.runUpload(this.getUploadCommand()) + ); + } + uiLogger.log(lib.LocalDevManager.uploadWarning.restartDev); + } + + private handleError( + e: unknown, + langFunction: (message: string) => string + ): void { + if (this.state.debug) { + logger.error(e); + } + uiLogger.error(langFunction(e instanceof Error ? e.message : '')); + } + + getUploadCommand(): string { + const currentDefaultAccount = getConfigDefaultAccount() || undefined; + + return this.state.targetProjectAccountId !== + getAccountId(currentDefaultAccount) + ? uiCommandReference( + `hs project upload --account=${this.state.targetProjectAccountId}` + ) + : uiCommandReference('hs project upload'); + } + + uploadWarning(): void { + // At the moment, there is only one additional warning. We may need to do this in a + // more robust way in the future + const additionalWarnings = Array.from(this.uploadWarnings).join('\n\n'); + const warning = `${lib.LocalDevManager.uploadWarning.defaultWarning} ${additionalWarnings}`; + + // Avoid logging the warning to the console if it is currently the most + // recently logged warning. We do not want to spam the console with the same message. + if (warning !== this.mostRecentUploadWarning) { + uiLogger.log(''); + uiLogger.warn(warning); + this.logUploadInstructions(); + + this.mostRecentUploadWarning = warning; + } + } + + addUploadWarning(warning: string): void { + this.uploadWarnings.add(warning); + } + + missingComponentsWarning(components: string[]): void { + const warning = lib.LocalDevManager.uploadWarning.missingComponents( + components.join(', ') + ); + + if (warning !== this.mostRecentUploadWarning) { + uiLogger.log(''); + uiLogger.warn(warning); + this.logUploadInstructions(); + this.mostRecentUploadWarning = warning; + } + } + + fileChangeError(e: unknown): void { + this.handleError(e, lib.LocalDevManager.devServer.fileChangeError); + } + + devServerSetupError(e: unknown): void { + this.handleError(e, lib.LocalDevManager.devServer.setupError); + } + + devServerStartError(e: unknown): void { + this.handleError(e, lib.LocalDevManager.devServer.startError); + } + + devServerCleanupError(e: unknown): void { + this.handleError(e, lib.LocalDevManager.devServer.cleanupError); + } + + noDeployedBuild(): void { + uiLogger.error( + lib.LocalDevManager.noDeployedBuild( + this.state.projectConfig.name, + uiAccountDescription(this.state.targetProjectAccountId), + this.getUploadCommand() + ) + ); + uiLogger.log(''); + } + + resetSpinnies(): void { + SpinniesManager.stopAll(); + SpinniesManager.init(); + } + + startupMessage(): void { + if (!this.state.debug) { + console.clear(); + } + + uiBetaTag(lib.LocalDevManager.betaMessage); + + uiLogger.log(lib.LocalDevManager.learnMoreLocalDevServer); + + uiLogger.log(''); + uiLogger.log( + lib.LocalDevManager.running( + this.state.projectConfig.name, + uiAccountDescription(this.state.targetProjectAccountId) + ) + ); + uiLogger.log( + lib.LocalDevManager.viewProjectLink( + this.state.projectConfig.name, + this.state.targetProjectAccountId + ) + ); + + uiLogger.log(''); + uiLogger.log(lib.LocalDevManager.quitHelper); + uiLine(); + uiLogger.log(''); + } + + cleanupStart(): void { + SpinniesManager.add('cleanupMessage', { + text: lib.LocalDevManager.exitingStart, + }); + } + + cleanupError(): void { + SpinniesManager.fail('cleanupMessage', { + text: lib.LocalDevManager.exitingFail, + }); + } + + cleanupSuccess(): void { + SpinniesManager.succeed('cleanupMessage', { + text: lib.LocalDevManager.exitingSucceed, + }); + } + + monitorConsoleOutput(): void { + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + + type StdoutCallback = (err?: Error) => void; + + // Need to provide both overloads for process.stdout.write to satisfy TS + function customStdoutWrite( + this: LocalDevLogger, + buffer: Uint8Array | string, + cb?: StdoutCallback + ): boolean; + function customStdoutWrite( + this: LocalDevLogger, + str: Uint8Array | string, + encoding?: BufferEncoding, + cb?: StdoutCallback + ): boolean; + function customStdoutWrite( + this: LocalDevLogger, + chunk: Uint8Array | string, + encoding?: BufferEncoding | StdoutCallback, + callback?: StdoutCallback + ) { + // Reset the most recently logged warning + if (this.mostRecentUploadWarning) { + this.mostRecentUploadWarning = null; + } + + if (typeof encoding === 'function') { + return originalStdoutWrite(chunk, callback); + } + return originalStdoutWrite(chunk, encoding, callback); + } + + customStdoutWrite.bind(this); + + process.stdout.write = customStdoutWrite; + } +} + +export default LocalDevLogger; diff --git a/lib/projects/localDev/LocalDevManager.ts b/lib/projects/localDev/LocalDevManager.ts index b09e6d428..b45e59129 100644 --- a/lib/projects/localDev/LocalDevManager.ts +++ b/lib/projects/localDev/LocalDevManager.ts @@ -19,7 +19,6 @@ 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 './helpers'; import { componentIsApp, @@ -180,8 +179,7 @@ class LocalDevManager { uiLogger.warn( lib.LocalDevManager.activeInstallWarning.installCount( this.activePublicAppData.name, - this.publicAppActiveInstalls, - this.publicAppActiveInstalls === 1 ? 'account' : 'accounts' + this.publicAppActiveInstalls ) ); uiLogger.log(lib.LocalDevManager.activeInstallWarning.explanation); @@ -241,12 +239,9 @@ class LocalDevManager { ) ); uiLogger.log( - uiLink( - lib.LocalDevManager.viewProjectLink, - getProjectDetailUrl( - this.projectConfig.name, - this.targetProjectAccountId - ) || '' + lib.LocalDevManager.viewProjectLink( + this.projectConfig.name, + this.targetProjectAccountId ) ); diff --git a/lib/projects/localDev/LocalDevManagerV2.ts b/lib/projects/localDev/LocalDevManagerV2.ts deleted file mode 100644 index 7e898ac4a..000000000 --- a/lib/projects/localDev/LocalDevManagerV2.ts +++ /dev/null @@ -1,556 +0,0 @@ -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, -} from '@hubspot/local-dev-lib/api/appsDev'; -import { - getAccountId, - getConfigDefaultAccount, -} 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 { mapToUserFriendlyName } from '@hubspot/project-parsing-lib'; - -import { APP_DISTRIBUTION_TYPES, PROJECT_CONFIG_FILE } from '../../constants'; -import SpinniesManager from '../../ui/SpinniesManager'; -import DevServerManagerV2 from './DevServerManagerV2'; -import { EXIT_CODES } from '../../enums/exitCodes'; -import { getProjectDetailUrl } from '../../projects/urls'; -import { isAppIRNode } from '../../projects/structure'; -import { ProjectConfig } from '../../../types/Projects'; -import { - UI_COLORS, - uiCommandReference, - uiAccountDescription, - uiBetaTag, - uiLink, - uiLine, -} from '../../ui'; -import { logError } from '../../errorHandlers/index'; -import { installPublicAppPrompt } from '../../prompts/installPublicAppPrompt'; -import { confirmPrompt } from '../../prompts/promptUtils'; -import { handleKeypress } from '../../process'; -import { IntermediateRepresentationNodeLocalDev } from '@hubspot/project-parsing-lib/src/lib/types'; -import { AppIRNode } from '../../../types/ProjectComponents'; -import { lib } from '../../../lang/en'; -import { uiLogger } from '../../ui/logger'; - -const WATCH_EVENTS = { - add: 'add', - change: 'change', - unlink: 'unlink', - unlinkDir: 'unlinkDir', -}; - -type LocalDevManagerConstructorOptions = { - targetProjectAccountId: number; - targetTestingAccountId: number; - projectConfig: ProjectConfig; - projectDir: string; - projectId: number; - debug?: boolean; - deployedBuild?: Build; - isGithubLinked: boolean; - projectNodes: { [key: string]: IntermediateRepresentationNodeLocalDev }; - env: Environment; -}; - -class LocalDevManagerV2 { - targetProjectAccountId: number; - targetTestingAccountId: number; - projectConfig: ProjectConfig; - projectDir: string; - projectId: number; - debug: boolean; - deployedBuild?: Build; - isGithubLinked: boolean; - watcher: FSWatcher | null; - uploadWarnings: { [key: string]: boolean }; - projectNodes: { [key: string]: IntermediateRepresentationNodeLocalDev }; - activeApp: AppIRNode | null; - activePublicAppData: PublicApp | null; - env: Environment; - publicAppActiveInstalls: number | null; - projectSourceDir: string; - mostRecentUploadWarning: string | null; - - constructor(options: LocalDevManagerConstructorOptions) { - this.targetProjectAccountId = options.targetProjectAccountId; - this.targetTestingAccountId = options.targetTestingAccountId; - this.projectConfig = options.projectConfig; - this.projectDir = options.projectDir; - this.projectId = options.projectId; - this.debug = options.debug || false; - this.deployedBuild = options.deployedBuild; - this.isGithubLinked = options.isGithubLinked; - this.watcher = null; - this.uploadWarnings = {}; - this.projectNodes = options.projectNodes; - this.activeApp = null; - this.activePublicAppData = null; - this.env = options.env; - this.publicAppActiveInstalls = null; - this.mostRecentUploadWarning = null; - - this.projectSourceDir = path.join( - this.projectDir, - this.projectConfig.srcDir - ); - - if ( - !this.targetProjectAccountId || - !this.projectConfig || - !this.projectDir - ) { - uiLogger.log(lib.LocalDevManager.failedToInitialize); - process.exit(EXIT_CODES.ERROR); - } - } - - async setActiveApp(appUid?: string): Promise { - if (!appUid) { - uiLogger.error(lib.LocalDevManager.missingUid); - process.exit(EXIT_CODES.ERROR); - } - const app = - Object.values(this.projectNodes).find( - component => component.uid === appUid - ) || null; - - if (app && isAppIRNode(app)) { - this.activeApp = app; - - if (app.config.distribution === APP_DISTRIBUTION_TYPES.MARKETPLACE) { - try { - await this.setActivePublicAppData(); - await this.checkActivePublicAppInstalls(); - await this.checkPublicAppInstallation(); - } catch (e) { - logError(e); - } - } - } - - return; - } - - async setActivePublicAppData(): Promise { - const { - data: { results: portalPublicApps }, - } = await fetchPublicAppsForPortal(this.targetProjectAccountId); - - const activePublicAppData = portalPublicApps.find( - ({ sourceId }) => sourceId === this.activeApp?.uid - ); - - if (!activePublicAppData) { - return; - } - - const { - data: { uniquePortalInstallCount }, - } = await fetchPublicAppProductionInstallCounts( - activePublicAppData.id, - this.targetProjectAccountId - ); - - this.activePublicAppData = activePublicAppData; - this.publicAppActiveInstalls = uniquePortalInstallCount; - } - - async checkActivePublicAppInstalls(): Promise { - if ( - !this.activePublicAppData || - !this.publicAppActiveInstalls || - this.publicAppActiveInstalls < 1 - ) { - return; - } - uiLine(); - - uiLogger.warn( - lib.LocalDevManager.activeInstallWarning.installCount( - this.activePublicAppData.name, - this.publicAppActiveInstalls, - this.publicAppActiveInstalls === 1 ? 'account' : 'accounts' - ) - ); - uiLogger.log(lib.LocalDevManager.activeInstallWarning.explanation); - uiLine(); - - const proceed = await confirmPrompt( - lib.LocalDevManager.activeInstallWarning.confirmationPrompt, - { defaultAnswer: false } - ); - - if (!proceed) { - process.exit(EXIT_CODES.SUCCESS); - } - } - - async start(): Promise { - SpinniesManager.stopAll(); - SpinniesManager.init(); - - // Local dev currently relies on the existence of a deployed build in the target account - if (!this.deployedBuild) { - uiLogger.error( - lib.LocalDevManager.noDeployedBuild( - this.projectConfig.name, - uiAccountDescription(this.targetProjectAccountId), - this.getUploadCommand() - ) - ); - uiLogger.log(''); - process.exit(EXIT_CODES.SUCCESS); - } - - const setupSucceeded = await this.devServerSetup(); - - if (!setupSucceeded) { - process.exit(EXIT_CODES.ERROR); - } else if (!this.debug) { - console.clear(); - } - - uiBetaTag(lib.LocalDevManager.betaMessage); - - uiLogger.log( - uiLink( - lib.LocalDevManager.learnMoreLocalDevServer, - 'https://developers.hubspot.com/docs/platform/project-cli-commands#start-a-local-development-server' - ) - ); - - uiLogger.log(''); - uiLogger.log( - chalk.hex(UI_COLORS.SORBET)( - lib.LocalDevManager.running( - this.projectConfig.name, - uiAccountDescription(this.targetProjectAccountId) - ) - ) - ); - uiLogger.log( - uiLink( - lib.LocalDevManager.viewProjectLink, - getProjectDetailUrl( - this.projectConfig.name, - this.targetProjectAccountId - ) || '' - ) - ); - - uiLogger.log(''); - uiLogger.log(lib.LocalDevManager.quitHelper); - uiLine(); - uiLogger.log(''); - - await this.devServerStart(); - - // Initialize project file watcher to detect configuration file changes - this.startWatching(); - - this.updateKeypressListeners(); - - this.monitorConsoleOutput(); - - // Verify that there are no mismatches between components in the local project - // and components in the deployed build of the project. - this.compareLocalProjectToDeployed(); - } - - async stop(showProgress = true): Promise { - if (showProgress) { - SpinniesManager.add('cleanupMessage', { - text: lib.LocalDevManager.exitingStart, - }); - } - await this.stopWatching(); - - const cleanupSucceeded = await this.devServerCleanup(); - - if (!cleanupSucceeded) { - if (showProgress) { - SpinniesManager.fail('cleanupMessage', { - text: lib.LocalDevManager.exitingFail, - }); - } - process.exit(EXIT_CODES.ERROR); - } - - if (showProgress) { - SpinniesManager.succeed('cleanupMessage', { - text: lib.LocalDevManager.exitingSucceed, - }); - } - process.exit(EXIT_CODES.SUCCESS); - } - - async checkPublicAppInstallation(): Promise { - if (!this.activeApp || !this.activePublicAppData) { - return; - } - - const { - data: { isInstalledWithScopeGroups, previouslyAuthorizedScopeGroups }, - } = await fetchAppInstallationData( - this.targetTestingAccountId, - this.projectId, - this.activeApp.uid, - this.activeApp.config.auth.requiredScopes, - this.activeApp.config.auth.optionalScopes - ); - const isReinstall = previouslyAuthorizedScopeGroups.length > 0; - - if (!isInstalledWithScopeGroups) { - await installPublicAppPrompt( - this.env, - this.targetTestingAccountId, - this.activePublicAppData.clientId, - this.activeApp.config.auth.requiredScopes, - this.activeApp.config.auth.redirectUrls, - isReinstall - ); - } - } - - updateKeypressListeners(): void { - handleKeypress(async key => { - if ((key.ctrl && key.name === 'c') || key.name === 'q') { - this.stop(); - } - }); - } - - getUploadCommand(): string { - const currentDefaultAccount = getConfigDefaultAccount() || undefined; - - return this.targetProjectAccountId !== getAccountId(currentDefaultAccount) - ? uiCommandReference( - `hs project upload --account=${this.targetProjectAccountId}` - ) - : uiCommandReference('hs project upload'); - } - - logUploadWarning(reason?: string): void { - let warning = reason; - - if (!warning) { - warning = - this.publicAppActiveInstalls && this.publicAppActiveInstalls > 0 - ? lib.LocalDevManager.uploadWarning.defaultMarketplaceAppWarning( - this.publicAppActiveInstalls, - this.publicAppActiveInstalls === 1 ? 'account' : 'accounts' - ) - : lib.LocalDevManager.uploadWarning.defaultWarning; - } - - // Avoid logging the warning to the console if it is currently the most - // recently logged warning. We do not want to spam the console with the same message. - if (!this.uploadWarnings[warning]) { - uiLogger.log(''); - uiLogger.warn(lib.LocalDevManager.uploadWarning.header(warning)); - uiLogger.log(lib.LocalDevManager.uploadWarning.stopDev); - if (this.isGithubLinked) { - uiLogger.log(lib.LocalDevManager.uploadWarning.pushToGithub); - } else { - uiLogger.log( - lib.LocalDevManager.uploadWarning.runUpload(this.getUploadCommand()) - ); - } - uiLogger.log(lib.LocalDevManager.uploadWarning.restartDev); - - this.mostRecentUploadWarning = warning; - this.uploadWarnings[warning] = true; - } - } - - monitorConsoleOutput(): void { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - - type StdoutCallback = (err?: Error) => void; - - // Need to provide both overloads for process.stdout.write to satisfy TS - function customStdoutWrite( - this: LocalDevManagerV2, - buffer: Uint8Array | string, - cb?: StdoutCallback - ): boolean; - function customStdoutWrite( - this: LocalDevManagerV2, - str: Uint8Array | string, - encoding?: BufferEncoding, - cb?: StdoutCallback - ): boolean; - function customStdoutWrite( - this: LocalDevManagerV2, - chunk: Uint8Array | string, - encoding?: BufferEncoding | StdoutCallback, - callback?: StdoutCallback - ) { - // Reset the most recently logged warning - if ( - this.mostRecentUploadWarning && - this.uploadWarnings[this.mostRecentUploadWarning] - ) { - delete this.uploadWarnings[this.mostRecentUploadWarning]; - } - - if (typeof encoding === 'function') { - return originalStdoutWrite(chunk, callback); - } - return originalStdoutWrite(chunk, encoding, callback); - } - - customStdoutWrite.bind(this); - - process.stdout.write = customStdoutWrite; - } - - compareLocalProjectToDeployed(): void { - const deployedComponentNames = this.deployedBuild!.subbuildStatuses.map( - subbuildStatus => subbuildStatus.buildName - ); - - const missingProjectNodes: string[] = []; - - Object.values(this.projectNodes).forEach(node => { - if (!deployedComponentNames.includes(node.uid)) { - const userFriendlyName = mapToUserFriendlyName(node.componentType); - const label = userFriendlyName ? `[${userFriendlyName}] ` : ''; - missingProjectNodes.push(`${label}${node.uid}`); - } - }); - - if (missingProjectNodes.length) { - this.logUploadWarning( - lib.LocalDevManager.uploadWarning.missingComponents( - missingProjectNodes.join(', ') - ) - ); - } - } - - startWatching(): void { - this.watcher = chokidar.watch(this.projectDir, { - ignoreInitial: true, - }); - - const configPaths = Object.values(this.projectNodes).map( - component => component.localDev.componentConfigPath - ); - - const projectConfigPath = path.join(this.projectDir, PROJECT_CONFIG_FILE); - configPaths.push(projectConfigPath); - - this.watcher.on('add', filePath => { - this.handleWatchEvent(filePath, WATCH_EVENTS.add, configPaths); - }); - this.watcher.on('change', filePath => { - this.handleWatchEvent(filePath, WATCH_EVENTS.change, configPaths); - }); - this.watcher.on('unlink', filePath => { - this.handleWatchEvent(filePath, WATCH_EVENTS.unlink, configPaths); - }); - this.watcher.on('unlinkDir', filePath => { - this.handleWatchEvent(filePath, WATCH_EVENTS.unlinkDir, configPaths); - }); - } - - async stopWatching(): Promise { - await this.watcher?.close(); - } - - handleWatchEvent( - filePath: string, - event: string, - configPaths: string[] - ): void { - if (configPaths.includes(filePath)) { - this.logUploadWarning(); - } else { - this.devServerFileChange(filePath, event); - } - } - - async devServerSetup(): Promise { - try { - await DevServerManagerV2.setup({ - projectNodes: this.projectNodes, - accountId: this.targetTestingAccountId, - setActiveApp: this.setActiveApp.bind(this), - }); - return true; - } catch (e) { - if (this.debug) { - logger.error(e); - } - - uiLogger.error( - lib.LocalDevManager.devServer.setupError( - e instanceof Error ? e.message : '' - ) - ); - return false; - } - } - - async devServerStart(): Promise { - try { - await DevServerManagerV2.start({ - accountId: this.targetTestingAccountId, - projectConfig: this.projectConfig, - }); - } catch (e) { - if (this.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.startError( - e instanceof Error ? e.message : '' - ) - ); - process.exit(EXIT_CODES.ERROR); - } - } - - devServerFileChange(filePath: string, event: string): void { - try { - DevServerManagerV2.fileChange({ filePath, event }); - } catch (e) { - if (this.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.fileChangeError( - e instanceof Error ? e.message : '' - ) - ); - } - } - - async devServerCleanup(): Promise { - try { - await DevServerManagerV2.cleanup(); - return true; - } catch (e) { - if (this.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.cleanupError( - e instanceof Error ? e.message : '' - ) - ); - return false; - } - } -} - -export default LocalDevManagerV2; diff --git a/lib/projects/localDev/LocalDevProcess.ts b/lib/projects/localDev/LocalDevProcess.ts new file mode 100644 index 000000000..e75642bfa --- /dev/null +++ b/lib/projects/localDev/LocalDevProcess.ts @@ -0,0 +1,198 @@ +import { IntermediateRepresentationNodeLocalDev } from '@hubspot/project-parsing-lib/src/lib/types'; +import { translateForLocalDev } from '@hubspot/project-parsing-lib'; +import { Build } from '@hubspot/local-dev-lib/types/Build'; +import { Environment } from '@hubspot/local-dev-lib/types/Config'; +import path from 'path'; + +import { ProjectConfig } from '../../../types/Projects'; +import { LocalDevState } from '../../../types/LocalDev'; +import LocalDevLogger from './LocalDevLogger'; +import DevServerManagerV2 from './DevServerManagerV2'; +import { EXIT_CODES } from '../../enums/exitCodes'; +import { mapToUserFriendlyName } from '@hubspot/project-parsing-lib/src/lib/transform'; + +type LocalDevProcessConstructorOptions = { + targetProjectAccountId: number; + targetTestingAccountId: number; + projectConfig: ProjectConfig; + projectDir: string; + projectId: number; + debug?: boolean; + deployedBuild?: Build; + isGithubLinked: boolean; + initialProjectNodes: { + [key: string]: IntermediateRepresentationNodeLocalDev; + }; + env: Environment; +}; + +class LocalDevProcess { + private state: LocalDevState; + private _logger: LocalDevLogger; + private devServerManager: DevServerManagerV2; + constructor({ + targetProjectAccountId, + targetTestingAccountId, + projectConfig, + projectDir, + projectId, + debug, + deployedBuild, + isGithubLinked, + initialProjectNodes, + env, + }: LocalDevProcessConstructorOptions) { + this.state = { + targetProjectAccountId, + targetTestingAccountId, + projectConfig, + projectDir, + projectId, + debug: debug || false, + deployedBuild, + isGithubLinked, + projectNodes: initialProjectNodes, + env, + }; + + this._logger = new LocalDevLogger(this.state); + this.devServerManager = new DevServerManagerV2({ + localDevState: this.state, + logger: this._logger, + }); + } + + get projectDir(): string { + return this.state.projectDir; + } + + get projectNodes(): { + [key: string]: IntermediateRepresentationNodeLocalDev; + } { + return this.state.projectNodes; + } + + get logger(): LocalDevLogger { + return this._logger; + } + + private async setupDevServers(): Promise { + try { + await this.devServerManager.setup(); + return true; + } catch (e) { + this.logger.devServerSetupError(e); + return false; + } + } + + private async startDevServers(): Promise { + try { + await this.devServerManager.start(); + } catch (e) { + this.logger.devServerStartError(e); + process.exit(EXIT_CODES.ERROR); + } + } + + private async cleanupDevServers(): Promise { + try { + await this.devServerManager.cleanup(); + return true; + } catch (e) { + this.logger.devServerCleanupError(e); + return false; + } + } + + private compareLocalProjectToDeployed(): void { + const deployedComponentNames = + this.state.deployedBuild!.subbuildStatuses.map( + subbuildStatus => subbuildStatus.buildName + ); + + const missingProjectNodes: string[] = []; + + Object.values(this.projectNodes).forEach(node => { + if (!deployedComponentNames.includes(node.uid)) { + const userFriendlyName = mapToUserFriendlyName(node.componentType); + const label = userFriendlyName ? `[${userFriendlyName}] ` : ''; + missingProjectNodes.push(`${label}${node.uid}`); + } + }); + + if (missingProjectNodes.length) { + this.logger.missingComponentsWarning(missingProjectNodes); + } + } + + handleFileChange(filePath: string, event: string): void { + try { + this.devServerManager.fileChange({ filePath, event }); + } catch (e) { + this.logger.fileChangeError(e); + } + } + + async start(): Promise { + this.logger.resetSpinnies(); + + // Local dev currently relies on the existence of a deployed build in the target account + if (!this.state.deployedBuild) { + this.logger.noDeployedBuild(); + process.exit(EXIT_CODES.SUCCESS); + } + + const setupSucceeded = await this.setupDevServers(); + + if (!setupSucceeded) { + process.exit(EXIT_CODES.ERROR); + } + + this.logger.startupMessage(); + + await this.startDevServers(); + + this.logger.monitorConsoleOutput(); + + // Verify that there are no mismatches between components in the local project + // and components in the deployed build of the project. + this.compareLocalProjectToDeployed(); + } + + async stop(showProgress = true): Promise { + if (showProgress) { + this.logger.cleanupStart(); + } + + const cleanupSucceeded = await this.cleanupDevServers(); + + if (!cleanupSucceeded) { + if (showProgress) { + this.logger.cleanupError(); + } + process.exit(EXIT_CODES.ERROR); + } + + if (showProgress) { + this.logger.cleanupSuccess(); + } + process.exit(EXIT_CODES.SUCCESS); + } + + async updateProjectNodes() { + const intermediateRepresentation = await translateForLocalDev({ + projectSourceDir: path.join( + this.state.projectDir, + this.state.projectConfig.srcDir + ), + platformVersion: this.state.projectConfig.platformVersion, + accountId: this.state.targetProjectAccountId, + }); + + this.state.projectNodes = + intermediateRepresentation.intermediateNodesIndexedByUid; + } +} + +export default LocalDevProcess; diff --git a/lib/projects/localDev/LocalDevWatcher.ts b/lib/projects/localDev/LocalDevWatcher.ts new file mode 100644 index 000000000..621455e59 --- /dev/null +++ b/lib/projects/localDev/LocalDevWatcher.ts @@ -0,0 +1,70 @@ +import path from 'path'; +import chokidar, { FSWatcher } from 'chokidar'; + +import { PROJECT_CONFIG_FILE } from '../../constants'; +import LocalDevProcess from './LocalDevProcess'; + +const WATCH_EVENTS = { + add: 'add', + change: 'change', + unlink: 'unlink', + unlinkDir: 'unlinkDir', +}; + +class LocalDevWatcher { + private localDevProcess: LocalDevProcess; + private watcher: FSWatcher | null; + + constructor(localDevProcess: LocalDevProcess) { + this.localDevProcess = localDevProcess; + this.watcher = null; + } + + private async handleWatchEvent( + filePath: string, + event: string, + configPaths: string[] + ): Promise { + await this.localDevProcess.updateProjectNodes(); + if (configPaths.includes(filePath)) { + this.localDevProcess.logger.uploadWarning(); + } else { + this.localDevProcess.handleFileChange(filePath, event); + } + } + + start(): void { + this.watcher = chokidar.watch(this.localDevProcess.projectDir, { + ignoreInitial: true, + }); + + const configPaths = Object.values(this.localDevProcess.projectNodes).map( + component => component.localDev.componentConfigPath + ); + + const projectConfigPath = path.join( + this.localDevProcess.projectDir, + PROJECT_CONFIG_FILE + ); + configPaths.push(projectConfigPath); + + this.watcher.on('add', filePath => { + this.handleWatchEvent(filePath, WATCH_EVENTS.add, configPaths); + }); + this.watcher.on('change', filePath => { + this.handleWatchEvent(filePath, WATCH_EVENTS.change, configPaths); + }); + this.watcher.on('unlink', filePath => { + this.handleWatchEvent(filePath, WATCH_EVENTS.unlink, configPaths); + }); + this.watcher.on('unlinkDir', filePath => { + this.handleWatchEvent(filePath, WATCH_EVENTS.unlinkDir, configPaths); + }); + } + + async stop(): Promise { + await this.watcher?.close(); + } +} + +export default LocalDevWatcher; diff --git a/package.json b/package.json index 4fddf058c..51ffc9087 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/js-yaml": "^4.0.9", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/parser": "^8.11.0", diff --git a/types/LocalDev.ts b/types/LocalDev.ts new file mode 100644 index 000000000..32692d013 --- /dev/null +++ b/types/LocalDev.ts @@ -0,0 +1,19 @@ +import { IntermediateRepresentationNodeLocalDev } from '@hubspot/project-parsing-lib/src/lib/types'; +import { Build } from '@hubspot/local-dev-lib/types/Build'; +import { Environment } from '@hubspot/local-dev-lib/types/Config'; +import { ProjectConfig } from './Projects'; + +export type LocalDevState = { + targetProjectAccountId: number; + targetTestingAccountId: number; + projectConfig: ProjectConfig; + projectDir: string; + projectId: number; + debug: boolean; + deployedBuild?: Build; + isGithubLinked: boolean; + projectNodes: { + [key: string]: IntermediateRepresentationNodeLocalDev; + }; + env: Environment; +};