From 9096dfe6ee3dbcd8a33f46feedba9a277f9e6220 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Mon, 28 Apr 2025 17:20:53 -0400 Subject: [PATCH 01/13] Local dev UI WIP --- .../LocalDevUIWebsocketServer.ts | 64 +++++++++++++++++++ .../localDev/LocalDevUIInterface/index.ts | 0 .../LocalDevUIInterface/messageHandlers.ts | 7 ++ package.json | 2 + types/LocalDevUIInterface.ts | 4 ++ 5 files changed, 77 insertions(+) create mode 100644 lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts create mode 100644 lib/projects/localDev/LocalDevUIInterface/index.ts create mode 100644 lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts create mode 100644 types/LocalDevUIInterface.ts diff --git a/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts new file mode 100644 index 000000000..472b3de7a --- /dev/null +++ b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts @@ -0,0 +1,64 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { + isPortManagerServerRunning, + requestPorts, +} from '@hubspot/local-dev-lib/portManager'; +import { handleWebsocketMessage } from './messageHandlers'; +import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDevUIInterface'; +const SERVER_INSTANCE_ID = 'local-dev-ui-websocket-server'; + +class LocalDevUIWebsocketServer { + private _server?: WebSocketServer; + private _websocket?: WebSocket; + + constructor() {} + + private server(): WebSocketServer { + if (!this._server) { + throw new Error('LocalDevUIWebsocketServer not initialized'); + } + return this._server; + } + + private websocket(): WebSocket { + if (!this._websocket) { + throw new Error('LocalDevUIWebsocketServer not initialized'); + } + return this._websocket; + } + + private setupMessageHandlers() { + this.websocket().on('message', data => { + const message: LocalDevUIWebsocketMessage = JSON.parse(data.toString()); + + handleWebsocketMessage(message); + }); + } + + async init() { + const portManagerIsRunning = await isPortManagerServerRunning(); + if (!portManagerIsRunning) { + throw new Error( + '@TODO: PortManagerServing must be running before starting LocalDevUIWebsocketServer.' + ); + } + + const portData = await requestPorts([{ instanceId: SERVER_INSTANCE_ID }]); + const port = portData[SERVER_INSTANCE_ID]; + + this._server = new WebSocketServer({ port }); + + this._server.on('connection', ws => { + this._websocket = ws; + this.setupMessageHandlers(); + }); + } + + shutdown() { + this._server?.close(); + this._server = undefined; + this._websocket = undefined; + } +} + +export default new LocalDevUIWebsocketServer(); diff --git a/lib/projects/localDev/LocalDevUIInterface/index.ts b/lib/projects/localDev/LocalDevUIInterface/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts b/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts new file mode 100644 index 000000000..77c0c18ab --- /dev/null +++ b/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts @@ -0,0 +1,7 @@ +import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDevUIInterface'; + +export function handleWebsocketMessage( + message: LocalDevUIWebsocketMessage +): void { + console.log(message); +} diff --git a/package.json b/package.json index 348a7a9cf..da792431e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "table": "6.9.0", "tmp": "0.2.3", "update-notifier": "5.1.0", + "ws": "^8.18.1", "yargs": "17.7.2", "yargs-parser": "21.1.1" }, @@ -41,6 +42,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/LocalDevUIInterface.ts b/types/LocalDevUIInterface.ts new file mode 100644 index 000000000..ce93a8369 --- /dev/null +++ b/types/LocalDevUIInterface.ts @@ -0,0 +1,4 @@ +export type LocalDevUIWebsocketMessage = { + type: string; + data: unknown; +}; From a9d2bbe1438b2d6618ed6e5e3a6fd153a40f6a36 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Wed, 30 Apr 2025 14:25:52 -0400 Subject: [PATCH 02/13] Local dev UI server POC --- .../LocalDevUIWebsocketServer.ts | 32 ++++++++++++++++--- .../LocalDevUIInterface/test-server.ts | 6 ++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 lib/projects/localDev/LocalDevUIInterface/test-server.ts diff --git a/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts index 472b3de7a..07e6f8984 100644 --- a/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts +++ b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts @@ -3,35 +3,58 @@ import { isPortManagerServerRunning, requestPorts, } from '@hubspot/local-dev-lib/portManager'; +import { logger } from '@hubspot/local-dev-lib/logger'; import { handleWebsocketMessage } from './messageHandlers'; import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDevUIInterface'; const SERVER_INSTANCE_ID = 'local-dev-ui-websocket-server'; +const LOG_PREFIX = '[LocalDevUIWebsocketServer] '; + class LocalDevUIWebsocketServer { private _server?: WebSocketServer; private _websocket?: WebSocket; + private debug?: boolean; constructor() {} private server(): WebSocketServer { if (!this._server) { - throw new Error('LocalDevUIWebsocketServer not initialized'); + throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); } return this._server; } private websocket(): WebSocket { if (!this._websocket) { - throw new Error('LocalDevUIWebsocketServer not initialized'); + throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); } return this._websocket; } + private log(...args: string[]) { + if (this.debug) { + logger.log(LOG_PREFIX, args); + } + } + + private logError(...args: unknown[]) { + if (this.debug) { + logger.error(LOG_PREFIX, ...args); + } + } + private setupMessageHandlers() { this.websocket().on('message', data => { - const message: LocalDevUIWebsocketMessage = JSON.parse(data.toString()); + try { + const message: LocalDevUIWebsocketMessage = JSON.parse(data.toString()); + + if (!message.type) { + } - handleWebsocketMessage(message); + handleWebsocketMessage(message); + } catch (e) { + this.logError('Unsupported message received:', data.toString()); + } }); } @@ -48,6 +71,7 @@ class LocalDevUIWebsocketServer { this._server = new WebSocketServer({ port }); + this.log(`LocalDevUIWebsocketServer running on port ${port}`); this._server.on('connection', ws => { this._websocket = ws; this.setupMessageHandlers(); diff --git a/lib/projects/localDev/LocalDevUIInterface/test-server.ts b/lib/projects/localDev/LocalDevUIInterface/test-server.ts new file mode 100644 index 000000000..81ae5ea01 --- /dev/null +++ b/lib/projects/localDev/LocalDevUIInterface/test-server.ts @@ -0,0 +1,6 @@ +import { startPortManagerServer } from '@hubspot/local-dev-lib/portManager'; +import LocalDevUIWebsocketServer from './LocalDevUIWebsocketServer'; + +startPortManagerServer().then(() => { + LocalDevUIWebsocketServer.init(); +}); From 415e1f35925cfddf5fb6cfaafb4c1d60d98b9527 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Mon, 5 May 2025 15:19:15 -0400 Subject: [PATCH 03/13] Revamp local dev WIP --- commands/project/dev/unifiedFlow.ts | 21 +- lang/en.ts | 7 +- lib/constants.ts | 6 + lib/projects/localDev/AppDevModeInterface.ts | 172 ++++++ lib/projects/localDev/DevServerManagerV2.ts | 54 +- lib/projects/localDev/LocalDevLogger.ts | 249 ++++++++ lib/projects/localDev/LocalDevManagerV2.ts | 557 ------------------ lib/projects/localDev/LocalDevProcess.ts | 179 ++++++ .../LocalDevUIWebsocketServer.ts | 28 +- .../localDev/LocalDevUIInterface/index.ts | 0 .../LocalDevUIInterface/messageHandlers.ts | 20 +- .../LocalDevUIInterface/test-server.ts | 7 +- lib/projects/localDev/LocalDevWatcher.ts | 69 +++ types/LocalDev.ts | 24 + types/LocalDevUIInterface.ts | 4 - 15 files changed, 788 insertions(+), 609 deletions(-) create mode 100644 lib/projects/localDev/AppDevModeInterface.ts create mode 100644 lib/projects/localDev/LocalDevLogger.ts delete mode 100644 lib/projects/localDev/LocalDevManagerV2.ts create mode 100644 lib/projects/localDev/LocalDevProcess.ts delete mode 100644 lib/projects/localDev/LocalDevUIInterface/index.ts create mode 100644 lib/projects/localDev/LocalDevWatcher.ts create mode 100644 types/LocalDev.ts delete mode 100644 types/LocalDevUIInterface.ts diff --git a/commands/project/dev/unifiedFlow.ts b/commands/project/dev/unifiedFlow.ts index 381156379..9e06cb01e 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,7 +155,8 @@ export async function unifiedProjectDevFlow( ); } - const LocalDev = new LocalDevManagerV2({ + // End setup, start local dev process + const localDevProcess = new LocalDevProcess({ projectNodes, debug: args.debug, deployedBuild, @@ -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 aeb35a5fa..d056953db 100644 --- a/lang/en.ts +++ b/lang/en.ts @@ -2643,12 +2643,11 @@ 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 => `${warning} To reflect these changes and continue testing:`, + instructionsHeader: '\nTo reflect these changes and continue testing:', stopDev: ` * Stop ${uiCommandReference('hs project dev')}`, runUpload: command => ` * Run ${command}`, restartDev: ` * Re-run ${uiCommandReference('hs project dev')}`, pushToGithub: ' * Commit and push your changes to GitHub', - defaultMarketplaceAppWarning: (installCount, accountText) => - `${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, installCount, installText) => @@ -2666,6 +2665,10 @@ export const lib = { `Failed to notify local dev server of file change: ${message}`, }, }, + AppDevModeInterface: { + defaultMarketplaceAppWarning: (installCount, accountText) => + `$\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.`, + }, 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/constants.ts b/lib/constants.ts index ea936e97f..534500adc 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -89,3 +89,9 @@ export const FEATURES = { UNIFIED_THEME_PREVIEW: 'cms:react:unifiedThemePreview', UNIFIED_APPS: 'Developers:UnifiedApps:PrivateBeta', } as const; + +export const LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES = { + UPLOAD: 'upload', + INSTALL_DEPS: 'installDeps', + APP_INSTALLED: 'appInstalled', +} as const; diff --git a/lib/projects/localDev/AppDevModeInterface.ts b/lib/projects/localDev/AppDevModeInterface.ts new file mode 100644 index 000000000..b180c2a88 --- /dev/null +++ b/lib/projects/localDev/AppDevModeInterface.ts @@ -0,0 +1,172 @@ +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 { 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; + logger: LocalDevLogger; +}; + +class AppDevModeInterface { + localDevState: LocalDevState; + logger: LocalDevLogger; + activeApp: AppIRNode | null; + activePublicAppData: PublicApp | null; + publicAppActiveInstalls: number | null; + + constructor(options: AppDevModeInterfaceConstructorOptions) { + this.localDevState = options.localDevState; + this.logger = options.logger; + this.activeApp = null; + this.activePublicAppData = null; + this.publicAppActiveInstalls = null; + + if ( + !this.localDevState.targetProjectAccountId || + !this.localDevState.projectConfig || + !this.localDevState.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.localDevState.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.localDevState.targetProjectAccountId + ); + + const activePublicAppData = portalPublicApps.find( + ({ sourceId }) => sourceId === this.activeApp?.uid + ); + + if (!activePublicAppData) { + return; + } + + const { + data: { uniquePortalInstallCount }, + } = await fetchPublicAppProductionInstallCounts( + activePublicAppData.id, + this.localDevState.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 checkPublicAppInstallation(): Promise { + if (!this.activeApp || !this.activePublicAppData) { + return; + } + + const { + data: { isInstalledWithScopeGroups, previouslyAuthorizedScopeGroups }, + } = await fetchAppInstallationData( + this.localDevState.targetTestingAccountId, + this.localDevState.projectId, + this.activeApp.uid, + this.activeApp.config.auth.requiredScopes, + this.activeApp.config.auth.optionalScopes + ); + const isReinstall = previouslyAuthorizedScopeGroups.length > 0; + + if (!isInstalledWithScopeGroups) { + await installPublicAppPrompt( + this.localDevState.env, + this.localDevState.targetTestingAccountId, + this.activePublicAppData.clientId, + this.activeApp.config.auth.requiredScopes, + this.activeApp.config.auth.redirectUrls, + isReinstall + ); + } + } + + setup({ promptUser, uiLogger, urls }): void { + return UIEDevModeInterface.setup(); + } + + async start() {} + async fileChange(filePath: string, event: string) {} + async cleanup() {} +} + +export default AppDevModeInterface; diff --git a/lib/projects/localDev/DevServerManagerV2.ts b/lib/projects/localDev/DevServerManagerV2.ts index b6abc2a10..23edf455a 100644 --- a/lib/projects/localDev/DevServerManagerV2.ts +++ b/lib/projects/localDev/DevServerManagerV2.ts @@ -1,6 +1,5 @@ import { Environment } from '@hubspot/local-dev-lib/types/Config'; import { promptUser } from '../../prompts/promptUtils'; -import { DevModeUnifiedInterface as UIEDevModeInterface } from '@hubspot/ui-extensions-dev-server'; import { startPortManagerServer, stopPortManagerServer, @@ -11,10 +10,11 @@ import { 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 { uiLogger } from '../../ui/logger'; +import { LocalDevState } from '../../../types/LocalDev'; +import LocalDevLogger from './LocalDevLogger'; type DevServerInterface = { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type @@ -24,15 +24,27 @@ type DevServerInterface = { 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, + logger: options.logger, + }); + this.devServers = [AppsDevServer]; } async iterateDevServers( @@ -41,17 +53,11 @@ class DevServerManagerV2 { 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; } @@ -59,14 +65,12 @@ class DevServerManagerV2 { await this.iterateDevServers(async serverInterface => { if (serverInterface.setup) { await serverInterface.setup({ - components: projectNodes, promptUser, uiLogger, urls: { api: getHubSpotApiOrigin(env), web: getHubSpotWebsiteOrigin(env), }, - setActiveApp, }); } }); @@ -74,19 +78,13 @@ 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, + accountId: this.localDevState.targetTestingAccountId, + projectConfig: this.localDevState.projectConfig, requestPorts, }); } @@ -127,6 +125,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..bac90e79e --- /dev/null +++ b/lib/projects/localDev/LocalDevLogger.ts @@ -0,0 +1,249 @@ +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 chalk from 'chalk'; + +import { uiLogger } from '../../ui/logger'; +import { + uiLink, + uiBetaTag, + uiLine, + UI_COLORS, + uiAccountDescription, + uiCommandReference, +} from '../../ui'; +import { lib } from '../../../lang/en'; +import { LocalDevState } from '../../../types/LocalDev'; +import { getProjectDetailUrl } from '../../projects/urls'; +import SpinniesManager from '../../ui/SpinniesManager'; + +class LocalDevLogger { + private state: LocalDevState; + private mostRecentUploadWarning: string | null; + private uploadWarnings: string[]; + + constructor(state: LocalDevState) { + this.state = state; + this.mostRecentUploadWarning = null; + this.uploadWarnings = []; + } + + 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); + } + + 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 = 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.push(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 { + if (this.state.debug) { + logger.error(e); + } + uiLogger.error( + lib.LocalDevManager.devServer.fileChangeError( + e instanceof Error ? e.message : '' + ) + ); + } + + devServerSetupError(e: unknown): void { + if (this.state.debug) { + logger.error(e); + } + + uiLogger.error( + lib.LocalDevManager.devServer.setupError( + e instanceof Error ? e.message : '' + ) + ); + } + + devServerStartError(e: unknown): void { + if (this.state.debug) { + logger.error(e); + } + uiLogger.error( + lib.LocalDevManager.devServer.startError( + e instanceof Error ? e.message : '' + ) + ); + } + + devServerCleanupError(e: unknown): void { + if (this.state.debug) { + logger.error(e); + } + uiLogger.error( + lib.LocalDevManager.devServer.cleanupError( + e instanceof Error ? e.message : '' + ) + ); + } + + 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( + 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.state.projectConfig.name, + uiAccountDescription(this.state.targetProjectAccountId) + ) + ) + ); + uiLogger.log( + uiLink( + lib.LocalDevManager.viewProjectLink, + getProjectDetailUrl( + 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/LocalDevManagerV2.ts b/lib/projects/localDev/LocalDevManagerV2.ts deleted file mode 100644 index 2aa7c564f..000000000 --- a/lib/projects/localDev/LocalDevManagerV2.ts +++ /dev/null @@ -1,557 +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..5dfb372e1 --- /dev/null +++ b/lib/projects/localDev/LocalDevProcess.ts @@ -0,0 +1,179 @@ +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 '../../../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; + projectNodes: { [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, + projectNodes, + env, + }: LocalDevProcessConstructorOptions) { + this.state = { + targetProjectAccountId, + targetTestingAccountId, + projectConfig, + projectDir, + projectId, + debug: debug || false, + deployedBuild, + isGithubLinked, + projectNodes, + 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); + } +} + +export default LocalDevProcess; diff --git a/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts index 07e6f8984..7823eafec 100644 --- a/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts +++ b/lib/projects/localDev/LocalDevUIInterface/LocalDevUIWebsocketServer.ts @@ -5,7 +5,8 @@ import { } from '@hubspot/local-dev-lib/portManager'; import { logger } from '@hubspot/local-dev-lib/logger'; import { handleWebsocketMessage } from './messageHandlers'; -import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDevUIInterface'; +import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDev'; +import { ProjectConfig } from '../../../../types/Projects'; const SERVER_INSTANCE_ID = 'local-dev-ui-websocket-server'; const LOG_PREFIX = '[LocalDevUIWebsocketServer] '; @@ -14,15 +15,18 @@ class LocalDevUIWebsocketServer { private _server?: WebSocketServer; private _websocket?: WebSocket; private debug?: boolean; + private accountId?: number; + private projectConfig?: ProjectConfig; + private projectDir?: string; constructor() {} - private server(): WebSocketServer { - if (!this._server) { - throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); - } - return this._server; - } + // private server(): WebSocketServer { + // if (!this._server) { + // throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); + // } + // return this._server; + // } private websocket(): WebSocket { if (!this._websocket) { @@ -49,11 +53,19 @@ class LocalDevUIWebsocketServer { const message: LocalDevUIWebsocketMessage = JSON.parse(data.toString()); if (!message.type) { + this.logError( + '@TODO Unsupported message received. Missing type field:', + data.toString() + ); + return; } handleWebsocketMessage(message); } catch (e) { - this.logError('Unsupported message received:', data.toString()); + this.logError( + '@TODO Unsupported message received. Invalid JSON:', + data.toString() + ); } }); } diff --git a/lib/projects/localDev/LocalDevUIInterface/index.ts b/lib/projects/localDev/LocalDevUIInterface/index.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts b/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts index 77c0c18ab..1fb935eb1 100644 --- a/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts +++ b/lib/projects/localDev/LocalDevUIInterface/messageHandlers.ts @@ -1,7 +1,23 @@ -import { LocalDevUIWebsocketMessage } from '../../../../types/LocalDevUIInterface'; +import { LocalDevUIWebsocketMessage } from '../../../../../types/LocalDevUIInterface'; +import { LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES } from '../../../../constants'; export function handleWebsocketMessage( message: LocalDevUIWebsocketMessage ): void { - console.log(message); + switch (message.type) { + case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.UPLOAD: + console.log('run upload'); + break; + case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.INSTALL_DEPS: + console.log('run install deps'); + break; + case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.APP_INSTALLED: + console.log('app installed'); + break; + default: + console.log( + '@TODO Unsupported message received. Unknown message type:', + message.type + ); + } } diff --git a/lib/projects/localDev/LocalDevUIInterface/test-server.ts b/lib/projects/localDev/LocalDevUIInterface/test-server.ts index 81ae5ea01..58a7701ca 100644 --- a/lib/projects/localDev/LocalDevUIInterface/test-server.ts +++ b/lib/projects/localDev/LocalDevUIInterface/test-server.ts @@ -1,6 +1,9 @@ import { startPortManagerServer } from '@hubspot/local-dev-lib/portManager'; import LocalDevUIWebsocketServer from './LocalDevUIWebsocketServer'; -startPortManagerServer().then(() => { +async function main() { + await startPortManagerServer(); LocalDevUIWebsocketServer.init(); -}); +} + +main(); diff --git a/lib/projects/localDev/LocalDevWatcher.ts b/lib/projects/localDev/LocalDevWatcher.ts new file mode 100644 index 000000000..2ca1a86a7 --- /dev/null +++ b/lib/projects/localDev/LocalDevWatcher.ts @@ -0,0 +1,69 @@ +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 handleWatchEvent( + filePath: string, + event: string, + configPaths: string[] + ): void { + 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/types/LocalDev.ts b/types/LocalDev.ts new file mode 100644 index 000000000..db57a8a2d --- /dev/null +++ b/types/LocalDev.ts @@ -0,0 +1,24 @@ +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; +}; + +export interface LocalDevUIWebsocketMessage { + type: string; + data: unknown; +} diff --git a/types/LocalDevUIInterface.ts b/types/LocalDevUIInterface.ts deleted file mode 100644 index ce93a8369..000000000 --- a/types/LocalDevUIInterface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type LocalDevUIWebsocketMessage = { - type: string; - data: unknown; -}; From 6a6d2ffc6e3b09db4a2842bd85423fdc137935af Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 10:50:28 -0400 Subject: [PATCH 04/13] Functional local dev flow --- lib/projects/localDev/AppDevModeInterface.ts | 1 + lib/projects/localDev/DevServerManagerV2.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/projects/localDev/AppDevModeInterface.ts b/lib/projects/localDev/AppDevModeInterface.ts index 7916603cf..056a5318d 100644 --- a/lib/projects/localDev/AppDevModeInterface.ts +++ b/lib/projects/localDev/AppDevModeInterface.ts @@ -149,6 +149,7 @@ class AppDevModeInterface { if (!this.app) { return; } + if (this.app?.config.distribution === APP_DISTRIBUTION_TYPES.MARKETPLACE) { try { await this.fetchMarketplaceAppData(); diff --git a/lib/projects/localDev/DevServerManagerV2.ts b/lib/projects/localDev/DevServerManagerV2.ts index a70d57dfd..7a3429732 100644 --- a/lib/projects/localDev/DevServerManagerV2.ts +++ b/lib/projects/localDev/DevServerManagerV2.ts @@ -63,7 +63,9 @@ class DevServerManagerV2 { 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: this.localDevState.projectNodes, promptUser, logger, urls: { From 6638562a0ff867ab5a8571268b3e31f0f76a69da Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 11:17:04 -0400 Subject: [PATCH 05/13] Generate new IR on file change --- commands/project/dev/unifiedFlow.ts | 2 +- lib/projects/localDev/LocalDevProcess.ts | 25 +++++++++++++++++++++--- lib/projects/localDev/LocalDevWatcher.ts | 5 +++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/commands/project/dev/unifiedFlow.ts b/commands/project/dev/unifiedFlow.ts index 9e06cb01e..276c75062 100644 --- a/commands/project/dev/unifiedFlow.ts +++ b/commands/project/dev/unifiedFlow.ts @@ -157,7 +157,7 @@ export async function unifiedProjectDevFlow( // End setup, start local dev process const localDevProcess = new LocalDevProcess({ - projectNodes, + initialProjectNodes: projectNodes, debug: args.debug, deployedBuild, isGithubLinked, diff --git a/lib/projects/localDev/LocalDevProcess.ts b/lib/projects/localDev/LocalDevProcess.ts index 5dfb372e1..e75642bfa 100644 --- a/lib/projects/localDev/LocalDevProcess.ts +++ b/lib/projects/localDev/LocalDevProcess.ts @@ -1,6 +1,9 @@ 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'; @@ -17,7 +20,9 @@ type LocalDevProcessConstructorOptions = { debug?: boolean; deployedBuild?: Build; isGithubLinked: boolean; - projectNodes: { [key: string]: IntermediateRepresentationNodeLocalDev }; + initialProjectNodes: { + [key: string]: IntermediateRepresentationNodeLocalDev; + }; env: Environment; }; @@ -34,7 +39,7 @@ class LocalDevProcess { debug, deployedBuild, isGithubLinked, - projectNodes, + initialProjectNodes, env, }: LocalDevProcessConstructorOptions) { this.state = { @@ -46,7 +51,7 @@ class LocalDevProcess { debug: debug || false, deployedBuild, isGithubLinked, - projectNodes, + projectNodes: initialProjectNodes, env, }; @@ -174,6 +179,20 @@ class LocalDevProcess { } 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 index 2ca1a86a7..621455e59 100644 --- a/lib/projects/localDev/LocalDevWatcher.ts +++ b/lib/projects/localDev/LocalDevWatcher.ts @@ -20,11 +20,12 @@ class LocalDevWatcher { this.watcher = null; } - private handleWatchEvent( + private async handleWatchEvent( filePath: string, event: string, configPaths: string[] - ): void { + ): Promise { + await this.localDevProcess.updateProjectNodes(); if (configPaths.includes(filePath)) { this.localDevProcess.logger.uploadWarning(); } else { From 1336d0ad6d3f828db850a969c9987b48db798b6c Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 12:44:05 -0400 Subject: [PATCH 06/13] make methods private --- lib/projects/localDev/AppDevModeInterface.ts | 8 ++++---- lib/projects/localDev/DevServerManagerV2.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/projects/localDev/AppDevModeInterface.ts b/lib/projects/localDev/AppDevModeInterface.ts index 056a5318d..2376d5453 100644 --- a/lib/projects/localDev/AppDevModeInterface.ts +++ b/lib/projects/localDev/AppDevModeInterface.ts @@ -47,7 +47,7 @@ class AppDevModeInterface { } // Assumes only one app per project - get app(): AppIRNode | null { + private get app(): AppIRNode | null { if (this._app === undefined) { this._app = Object.values(this.localDevState.projectNodes).find(isAppIRNode) || @@ -56,7 +56,7 @@ class AppDevModeInterface { return this._app; } - async fetchMarketplaceAppData(): Promise { + private async fetchMarketplaceAppData(): Promise { const { data: { results: portalMarketplaceApps }, } = await fetchPublicAppsForPortal( @@ -82,7 +82,7 @@ class AppDevModeInterface { this.marketplaceAppInstalls = uniquePortalInstallCount; } - async checkMarketplaceAppInstalls(): Promise { + private async checkMarketplaceAppInstalls(): Promise { if (!this.marketplaceAppData || !this.marketplaceAppInstalls) { return; } @@ -115,7 +115,7 @@ class AppDevModeInterface { ); } - async checkMarketplaceAppInstallation(): Promise { + private async checkMarketplaceAppInstallation(): Promise { if (!this.app || !this.marketplaceAppData) { return; } diff --git a/lib/projects/localDev/DevServerManagerV2.ts b/lib/projects/localDev/DevServerManagerV2.ts index 7a3429732..d79453360 100644 --- a/lib/projects/localDev/DevServerManagerV2.ts +++ b/lib/projects/localDev/DevServerManagerV2.ts @@ -46,7 +46,7 @@ class DevServerManagerV2 { this.devServers = [AppsDevServer]; } - async iterateDevServers( + private async iterateDevServers( callback: (serverInterface: DevServerInterface) => Promise ): Promise { await Promise.all(this.devServers.map(devServer => callback(devServer))); From 3c3c6b3bc7200cffe3eec027f5c2f9d5ac1b9a9e Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 15:28:29 -0400 Subject: [PATCH 07/13] Copy fix --- lang/en.ts | 8 ++++---- lib/projects/localDev/AppDevModeInterface.ts | 4 +--- lib/projects/localDev/LocalDevManager.ts | 3 +-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lang/en.ts b/lang/en.ts index d622d4034..6e07d88e7 100644 --- a/lang/en.ts +++ b/lang/en.ts @@ -2648,15 +2648,15 @@ 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 => `${warning} To reflect these changes and continue testing:`, - instructionsHeader: '\nTo reflect these changes and continue testing:', + instructionsHeader: 'To reflect these changes and continue testing:', stopDev: ` * Stop ${uiCommandReference('hs project dev')}`, runUpload: command => ` * Run ${command}`, restartDev: ` * Re-run ${uiCommandReference('hs project dev')}`, pushToGithub: ' * Commit and push your changes to GitHub', }, activeInstallWarning: { - installCount: (appName, installCount, installText) => - `${chalk.bold(`The app ${appName} has ${installCount} production ${installText}`)}`, + installCount: (appName, installCount) => + `${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.`, @@ -2672,7 +2672,7 @@ export const lib = { }, AppDevModeInterface: { defaultMarketplaceAppWarning: installCount => - `$\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.`, + `\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: { diff --git a/lib/projects/localDev/AppDevModeInterface.ts b/lib/projects/localDev/AppDevModeInterface.ts index 2376d5453..02959d408 100644 --- a/lib/projects/localDev/AppDevModeInterface.ts +++ b/lib/projects/localDev/AppDevModeInterface.ts @@ -91,9 +91,7 @@ class AppDevModeInterface { uiLogger.warn( lib.LocalDevManager.activeInstallWarning.installCount( this.marketplaceAppData.name, - this.marketplaceAppInstalls, - - this.marketplaceAppInstalls === 1 ? 'account' : 'accounts' + this.marketplaceAppInstalls ) ); uiLogger.log(lib.LocalDevManager.activeInstallWarning.explanation); diff --git a/lib/projects/localDev/LocalDevManager.ts b/lib/projects/localDev/LocalDevManager.ts index b09e6d428..0940a7f4d 100644 --- a/lib/projects/localDev/LocalDevManager.ts +++ b/lib/projects/localDev/LocalDevManager.ts @@ -180,8 +180,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); From 3aa1e6806bc1e37de62038a023a50a6b60a651de Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 15:30:50 -0400 Subject: [PATCH 08/13] Rm websocket server --- .../localDev/LocalDevUIWebsocketServer.ts | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 lib/projects/localDev/LocalDevUIWebsocketServer.ts diff --git a/lib/projects/localDev/LocalDevUIWebsocketServer.ts b/lib/projects/localDev/LocalDevUIWebsocketServer.ts deleted file mode 100644 index 3557d7ff4..000000000 --- a/lib/projects/localDev/LocalDevUIWebsocketServer.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { WebSocketServer, WebSocket } from 'ws'; -import { - isPortManagerServerRunning, - requestPorts, -} from '@hubspot/local-dev-lib/portManager'; -import { logger } from '@hubspot/local-dev-lib/logger'; -import { LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES } from '../../constants'; -import { LocalDevUIWebsocketMessage } from '../../../types/LocalDev'; -import { ProjectConfig } from '../../../types/Projects'; -const SERVER_INSTANCE_ID = 'local-dev-ui-websocket-server'; - -const LOG_PREFIX = '[LocalDevUIWebsocketServer] '; - -class LocalDevUIWebsocketServer { - private _server?: WebSocketServer; - private _websocket?: WebSocket; - private debug?: boolean; - private accountId?: number; - private projectConfig?: ProjectConfig; - private projectDir?: string; - - constructor() {} - - // private server(): WebSocketServer { - // if (!this._server) { - // throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); - // } - // return this._server; - // } - - private websocket(): WebSocket { - if (!this._websocket) { - throw new Error('@TODO LocalDevUIWebsocketServer not initialized'); - } - return this._websocket; - } - - private log(...args: string[]) { - if (this.debug) { - logger.log(LOG_PREFIX, args); - } - } - - private logError(...args: unknown[]) { - if (this.debug) { - logger.error(LOG_PREFIX, ...args); - } - } - - private setupMessageHandlers() { - this.websocket().on('message', data => { - try { - const message: LocalDevUIWebsocketMessage = JSON.parse(data.toString()); - - if (!message.type) { - this.logError( - '@TODO Unsupported message received. Missing type field:', - data.toString() - ); - return; - } - - switch (message.type) { - case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.UPLOAD: - console.log('run upload'); - break; - case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.INSTALL_DEPS: - console.log('run install deps'); - break; - case LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES.APP_INSTALLED: - console.log('app installed'); - break; - default: - console.log( - '@TODO Unsupported message received. Unknown message type:', - message.type - ); - } - } catch (e) { - this.logError( - '@TODO Unsupported message received. Invalid JSON:', - data.toString() - ); - } - }); - } - - async init() { - const portManagerIsRunning = await isPortManagerServerRunning(); - if (!portManagerIsRunning) { - throw new Error( - '@TODO: PortManagerServing must be running before starting LocalDevUIWebsocketServer.' - ); - } - - const portData = await requestPorts([{ instanceId: SERVER_INSTANCE_ID }]); - const port = portData[SERVER_INSTANCE_ID]; - - this._server = new WebSocketServer({ port }); - - this.log(`LocalDevUIWebsocketServer running on port ${port}`); - this._server.on('connection', ws => { - this._websocket = ws; - this.setupMessageHandlers(); - }); - } - - shutdown() { - this._server?.close(); - this._server = undefined; - this._websocket = undefined; - } -} - -export default new LocalDevUIWebsocketServer(); From e8131632e7988c258d53b9375052a2ee84cb986e Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 15:35:56 -0400 Subject: [PATCH 09/13] remove ws --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1880cc462..6f1fcfada 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "table": "6.9.0", "tmp": "0.2.3", "update-notifier": "5.1.0", - "ws": "^8.18.1", "yargs": "17.7.2", "yargs-parser": "21.1.1" }, From 7eeb2b4e0558598ab1ad83f4a0900181f3bcc762 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 15:44:16 -0400 Subject: [PATCH 10/13] remove other websocket things --- lib/constants.ts | 6 ------ types/LocalDev.ts | 5 ----- 2 files changed, 11 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 534500adc..ea936e97f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -89,9 +89,3 @@ export const FEATURES = { UNIFIED_THEME_PREVIEW: 'cms:react:unifiedThemePreview', UNIFIED_APPS: 'Developers:UnifiedApps:PrivateBeta', } as const; - -export const LOCAL_DEV_UI_WEBSOCKET_MESSAGE_TYPES = { - UPLOAD: 'upload', - INSTALL_DEPS: 'installDeps', - APP_INSTALLED: 'appInstalled', -} as const; diff --git a/types/LocalDev.ts b/types/LocalDev.ts index db57a8a2d..32692d013 100644 --- a/types/LocalDev.ts +++ b/types/LocalDev.ts @@ -17,8 +17,3 @@ export type LocalDevState = { }; env: Environment; }; - -export interface LocalDevUIWebsocketMessage { - type: string; - data: unknown; -} From 01fe00fa85552bf39e92c427208806a3bed08a58 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Tue, 6 May 2025 16:28:06 -0400 Subject: [PATCH 11/13] Clean up local dev logger error handlers --- lib/projects/localDev/LocalDevLogger.ts | 47 ++++++++----------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/lib/projects/localDev/LocalDevLogger.ts b/lib/projects/localDev/LocalDevLogger.ts index bac90e79e..59442abb9 100644 --- a/lib/projects/localDev/LocalDevLogger.ts +++ b/lib/projects/localDev/LocalDevLogger.ts @@ -43,6 +43,16 @@ class LocalDevLogger { 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; @@ -89,48 +99,19 @@ class LocalDevLogger { } fileChangeError(e: unknown): void { - if (this.state.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.fileChangeError( - e instanceof Error ? e.message : '' - ) - ); + this.handleError(e, lib.LocalDevManager.devServer.fileChangeError); } devServerSetupError(e: unknown): void { - if (this.state.debug) { - logger.error(e); - } - - uiLogger.error( - lib.LocalDevManager.devServer.setupError( - e instanceof Error ? e.message : '' - ) - ); + this.handleError(e, lib.LocalDevManager.devServer.setupError); } devServerStartError(e: unknown): void { - if (this.state.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.startError( - e instanceof Error ? e.message : '' - ) - ); + this.handleError(e, lib.LocalDevManager.devServer.startError); } devServerCleanupError(e: unknown): void { - if (this.state.debug) { - logger.error(e); - } - uiLogger.error( - lib.LocalDevManager.devServer.cleanupError( - e instanceof Error ? e.message : '' - ) - ); + this.handleError(e, lib.LocalDevManager.devServer.cleanupError); } noDeployedBuild(): void { From 2963cde41fc18fbc5ec9e8443d2f72a79c900a7d Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Mon, 12 May 2025 11:42:09 -0400 Subject: [PATCH 12/13] Clean up --- lang/en.ts | 21 ++++++++++++--- lib/projects/localDev/LocalDevLogger.ts | 36 ++++++++----------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/lang/en.ts b/lang/en.ts index 0f1f166a7..3bdadb14e 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', diff --git a/lib/projects/localDev/LocalDevLogger.ts b/lib/projects/localDev/LocalDevLogger.ts index 59442abb9..98a9220e2 100644 --- a/lib/projects/localDev/LocalDevLogger.ts +++ b/lib/projects/localDev/LocalDevLogger.ts @@ -1,31 +1,27 @@ 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 chalk from 'chalk'; import { uiLogger } from '../../ui/logger'; import { - uiLink, uiBetaTag, uiLine, - UI_COLORS, uiAccountDescription, uiCommandReference, } from '../../ui'; import { lib } from '../../../lang/en'; import { LocalDevState } from '../../../types/LocalDev'; -import { getProjectDetailUrl } from '../../projects/urls'; import SpinniesManager from '../../ui/SpinniesManager'; class LocalDevLogger { private state: LocalDevState; private mostRecentUploadWarning: string | null; - private uploadWarnings: string[]; + private uploadWarnings: Set; constructor(state: LocalDevState) { this.state = state; this.mostRecentUploadWarning = null; - this.uploadWarnings = []; + this.uploadWarnings = new Set(); } private logUploadInstructions(): void { @@ -67,7 +63,7 @@ class LocalDevLogger { 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 = this.uploadWarnings.join('\n\n'); + 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 @@ -82,7 +78,7 @@ class LocalDevLogger { } addUploadWarning(warning: string): void { - this.uploadWarnings.push(warning); + this.uploadWarnings.add(warning); } missingComponentsWarning(components: string[]): void { @@ -137,29 +133,19 @@ class LocalDevLogger { 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(lib.LocalDevManager.learnMoreLocalDevServer); uiLogger.log(''); uiLogger.log( - chalk.hex(UI_COLORS.SORBET)( - lib.LocalDevManager.running( - this.state.projectConfig.name, - uiAccountDescription(this.state.targetProjectAccountId) - ) + lib.LocalDevManager.running( + this.state.projectConfig.name, + uiAccountDescription(this.state.targetProjectAccountId) ) ); uiLogger.log( - uiLink( - lib.LocalDevManager.viewProjectLink, - getProjectDetailUrl( - this.state.projectConfig.name, - this.state.targetProjectAccountId - ) || '' + lib.LocalDevManager.viewProjectLink( + this.state.projectConfig.name, + this.state.targetProjectAccountId ) ); From 610f4f042fc5c9f46a5dae8c82cafd235e8958ad Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Mon, 12 May 2025 11:48:13 -0400 Subject: [PATCH 13/13] fix type errors --- lang/en.ts | 4 ++-- lib/projects/localDev/LocalDevManager.ts | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lang/en.ts b/lang/en.ts index 3bdadb14e..eac0dbcf9 100644 --- a/lang/en.ts +++ b/lang/en.ts @@ -2736,7 +2736,7 @@ 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, installCount) => + 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.', @@ -2755,7 +2755,7 @@ export const lib = { }, }, AppDevModeInterface: { - defaultMarketplaceAppWarning: installCount => + 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: { diff --git a/lib/projects/localDev/LocalDevManager.ts b/lib/projects/localDev/LocalDevManager.ts index 0940a7f4d..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, @@ -240,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 ) );