Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions commands/project/dev/unifiedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocalDevProcess represents the current local dev session (similar to the old LocalDevManager). Everything that interacts with the LocalDevProcess now lives outside of it and uses public methods to make necessary changes to the process.

This includes

  • Watcher
  • Keypress listener
  • In the future, the websocket server

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a note with that explanation directly into the code.

}
34 changes: 24 additions & 10 deletions lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these translations in the en.lyaml file too? Would it make sense to remove them as a part of this, or should we wait and do that later once everything is ported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been operating under the assumption that we'd just delete the entire file at the end, so haven't been removing things from it. I don't think there's really any need to, especially since its easy to look up what we haven't ported to the new system by searching for i18n(

'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',
Expand All @@ -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')}`,
Expand All @@ -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.`,
Expand All @@ -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`,
Expand Down
191 changes: 191 additions & 0 deletions lib/projects/localDev/AppDevModeInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { fetchAppInstallationData } from '@hubspot/local-dev-lib/api/localDevAuth';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a wrapper around the UIEDevModeInterface and includes all app-specific logic that previously lived in LocalDevManager. This lets us keep LocalDevProcess component agnostic

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<void> {
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<void> {
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<void> {
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<void> {
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;
Loading