From b20ec8b1a4cac1282f31d855e95dfa91911f8bc3 Mon Sep 17 00:00:00 2001 From: Camden Phalen Date: Wed, 18 Dec 2024 15:36:38 -0500 Subject: [PATCH] Convert Oauth, Polling, and Projects to TS (#1306) --- commands/project/__tests__/deploy.test.ts | 10 +- commands/project/deploy.ts | 8 +- commands/project/dev.ts | 2 +- commands/project/listBuilds.ts | 2 +- commands/project/open.ts | 7 +- commands/project/upload.ts | 8 +- commands/project/watch.ts | 16 +- commands/theme/preview.ts | 2 +- lang/en.lyaml | 32 +- lib/DevServerManager.ts | 2 +- lib/LocalDevManager.ts | 4 +- lib/constants.ts | 4 +- lib/doctor/DiagnosticInfoBuilder.ts | 12 +- lib/doctor/Doctor.ts | 10 +- lib/doctor/__tests__/Diagnosis.test.ts | 2 + .../__tests__/DiagnosticInfoBuilder.test.ts | 14 +- lib/doctor/__tests__/Doctor.test.ts | 36 +- .../DiagnosticInfoBuilder.test.ts.snap | 2 + lib/localDev.ts | 16 +- lib/oauth.ts | 94 +- lib/polling.ts | 26 +- lib/projects.ts | 1057 ----------------- lib/projects/buildAndDeploy.ts | 610 ++++++++++ lib/projects/index.ts | 372 ++++++ .../structure.ts} | 8 +- lib/projects/upload.ts | 177 +++ lib/projects/urls.ts | 42 + lib/{projectsWatch.ts => projects/watch.ts} | 6 +- lib/prompts/accountNamePrompt.ts | 2 +- lib/prompts/accountsPrompt.ts | 2 +- lib/prompts/createApiSamplePrompt.ts | 10 +- lib/prompts/createFunctionPrompt.ts | 2 +- lib/prompts/createModulePrompt.ts | 4 +- lib/prompts/createProjectPrompt.ts | 40 +- lib/prompts/createTemplatePrompt.ts | 4 +- lib/prompts/personalAccessKeyPrompt.ts | 25 +- lib/prompts/projectAddPrompt.ts | 11 +- lib/prompts/projectDevTargetAccountPrompt.ts | 6 +- lib/prompts/promptUtils.ts | 2 +- lib/prompts/sandboxesPrompt.ts | 2 +- package.json | 6 +- types/Projects.ts | 51 + types/{prompts.ts => Prompts.ts} | 0 yarn.lock | 66 +- 44 files changed, 1549 insertions(+), 1265 deletions(-) delete mode 100644 lib/projects.ts create mode 100644 lib/projects/buildAndDeploy.ts create mode 100644 lib/projects/index.ts rename lib/{projectStructure.ts => projects/structure.ts} (96%) create mode 100644 lib/projects/upload.ts create mode 100644 lib/projects/urls.ts rename lib/{projectsWatch.ts => projects/watch.ts} (97%) create mode 100644 types/Projects.ts rename types/{prompts.ts => Prompts.ts} (100%) diff --git a/commands/project/__tests__/deploy.test.ts b/commands/project/__tests__/deploy.test.ts index 124c71a45..fe8f3dbe5 100644 --- a/commands/project/__tests__/deploy.test.ts +++ b/commands/project/__tests__/deploy.test.ts @@ -18,11 +18,9 @@ const { addUseEnvironmentOptions, } = require('../../../lib/commonOpts'); const { loadAndValidateOptions } = require('../../../lib/validation'); -const { - getProjectConfig, - pollDeployStatus, - getProjectDetailUrl, -} = require('../../../lib/projects'); +const { getProjectConfig } = require('../../../lib/projects'); +const { getProjectDetailUrl } = require('../../../lib/projects/urls'); +const { pollDeployStatus } = require('../../../lib/projects/buildAndDeploy'); const { projectNamePrompt } = require('../../../lib/prompts/projectNamePrompt'); const { promptUser } = require('../../../lib/prompts/promptUtils'); const { trackCommandUsage } = require('../../../lib/usageTracking'); @@ -35,6 +33,8 @@ jest.mock('@hubspot/local-dev-lib/config'); jest.mock('../../../lib/commonOpts'); jest.mock('../../../lib/validation'); jest.mock('../../../lib/projects'); +jest.mock('../../../lib/projects/urls'); +jest.mock('../../../lib/projects/buildAndDeploy'); jest.mock('../../../lib/prompts/projectNamePrompt'); jest.mock('../../../lib/prompts/promptUtils'); jest.mock('../../../lib/usageTracking'); diff --git a/commands/project/deploy.ts b/commands/project/deploy.ts index 1114b61a4..0e3831b2e 100644 --- a/commands/project/deploy.ts +++ b/commands/project/deploy.ts @@ -13,11 +13,9 @@ const { fetchProject, } = require('@hubspot/local-dev-lib/api/projects'); const { loadAndValidateOptions } = require('../../lib/validation'); -const { - getProjectConfig, - pollDeployStatus, - getProjectDetailUrl, -} = require('../../lib/projects'); +const { getProjectConfig } = require('../../lib/projects'); +const { pollDeployStatus } = require('../../lib/projects/buildAndDeploy'); +const { getProjectDetailUrl } = require('../../lib/projects/urls'); const { projectNamePrompt } = require('../../lib/prompts/projectNamePrompt'); const { promptUser } = require('../../lib/prompts/promptUtils'); const { i18n } = require('../../lib/lang'); diff --git a/commands/project/dev.ts b/commands/project/dev.ts index cc2a8545b..5b8843bee 100644 --- a/commands/project/dev.ts +++ b/commands/project/dev.ts @@ -36,7 +36,7 @@ const { findProjectComponents, getProjectComponentTypes, COMPONENT_TYPES, -} = require('../../lib/projectStructure'); +} = require('../../lib/projects/structure'); const { confirmDefaultAccountIsTarget, suggestRecommendedNestedAccount, diff --git a/commands/project/listBuilds.ts b/commands/project/listBuilds.ts index b949f619f..956c34033 100644 --- a/commands/project/listBuilds.ts +++ b/commands/project/listBuilds.ts @@ -17,9 +17,9 @@ const { uiBetaTag, uiLink } = require('../../lib/ui'); const { loadAndValidateOptions } = require('../../lib/validation'); const { getProjectConfig, - getProjectDetailUrl, validateProjectConfig, } = require('../../lib/projects'); +const { getProjectDetailUrl } = require('../../lib/projects/urls'); const moment = require('moment'); const { promptUser } = require('../../lib/prompts/promptUtils'); const { isHubSpotHttpError } = require('@hubspot/local-dev-lib/errors/index'); diff --git a/commands/project/open.ts b/commands/project/open.ts index 63c01d45b..370eacf94 100644 --- a/commands/project/open.ts +++ b/commands/project/open.ts @@ -10,11 +10,8 @@ const { trackCommandUsage } = require('../../lib/usageTracking'); const { loadAndValidateOptions } = require('../../lib/validation'); const { i18n } = require('../../lib/lang'); const { logger } = require('@hubspot/local-dev-lib/logger'); -const { - getProjectConfig, - getProjectDetailUrl, - ensureProjectExists, -} = require('../../lib/projects'); +const { getProjectConfig, ensureProjectExists } = require('../../lib/projects'); +const { getProjectDetailUrl } = require('../../lib/projects/urls'); const { projectNamePrompt } = require('../../lib/prompts/projectNamePrompt'); const { uiBetaTag } = require('../../lib/ui'); const { EXIT_CODES } = require('../../lib/enums/exitCodes'); diff --git a/commands/project/upload.ts b/commands/project/upload.ts index cad644610..29b349e32 100644 --- a/commands/project/upload.ts +++ b/commands/project/upload.ts @@ -12,12 +12,14 @@ const { loadAndValidateOptions } = require('../../lib/validation'); const { ensureProjectExists, getProjectConfig, - handleProjectUpload, logFeedbackMessage, validateProjectConfig, - pollProjectBuildAndDeploy, - displayWarnLogs, } = require('../../lib/projects'); +const { handleProjectUpload } = require('../../lib/projects/upload'); +const { + displayWarnLogs, + pollProjectBuildAndDeploy, +} = require('../../lib/projects/buildAndDeploy'); const { i18n } = require('../../lib/lang'); const { getAccountConfig } = require('@hubspot/local-dev-lib/config'); const { isSpecifiedError } = require('@hubspot/local-dev-lib/errors/index'); diff --git a/commands/project/watch.ts b/commands/project/watch.ts index 1d67ecf3d..96c218303 100644 --- a/commands/project/watch.ts +++ b/commands/project/watch.ts @@ -1,6 +1,6 @@ // @ts-nocheck const { i18n } = require('../../lib/lang'); -const { createWatcher } = require('../../lib/projectsWatch'); +const { createWatcher } = require('../../lib/projects/watch'); const { logError, ApiErrorContext } = require('../../lib/errorHandlers/index'); const { logger } = require('@hubspot/local-dev-lib/logger'); const { PROJECT_ERROR_TYPES } = require('../../lib/constants'); @@ -14,12 +14,14 @@ const { uiBetaTag } = require('../../lib/ui'); const { ensureProjectExists, getProjectConfig, - handleProjectUpload, - pollBuildStatus, - pollDeployStatus, validateProjectConfig, logFeedbackMessage, } = require('../../lib/projects'); +const { handleProjectUpload } = require('../../lib/projects/upload'); +const { + pollBuildStatus, + pollDeployStatus, +} = require('../../lib/projects/buildAndDeploy'); const { cancelStagedBuild, fetchProjectBuilds, @@ -35,10 +37,8 @@ exports.command = 'watch'; exports.describe = uiBetaTag(i18n(`${i18nKey}.describe`), false); const handleBuildStatus = async (accountId, projectName, buildId) => { - const { - isAutoDeployEnabled, - deployStatusTaskLocator, - } = await pollBuildStatus(accountId, projectName, buildId); + const { isAutoDeployEnabled, deployStatusTaskLocator } = + await pollBuildStatus(accountId, projectName, buildId); if (isAutoDeployEnabled && deployStatusTaskLocator) { await pollDeployStatus( diff --git a/commands/theme/preview.ts b/commands/theme/preview.ts index 1335755cc..a88625942 100644 --- a/commands/theme/preview.ts +++ b/commands/theme/preview.ts @@ -24,7 +24,7 @@ const { getProjectConfig } = require('../../lib/projects'); const { findProjectComponents, COMPONENT_TYPES, -} = require('../../lib/projectStructure'); +} = require('../../lib/projects/structure'); const { preview } = require('@hubspot/theme-preview-dev-server'); const { hasFeature } = require('../../lib/hasFeature'); const i18nKey = 'commands.theme.subcommands.preview'; diff --git a/lang/en.lyaml b/lang/en.lyaml index d7078bf9c..ee6bbdbcf 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -1094,16 +1094,6 @@ en: checkIfParentAccountIsAuthed: notAuthedError: "To develop this project locally, run {{ authCommand }} to authenticate the App Developer Account {{ accountId }} associated with {{ accountIdentifier }}." projects: - uploadProjectFiles: - add: "Uploading {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" - fail: "Failed to upload {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" - succeed: "Uploaded {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" - buildCreated: "Project \"{{ projectName }}\" uploaded and build #{{ buildId }} created" - handleProjectUpload: - emptySource: "Source directory \"{{ srcDir }}\" is empty. Add files to your project and rerun `{{#yellow}}hs project upload{{/yellow}}` to upload them to HubSpot." - compressed: "Project files compressed: {{ byteCount }} bytes" - compressing: "Compressing build files to \"{{ path }}\"" - fileFiltered: "Ignore rule triggered for \"{{ filename }}\"" validateProjectConfig: configNotFound: "Unable to locate a project configuration file. Try running again from a project directory, or run {{ createCommand }} to create a new project." configMissingFields: "The project configuruation file is missing required fields." @@ -1116,6 +1106,11 @@ en: notFound: "Your project {{#bold}}{{ projectName }}{{/bold}} could not be found in {{#bold}}{{ accountIdentifier }}{{/bold}}." pollFetchProject: checkingProject: "Checking if project exists in {{ accountIdentifier }}" + unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}." + logFeedbackMessage: + feedbackHeader: "We'd love to hear your feedback!" + feedbackMessage: "How are you liking the new projects and developer tools? \n > Run `{{#yellow}}hs feedback{{/yellow}}` to let us know what you think!\n" + projectBuildAndDeploy: makePollTaskStatusFunc: componentCountSingular: "Found 1 component in this project" componentCount: "Found {{ numComponents }} components in this project" @@ -1127,10 +1122,17 @@ en: buildSucceededAutomaticallyDeploying: "Build #{{ buildId }} succeeded. {{#bold}}Automatically deploying{{/bold}} to {{ accountIdentifier }}\n" cleanedUpTempFile: "Cleaned up temporary file {{ path }}" viewDeploys: "View all deploys for this project in HubSpot" - unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}." - logFeedbackMessage: - feedbackHeader: "We'd love to hear your feedback!" - feedbackMessage: "How are you liking the new projects and developer tools? \n > Run `{{#yellow}}hs feedback{{/yellow}}` to let us know what you think!\n" + projectUpload: + uploadProjectFiles: + add: "Uploading {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" + fail: "Failed to upload {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" + succeed: "Uploaded {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" + buildCreated: "Project \"{{ projectName }}\" uploaded and build #{{ buildId }} created" + handleProjectUpload: + emptySource: "Source directory \"{{ srcDir }}\" is empty. Add files to your project and rerun `{{#yellow}}hs project upload{{/yellow}}` to upload them to HubSpot." + compressed: "Project files compressed: {{ byteCount }} bytes" + compressing: "Compressing build files to \"{{ path }}\"" + fileFiltered: "Ignore rule triggered for \"{{ filename }}\"" ui: betaTag: "{{#bold}}[BETA]{{/bold}}" betaWarning: @@ -1551,6 +1553,8 @@ en: counts: errors: '{{#bold}}Errors:{{/bold}} {{ count }}' warnings: "{{#bold}}Warning:{{/bold}} {{ count }}" + oauth: + missingClientId: "Error building oauth URL: missing client ID." diff --git a/lib/DevServerManager.ts b/lib/DevServerManager.ts index 23c7d392a..525cf5751 100644 --- a/lib/DevServerManager.ts +++ b/lib/DevServerManager.ts @@ -1,6 +1,6 @@ // @ts-nocheck const { logger } = require('@hubspot/local-dev-lib/logger'); -const { COMPONENT_TYPES } = require('./projectStructure'); +const { COMPONENT_TYPES } = require('./projects/structure'); const { i18n } = require('./lang'); const { promptUser } = require('./prompts/promptUtils'); const { DevModeInterface } = require('@hubspot/ui-extensions-dev-server'); diff --git a/lib/LocalDevManager.ts b/lib/LocalDevManager.ts index 8958d491d..5503f6c10 100644 --- a/lib/LocalDevManager.ts +++ b/lib/LocalDevManager.ts @@ -20,13 +20,13 @@ const { PROJECT_CONFIG_FILE } = require('./constants'); const SpinniesManager = require('./ui/SpinniesManager'); const DevServerManager = require('./DevServerManager'); const { EXIT_CODES } = require('./enums/exitCodes'); -const { getProjectDetailUrl } = require('./projects'); +const { getProjectDetailUrl } = require('./projects/urls'); const { getAccountHomeUrl } = require('./localDev'); const { CONFIG_FILES, COMPONENT_TYPES, getAppCardConfigs, -} = require('./projectStructure'); +} = require('./projects/structure'); const { UI_COLORS, uiCommandReference, diff --git a/lib/constants.ts b/lib/constants.ts index 754a41d85..94c30f6ad 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -61,12 +61,12 @@ export const PROJECT_ERROR_TYPES = { SUBDEPLOY_FAILED: 'DeployPipelineErrorType.DEPENDENT_SUBDEPLOY_FAILED', } as const; -export const PROJECT_TASK_TYPES = { +export const PROJECT_TASK_TYPES: { [key: string]: string } = { PRIVATE_APP: 'private app', PUBLIC_APP: 'public app', APP_FUNCTION: 'function', CRM_CARD_V2: 'card', -} as const; +}; export const PROJECT_COMPONENT_TYPES = { PROJECTS: 'projects', diff --git a/lib/doctor/DiagnosticInfoBuilder.ts b/lib/doctor/DiagnosticInfoBuilder.ts index 6c05a38ac..d63c05e89 100644 --- a/lib/doctor/DiagnosticInfoBuilder.ts +++ b/lib/doctor/DiagnosticInfoBuilder.ts @@ -87,12 +87,14 @@ export class DiagnosticInfoBuilder { } async generateDiagnosticInfo(): Promise { - // @ts-expect-error getProjectConfig not typed yet this._projectConfig = await getProjectConfig(); if (this._projectConfig?.projectConfig) { await this.fetchProjectDetails(); await this.fetchAccessToken(); + } + + if (this._projectConfig?.projectDir) { await this.fetchProjectFilenames(); } @@ -134,7 +136,8 @@ export class DiagnosticInfoBuilder { try { const { data } = await fetchProject( this.accountId!, - this._projectConfig?.projectConfig?.name + // We check that config exists before running this function + this._projectConfig!.projectConfig!.name ); this.projectDetails = data; } catch (e) { @@ -158,10 +161,11 @@ export class DiagnosticInfoBuilder { private async fetchProjectFilenames(): Promise { try { - this.files = (await walk(this._projectConfig?.projectDir)) + // We check that projectDir exists before running this function + this.files = (await walk(this._projectConfig!.projectDir!)) .filter(file => !path.dirname(file).includes('node_modules')) .map(filename => - path.relative(this._projectConfig?.projectDir, filename) + path.relative(this._projectConfig!.projectDir!, filename) ); } catch (e) { logger.debug(e); diff --git a/lib/doctor/Doctor.ts b/lib/doctor/Doctor.ts index 34a3cce0e..dea9bdbc3 100644 --- a/lib/doctor/Doctor.ts +++ b/lib/doctor/Doctor.ts @@ -51,7 +51,8 @@ export class Doctor { text: i18n(`${i18nKey}.runningDiagnostics`), }); - this.diagnosticInfo = await this.diagnosticInfoBuilder.generateDiagnosticInfo(); + this.diagnosticInfo = + await this.diagnosticInfoBuilder.generateDiagnosticInfo(); this.projectConfig = this.diagnosticInfo?.project.config; @@ -279,7 +280,7 @@ export class Doctor { const packageDirName = path.dirname(packageFile); try { const needsInstall = await hasMissingPackages( - path.join(this.projectConfig?.projectDir, packageDirName) + path.join(this.projectConfig?.projectDir || '', packageDirName) ); if (needsInstall) { @@ -343,7 +344,10 @@ export class Doctor { private async checkProjectConfigJsonFiles(): Promise { let foundError = false; for (const jsonFile of this.diagnosticInfo?.jsonFiles || []) { - const fileToCheck = path.join(this.projectConfig?.projectDir, jsonFile); + const fileToCheck = path.join( + this.projectConfig?.projectDir || '', + jsonFile + ); if (!(await this.isValidJsonFile(fileToCheck))) { foundError = true; this.diagnosis?.addProjectSection({ diff --git a/lib/doctor/__tests__/Diagnosis.test.ts b/lib/doctor/__tests__/Diagnosis.test.ts index 6fe6ebc1f..a9985c4a4 100644 --- a/lib/doctor/__tests__/Diagnosis.test.ts +++ b/lib/doctor/__tests__/Diagnosis.test.ts @@ -28,6 +28,8 @@ describe('lib/doctor/Diagnosis', () => { projectDir: 'project-dir', projectConfig: { name: 'Super cool project', + srcDir: 'project-dir', + platformVersion: 'test', }, }, }, diff --git a/lib/doctor/__tests__/DiagnosticInfoBuilder.test.ts b/lib/doctor/__tests__/DiagnosticInfoBuilder.test.ts index 34e90cb70..ac741a807 100644 --- a/lib/doctor/__tests__/DiagnosticInfoBuilder.test.ts +++ b/lib/doctor/__tests__/DiagnosticInfoBuilder.test.ts @@ -26,7 +26,7 @@ import { AccessToken, CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; import { getProjectConfig as _getProjectConfig } from '../../projects'; import { fetchProject as _fetchProject } from '@hubspot/local-dev-lib/api/projects'; import { Project } from '@hubspot/local-dev-lib/types/Project'; -import { AxiosPromise } from 'axios'; +import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; const walk = _walk as jest.MockedFunction; const getAccessToken = _getAccessToken as jest.MockedFunction< @@ -124,6 +124,8 @@ describe('lib/doctor/DiagnosticInfo', () => { projectDir, projectConfig: { name: 'My project', + srcDir: 'project-dir', + platformVersion: 'test', }, }; @@ -133,7 +135,7 @@ describe('lib/doctor/DiagnosticInfo', () => { deployedBuildId: 1, id: 8989898, isLocked: false, - name: projectConfig.projectConfig.name, + name: projectConfig!.projectConfig!.name, portalId: accountId, updatedAt: 12345, }; @@ -143,16 +145,16 @@ describe('lib/doctor/DiagnosticInfo', () => { accountType: 'STANDARD', encodedOAuthRefreshToken: '', expiresAt: '', - hubName: projectConfig.projectConfig.name, + hubName: projectConfig!.projectConfig!.name, portalId: accountId, scopeGroups: [], enabledFeatures: {}, }; getProjectConfig.mockResolvedValue(projectConfig); - fetchProject.mockResolvedValue(({ + fetchProject.mockResolvedValue({ data: projectDetails, - } as unknown) as AxiosPromise); + } as unknown as HubSpotPromise); getAccessToken.mockResolvedValue(accessToken); getConfigPath.mockReturnValue(configPath); utilPromisify.mockReturnValue(jest.fn().mockResolvedValue(npmVersion)); @@ -166,7 +168,7 @@ describe('lib/doctor/DiagnosticInfo', () => { expect(fetchProject).toHaveBeenCalledTimes(1); expect(fetchProject).toHaveBeenCalledWith( accountId, - projectConfig!.projectConfig.name + projectConfig!.projectConfig!.name ); expect(getAccessToken).toHaveBeenCalledTimes(1); diff --git a/lib/doctor/__tests__/Doctor.test.ts b/lib/doctor/__tests__/Doctor.test.ts index cf6fc19ef..18977976e 100644 --- a/lib/doctor/__tests__/Doctor.test.ts +++ b/lib/doctor/__tests__/Doctor.test.ts @@ -28,17 +28,19 @@ jest.mock('util', () => ({ const hasMissingPackages = _hasMissingPackages as jest.MockedFunction< typeof _hasMissingPackages >; -const isPortManagerPortAvailable = _isPortManagerPortAvailable as jest.MockedFunction< - typeof _isPortManagerPortAvailable ->; +const isPortManagerPortAvailable = + _isPortManagerPortAvailable as jest.MockedFunction< + typeof _isPortManagerPortAvailable + >; const utilPromisify = util.promisify as jest.MockedFunction< typeof util.promisify >; -const accessTokenForPersonalAccessKey = _accessTokenForPersonalAccessKey as jest.MockedFunction< - typeof _accessTokenForPersonalAccessKey ->; +const accessTokenForPersonalAccessKey = + _accessTokenForPersonalAccessKey as jest.MockedFunction< + typeof _accessTokenForPersonalAccessKey + >; const isSpecifiedError = _isSpecifiedError as jest.MockedFunction< typeof _isSpecifiedError @@ -64,7 +66,11 @@ describe('lib/doctor/Doctor', () => { project: { config: { projectDir: '/path/to/project', - projectConfig: {}, + projectConfig: { + name: 'my-project', + srcDir: '/path/to/project', + platformVersion: 'test', + }, }, }, versions: { @@ -75,11 +81,11 @@ describe('lib/doctor/Doctor', () => { }; beforeEach(() => { - doctor = new Doctor(({ + doctor = new Doctor({ generateDiagnosticInfo: jest.fn().mockResolvedValue({ ...diagnosticInfo, }), - } as unknown) as DiagnosticInfoBuilder); + } as unknown as DiagnosticInfoBuilder); utilPromisify.mockReturnValue( jest.fn().mockImplementationOnce((filename: string) => { @@ -104,12 +110,12 @@ describe('lib/doctor/Doctor', () => { }); it('should add error section if node version is not available', async () => { - doctor = new Doctor(({ + doctor = new Doctor({ generateDiagnosticInfo: jest.fn().mockResolvedValue({ ...diagnosticInfo, versions: {}, }), - } as unknown) as DiagnosticInfoBuilder); + } as unknown as DiagnosticInfoBuilder); await doctor.diagnose(); @@ -121,12 +127,12 @@ describe('lib/doctor/Doctor', () => { }); it('should add error section if minimum node version is not met', async () => { - doctor = new Doctor(({ + doctor = new Doctor({ generateDiagnosticInfo: jest.fn().mockResolvedValue({ ...diagnosticInfo, versions: { node: '1.0.0' }, }), - } as unknown) as DiagnosticInfoBuilder); + } as unknown as DiagnosticInfoBuilder); await doctor.diagnose(); @@ -149,12 +155,12 @@ describe('lib/doctor/Doctor', () => { }); it('should add error section if npm is not installed', async () => { - doctor = new Doctor(({ + doctor = new Doctor({ generateDiagnosticInfo: jest.fn().mockResolvedValue({ ...diagnosticInfo, versions: {}, }), - } as unknown) as DiagnosticInfoBuilder); + } as unknown as DiagnosticInfoBuilder); await doctor.diagnose(); diff --git a/lib/doctor/__tests__/__snapshots__/DiagnosticInfoBuilder.test.ts.snap b/lib/doctor/__tests__/__snapshots__/DiagnosticInfoBuilder.test.ts.snap index 46dcb2477..4bef91f57 100644 --- a/lib/doctor/__tests__/__snapshots__/DiagnosticInfoBuilder.test.ts.snap +++ b/lib/doctor/__tests__/__snapshots__/DiagnosticInfoBuilder.test.ts.snap @@ -58,6 +58,8 @@ exports[`lib/doctor/DiagnosticInfo generateDiagnosticInfo should gather the requ "config": { "projectConfig": { "name": "My project", + "platformVersion": "test", + "srcDir": "project-dir", }, "projectDir": "/Users/test/project", }, diff --git a/lib/localDev.ts b/lib/localDev.ts index d983cad74..0f9d7ebd2 100644 --- a/lib/localDev.ts +++ b/lib/localDev.ts @@ -38,10 +38,8 @@ const { isAppDeveloperAccount, isDeveloperTestAccount, } = require('./accountTypes'); -const { - handleProjectUpload, - pollProjectBuildAndDeploy, -} = require('./projects'); +const { handleProjectUpload } = require('./projects/upload'); +const { pollProjectBuildAndDeploy } = require('./projects/buildAndDeploy'); const { PROJECT_ERROR_TYPES, PROJECT_BUILD_TEXT, @@ -248,9 +246,8 @@ const createDeveloperTestAccountForLocalDev = async ( let currentPortalCount = 0; let maxTestPortals = 10; try { - const validateResult = await validateDevTestAccountUsageLimits( - accountConfig - ); + const validateResult = + await validateDevTestAccountUsageLimits(accountConfig); if (validateResult) { currentPortalCount = validateResult.results ? validateResult.results.length @@ -306,9 +303,8 @@ const createDeveloperTestAccountForLocalDev = async ( // Prompt user to confirm usage of an existing developer test account that is not currently in the config const useExistingDevTestAccount = async (env, account) => { - const useExistingDevTestAcct = await confirmUseExistingDeveloperTestAccountPrompt( - account - ); + const useExistingDevTestAcct = + await confirmUseExistingDeveloperTestAccountPrompt(account); if (!useExistingDevTestAcct) { logger.log(''); logger.log( diff --git a/lib/oauth.ts b/lib/oauth.ts index c843c1e7a..875ec08a4 100644 --- a/lib/oauth.ts +++ b/lib/oauth.ts @@ -1,51 +1,66 @@ -// @ts-nocheck -const express = require('express'); -const open = require('open'); -const { - OAuth2Manager, -} = require('@hubspot/local-dev-lib/models/OAuth2Manager'); -const { getAccountConfig } = require('@hubspot/local-dev-lib/config'); -const { - getAccountIdentifier, -} = require('@hubspot/local-dev-lib/config/getAccountIdentifier'); -const { addOauthToAccountConfig } = require('@hubspot/local-dev-lib/oauth'); -const { handleExit } = require('./process'); -const { getHubSpotWebsiteOrigin } = require('@hubspot/local-dev-lib/urls'); -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { - ENVIRONMENTS, -} = require('@hubspot/local-dev-lib/constants/environments'); +import express from 'express'; +import open from 'open'; +import { OAuth2Manager } from '@hubspot/local-dev-lib/models/OAuth2Manager'; +import { getAccountConfig } from '@hubspot/local-dev-lib/config'; +import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountIdentifier'; +import { addOauthToAccountConfig } from '@hubspot/local-dev-lib/oauth'; +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { ENVIRONMENTS } from '@hubspot/local-dev-lib/constants/environments'; +import { DEFAULT_OAUTH_SCOPES } from '@hubspot/local-dev-lib/constants/auth'; +import { OAuth2ManagerAccountConfig } from '@hubspot/local-dev-lib/types/Accounts'; +import { Server } from 'http'; + +import { handleExit } from './process'; +import { i18n } from './lang'; +import { EXIT_CODES } from './enums/exitCodes'; const PORT = 3000; const redirectUri = `http://localhost:${PORT}/oauth-callback`; -const buildAuthUrl = oauthManager => { +const i18nKey = 'lib.oauth'; + +function buildAuthUrl(oauthManager: OAuth2Manager): string { + const { + env: accountEnv, + clientId, + scopes: accountScopes, + } = oauthManager.account; + + const env = accountEnv || ENVIRONMENTS.PROD; + const scopes = accountScopes || DEFAULT_OAUTH_SCOPES; + + if (!clientId) { + logger.error(i18n(`${i18nKey}.missingClientId`)); + process.exit(EXIT_CODES.ERROR); + } + return ( - `${getHubSpotWebsiteOrigin(oauthManager.account.env)}/oauth/${ + `${getHubSpotWebsiteOrigin(env)}/oauth/${ oauthManager.account.accountId }/authorize` + - `?client_id=${encodeURIComponent(oauthManager.account.clientId)}` + // app's client ID - `&scope=${encodeURIComponent(oauthManager.account.scopes.join(' '))}` + // scopes being requested by the app + `?client_id=${encodeURIComponent(clientId)}` + // app's client ID + `&scope=${encodeURIComponent(scopes.join(' '))}` + // scopes being requested by the app `&redirect_uri=${encodeURIComponent(redirectUri)}` // where to send the user after the consent page ); -}; +} -const handleServerOnProcessEnd = server => { +function handleServerOnProcessEnd(server: Server): void { const shutdownServerIfRunning = () => { server?.close(); }; handleExit(shutdownServerIfRunning); -}; +} -const authorize = async oauthManager => { +async function authorize(oauthManager: OAuth2Manager): Promise { if (process.env.BROWSER !== 'none') { open(buildAuthUrl(oauthManager), { url: true }); } // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - let server; + return new Promise(async (resolve, reject) => { + let server: Server | null; const app = express(); app.get('/oauth-callback', async (req, res) => { @@ -87,25 +102,22 @@ const authorize = async oauthManager => { handleServerOnProcessEnd(server); }); -}; +} -const setupOauth = accountConfig => { - const id = getAccountIdentifier(accountConfig); - const accountId = parseInt(id, 10); - const config = getAccountConfig(accountId) || {}; +function setupOauth(accountConfig: OAuth2ManagerAccountConfig): OAuth2Manager { + const accountId = getAccountIdentifier(accountConfig); + const config = getAccountConfig(accountId); return new OAuth2Manager({ ...accountConfig, - environment: accountConfig.env || config.env || ENVIRONMENTS.PROD, + env: accountConfig.env || config?.env || ENVIRONMENTS.PROD, }); -}; +} -const authenticateWithOauth = async configData => { - const oauthManager = setupOauth(configData); +export async function authenticateWithOauth( + accountConfig: OAuth2ManagerAccountConfig +): Promise { + const oauthManager = setupOauth(accountConfig); logger.log('Authorizing'); await authorize(oauthManager); addOauthToAccountConfig(oauthManager); -}; - -module.exports = { - authenticateWithOauth, -}; +} diff --git a/lib/polling.ts b/lib/polling.ts index a62d2bbd6..baced84f2 100644 --- a/lib/polling.ts +++ b/lib/polling.ts @@ -1,7 +1,21 @@ -// @ts-nocheck -const { POLLING_DELAY, POLLING_STATUS } = require('./constants'); +import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; +import { ValueOf } from '@hubspot/local-dev-lib/types/Utils'; +import { POLLING_DELAY, POLLING_STATUS } from './constants'; -const poll = (callback, accountId, taskId) => { +type GenericPollingResponse = { + status: ValueOf; +}; + +type PollingCallback = ( + accountId: number, + taskId: number | string +) => HubSpotPromise; + +export function poll( + callback: PollingCallback, + accountId: number, + taskId: number | string +): Promise { return new Promise((resolve, reject) => { const pollInterval = setInterval(async () => { try { @@ -25,8 +39,4 @@ const poll = (callback, accountId, taskId) => { } }, POLLING_DELAY); }); -}; - -module.exports = { - poll, -}; +} diff --git a/lib/projects.ts b/lib/projects.ts deleted file mode 100644 index b560635cf..000000000 --- a/lib/projects.ts +++ /dev/null @@ -1,1057 +0,0 @@ -// @ts-nocheck -const fs = require('fs-extra'); -const path = require('path'); -const archiver = require('archiver'); -const tmp = require('tmp'); -const chalk = require('chalk'); -const findup = require('findup-sync'); -const { logger } = require('@hubspot/local-dev-lib/logger'); -const { getEnv } = require('@hubspot/local-dev-lib/config'); -const { getHubSpotWebsiteOrigin } = require('@hubspot/local-dev-lib/urls'); -const { fetchFileFromRepository } = require('@hubspot/local-dev-lib/github'); -const { - ENVIRONMENTS, -} = require('@hubspot/local-dev-lib/constants/environments'); -const { - FEEDBACK_INTERVAL, - POLLING_DELAY, - PROJECT_BUILD_TEXT, - PROJECT_DEPLOY_TEXT, - PROJECT_CONFIG_FILE, - PROJECT_TASK_TYPES, - PROJECT_ERROR_TYPES, - HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, - PROJECT_COMPONENT_TYPES, -} = require('./constants'); -const { - createProject, - getBuildStatus, - getBuildStructure, - getDeployStatus, - getDeployStructure, - fetchProject, - uploadProject, - fetchBuildWarnLogs, - fetchDeployWarnLogs, -} = require('@hubspot/local-dev-lib/api/projects'); -const { isSpecifiedError } = require('@hubspot/local-dev-lib/errors/index'); -const { shouldIgnoreFile } = require('@hubspot/local-dev-lib/ignoreRules'); -const { getCwd, getAbsoluteFilePath } = require('@hubspot/local-dev-lib/path'); -const { downloadGithubRepoContents } = require('@hubspot/local-dev-lib/github'); -const { promptUser } = require('./prompts/promptUtils'); -const { EXIT_CODES } = require('./enums/exitCodes'); -const { - uiLine, - uiLink, - uiAccountDescription, - uiCommandReference, -} = require('./ui'); -const { i18n } = require('./lang'); -const SpinniesManager = require('./ui/SpinniesManager'); -const { logError, ApiErrorContext } = require('./errorHandlers/index'); - -const i18nKey = 'lib.projects'; - -const SPINNER_STATUS = { - SPINNING: 'spinning', -}; - -const writeProjectConfig = (configPath, config) => { - try { - fs.ensureFileSync(configPath); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - logger.debug(`Wrote project config at ${configPath}`); - } catch (e) { - logger.debug(e); - return false; - } - return true; -}; - -const getIsInProject = _dir => { - const configPath = getProjectConfigPath(_dir); - return !!configPath; -}; - -const getProjectConfigPath = _dir => { - const projectDir = _dir ? getAbsoluteFilePath(_dir) : getCwd(); - - const configPath = findup(PROJECT_CONFIG_FILE, { - cwd: projectDir, - nocase: true, - }); - - return configPath; -}; - -export const getProjectConfig = async _dir => { - const configPath = await getProjectConfigPath(_dir); - if (!configPath) { - return { projectConfig: null, projectDir: null }; - } - - try { - const config = fs.readFileSync(configPath); - const projectConfig = JSON.parse(config); - return { - projectDir: path.dirname(configPath), - projectConfig, - }; - } catch (e) { - logger.error('Could not read from project config'); - } -}; - -const createProjectConfig = async ( - projectPath, - projectName, - template, - templateSource, - githubRef -) => { - const { projectConfig, projectDir } = await getProjectConfig(projectPath); - - if (projectConfig) { - logger.warn( - projectPath === projectDir - ? 'A project already exists in that location.' - : `Found an existing project definition in ${projectDir}.` - ); - - const { shouldContinue } = await promptUser([ - { - name: 'shouldContinue', - message: () => { - return projectPath === projectDir - ? 'Do you want to overwrite the existing project definition with a new one?' - : `Continue creating a new project in ${projectPath}?`; - }, - type: 'confirm', - default: false, - }, - ]); - - if (!shouldContinue) { - return false; - } - } - - const projectConfigPath = path.join(projectPath, PROJECT_CONFIG_FILE); - - logger.log( - `Creating project config in ${ - projectPath ? projectPath : 'the current folder' - }` - ); - - const hasCustomTemplateSource = Boolean(templateSource); - - await downloadGithubRepoContents( - templateSource || HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, - template.path, - projectPath, - hasCustomTemplateSource ? undefined : githubRef - ); - const _config = JSON.parse(fs.readFileSync(projectConfigPath)); - writeProjectConfig(projectConfigPath, { - ..._config, - name: projectName, - }); - - if (template.name === 'no-template') { - fs.ensureDirSync(path.join(projectPath, 'src')); - } - - return true; -}; - -const validateProjectConfig = (projectConfig, projectDir) => { - if (!projectConfig) { - logger.error( - i18n(`${i18nKey}.validateProjectConfig.configNotFound`, { - createCommand: uiCommandReference('hs project create'), - }) - ); - return process.exit(EXIT_CODES.ERROR); - } - - if (!projectConfig.name || !projectConfig.srcDir) { - logger.error(i18n(`${i18nKey}.validateProjectConfig.configMissingFields`)); - return process.exit(EXIT_CODES.ERROR); - } - - const resolvedPath = path.resolve(projectDir, projectConfig.srcDir); - if (!resolvedPath.startsWith(projectDir)) { - const projectConfigFile = path.relative( - '.', - path.join(projectDir, PROJECT_CONFIG_FILE) - ); - logger.error( - i18n(`${i18nKey}.validateProjectConfig.srcOutsideProjectDir`, { - srcDir: projectConfig.srcDir, - projectConfig: projectConfigFile, - }) - ); - return process.exit(EXIT_CODES.ERROR); - } - - if (!fs.existsSync(resolvedPath)) { - logger.error( - i18n(`${i18nKey}.validateProjectConfig.srcDirNotFound`, { - srcDir: projectConfig.srcDir, - projectDir: projectDir, - }) - ); - - return process.exit(EXIT_CODES.ERROR); - } -}; - -const pollFetchProject = async (accountId, projectName) => { - // Temporary solution for gating slowness. Retry on 403 statusCode - return new Promise((resolve, reject) => { - let pollCount = 0; - SpinniesManager.init(); - SpinniesManager.add('pollFetchProject', { - text: i18n(`${i18nKey}.pollFetchProject.checkingProject`, { - accountIdentifier: uiAccountDescription(accountId), - }), - }); - const pollInterval = setInterval(async () => { - try { - const response = await fetchProject(accountId, projectName); - if (response && response.data) { - SpinniesManager.remove('pollFetchProject'); - clearInterval(pollInterval); - resolve(response); - } - } catch (err) { - if ( - isSpecifiedError(err, { - statusCode: 403, - category: 'GATED', - subCategory: 'BuildPipelineErrorType.PORTAL_GATED', - }) && - pollCount < 15 - ) { - pollCount += 1; - } else { - SpinniesManager.remove('pollFetchProject'); - clearInterval(pollInterval); - reject(err); - } - } - }, POLLING_DELAY); - }); -}; - -const ensureProjectExists = async ( - accountId, - projectName, - { - forceCreate = false, - allowCreate = true, - noLogs = false, - withPolling = false, - uploadCommand = false, - } = {} -) => { - const accountIdentifier = uiAccountDescription(accountId); - try { - const { data: project } = withPolling - ? await pollFetchProject(accountId, projectName) - : await fetchProject(accountId, projectName); - return { projectExists: !!project, project }; - } catch (err) { - if (isSpecifiedError(err, { statusCode: 404 })) { - let shouldCreateProject = forceCreate; - if (allowCreate && !shouldCreateProject) { - const promptKey = uploadCommand ? 'createPromptUpload' : 'createPrompt'; - const promptResult = await promptUser([ - { - name: 'shouldCreateProject', - message: i18n(`${i18nKey}.ensureProjectExists.${promptKey}`, { - projectName, - accountIdentifier, - }), - type: 'confirm', - }, - ]); - shouldCreateProject = promptResult.shouldCreateProject; - } - - if (shouldCreateProject) { - try { - const { data: project } = await createProject(accountId, projectName); - logger.success( - i18n(`${i18nKey}.ensureProjectExists.createSuccess`, { - projectName, - accountIdentifier, - }) - ); - return { projectExists: true, project }; - } catch (err) { - return logError(err, new ApiErrorContext({ accountId })); - } - } else { - if (!noLogs) { - logger.log( - i18n(`${i18nKey}.ensureProjectExists.notFound`, { - projectName, - accountIdentifier, - }) - ); - } - return { projectExists: false }; - } - } - if ( - isSpecifiedError(err, { - statusCode: 401, - }) - ) { - logger.error(err.message); - process.exit(EXIT_CODES.ERROR); - } - logError(err, new ApiErrorContext({ accountId })); - process.exit(EXIT_CODES.ERROR); - } -}; - -const getProjectHomeUrl = accountId => { - const baseUrl = getHubSpotWebsiteOrigin( - getEnv(accountId) === 'qa' ? ENVIRONMENTS.QA : ENVIRONMENTS.PROD - ); - - return `${baseUrl}/developer-projects/${accountId}`; -}; - -const getProjectDetailUrl = (projectName, accountId) => { - if (!projectName) return; - return `${getProjectHomeUrl(accountId)}/project/${projectName}`; -}; - -const getProjectActivityUrl = (projectName, accountId) => { - if (!projectName) return; - return `${getProjectDetailUrl(projectName, accountId)}/activity`; -}; - -const getProjectBuildDetailUrl = (projectName, buildId, accountId) => { - if (!projectName || !buildId || !accountId) return; - return `${getProjectActivityUrl(projectName, accountId)}/build/${buildId}`; -}; - -const getProjectDeployDetailUrl = (projectName, deployId, accountId) => { - if (!projectName || !deployId || !accountId) return; - return `${getProjectActivityUrl(projectName, accountId)}/deploy/${deployId}`; -}; - -const uploadProjectFiles = async ( - accountId, - projectName, - filePath, - uploadMessage, - platformVersion -) => { - SpinniesManager.init({}); - const accountIdentifier = uiAccountDescription(accountId); - - SpinniesManager.add('upload', { - text: i18n(`${i18nKey}.uploadProjectFiles.add`, { - accountIdentifier, - projectName, - }), - succeedColor: 'white', - }); - - let buildId; - let error; - - try { - const { data: upload } = await uploadProject( - accountId, - projectName, - filePath, - uploadMessage, - platformVersion - ); - - buildId = upload.buildId; - - SpinniesManager.succeed('upload', { - text: i18n(`${i18nKey}.uploadProjectFiles.succeed`, { - accountIdentifier, - projectName, - }), - }); - - logger.debug( - i18n(`${i18nKey}.uploadProjectFiles.buildCreated`, { - buildId, - projectName, - }) - ); - } catch (err) { - SpinniesManager.fail('upload', { - text: i18n(`${i18nKey}.uploadProjectFiles.fail`, { - accountIdentifier, - projectName, - }), - }); - - error = err; - } - - return { buildId, error }; -}; - -const pollProjectBuildAndDeploy = async ( - accountId, - projectConfig, - tempFile, - buildId, - silenceLogs = false -) => { - let buildStatus = await pollBuildStatus( - accountId, - projectConfig.name, - buildId, - null, - silenceLogs - ); - - if (!silenceLogs) { - uiLine(); - } - - const result = { - succeeded: true, - buildId, - buildResult: buildStatus, - deployResult: null, - }; - - if (buildStatus.status === 'FAILURE') { - result.succeeded = false; - return result; - } else if (buildStatus.isAutoDeployEnabled) { - if (!silenceLogs) { - logger.log( - i18n( - `${i18nKey}.pollProjectBuildAndDeploy.buildSucceededAutomaticallyDeploying`, - { - accountIdentifier: uiAccountDescription(accountId), - buildId, - } - ) - ); - - await displayWarnLogs(accountId, projectConfig.name, buildId); - } - - // autoDeployId of 0 indicates a skipped deploy - const getIsDeploying = () => - buildStatus.autoDeployId > 0 && buildStatus.deployStatusTaskLocator; - - // Sometimes the deploys do not immediately initiate, give them a chance to kick off - if (!getIsDeploying()) { - buildStatus = await pollBuildAutodeployStatus( - accountId, - projectConfig.name, - buildId - ); - } - - if (getIsDeploying()) { - const deployStatus = await pollDeployStatus( - accountId, - projectConfig.name, - buildStatus.deployStatusTaskLocator.id, - buildId, - silenceLogs - ); - result.deployResult = deployStatus; - - if (deployStatus.status === 'FAILURE') { - result.succeeded = false; - } - } else if (!silenceLogs) { - logger.log( - i18n( - `${i18nKey}.pollProjectBuildAndDeploy.unableToFindAutodeployStatus`, - { - buildId, - viewDeploysLink: uiLink( - i18n(`${i18nKey}.pollProjectBuildAndDeploy.viewDeploys`), - getProjectActivityUrl(projectConfig.name, accountId) - ), - } - ) - ); - } - } - - try { - if (tempFile) { - tempFile.removeCallback(); - logger.debug( - i18n(`${i18nKey}.pollProjectBuildAndDeploy.cleanedUpTempFile`, { - path: tempFile.name, - }) - ); - } - } catch (e) { - logger.error(e); - } - - if (result && result.deployResult) { - await displayWarnLogs( - accountId, - projectConfig.name, - result.deployResult.deployId, - true - ); - } - return result; -}; - -const handleProjectUpload = async ( - accountId, - projectConfig, - projectDir, - callbackFunc, - uploadMessage -) => { - const srcDir = path.resolve(projectDir, projectConfig.srcDir); - - const filenames = fs.readdirSync(srcDir); - if (!filenames || filenames.length === 0) { - logger.log( - i18n(`${i18nKey}.handleProjectUpload.emptySource`, { - srcDir: projectConfig.srcDir, - }) - ); - process.exit(EXIT_CODES.SUCCESS); - } - - const tempFile = tmp.fileSync({ postfix: '.zip' }); - - logger.debug( - i18n(`${i18nKey}.handleProjectUpload.compressing`, { - path: tempFile.name, - }) - ); - - const output = fs.createWriteStream(tempFile.name); - const archive = archiver('zip'); - - const result = new Promise(resolve => - output.on('close', async function () { - let uploadResult = {}; - - logger.debug( - i18n(`${i18nKey}.handleProjectUpload.compressed`, { - byteCount: archive.pointer(), - }) - ); - - const { buildId, error } = await uploadProjectFiles( - accountId, - projectConfig.name, - tempFile.name, - uploadMessage, - projectConfig.platformVersion - ); - - if (error) { - uploadResult.uploadError = error; - } else if (callbackFunc) { - uploadResult = await callbackFunc( - accountId, - projectConfig, - tempFile, - buildId - ); - } - resolve(uploadResult || {}); - }) - ); - - archive.pipe(output); - - let loggedIgnoredNodeModule = false; - - archive.directory(srcDir, false, file => { - const ignored = shouldIgnoreFile(file.name, true); - if (ignored) { - const isNodeModule = file.name.includes('node_modules'); - - if (!isNodeModule || !loggedIgnoredNodeModule) { - logger.debug( - i18n(`${i18nKey}.handleProjectUpload.fileFiltered`, { - filename: file.name, - }) - ); - } - - if (isNodeModule && !loggedIgnoredNodeModule) { - loggedIgnoredNodeModule = true; - } - } - return ignored ? false : file; - }); - - archive.finalize(); - - return result; -}; - -const makePollTaskStatusFunc = ({ - statusFn, - structureFn, - statusText, - statusStrings, - linkToHubSpot, -}) => { - return async ( - accountId, - taskName, - taskId, - deployedBuildId = null, - silenceLogs = false - ) => { - const displayId = deployedBuildId || taskId; - - if (linkToHubSpot && !silenceLogs) { - logger.log( - `\n${linkToHubSpot(accountId, taskName, taskId, deployedBuildId)}\n` - ); - } - - SpinniesManager.init(); - - const overallTaskSpinniesKey = `overallTaskStatus-${statusText.STATUS_TEXT}`; - - SpinniesManager.add(overallTaskSpinniesKey, { - text: 'Beginning', - succeedColor: 'white', - failColor: 'white', - failPrefix: chalk.bold('!'), - }); - - const [ - { data: initialTaskStatus }, - { - data: { topLevelComponentsWithChildren: taskStructure }, - }, - ] = await Promise.all([ - statusFn(accountId, taskName, taskId), - structureFn(accountId, taskName, taskId), - ]); - - const tasksById = initialTaskStatus[statusText.SUBTASK_KEY].reduce( - (acc, task) => { - const { id, visible } = task; - if (visible) { - acc[id] = task; - } - return acc; - }, - {} - ); - - const structuredTasks = Object.keys(taskStructure).map(key => { - return { - ...tasksById[key], - subtasks: taskStructure[key] - .filter(taskId => Boolean(tasksById[taskId])) - .map(taskId => tasksById[taskId]), - }; - }); - - const numComponents = structuredTasks.length; - const componentCountText = silenceLogs - ? '' - : i18n( - numComponents === 1 - ? `${i18nKey}.makePollTaskStatusFunc.componentCountSingular` - : `${i18nKey}.makePollTaskStatusFunc.componentCount`, - { numComponents } - ) + '\n'; - - SpinniesManager.update(overallTaskSpinniesKey, { - text: `${statusStrings.INITIALIZE( - taskName, - displayId - )}\n${componentCountText}`, - }); - - if (!silenceLogs) { - const addTaskSpinner = (task, indent, newline) => { - const taskName = task[statusText.SUBTASK_NAME_KEY]; - const taskType = task[statusText.TYPE_KEY]; - const formattedTaskType = PROJECT_TASK_TYPES[taskType] - ? `[${PROJECT_TASK_TYPES[taskType]}]` - : ''; - const text = `${indent <= 2 ? statusText.STATUS_TEXT : ''} ${chalk.bold( - taskName - )} ${formattedTaskType} ...${newline ? '\n' : ''}`; - - SpinniesManager.add(task.id, { - text, - indent, - succeedColor: 'white', - failColor: 'white', - }); - }; - - structuredTasks.forEach(task => { - addTaskSpinner(task, 2, !task.subtasks || task.subtasks.length === 0); - task.subtasks.forEach((subtask, i) => - addTaskSpinner(subtask, 4, i === task.subtasks.length - 1) - ); - }); - } - - return new Promise((resolve, reject) => { - const pollInterval = setInterval(async () => { - let taskStatus; - try { - const { data } = await statusFn(accountId, taskName, taskId); - taskStatus = data; - } catch (e) { - logger.debug(e); - logError( - e, - new ApiErrorContext({ - accountId, - projectName: taskName, - }) - ); - return reject( - new Error( - i18n( - `${i18nKey}.makePollTaskStatusFunc.errorFetchingTaskStatus`, - { - taskType: - statusText.TYPE_KEY === PROJECT_BUILD_TEXT.TYPE_KEY - ? 'build' - : 'deploy', - } - ) - ) - ); - } - - if ( - !taskStatus || - !taskStatus.status || - !taskStatus[statusText.SUBTASK_KEY] - ) { - return reject( - new Error( - i18n( - `${i18nKey}.makePollTaskStatusFunc.errorFetchingTaskStatus`, - { - taskType: - statusText.TYPE_KEY === PROJECT_BUILD_TEXT.TYPE_KEY - ? 'build' - : 'deploy', - } - ) - ) - ); - } - - const { status, [statusText.SUBTASK_KEY]: subTaskStatus } = taskStatus; - - if (SpinniesManager.hasActiveSpinners()) { - subTaskStatus.forEach(subTask => { - const { id, status } = subTask; - const spinner = SpinniesManager.pick(id); - - if (!spinner || spinner.status !== SPINNER_STATUS.SPINNING) { - return; - } - - const topLevelTask = structuredTasks.find(t => t.id == id); - - if ( - status === statusText.STATES.SUCCESS || - status === statusText.STATES.FAILURE - ) { - const taskStatusText = - subTask.status === statusText.STATES.SUCCESS - ? i18n(`${i18nKey}.makePollTaskStatusFunc.successStatusText`) - : i18n(`${i18nKey}.makePollTaskStatusFunc.failedStatusText`); - const hasNewline = - spinner.text.includes('\n') || Boolean(topLevelTask); - const updatedText = `${spinner.text.replace( - '\n', - '' - )} ${taskStatusText}${hasNewline ? '\n' : ''}`; - - if (status === statusText.STATES.SUCCESS) { - SpinniesManager.succeed(id, { text: updatedText }); - } else { - SpinniesManager.fail(id, { text: updatedText }); - } - - if (topLevelTask) { - topLevelTask.subtasks.forEach(currentSubtask => - SpinniesManager.remove(currentSubtask.id) - ); - } - } - }); - - if (status === statusText.STATES.SUCCESS) { - SpinniesManager.succeed(overallTaskSpinniesKey, { - text: statusStrings.SUCCESS(taskName, displayId), - }); - clearInterval(pollInterval); - resolve(taskStatus); - } else if (status === statusText.STATES.FAILURE) { - SpinniesManager.fail(overallTaskSpinniesKey, { - text: statusStrings.FAIL(taskName, displayId), - }); - - if (!silenceLogs) { - const failedSubtasks = subTaskStatus.filter( - subtask => subtask.status === 'FAILURE' - ); - - uiLine(); - logger.log( - `${statusStrings.SUBTASK_FAIL( - displayId, - failedSubtasks.length === 1 - ? failedSubtasks[0][statusText.SUBTASK_NAME_KEY] - : failedSubtasks.length + ' components' - )}\n` - ); - logger.log('See below for a summary of errors.'); - uiLine(); - - const displayErrors = failedSubtasks.filter( - subtask => - subtask.standardError.subCategory !== - PROJECT_ERROR_TYPES.SUBBUILD_FAILED && - subtask.standardError.subCategory !== - PROJECT_ERROR_TYPES.SUBDEPLOY_FAILED - ); - - displayErrors.forEach(subTask => { - logger.log( - `\n--- ${chalk.bold( - subTask[statusText.SUBTASK_NAME_KEY] - )} failed with the following error ---` - ); - logger.error(subTask.errorMessage); - - // Log nested errors - if (subTask.standardError && subTask.standardError.errors) { - logger.log(); - subTask.standardError.errors.forEach(error => { - logger.log(error.message); - }); - } - }); - } - clearInterval(pollInterval); - resolve(taskStatus); - } else if (!subTaskStatus.length) { - clearInterval(pollInterval); - resolve(taskStatus); - } - } - }, POLLING_DELAY); - }); - }; -}; - -const pollBuildAutodeployStatus = (accountId, taskName, buildId) => { - return new Promise((resolve, reject) => { - let maxIntervals = (30 * 1000) / POLLING_DELAY; // Num of intervals in ~30s - - const pollInterval = setInterval(async () => { - let taskStatus; - try { - taskStatus = await getBuildStatus(accountId, taskName, buildId); - } catch (e) { - logger.debug(e); - return reject( - new Error( - i18n(`${i18nKey}.pollBuildAutodeployStatusError`, { buildId }) - ) - ); - } - - if (!taskStatus || !taskStatus.status) { - return reject( - new Error( - i18n(`${i18nKey}.pollBuildAutodeployStatusError`, { buildId }) - ) - ); - } - - if (taskStatus.deployStatusTaskLocator || maxIntervals <= 0) { - clearInterval(pollInterval); - resolve(taskStatus); - } else { - maxIntervals -= 1; - } - }, POLLING_DELAY); - }); -}; - -const pollBuildStatus = makePollTaskStatusFunc({ - linkToHubSpot: (accountId, taskName, taskId) => - uiLink( - `View build #${taskId} in HubSpot`, - getProjectBuildDetailUrl(taskName, taskId, accountId) - ), - statusFn: getBuildStatus, - structureFn: getBuildStructure, - statusText: PROJECT_BUILD_TEXT, - statusStrings: { - INITIALIZE: (name, buildId) => `Building ${chalk.bold(name)} #${buildId}`, - SUCCESS: (name, buildId) => `Built ${chalk.bold(name)} #${buildId}`, - FAIL: (name, buildId) => `Failed to build ${chalk.bold(name)} #${buildId}`, - SUBTASK_FAIL: (buildId, name) => - `Build #${buildId} failed because there was a problem\nbuilding ${chalk.bold( - name - )}`, - }, -}); - -const pollDeployStatus = makePollTaskStatusFunc({ - linkToHubSpot: (accountId, taskName, taskId, deployedBuildId) => - uiLink( - `View deploy of build #${deployedBuildId} in HubSpot`, - getProjectDeployDetailUrl(taskName, taskId, accountId) - ), - statusFn: getDeployStatus, - structureFn: getDeployStructure, - statusText: PROJECT_DEPLOY_TEXT, - statusStrings: { - INITIALIZE: (name, buildId) => - `Deploying build #${buildId} in ${chalk.bold(name)}`, - SUCCESS: (name, buildId) => - `Deployed build #${buildId} in ${chalk.bold(name)}`, - FAIL: (name, buildId) => - `Failed to deploy build #${buildId} in ${chalk.bold(name)}`, - SUBTASK_FAIL: (deployedBuildId, name) => - `Deploy for build #${deployedBuildId} failed because there was a\nproblem deploying ${chalk.bold( - name - )}`, - }, -}); - -const logFeedbackMessage = buildId => { - if (buildId > 0 && buildId % FEEDBACK_INTERVAL === 0) { - uiLine(); - logger.log(i18n(`${i18nKey}.logFeedbackMessage.feedbackHeader`)); - uiLine(); - logger.log(i18n(`${i18nKey}.logFeedbackMessage.feedbackMessage`)); - } -}; - -const createProjectComponent = async ( - component, - name, - projectComponentsVersion -) => { - const i18nKey = 'commands.project.subcommands.add'; - const componentName = name; - - const configInfo = await getProjectConfig(); - - if (!configInfo.projectDir && !configInfo.projectConfig) { - logger.error(i18n(`${i18nKey}.error.locationInProject`)); - process.exit(EXIT_CODES.ERROR); - } - - const componentPath = path.join( - configInfo.projectDir, - configInfo.projectConfig.srcDir, - component.insertPath, - componentName - ); - - await downloadGithubRepoContents( - HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, - component.path, - componentPath, - projectComponentsVersion - ); -}; - -const displayWarnLogs = async ( - accountId, - projectName, - taskId, - isDeploy = false -) => { - let result; - - if (isDeploy) { - try { - const { data } = await fetchDeployWarnLogs( - accountId, - projectName, - taskId - ); - result = data; - } catch (e) { - logError(e); - } - } else { - try { - const { data } = await fetchBuildWarnLogs(accountId, projectName, taskId); - result = data; - } catch (e) { - logError(e); - } - } - - if (result && result.logs) { - const logLength = result.logs.length; - result.logs.forEach((log, i) => { - logger.warn(log.message); - if (i < logLength - 1) { - logger.log(''); - } - }); - } -}; - -const getProjectComponentsByVersion = async projectComponentsVersion => { - const config = await fetchFileFromRepository( - HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, - 'config.json', - projectComponentsVersion - ); - - return config[PROJECT_COMPONENT_TYPES.COMPONENTS]; -}; - -module.exports = { - writeProjectConfig, - getProjectConfig, - getIsInProject, - pollProjectBuildAndDeploy, - handleProjectUpload, - createProjectConfig, - validateProjectConfig, - getProjectHomeUrl, - getProjectDetailUrl, - getProjectBuildDetailUrl, - pollBuildStatus, - pollDeployStatus, - ensureProjectExists, - logFeedbackMessage, - createProjectComponent, - displayWarnLogs, - getProjectComponentsByVersion, -}; diff --git a/lib/projects/buildAndDeploy.ts b/lib/projects/buildAndDeploy.ts new file mode 100644 index 000000000..118577b22 --- /dev/null +++ b/lib/projects/buildAndDeploy.ts @@ -0,0 +1,610 @@ +import chalk from 'chalk'; +import { FileResult } from 'tmp'; +import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; +import { ComponentStructureResponse } from '@hubspot/local-dev-lib/types/ComponentStructure'; +import { Build } from '@hubspot/local-dev-lib/types/Build'; +import { Deploy } from '@hubspot/local-dev-lib/types/Deploy'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { + getBuildStatus, + getBuildStructure, + getDeployStatus, + getDeployStructure, + fetchBuildWarnLogs, + fetchDeployWarnLogs, +} from '@hubspot/local-dev-lib/api/projects'; +import { WarnLogsResponse } from '@hubspot/local-dev-lib/types/Project'; + +import { + POLLING_DELAY, + PROJECT_BUILD_TEXT, + PROJECT_DEPLOY_TEXT, + PROJECT_TASK_TYPES, + PROJECT_ERROR_TYPES, +} from '../constants'; +import SpinniesManager from '../ui/SpinniesManager'; +import { i18n } from '../lang'; +import { logError, ApiErrorContext } from '../errorHandlers'; +import { uiLine, uiLink, uiAccountDescription } from '../ui'; +import { + getProjectBuildDetailUrl, + getProjectDeployDetailUrl, + getProjectActivityUrl, +} from './urls'; +import { + ProjectConfig, + ProjectTask, + ProjectSubtask, + ProjectPollStatusFunctionText, +} from '../../types/Projects'; + +const i18nKey = 'lib.projectBuildAndDeploy'; + +const SPINNER_STATUS = { + SPINNING: 'spinning', +}; + +function getSubtasks(task: ProjectTask): ProjectSubtask[] { + if ('subbuildStatuses' in task) { + return task.subbuildStatuses; + } + return task.subdeployStatuses; +} + +function getSubtaskName(task: ProjectSubtask): string { + if ('buildName' in task) { + return task.buildName; + } + return task.deployName; +} + +function getSubtaskType(task: ProjectSubtask): string { + if ('buildType' in task) { + return task.buildType; + } + return task.deployType; +} + +type PollTaskStatusFunctionConfig = { + statusFn: ( + accountId: number, + projectName: string, + taskId: number + ) => HubSpotPromise; + structureFn: ( + accountId: number, + projectName: string, + taskId: number + ) => HubSpotPromise; + statusText: ProjectPollStatusFunctionText; + statusStrings: PollTaskStatusStrings; + linkToHubSpot: ( + accountId: number, + taskName: string, + taskId: number, + deployedBuildId: number + ) => void; +}; + +type PollTaskStatus = 'INITIALIZE' | 'SUCCESS' | 'FAIL' | 'SUBTASK_FAIL'; + +type PollTaskStatusStringFunction = (name: string, taskId: number) => string; + +type PollTaskStatusStrings = { + [k in PollTaskStatus]: PollTaskStatusStringFunction; +}; + +type PollTaskStatusFunction = ( + accountId: number, + taskName: string, + taskId: number, + deployedBuildId: number | null, + silenceLogs: boolean +) => Promise; + +function makePollTaskStatusFunc({ + statusFn, + structureFn, + statusText, + statusStrings, + linkToHubSpot, +}: PollTaskStatusFunctionConfig): PollTaskStatusFunction { + return async function ( + accountId, + taskName, + taskId, + deployedBuildId = null, + silenceLogs = false + ) { + const displayId = deployedBuildId || taskId; + + if (linkToHubSpot && !silenceLogs && deployedBuildId) { + logger.log( + `\n${linkToHubSpot(accountId, taskName, taskId, deployedBuildId)}\n` + ); + } + + SpinniesManager.init(); + + const overallTaskSpinniesKey = `overallTaskStatus-${statusText.STATUS_TEXT}`; + + SpinniesManager.add(overallTaskSpinniesKey, { + text: 'Beginning', + succeedColor: 'white', + failColor: 'white', + failPrefix: chalk.bold('!'), + }); + + const [ + { data: initialTaskStatus }, + { + data: { topLevelComponentsWithChildren: taskStructure }, + }, + ] = await Promise.all([ + statusFn(accountId, taskName, taskId), + structureFn(accountId, taskName, taskId), + ]); + + const subtasks = getSubtasks(initialTaskStatus); + + const tasksById = subtasks.reduce( + (acc: { [key: string]: ProjectSubtask }, subtask) => { + const { id, visible } = subtask; + if (visible) { + acc[id] = subtask; + } + return acc; + }, + {} + ); + + const structuredTasks = Object.keys(taskStructure).map(key => { + return { + ...tasksById[key], + subtasks: taskStructure[key] + .filter(taskId => Boolean(tasksById[taskId])) + .map(taskId => tasksById[taskId]), + }; + }); + + const numComponents = structuredTasks.length; + const componentCountText = silenceLogs + ? '' + : i18n( + numComponents === 1 + ? `${i18nKey}.makePollTaskStatusFunc.componentCountSingular` + : `${i18nKey}.makePollTaskStatusFunc.componentCount`, + { numComponents } + ) + '\n'; + + SpinniesManager.update(overallTaskSpinniesKey, { + text: `${statusStrings.INITIALIZE( + taskName, + displayId + )}\n${componentCountText}`, + }); + + if (!silenceLogs) { + function addTaskSpinner( + subtask: ProjectSubtask, + indent: number, + newline: boolean + ): void { + const taskName = getSubtaskName(subtask); + const taskType = getSubtaskType(subtask); + const formattedTaskType = PROJECT_TASK_TYPES[taskType] + ? `[${PROJECT_TASK_TYPES[taskType]}]` + : ''; + const text = `${indent <= 2 ? statusText.STATUS_TEXT : ''} ${chalk.bold( + taskName + )} ${formattedTaskType} ...${newline ? '\n' : ''}`; + + SpinniesManager.add(subtask.id, { + text, + indent, + succeedColor: 'white', + failColor: 'white', + }); + } + + structuredTasks.forEach(task => { + addTaskSpinner(task, 2, !task.subtasks || task.subtasks.length === 0); + task.subtasks.forEach((subtask, i) => + addTaskSpinner(subtask, 4, i === task.subtasks.length - 1) + ); + }); + } + + return new Promise((resolve, reject) => { + const pollInterval = setInterval(async () => { + let taskStatus: T; + try { + const { data } = await statusFn(accountId, taskName, taskId); + taskStatus = data; + } catch (e) { + logger.debug(e); + logError( + e, + new ApiErrorContext({ + accountId, + projectName: taskName, + }) + ); + return reject( + new Error( + i18n( + `${i18nKey}.makePollTaskStatusFunc.errorFetchingTaskStatus`, + { + taskType: + statusText.TYPE_KEY === PROJECT_BUILD_TEXT.TYPE_KEY + ? 'build' + : 'deploy', + } + ) + ) + ); + } + + const subtasks = getSubtasks(taskStatus); + + if (!taskStatus || !taskStatus.status || !subtasks) { + return reject( + new Error( + i18n( + `${i18nKey}.makePollTaskStatusFunc.errorFetchingTaskStatus`, + { + taskType: + statusText.TYPE_KEY === PROJECT_BUILD_TEXT.TYPE_KEY + ? 'build' + : 'deploy', + } + ) + ) + ); + } + + const { status } = taskStatus; + + if (SpinniesManager.hasActiveSpinners()) { + subtasks.forEach(subtask => { + const { id, status } = subtask; + const spinner = SpinniesManager.pick(id); + + if (!spinner || spinner.status !== SPINNER_STATUS.SPINNING) { + return; + } + + const topLevelTask = structuredTasks.find(t => t.id == id); + + if ( + status === statusText.STATES.SUCCESS || + status === statusText.STATES.FAILURE + ) { + const taskStatusText = + subtask.status === statusText.STATES.SUCCESS + ? i18n(`${i18nKey}.makePollTaskStatusFunc.successStatusText`) + : i18n(`${i18nKey}.makePollTaskStatusFunc.failedStatusText`); + const hasNewline = + spinner?.text?.includes('\n') || Boolean(topLevelTask); + const updatedText = `${spinner?.text?.replace( + '\n', + '' + )} ${taskStatusText}${hasNewline ? '\n' : ''}`; + + if (status === statusText.STATES.SUCCESS) { + SpinniesManager.succeed(id, { text: updatedText }); + } else { + SpinniesManager.fail(id, { text: updatedText }); + } + + if (topLevelTask) { + topLevelTask.subtasks.forEach(currentSubtask => + SpinniesManager.remove(currentSubtask.id) + ); + } + } + }); + + if (status === statusText.STATES.SUCCESS) { + SpinniesManager.succeed(overallTaskSpinniesKey, { + text: statusStrings.SUCCESS(taskName, displayId), + }); + clearInterval(pollInterval); + resolve(taskStatus); + } else if (status === statusText.STATES.FAILURE) { + SpinniesManager.fail(overallTaskSpinniesKey, { + text: statusStrings.FAIL(taskName, displayId), + }); + + if (!silenceLogs) { + const failedSubtasks = subtasks.filter( + subtask => subtask.status === 'FAILURE' + ); + + uiLine(); + logger.log( + `${statusStrings.SUBTASK_FAIL( + failedSubtasks.length === 1 + ? getSubtaskName(failedSubtasks[0]) + : failedSubtasks.length + ' components', + displayId + )}\n` + ); + logger.log('See below for a summary of errors.'); + uiLine(); + + const displayErrors = failedSubtasks.filter( + subtask => + subtask?.standardError?.subCategory !== + PROJECT_ERROR_TYPES.SUBBUILD_FAILED && + subtask?.standardError?.subCategory !== + PROJECT_ERROR_TYPES.SUBDEPLOY_FAILED + ); + + displayErrors.forEach(subTask => { + logger.log( + `\n--- ${chalk.bold( + getSubtaskName(subTask) + )} failed with the following error ---` + ); + logger.error(subTask.errorMessage); + + // Log nested errors + if (subTask.standardError && subTask.standardError.errors) { + logger.log(); + subTask.standardError.errors.forEach(error => { + logger.log(error.message); + }); + } + }); + } + clearInterval(pollInterval); + resolve(taskStatus); + } else if (!subtasks.length) { + clearInterval(pollInterval); + resolve(taskStatus); + } + } + }, POLLING_DELAY); + }); + }; +} + +function pollBuildAutodeployStatus( + accountId: number, + taskName: string, + buildId: number +): Promise { + return new Promise((resolve, reject) => { + let maxIntervals = (30 * 1000) / POLLING_DELAY; // Num of intervals in ~30s + + const pollInterval = setInterval(async () => { + let build: Build; + try { + const response = await getBuildStatus(accountId, taskName, buildId); + build = response.data; + } catch (e) { + logger.debug(e); + return reject( + new Error( + i18n(`${i18nKey}.pollBuildAutodeployStatusError`, { buildId }) + ) + ); + } + + if (!build || !build.status) { + return reject( + new Error( + i18n(`${i18nKey}.pollBuildAutodeployStatusError`, { buildId }) + ) + ); + } + + if (build.deployStatusTaskLocator || maxIntervals <= 0) { + clearInterval(pollInterval); + resolve(build); + } else { + maxIntervals -= 1; + } + }, POLLING_DELAY); + }); +} + +export const pollBuildStatus = makePollTaskStatusFunc({ + linkToHubSpot: (accountId, taskName, taskId) => + uiLink( + `View build #${taskId} in HubSpot`, + getProjectBuildDetailUrl(taskName, taskId, accountId) + ), + statusFn: getBuildStatus, + structureFn: getBuildStructure, + statusText: PROJECT_BUILD_TEXT, + statusStrings: { + INITIALIZE: (name, buildId) => `Building ${chalk.bold(name)} #${buildId}`, + SUCCESS: (name, buildId) => `Built ${chalk.bold(name)} #${buildId}`, + FAIL: (name, buildId) => `Failed to build ${chalk.bold(name)} #${buildId}`, + SUBTASK_FAIL: (buildId, name) => + `Build #${buildId} failed because there was a problem\nbuilding ${chalk.bold( + name + )}`, + }, +}); + +export const pollDeployStatus = makePollTaskStatusFunc({ + linkToHubSpot: (accountId, taskName, taskId, deployedBuildId) => + uiLink( + `View deploy of build #${deployedBuildId} in HubSpot`, + getProjectDeployDetailUrl(taskName, taskId, accountId) + ), + statusFn: getDeployStatus, + structureFn: getDeployStructure, + statusText: PROJECT_DEPLOY_TEXT, + statusStrings: { + INITIALIZE: (name, buildId) => + `Deploying build #${buildId} in ${chalk.bold(name)}`, + SUCCESS: (name, buildId) => + `Deployed build #${buildId} in ${chalk.bold(name)}`, + FAIL: (name, buildId) => + `Failed to deploy build #${buildId} in ${chalk.bold(name)}`, + SUBTASK_FAIL: (deployedBuildId, name) => + `Deploy for build #${deployedBuildId} failed because there was a\nproblem deploying ${chalk.bold( + name + )}`, + }, +}); + +type ProjectPollResult = { + succeeded: boolean; + buildId: number; + buildResult: Build; + deployResult: Deploy | null; +}; + +export async function displayWarnLogs( + accountId: number, + projectName: string, + taskId: number, + isDeploy = false +): Promise { + let result: WarnLogsResponse | undefined; + + if (isDeploy) { + try { + const { data } = await fetchDeployWarnLogs( + accountId, + projectName, + taskId + ); + result = data; + } catch (e) { + logError(e); + } + } else { + try { + const { data } = await fetchBuildWarnLogs(accountId, projectName, taskId); + result = data; + } catch (e) { + logError(e); + } + } + + if (result && result.logs) { + const logLength = result.logs.length; + result.logs.forEach((log, i) => { + logger.warn(log.message); + if (i < logLength - 1) { + logger.log(''); + } + }); + } +} + +export async function pollProjectBuildAndDeploy( + accountId: number, + projectConfig: ProjectConfig, + tempFile: FileResult, + buildId: number, + silenceLogs = false +): Promise { + let buildStatus = await pollBuildStatus( + accountId, + projectConfig.name, + buildId, + null, + silenceLogs + ); + + if (!silenceLogs) { + uiLine(); + } + + const result: ProjectPollResult = { + succeeded: true, + buildId, + buildResult: buildStatus, + deployResult: null, + }; + + if (buildStatus.status === 'FAILURE') { + result.succeeded = false; + return result; + } else if (buildStatus.isAutoDeployEnabled) { + if (!silenceLogs) { + logger.log( + i18n( + `${i18nKey}.pollProjectBuildAndDeploy.buildSucceededAutomaticallyDeploying`, + { + accountIdentifier: uiAccountDescription(accountId), + buildId, + } + ) + ); + + await displayWarnLogs(accountId, projectConfig.name, buildId); + } + + // autoDeployId of 0 indicates a skipped deploy + const getIsDeploying = () => + buildStatus.autoDeployId > 0 && buildStatus.deployStatusTaskLocator; + + // Sometimes the deploys do not immediately initiate, give them a chance to kick off + if (!getIsDeploying()) { + buildStatus = await pollBuildAutodeployStatus( + accountId, + projectConfig.name, + buildId + ); + } + + if (getIsDeploying()) { + const deployStatus = await pollDeployStatus( + accountId, + projectConfig.name, + Number(buildStatus.deployStatusTaskLocator.id), + buildId, + silenceLogs + ); + result.deployResult = deployStatus; + + if (deployStatus.status === 'FAILURE') { + result.succeeded = false; + } + } else if (!silenceLogs) { + logger.log( + i18n( + `${i18nKey}.pollProjectBuildAndDeploy.unableToFindAutodeployStatus`, + { + buildId, + viewDeploysLink: uiLink( + i18n(`${i18nKey}.pollProjectBuildAndDeploy.viewDeploys`), + getProjectActivityUrl(projectConfig.name, accountId) + ), + } + ) + ); + } + } + + try { + if (tempFile) { + tempFile.removeCallback(); + logger.debug( + i18n(`${i18nKey}.pollProjectBuildAndDeploy.cleanedUpTempFile`, { + path: tempFile.name, + }) + ); + } + } catch (e) { + logger.error(e); + } + + if (result && result.deployResult) { + await displayWarnLogs( + accountId, + projectConfig.name, + result.deployResult.deployId, + true + ); + } + return result; +} diff --git a/lib/projects/index.ts b/lib/projects/index.ts new file mode 100644 index 000000000..9a86d6b1e --- /dev/null +++ b/lib/projects/index.ts @@ -0,0 +1,372 @@ +import fs from 'fs-extra'; +import path from 'path'; +import findup from 'findup-sync'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { fetchFileFromRepository } from '@hubspot/local-dev-lib/github'; +import { + createProject, + fetchProject, +} from '@hubspot/local-dev-lib/api/projects'; +import { isSpecifiedError } from '@hubspot/local-dev-lib/errors/index'; +import { getCwd, getAbsoluteFilePath } from '@hubspot/local-dev-lib/path'; +import { downloadGithubRepoContents } from '@hubspot/local-dev-lib/github'; +import { RepoPath } from '@hubspot/local-dev-lib/types/Github'; +import { Project } from '@hubspot/local-dev-lib/types/Project'; +import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; + +import { + FEEDBACK_INTERVAL, + POLLING_DELAY, + PROJECT_CONFIG_FILE, + HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, + PROJECT_COMPONENT_TYPES, +} from '../constants'; +import { promptUser } from '../prompts/promptUtils'; +import { EXIT_CODES } from '../enums/exitCodes'; +import { uiLine, uiAccountDescription, uiCommandReference } from '../ui'; +import { i18n } from '../lang'; +import SpinniesManager from '../ui/SpinniesManager'; +import { + ProjectTemplate, + ProjectConfig, + ProjectAddComponentData, + ProjectTemplateRepoConfig, + ComponentTemplate, +} from '../../types/Projects'; +import { logError, ApiErrorContext } from '../errorHandlers/index'; + +const i18nKey = 'lib.projects'; + +export function writeProjectConfig( + configPath: string, + config: ProjectConfig +): boolean { + try { + fs.ensureFileSync(configPath); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + logger.debug(`Wrote project config at ${configPath}`); + } catch (e) { + logger.debug(e); + return false; + } + return true; +} + +export function getIsInProject(dir?: string): boolean { + const configPath = getProjectConfigPath(dir); + return !!configPath; +} + +function getProjectConfigPath(dir?: string): string | null { + const projectDir = dir ? getAbsoluteFilePath(dir) : getCwd(); + + const configPath = findup(PROJECT_CONFIG_FILE, { + cwd: projectDir, + nocase: true, + }); + + return configPath; +} + +export async function getProjectConfig(dir?: string): Promise<{ + projectDir: string | null; + projectConfig: ProjectConfig | null; +}> { + const configPath = await getProjectConfigPath(dir); + if (!configPath) { + return { projectConfig: null, projectDir: null }; + } + + try { + const config = fs.readFileSync(configPath); + const projectConfig: ProjectConfig = JSON.parse(config.toString()); + return { + projectDir: path.dirname(configPath), + projectConfig, + }; + } catch (e) { + logger.error('Could not read from project config'); + return { projectConfig: null, projectDir: null }; + } +} + +export async function createProjectConfig( + projectPath: string, + projectName: string, + template: ProjectTemplate, + templateSource: RepoPath, + githubRef: string +): Promise { + const { projectConfig, projectDir } = await getProjectConfig(projectPath); + + if (projectConfig) { + logger.warn( + projectPath === projectDir + ? 'A project already exists in that location.' + : `Found an existing project definition in ${projectDir}.` + ); + + const { shouldContinue } = await promptUser<{ shouldContinue: boolean }>([ + { + name: 'shouldContinue', + message: () => { + return projectPath === projectDir + ? 'Do you want to overwrite the existing project definition with a new one?' + : `Continue creating a new project in ${projectPath}?`; + }, + type: 'confirm', + default: false, + }, + ]); + + if (!shouldContinue) { + return false; + } + } + + const projectConfigPath = path.join(projectPath, PROJECT_CONFIG_FILE); + + logger.log( + `Creating project config in ${ + projectPath ? projectPath : 'the current folder' + }` + ); + + const hasCustomTemplateSource = Boolean(templateSource); + + await downloadGithubRepoContents( + templateSource || HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, + template.path, + projectPath, + hasCustomTemplateSource ? undefined : githubRef + ); + const _config: ProjectConfig = JSON.parse( + fs.readFileSync(projectConfigPath).toString() + ); + writeProjectConfig(projectConfigPath, { + ..._config, + name: projectName, + }); + + if (template.name === 'no-template') { + fs.ensureDirSync(path.join(projectPath, 'src')); + } + + return true; +} + +export function validateProjectConfig( + projectConfig: ProjectConfig, + projectDir: string +): void { + if (!projectConfig) { + logger.error( + i18n(`${i18nKey}.validateProjectConfig.configNotFound`, { + createCommand: uiCommandReference('hs project create'), + }) + ); + return process.exit(EXIT_CODES.ERROR); + } + + if (!projectConfig.name || !projectConfig.srcDir) { + logger.error(i18n(`${i18nKey}.validateProjectConfig.configMissingFields`)); + return process.exit(EXIT_CODES.ERROR); + } + + const resolvedPath = path.resolve(projectDir, projectConfig.srcDir); + if (!resolvedPath.startsWith(projectDir)) { + const projectConfigFile = path.relative( + '.', + path.join(projectDir, PROJECT_CONFIG_FILE) + ); + logger.error( + i18n(`${i18nKey}.validateProjectConfig.srcOutsideProjectDir`, { + srcDir: projectConfig.srcDir, + projectConfig: projectConfigFile, + }) + ); + return process.exit(EXIT_CODES.ERROR); + } + + if (!fs.existsSync(resolvedPath)) { + logger.error( + i18n(`${i18nKey}.validateProjectConfig.srcDirNotFound`, { + srcDir: projectConfig.srcDir, + projectDir: projectDir, + }) + ); + + return process.exit(EXIT_CODES.ERROR); + } +} + +async function pollFetchProject( + accountId: number, + projectName: string +): HubSpotPromise { + // Temporary solution for gating slowness. Retry on 403 statusCode + return new Promise((resolve, reject) => { + let pollCount = 0; + SpinniesManager.init(); + SpinniesManager.add('pollFetchProject', { + text: i18n(`${i18nKey}.pollFetchProject.checkingProject`, { + accountIdentifier: uiAccountDescription(accountId), + }), + }); + const pollInterval = setInterval(async () => { + try { + const response = await fetchProject(accountId, projectName); + if (response && response.data) { + SpinniesManager.remove('pollFetchProject'); + clearInterval(pollInterval); + resolve(response); + } + } catch (err) { + if ( + isSpecifiedError(err, { + statusCode: 403, + category: 'GATED', + subCategory: 'BuildPipelineErrorType.PORTAL_GATED', + }) && + pollCount < 15 + ) { + pollCount += 1; + } else { + SpinniesManager.remove('pollFetchProject'); + clearInterval(pollInterval); + reject(err); + } + } + }, POLLING_DELAY); + }); +} + +export async function ensureProjectExists( + accountId: number, + projectName: string, + { + forceCreate = false, + allowCreate = true, + noLogs = false, + withPolling = false, + uploadCommand = false, + } = {} +): Promise<{ + projectExists: boolean; + project?: Project; +}> { + const accountIdentifier = uiAccountDescription(accountId); + try { + const { data: project } = withPolling + ? await pollFetchProject(accountId, projectName) + : await fetchProject(accountId, projectName); + return { projectExists: !!project, project }; + } catch (err) { + if (isSpecifiedError(err, { statusCode: 404 })) { + let shouldCreateProject = forceCreate; + if (allowCreate && !shouldCreateProject) { + const promptKey = uploadCommand ? 'createPromptUpload' : 'createPrompt'; + const promptResult = await promptUser<{ shouldCreateProject: boolean }>( + [ + { + name: 'shouldCreateProject', + message: i18n(`${i18nKey}.ensureProjectExists.${promptKey}`, { + projectName, + accountIdentifier, + }), + type: 'confirm', + }, + ] + ); + shouldCreateProject = promptResult.shouldCreateProject; + } + + if (shouldCreateProject) { + try { + const { data: project } = await createProject(accountId, projectName); + logger.success( + i18n(`${i18nKey}.ensureProjectExists.createSuccess`, { + projectName, + accountIdentifier, + }) + ); + return { projectExists: true, project }; + } catch (err) { + logError(err, new ApiErrorContext({ accountId })); + return { projectExists: false }; + } + } else { + if (!noLogs) { + logger.log( + i18n(`${i18nKey}.ensureProjectExists.notFound`, { + projectName, + accountIdentifier, + }) + ); + } + return { projectExists: false }; + } + } + if ( + isSpecifiedError(err, { + statusCode: 401, + }) + ) { + logger.error(err.message); + process.exit(EXIT_CODES.ERROR); + } + logError(err, new ApiErrorContext({ accountId })); + process.exit(EXIT_CODES.ERROR); + } +} + +export function logFeedbackMessage(buildId: number): void { + if (buildId > 0 && buildId % FEEDBACK_INTERVAL === 0) { + uiLine(); + logger.log(i18n(`${i18nKey}.logFeedbackMessage.feedbackHeader`)); + uiLine(); + logger.log(i18n(`${i18nKey}.logFeedbackMessage.feedbackMessage`)); + } +} + +export async function createProjectComponent( + component: ProjectAddComponentData, + name: string, + projectComponentsVersion: string +): Promise { + const i18nKey = 'commands.project.subcommands.add'; + const componentName = name; + + const configInfo = await getProjectConfig(); + + if (!configInfo.projectDir || !configInfo.projectConfig) { + logger.error(i18n(`${i18nKey}.error.locationInProject`)); + process.exit(EXIT_CODES.ERROR); + } + + const componentPath = path.join( + configInfo.projectDir, + configInfo.projectConfig.srcDir, + component.insertPath, + componentName + ); + + await downloadGithubRepoContents( + HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, + component.path, + componentPath, + projectComponentsVersion + ); +} + +export async function getProjectComponentsByVersion( + projectComponentsVersion: string +): Promise { + const config = await fetchFileFromRepository( + HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, + 'config.json', + projectComponentsVersion + ); + + return config[PROJECT_COMPONENT_TYPES.COMPONENTS] || []; +} diff --git a/lib/projectStructure.ts b/lib/projects/structure.ts similarity index 96% rename from lib/projectStructure.ts rename to lib/projects/structure.ts index cc5d25e52..a36b10480 100644 --- a/lib/projectStructure.ts +++ b/lib/projects/structure.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { ValueOf } from '@hubspot/local-dev-lib/types/Utils'; import { walk } from '@hubspot/local-dev-lib/fs'; import { logger } from '@hubspot/local-dev-lib/logger'; -import { logError } from './errorHandlers/index'; +import { logError } from '../errorHandlers/index'; export type Component = { type: ComponentTypes; @@ -207,9 +207,9 @@ export async function findProjectComponents( return components; } -export function getProjectComponentTypes( - components: Array -): { [key in ComponentTypes]?: boolean } { +export function getProjectComponentTypes(components: Array): { + [key in ComponentTypes]?: boolean; +} { const projectContents: { [key in ComponentTypes]?: boolean } = {}; components.forEach(({ type }) => (projectContents[type] = true)); diff --git a/lib/projects/upload.ts b/lib/projects/upload.ts new file mode 100644 index 000000000..2f2efb5d7 --- /dev/null +++ b/lib/projects/upload.ts @@ -0,0 +1,177 @@ +import archiver from 'archiver'; +import tmp, { FileResult } from 'tmp'; +import fs from 'fs-extra'; +import path from 'path'; +import { uploadProject } from '@hubspot/local-dev-lib/api/projects'; +import { shouldIgnoreFile } from '@hubspot/local-dev-lib/ignoreRules'; +import { logger } from '@hubspot/local-dev-lib/logger'; + +import SpinniesManager from '../ui/SpinniesManager'; +import { uiAccountDescription } from '../ui'; +import { i18n } from '../lang'; +import { EXIT_CODES } from '../enums/exitCodes'; +import { ProjectConfig } from '../../types/Projects'; + +const i18nKey = 'lib.projectUpload'; + +async function uploadProjectFiles( + accountId: number, + projectName: string, + filePath: string, + uploadMessage: string, + platformVersion: string +): Promise<{ buildId?: number; error: unknown }> { + SpinniesManager.init({}); + const accountIdentifier = uiAccountDescription(accountId); + + SpinniesManager.add('upload', { + text: i18n(`${i18nKey}.uploadProjectFiles.add`, { + accountIdentifier, + projectName, + }), + succeedColor: 'white', + }); + + let buildId: number | undefined; + let error: unknown; + + try { + const { data: upload } = await uploadProject( + accountId, + projectName, + filePath, + uploadMessage, + platformVersion + ); + + buildId = upload.buildId; + + SpinniesManager.succeed('upload', { + text: i18n(`${i18nKey}.uploadProjectFiles.succeed`, { + accountIdentifier, + projectName, + }), + }); + + logger.debug( + i18n(`${i18nKey}.uploadProjectFiles.buildCreated`, { + buildId, + projectName, + }) + ); + } catch (err) { + SpinniesManager.fail('upload', { + text: i18n(`${i18nKey}.uploadProjectFiles.fail`, { + accountIdentifier, + projectName, + }), + }); + + error = err; + } + + return { buildId, error }; +} + +type ProjectUploadCallbackFunction = ( + accountId: number, + projectConfig: ProjectConfig, + tempFile: FileResult, + buildId?: number +) => Promise; + +type ProjectUploadDefaultResult = { + uploadError?: unknown; +}; + +export async function handleProjectUpload( + accountId: number, + projectConfig: ProjectConfig, + projectDir: string, + callbackFunc: ProjectUploadCallbackFunction, + uploadMessage: string +) { + const srcDir = path.resolve(projectDir, projectConfig.srcDir); + + const filenames = fs.readdirSync(srcDir); + if (!filenames || filenames.length === 0) { + logger.log( + i18n(`${i18nKey}.handleProjectUpload.emptySource`, { + srcDir: projectConfig.srcDir, + }) + ); + process.exit(EXIT_CODES.SUCCESS); + } + + const tempFile = tmp.fileSync({ postfix: '.zip' }); + + logger.debug( + i18n(`${i18nKey}.handleProjectUpload.compressing`, { + path: tempFile.name, + }) + ); + + const output = fs.createWriteStream(tempFile.name); + const archive = archiver('zip'); + + const result = new Promise(resolve => + output.on('close', async function () { + let uploadResult: ProjectUploadDefaultResult | T | undefined; + + logger.debug( + i18n(`${i18nKey}.handleProjectUpload.compressed`, { + byteCount: archive.pointer(), + }) + ); + + const { buildId, error } = await uploadProjectFiles( + accountId, + projectConfig.name, + tempFile.name, + uploadMessage, + projectConfig.platformVersion + ); + + if (error) { + console.log(error); + uploadResult = { uploadError: error }; + } else if (callbackFunc) { + uploadResult = await callbackFunc( + accountId, + projectConfig, + tempFile, + buildId + ); + } + resolve(uploadResult || {}); + }) + ); + + archive.pipe(output); + + let loggedIgnoredNodeModule = false; + + archive.directory(srcDir, false, file => { + const ignored = shouldIgnoreFile(file.name, true); + if (ignored) { + const isNodeModule = file.name.includes('node_modules'); + + if (!isNodeModule || !loggedIgnoredNodeModule) { + logger.debug( + i18n(`${i18nKey}.handleProjectUpload.fileFiltered`, { + filename: file.name, + }) + ); + } + + if (isNodeModule && !loggedIgnoredNodeModule) { + loggedIgnoredNodeModule = true; + } + } + return ignored ? false : file; + }); + + archive.finalize(); + + return result; +} diff --git a/lib/projects/urls.ts b/lib/projects/urls.ts new file mode 100644 index 000000000..55e33bd42 --- /dev/null +++ b/lib/projects/urls.ts @@ -0,0 +1,42 @@ +import { getHubSpotWebsiteOrigin } from '@hubspot/local-dev-lib/urls'; +import { getEnv } from '@hubspot/local-dev-lib/config'; +import { ENVIRONMENTS } from '@hubspot/local-dev-lib/constants/environments'; + +function getProjectHomeUrl(accountId: number): string { + const baseUrl = getHubSpotWebsiteOrigin( + getEnv(accountId) === 'qa' ? ENVIRONMENTS.QA : ENVIRONMENTS.PROD + ); + + return `${baseUrl}/developer-projects/${accountId}`; +} + +export function getProjectDetailUrl( + projectName: string, + accountId: number +): string | undefined { + if (!projectName) return; + return `${getProjectHomeUrl(accountId)}/project/${projectName}`; +} + +export function getProjectActivityUrl( + projectName: string, + accountId: number +): string { + return `${getProjectDetailUrl(projectName, accountId)}/activity`; +} + +export function getProjectBuildDetailUrl( + projectName: string, + buildId: number, + accountId: number +): string { + return `${getProjectActivityUrl(projectName, accountId)}/build/${buildId}`; +} + +export function getProjectDeployDetailUrl( + projectName: string, + deployId: number, + accountId: number +): string { + return `${getProjectActivityUrl(projectName, accountId)}/deploy/${deployId}`; +} diff --git a/lib/projectsWatch.ts b/lib/projects/watch.ts similarity index 97% rename from lib/projectsWatch.ts rename to lib/projects/watch.ts index 95ea171b1..a67104526 100644 --- a/lib/projectsWatch.ts +++ b/lib/projects/watch.ts @@ -3,8 +3,8 @@ const chokidar = require('chokidar'); const path = require('path'); const chalk = require('chalk'); const { default: PQueue } = require('p-queue'); -const { logError, ApiErrorContext } = require('./errorHandlers/index'); -const { i18n } = require('./lang'); +const { logError, ApiErrorContext } = require('../errorHandlers/index'); +const { i18n } = require('../lang'); const { logger } = require('@hubspot/local-dev-lib/logger'); const { isAllowedExtension } = require('@hubspot/local-dev-lib/path'); const { shouldIgnoreFile } = require('@hubspot/local-dev-lib/ignoreRules'); @@ -16,7 +16,7 @@ const { queueBuild, } = require('@hubspot/local-dev-lib/api/projects'); const { isSpecifiedError } = require('@hubspot/local-dev-lib/errors/index'); -const { PROJECT_ERROR_TYPES } = require('./constants'); +const { PROJECT_ERROR_TYPES } = require('../constants'); const i18nKey = 'commands.project.subcommands.watch'; diff --git a/lib/prompts/accountNamePrompt.ts b/lib/prompts/accountNamePrompt.ts index 4efaf6c59..4ca2bf86b 100644 --- a/lib/prompts/accountNamePrompt.ts +++ b/lib/prompts/accountNamePrompt.ts @@ -1,7 +1,7 @@ import { accountNameExistsInConfig } from '@hubspot/local-dev-lib/config'; import { promptUser } from './promptUtils'; import { i18n } from '../lang'; -import { PromptConfig } from '../../types/prompts'; +import { PromptConfig } from '../../types/Prompts'; import { HUBSPOT_ACCOUNT_TYPES } from '@hubspot/local-dev-lib/constants/config'; import { AccountType } from '@hubspot/local-dev-lib/types/Accounts'; diff --git a/lib/prompts/accountsPrompt.ts b/lib/prompts/accountsPrompt.ts index 58f4c8e26..bee5ce10a 100644 --- a/lib/prompts/accountsPrompt.ts +++ b/lib/prompts/accountsPrompt.ts @@ -7,7 +7,7 @@ import { promptUser } from './promptUtils'; import { i18n } from '../lang'; import { uiAccountDescription } from '../ui'; import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { PromptChoices } from '../../types/prompts'; +import { PromptChoices } from '../../types/Prompts'; function mapAccountChoices( portals: CLIAccount[] | null | undefined diff --git a/lib/prompts/createApiSamplePrompt.ts b/lib/prompts/createApiSamplePrompt.ts index d9ab223ba..c8d78b6ef 100644 --- a/lib/prompts/createApiSamplePrompt.ts +++ b/lib/prompts/createApiSamplePrompt.ts @@ -1,6 +1,6 @@ import { promptUser } from './promptUtils'; import { i18n } from '../lang'; -import { PromptConfig } from '../../types/prompts'; +import { PromptConfig } from '../../types/Prompts'; const i18nKey = 'lib.prompts.createApiSamplePrompt'; @@ -37,8 +37,8 @@ function getSampleTypesPrompt( name: `${choice.name} - ${choice.description}`, value: choice.id, })), - validate: function(input?: string) { - return new Promise(function(resolve, reject) { + validate: function (input?: string) { + return new Promise(function (resolve, reject) { if (input && input.length > 0) { resolve(true); } else { @@ -60,8 +60,8 @@ function getLanguagesPrompt( name: choice, value: choice, })), - validate: function(input: string | undefined) { - return new Promise(function(resolve, reject) { + validate: function (input: string | undefined) { + return new Promise(function (resolve, reject) { if (input && input.length > 0) { resolve(true); } diff --git a/lib/prompts/createFunctionPrompt.ts b/lib/prompts/createFunctionPrompt.ts index 197fdf8ba..0ec6f1f46 100644 --- a/lib/prompts/createFunctionPrompt.ts +++ b/lib/prompts/createFunctionPrompt.ts @@ -1,6 +1,6 @@ import { promptUser } from './promptUtils'; import { i18n } from '../lang'; -import { PromptConfig } from '../../types/prompts'; +import { PromptConfig } from '../../types/Prompts'; const i18nKey = 'lib.prompts.createFunctionPrompt'; diff --git a/lib/prompts/createModulePrompt.ts b/lib/prompts/createModulePrompt.ts index c7b90d1ee..bd4e1cf3d 100644 --- a/lib/prompts/createModulePrompt.ts +++ b/lib/prompts/createModulePrompt.ts @@ -1,4 +1,4 @@ -import { PromptConfig } from '../../types/prompts'; +import { PromptConfig } from '../../types/Prompts'; import { promptUser } from './promptUtils'; import { i18n } from '../lang'; @@ -53,7 +53,7 @@ const CONTENT_TYPES_PROMPT: PromptConfig = { { name: 'Membership', value: 'MEMBERSHIP' }, ], validate: (input: string[]) => { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { if (input.length > 0) { resolve(true); } diff --git a/lib/prompts/createProjectPrompt.ts b/lib/prompts/createProjectPrompt.ts index cb279d92d..dfae0cad5 100644 --- a/lib/prompts/createProjectPrompt.ts +++ b/lib/prompts/createProjectPrompt.ts @@ -6,42 +6,36 @@ import { isValidPath, untildify, } from '@hubspot/local-dev-lib/path'; +import { RepoPath } from '@hubspot/local-dev-lib/types/Github'; +import { fetchFileFromRepository } from '@hubspot/local-dev-lib/github'; +import { logger } from '@hubspot/local-dev-lib/logger'; + import { PROJECT_COMPONENT_TYPES, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, DEFAULT_PROJECT_TEMPLATE_BRANCH, } from '../constants'; import { promptUser } from './promptUtils'; -import { fetchFileFromRepository } from '@hubspot/local-dev-lib/github'; import { i18n } from '../lang'; -import { logger } from '@hubspot/local-dev-lib/logger'; import { EXIT_CODES } from '../enums/exitCodes'; -import { RepoPath } from '@hubspot/local-dev-lib/types/Github'; +import { + ProjectTemplate, + ProjectTemplateRepoConfig, +} from '../../types/Projects'; const i18nKey = 'lib.prompts.createProjectPrompt'; -const PROJECT_PROPERTIES = ['name', 'label', 'path', 'insertPath']; - -type ProjectsConfig = { - projects?: ProjectProperties[]; -}; - -type ProjectProperties = { - name: string; - label: string; - path: string; - insertPath: string; -}; +const PROJECT_TEMPLATE_PROPERTIES = ['name', 'label', 'path', 'insertPath']; type CreateProjectPromptResponse = { name: string; dest: string; - template: ProjectProperties; + template: ProjectTemplate; }; -function hasAllProperties(projectList: ProjectProperties[]): boolean { +function hasAllProperties(projectList: ProjectTemplate[]): boolean { return projectList.every(config => - PROJECT_PROPERTIES.every(p => + PROJECT_TEMPLATE_PROPERTIES.every(p => Object.prototype.hasOwnProperty.call(config, p) ) ); @@ -50,13 +44,13 @@ function hasAllProperties(projectList: ProjectProperties[]): boolean { async function createTemplateOptions( templateSource: RepoPath, githubRef: string -): Promise { +): Promise { const hasCustomTemplateSource = Boolean(templateSource); const branch = hasCustomTemplateSource ? DEFAULT_PROJECT_TEMPLATE_BRANCH : githubRef; - const config: ProjectsConfig = await fetchFileFromRepository( + const config = await fetchFileFromRepository( templateSource || HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'config.json', branch @@ -76,9 +70,9 @@ async function createTemplateOptions( } function findTemplate( - projectTemplates: ProjectProperties[], + projectTemplates: ProjectTemplate[], templateNameOrLabel: string -): ProjectProperties | undefined { +): ProjectTemplate | undefined { return projectTemplates.find( t => t.name === templateNameOrLabel || t.label === templateNameOrLabel ); @@ -94,7 +88,7 @@ export async function createProjectPrompt( }, skipTemplatePrompt = false ): Promise { - let projectTemplates: ProjectProperties[] = []; + let projectTemplates: ProjectTemplate[] = []; let selectedTemplate; if (!skipTemplatePrompt) { diff --git a/lib/prompts/createTemplatePrompt.ts b/lib/prompts/createTemplatePrompt.ts index ecfae6da1..7aa0b1a41 100644 --- a/lib/prompts/createTemplatePrompt.ts +++ b/lib/prompts/createTemplatePrompt.ts @@ -1,6 +1,6 @@ import { promptUser } from './promptUtils'; import { i18n } from '../lang'; -import { PromptChoices, PromptConfig } from '../../types/prompts'; +import { PromptChoices, PromptConfig } from '../../types/Prompts'; const i18nKey = 'lib.prompts.createTemplatePrompt'; @@ -15,7 +15,7 @@ const templateTypeChoices = [ ] satisfies PromptChoices; interface CreateTemplatePromptResponse { - templateType: typeof templateTypeChoices[number]['value']; + templateType: (typeof templateTypeChoices)[number]['value']; } const TEMPLATE_TYPE_PROMPT: PromptConfig = { diff --git a/lib/prompts/personalAccessKeyPrompt.ts b/lib/prompts/personalAccessKeyPrompt.ts index 7762868ef..dd15dc6ef 100644 --- a/lib/prompts/personalAccessKeyPrompt.ts +++ b/lib/prompts/personalAccessKeyPrompt.ts @@ -11,7 +11,7 @@ import { getCliAccountNamePromptConfig } from './accountNamePrompt'; import { i18n } from '../lang'; import { uiInfoSection } from '../ui'; import { EXIT_CODES } from '../enums/exitCodes'; -import { PromptConfig } from '../../types/prompts'; +import { PromptConfig } from '../../types/Prompts'; const i18nKey = 'lib.prompts.personalAccessKeyPrompt'; @@ -60,9 +60,10 @@ export async function personalAccessKeyPrompt({ if (account) { url = `${websiteOrigin}/personal-access-key/${account}`; } - const { personalAcessKeyBrowserOpenPrep: shouldOpen } = await promptUser< - PersonalAccessKeyBrowserOpenPrepResponse - >([PERSONAL_ACCESS_KEY_BROWSER_OPEN_PREP]); + const { personalAcessKeyBrowserOpenPrep: shouldOpen } = + await promptUser([ + PERSONAL_ACCESS_KEY_BROWSER_OPEN_PREP, + ]); if (shouldOpen) { open(url, { url: true }); } else { @@ -72,9 +73,8 @@ export async function personalAccessKeyPrompt({ } logger.log(i18n(`${i18nKey}.logs.openingWebBrowser`, { url })); - const { personalAccessKey } = await promptUser< - PersonalAccessKeyPromptResponse - >(PERSONAL_ACCESS_KEY); + const { personalAccessKey } = + await promptUser(PERSONAL_ACCESS_KEY); return { personalAccessKey, @@ -122,11 +122,12 @@ const CLIENT_SECRET: PromptConfig = { }, }; -const PERSONAL_ACCESS_KEY_BROWSER_OPEN_PREP: PromptConfig = { - name: 'personalAcessKeyBrowserOpenPrep', - type: 'confirm', - message: i18n(`${i18nKey}.personalAccessKeyBrowserOpenPrompt`), -}; +const PERSONAL_ACCESS_KEY_BROWSER_OPEN_PREP: PromptConfig = + { + name: 'personalAcessKeyBrowserOpenPrep', + type: 'confirm', + message: i18n(`${i18nKey}.personalAccessKeyBrowserOpenPrompt`), + }; const PERSONAL_ACCESS_KEY: PromptConfig = { name: 'personalAccessKey', diff --git a/lib/prompts/projectAddPrompt.ts b/lib/prompts/projectAddPrompt.ts index 1d682bc3f..77def953a 100644 --- a/lib/prompts/projectAddPrompt.ts +++ b/lib/prompts/projectAddPrompt.ts @@ -1,21 +1,16 @@ import { promptUser } from './promptUtils'; +import { ProjectAddComponentData } from '../../types/Projects'; import { i18n } from '../lang'; const i18nKey = 'lib.prompts.projectAddPrompt'; -type Component = { - path: string; - label: string; - insertPath: string; -}; - type ProjectAddPromptResponse = { - component: Component; + component: ProjectAddComponentData; name: string; }; export async function projectAddPrompt( - components: Component[], + components: ProjectAddComponentData[], promptOptions: { name?: string; type?: string } = {} ): Promise { return promptUser([ diff --git a/lib/prompts/projectDevTargetAccountPrompt.ts b/lib/prompts/projectDevTargetAccountPrompt.ts index ad56fdf58..8be678aa2 100644 --- a/lib/prompts/projectDevTargetAccountPrompt.ts +++ b/lib/prompts/projectDevTargetAccountPrompt.ts @@ -17,14 +17,12 @@ import { DeveloperTestAccount, FetchDeveloperTestAccountsResponse, } from '@hubspot/local-dev-lib/types/developerTestAccounts'; -import { PromptChoices } from '../../types/prompts'; +import { PromptChoices } from '../../types/Prompts'; import { EXIT_CODES } from '../enums/exitCodes'; const i18nKey = 'lib.prompts.projectDevTargetAccountPrompt'; -function mapNestedAccount( - accountConfig: CLIAccount -): { +function mapNestedAccount(accountConfig: CLIAccount): { name: string; value: { targetAccountId: number | null; diff --git a/lib/prompts/promptUtils.ts b/lib/prompts/promptUtils.ts index 8f8abec7b..c1b1d10c8 100644 --- a/lib/prompts/promptUtils.ts +++ b/lib/prompts/promptUtils.ts @@ -8,7 +8,7 @@ import { GenericPromptResponse, PromptWhen, PromptChoices, -} from '../../types/prompts'; +} from '../../types/Prompts'; const promptModule = inquirer.createPromptModule(); diff --git a/lib/prompts/sandboxesPrompt.ts b/lib/prompts/sandboxesPrompt.ts index 1046875a8..5a8b4e983 100644 --- a/lib/prompts/sandboxesPrompt.ts +++ b/lib/prompts/sandboxesPrompt.ts @@ -9,7 +9,7 @@ import { getConfigAccounts, } from '@hubspot/local-dev-lib/config'; import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; -import { PromptChoices } from '../../types/prompts'; +import { PromptChoices } from '../../types/Prompts'; const i18nKey = 'lib.prompts.sandboxesPrompt'; diff --git a/package.json b/package.json index d9c4db28b..0d9943aae 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "repository": "https://github.com/HubSpot/hubspot-cli", "dependencies": { - "@hubspot/local-dev-lib": "3.0.1", + "@hubspot/local-dev-lib": "3.0.2", "@hubspot/serverless-dev-runtime": "7.0.0", "@hubspot/theme-preview-dev-server": "0.0.10", "@hubspot/ui-extensions-dev-server": "0.8.33", @@ -30,10 +30,14 @@ "yargs-parser": "^21.1.1" }, "devDependencies": { + "@types/archiver": "^6.0.3", + "@types/express": "^5.0.0", + "@types/findup-sync": "^4.0.5", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", "@types/semver": "^7.5.8", + "@types/tmp": "^0.2.6", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.11.0", "@typescript-eslint/parser": "^8.11.0", diff --git a/types/Projects.ts b/types/Projects.ts new file mode 100644 index 000000000..e6010faeb --- /dev/null +++ b/types/Projects.ts @@ -0,0 +1,51 @@ +import { Build, SubbuildStatus } from '@hubspot/local-dev-lib/types/Build'; +import { Deploy, SubdeployStatus } from '@hubspot/local-dev-lib/types/Deploy'; + +export type ProjectTemplate = { + name: string; + label: string; + path: string; + insertPath: string; +}; + +export type ComponentTemplate = { + label: string; + path: string; + insertPath: string; +}; + +export type ProjectConfig = { + name: string; + srcDir: string; + platformVersion: string; +}; + +export type ProjectTaskStates = { + BUILDING?: string; + ENQUEUED?: string; + DEPLOYING?: string; + FAILURE: string; + PENDING: string; + SUCCESS: string; +}; + +export type ProjectTask = Build | Deploy; +export type ProjectSubtask = SubbuildStatus | SubdeployStatus; + +export type ProjectPollStatusFunctionText = { + STATES: ProjectTaskStates; + STATUS_TEXT: string; + TYPE_KEY: string; + SUBTASK_NAME_KEY: string; +}; + +export type ProjectAddComponentData = { + path: string; + label: string; + insertPath: string; +}; + +export type ProjectTemplateRepoConfig = { + projects?: ProjectTemplate[]; + components?: ComponentTemplate[]; +}; diff --git a/types/prompts.ts b/types/Prompts.ts similarity index 100% rename from types/prompts.ts rename to types/Prompts.ts diff --git a/yarn.lock b/yarn.lock index 52879ffe0..d0ed5ab61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1463,10 +1463,10 @@ semver "^6.3.0" unixify "^1.0.0" -"@hubspot/local-dev-lib@3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@hubspot/local-dev-lib/-/local-dev-lib-3.0.1.tgz#1dd22f439d6e262353f14915a354115bbc1f5f76" - integrity sha512-h1jOmZJNdHZFbrOA5Gn815YCsix8eY81A4dkrUuDZI4MzVaJH3o4RoRalMl+Hr2e35nDrbrbcIR1RMQADPSmwg== +"@hubspot/local-dev-lib@3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@hubspot/local-dev-lib/-/local-dev-lib-3.0.2.tgz#8412c26c51d26cdb015312f5c5ac2daeced5e30f" + integrity sha512-brpFWcQIdP71YzwQ2ZMKqChzIEo4cCLJYPCTTVmMUwmuXtL35T/7vL06OukNEND5+B8VlmZeJ4PMrvsAT4YuWA== dependencies: address "^2.0.1" axios "^1.3.5" @@ -3224,6 +3224,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@types/archiver@^6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.3.tgz#074eb6f4febc0128c25a205a8263da3d4688df53" + integrity sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ== + dependencies: + "@types/readdir-glob" "*" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -3270,6 +3277,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/braces@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.4.tgz#403488dc1c8d0db288270d3bbf0ce5f9c45678b4" + integrity sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA== + "@types/connect@*": version "3.4.38" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" @@ -3329,6 +3341,16 @@ "@types/range-parser" "*" "@types/send" "*" +"@types/express-serve-static-core@^5.0.0": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz#812d2871e5eea17fb0bd5214dda7a7b748c0e12a" + integrity sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + "@types/express@^4.7.0": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" @@ -3339,11 +3361,28 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/express@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.0.tgz#13a7d1f75295e90d19ed6e74cab3678488eaa96c" + integrity sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/find-cache-dir@^3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz#7b959a4b9643a1e6a1a5fe49032693cc36773501" integrity sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw== +"@types/findup-sync@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-4.0.5.tgz#df25b72fb256a65b346bc66e2dcd7de04393ec84" + integrity sha512-Y4NCs+7uDZ3SFF0GWowN2IANqOJr+Cdvp9hfMSGzQYqJkyvAEJIq/s0/8AP88fq+yswykWPWZRBrqWPEhCLsgg== + dependencies: + "@types/micromatch" "^4.0.0" + "@types/fs-extra@^11.0.4": version "11.0.4" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" @@ -3438,6 +3477,13 @@ resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw== +"@types/micromatch@^4.0.0": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.9.tgz#8e5763a8c1fc7fbf26144d9215a01ab0ff702dbb" + integrity sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg== + dependencies: + "@types/braces" "*" + "@types/mime-types@^2.1.0": version "2.1.4" resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.4.tgz#93a1933e24fed4fb9e4adc5963a63efcbb3317a2" @@ -3518,6 +3564,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/readdir-glob@*": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@types/readdir-glob/-/readdir-glob-1.1.5.tgz#21a4a98898fc606cb568ad815f2a0eedc24d412a" + integrity sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg== + dependencies: + "@types/node" "*" + "@types/semver@^7.3.4", "@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -3545,6 +3598,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/tmp@^0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.6.tgz#d785ee90c52d7cc020e249c948c36f7b32d1e217" + integrity sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA== + "@types/unist@^2.0.0": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"