diff --git a/config/CLIConfiguration.ts b/config/CLIConfiguration.ts deleted file mode 100644 index 8cb7285f..00000000 --- a/config/CLIConfiguration.ts +++ /dev/null @@ -1,752 +0,0 @@ -import fs from 'fs'; -import findup from 'findup-sync'; -import { getCwd } from '../lib/path'; -import { logger } from '../lib/logger'; -import { loadConfigFromEnvironment } from './environment'; -import { getValidEnv } from '../lib/environment'; -import { - loadConfigFromFile, - writeConfigToFile, - configFileExists, - configFileIsBlank, - deleteConfigFile, -} from './configFile'; -import { commaSeparatedValues } from '../lib/text'; -import { ENVIRONMENTS } from '../constants/environments'; -import { API_KEY_AUTH_METHOD } from '../constants/auth'; -import { - HUBSPOT_ACCOUNT_TYPES, - MIN_HTTP_TIMEOUT, - DEFAULT_ACCOUNT_OVERRIDE_FILE_NAME, - DEFAULT_ACCOUNT_OVERRIDE_ERROR_INVALID_ID, - DEFAULT_ACCOUNT_OVERRIDE_ERROR_ACCOUNT_NOT_FOUND, -} from '../constants/config'; -import { CMS_PUBLISH_MODE } from '../constants/files'; -import { CLIConfig_NEW, Environment } from '../types/Config'; -import { - CLIAccount_NEW, - OAuthAccount_NEW, - FlatAccountFields_NEW, - AccountType, -} from '../types/Accounts'; -import { CLIOptions } from '../types/CLIOptions'; -import { i18n } from '../utils/lang'; -import { CmsPublishMode } from '../types/Files'; - -const i18nKey = 'config.cliConfiguration'; - -class _CLIConfiguration { - options: CLIOptions; - useEnvConfig: boolean; - config: CLIConfig_NEW | null; - active: boolean; - - constructor() { - this.options = {}; - this.useEnvConfig = false; - this.config = null; - this.active = false; - } - - setActive(isActive: boolean): void { - this.active = isActive; - } - - isActive(): boolean { - return this.active; - } - - init(options: CLIOptions = {}): CLIConfig_NEW | null { - this.options = options; - this.load(); - this.setActive(true); - return this.config; - } - - load(): CLIConfig_NEW | null { - if (this.options.useEnv) { - const configFromEnv = loadConfigFromEnvironment(); - if (configFromEnv) { - logger.debug( - i18n(`${i18nKey}.load.configFromEnv`, { - accountId: `${configFromEnv.accounts?.[0].accountId}`, - }) - ); - this.useEnvConfig = true; - this.config = this.handleLegacyCmsPublishMode(configFromEnv); - } - } else { - const configFromFile = loadConfigFromFile(); - logger.debug(i18n(`${i18nKey}.load.configFromFile`)); - - if (!configFromFile) { - logger.debug(i18n(`${i18nKey}.load.empty`)); - this.config = { accounts: [] }; - } - this.useEnvConfig = false; - this.config = this.handleLegacyCmsPublishMode(configFromFile); - } - - return this.config; - } - - configIsEmpty(): boolean { - if (!configFileExists() || configFileIsBlank()) { - return true; - } else { - this.load(); - if ( - !!this.config && - Object.keys(this.config).length === 1 && - !!this.config.accounts - ) { - return true; - } - } - return false; - } - - delete(): void { - if (!this.useEnvConfig && this.configIsEmpty()) { - deleteConfigFile(); - this.config = null; - } - } - - write(updatedConfig?: CLIConfig_NEW): CLIConfig_NEW | null { - if (!this.useEnvConfig) { - if (updatedConfig) { - this.config = updatedConfig; - } - if (this.config) { - writeConfigToFile(this.config); - } - } - return this.config; - } - - validate(): boolean { - if (!this.config) { - logger.log(i18n(`${i18nKey}.validate.noConfig`)); - return false; - } - if (!Array.isArray(this.config.accounts)) { - logger.log(i18n(`${i18nKey}.validate.noConfigAccounts`)); - return false; - } - - const accountIdsMap: { [key: number]: boolean } = {}; - const accountNamesMap: { [key: string]: boolean } = {}; - - return this.config.accounts.every(accountConfig => { - if (!accountConfig) { - logger.log(i18n(`${i18nKey}.validate.emptyAccountConfig`)); - return false; - } - if (!accountConfig.accountId) { - logger.log(i18n(`${i18nKey}.validate.noAccountId`)); - return false; - } - if (accountIdsMap[accountConfig.accountId]) { - logger.log( - i18n(`${i18nKey}.validate.duplicateAccountIds`, { - accountId: accountConfig.accountId, - }) - ); - return false; - } - if (accountConfig.name) { - if (accountNamesMap[accountConfig.name.toLowerCase()]) { - logger.log( - i18n(`${i18nKey}.validate.duplicateAccountNames`, { - accountName: accountConfig.name, - }) - ); - return false; - } - if (/\s+/.test(accountConfig.name)) { - logger.log( - i18n(`${i18nKey}.validate.nameContainsSpaces`, { - accountName: accountConfig.name, - }) - ); - return false; - } - accountNamesMap[accountConfig.name] = true; - } - if (!accountConfig.accountType) { - this.addOrUpdateAccount({ - ...accountConfig, - accountId: accountConfig.accountId, - accountType: this.getAccountType( - undefined, - accountConfig.sandboxAccountType - ), - }); - } - - accountIdsMap[accountConfig.accountId] = true; - return true; - }); - } - - getAccount(nameOrId: string | number | undefined): CLIAccount_NEW | null { - let name: string | null = null; - let accountId: number | null = null; - - if (!this.config) { - return null; - } - - const nameOrIdToCheck = nameOrId ? nameOrId : this.getDefaultAccount(); - - if (!nameOrIdToCheck) { - return null; - } - - if (typeof nameOrIdToCheck === 'string') { - name = nameOrIdToCheck; - if (/^\d+$/.test(nameOrIdToCheck)) { - accountId = parseInt(nameOrIdToCheck, 10); - } - } else if (typeof nameOrIdToCheck === 'number') { - accountId = nameOrIdToCheck; - } - - let account: CLIAccount_NEW | null = null; - if (name) { - account = this.config.accounts?.find(a => a.name === name) || null; - } - - if (accountId && !account) { - account = - this.config.accounts?.find(a => accountId === a.accountId) || null; - } - - return account; - } - - isConfigFlagEnabled( - flag: keyof CLIConfig_NEW, - defaultValue = false - ): boolean { - if (this.config && typeof this.config[flag] !== 'undefined') { - return Boolean(this.config[flag]); - } - return defaultValue; - } - - getAccountId(nameOrId?: string | number): number | null { - const account = this.getAccount(nameOrId); - return account ? account.accountId : null; - } - - getDefaultAccount(): string | number | null { - return this.getCWDAccountOverride() || this.config?.defaultAccount || null; - } - - getDefaultAccountOverrideFilePath(): string | null { - return findup([DEFAULT_ACCOUNT_OVERRIDE_FILE_NAME], { - cwd: getCwd(), - }); - } - - getCWDAccountOverride(): string | number | null { - const defaultOverrideFile = this.getDefaultAccountOverrideFilePath(); - if (!defaultOverrideFile) { - return null; - } - - let source: string; - try { - source = fs.readFileSync(defaultOverrideFile, 'utf8'); - } catch (e) { - if (e instanceof Error) { - logger.error( - i18n(`${i18nKey}.getCWDAccountOverride.readFileError`, { - error: e.message, - }) - ); - } - return null; - } - - const accountId = Number(source); - - if (isNaN(accountId)) { - throw new Error( - i18n(`${i18nKey}.getCWDAccountOverride.errorHeader`, { - hsAccountFile: defaultOverrideFile, - }), - { - cause: DEFAULT_ACCOUNT_OVERRIDE_ERROR_INVALID_ID, - } - ); - } - - const account = this.config?.accounts?.find( - account => account.accountId === accountId - ); - if (!account) { - throw new Error( - i18n(`${i18nKey}.getCWDAccountOverride.errorHeader`, { - hsAccountFile: defaultOverrideFile, - }), - { - cause: DEFAULT_ACCOUNT_OVERRIDE_ERROR_ACCOUNT_NOT_FOUND, - } - ); - } - - return account.name || account.accountId; - } - - getAccountIndex(accountId: number): number { - return this.config && Array.isArray(this.config.accounts) - ? this.config.accounts.findIndex( - account => account.accountId === accountId - ) - : -1; - } - - getConfigAccounts(): Array | null { - if (this.config) { - return this.config.accounts || null; - } - return null; - } - - isAccountInConfig(nameOrId: string | number): boolean { - if (typeof nameOrId === 'string') { - return ( - !!this.config && - !!this.config.accounts && - !!this.getAccountId(nameOrId.toLowerCase()) - ); - } - return ( - !!this.config && !!this.config.accounts && !!this.getAccountId(nameOrId) - ); - } - - getAndLoadConfigIfNeeded(options?: CLIOptions): CLIConfig_NEW { - if (!this.config) { - this.init(options); - } - return this.config!; - } - - getEnv(nameOrId?: string | number): Environment { - const accountConfig = this.getAccount(nameOrId); - - if (accountConfig && accountConfig.accountId && accountConfig.env) { - return accountConfig.env; - } - if (this.config && this.config.env) { - return this.config.env; - } - return ENVIRONMENTS.PROD; - } - - // Deprecating sandboxAccountType in favor of accountType - getAccountType( - accountType?: AccountType | null, - sandboxAccountType?: string | null - ): AccountType { - if (accountType) { - return accountType; - } - if (typeof sandboxAccountType === 'string') { - if (sandboxAccountType.toUpperCase() === 'DEVELOPER') { - return HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX; - } - if (sandboxAccountType.toUpperCase() === 'STANDARD') { - return HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX; - } - } - return HUBSPOT_ACCOUNT_TYPES.STANDARD; - } - - /* - * Config Update Utils - */ - - /** - * @throws {Error} - */ - addOrUpdateAccount( - updatedAccountFields: Partial, - writeUpdate = true - ): FlatAccountFields_NEW | null { - const { - accountId, - accountType, - apiKey, - authType, - clientId, - clientSecret, - defaultCmsPublishMode, - env, - name, - parentAccountId, - personalAccessKey, - sandboxAccountType, - scopes, - tokenInfo, - } = updatedAccountFields; - - if (!accountId) { - throw new Error( - i18n(`${i18nKey}.updateAccount.errors.accountIdRequired`) - ); - } - if (!this.config) { - logger.debug(i18n(`${i18nKey}.updateAccount.noConfigToUpdate`)); - return null; - } - - // Check whether the account is already listed in the config.yml file. - const currentAccountConfig = this.getAccount(accountId); - - // For accounts that are already in the config.yml file, sets the auth property. - let auth: OAuthAccount_NEW['auth'] = - (currentAccountConfig && currentAccountConfig.auth) || {}; - // For accounts not already in the config.yml file, sets the auth property. - if (clientId || clientSecret || scopes || tokenInfo) { - auth = { - ...(currentAccountConfig ? currentAccountConfig.auth : {}), - clientId, - clientSecret, - scopes, - tokenInfo, - }; - } - - const nextAccountConfig: Partial = { - ...(currentAccountConfig ? currentAccountConfig : {}), - }; - - // Allow everything except for 'undefined' values to override the existing values - function safelyApplyUpdates( - fieldName: T, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - newValue: FlatAccountFields_NEW[T] - ) { - if (typeof newValue !== 'undefined') { - nextAccountConfig[fieldName] = newValue; - } - } - - const updatedEnv = getValidEnv( - env || (currentAccountConfig && currentAccountConfig.env) - ); - const updatedDefaultCmsPublishMode: CmsPublishMode | undefined = - defaultCmsPublishMode && - (defaultCmsPublishMode.toLowerCase() as CmsPublishMode); - const updatedAccountType = - accountType || (currentAccountConfig && currentAccountConfig.accountType); - - safelyApplyUpdates('name', name); - safelyApplyUpdates('env', updatedEnv); - safelyApplyUpdates('accountId', accountId); - safelyApplyUpdates('authType', authType); - safelyApplyUpdates('auth', auth); - if (nextAccountConfig.authType === API_KEY_AUTH_METHOD.value) { - safelyApplyUpdates('apiKey', apiKey); - } - if (typeof updatedDefaultCmsPublishMode !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - safelyApplyUpdates( - 'defaultCmsPublishMode', - CMS_PUBLISH_MODE[updatedDefaultCmsPublishMode] - ); - } - safelyApplyUpdates('personalAccessKey', personalAccessKey); - - // Deprecating sandboxAccountType in favor of the more generic accountType - safelyApplyUpdates('sandboxAccountType', sandboxAccountType); - safelyApplyUpdates( - 'accountType', - this.getAccountType(updatedAccountType, sandboxAccountType) - ); - - safelyApplyUpdates('parentAccountId', parentAccountId); - - const completedAccountConfig = nextAccountConfig as FlatAccountFields_NEW; - if (!Array.isArray(this.config.accounts)) { - this.config.accounts = []; - } - - if (currentAccountConfig) { - logger.debug( - i18n(`${i18nKey}.updateAccount.updating`, { - accountId, - }) - ); - const index = this.getAccountIndex(accountId); - if (index < 0) { - this.config.accounts.push(completedAccountConfig); - } else { - this.config.accounts[index] = completedAccountConfig; - } - logger.debug( - i18n(`${i18nKey}.updateAccount.addingConfigEntry`, { - accountId, - }) - ); - } else { - this.config.accounts.push(completedAccountConfig); - } - - if (writeUpdate) { - this.write(); - } - - return completedAccountConfig; - } - - /** - * @throws {Error} - */ - updateDefaultAccount(defaultAccount: string | number): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - if ( - !defaultAccount || - (typeof defaultAccount !== 'number' && typeof defaultAccount !== 'string') - ) { - throw new Error( - i18n(`${i18nKey}.updateDefaultAccount.errors.invalidInput`) - ); - } - - this.config.defaultAccount = defaultAccount; - return this.write(); - } - - /** - * @throws {Error} - */ - renameAccount(currentName: string, newName: string): void { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - const accountId = this.getAccountId(currentName); - let accountConfigToRename: CLIAccount_NEW | null = null; - - if (accountId) { - accountConfigToRename = this.getAccount(accountId); - } - - if (!accountConfigToRename) { - throw new Error( - i18n(`${i18nKey}.renameAccount.errors.invalidName`, { - currentName, - }) - ); - } - - if (accountId) { - this.addOrUpdateAccount({ - accountId, - name: newName, - env: this.getEnv(), - accountType: accountConfigToRename.accountType, - }); - } - - if (accountConfigToRename.name === this.getDefaultAccount()) { - this.updateDefaultAccount(newName); - } - } - - /** - * @throws {Error} - * TODO: this does not account for the special handling of sandbox account deletes - */ - removeAccountFromConfig(nameOrId: string | number): boolean { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - const accountId = this.getAccountId(nameOrId); - - if (!accountId) { - throw new Error( - i18n(`${i18nKey}.removeAccountFromConfig.errors.invalidId`, { - nameOrId, - }) - ); - } - - let removedAccountIsDefault = false; - const accountConfig = this.getAccount(accountId); - - if (accountConfig) { - logger.debug( - i18n(`${i18nKey}.removeAccountFromConfig.deleting`, { accountId }) - ); - - const index = this.getAccountIndex(accountId); - if (index === -1) { - return removedAccountIsDefault; - } - this.config.accounts?.splice(index, 1); - - if (this.getDefaultAccount() === accountConfig.name) { - removedAccountIsDefault = true; - } - - this.write(); - } - - return removedAccountIsDefault; - } - - /** - * @throws {Error} - */ - updateDefaultCmsPublishMode( - defaultCmsPublishMode: CmsPublishMode - ): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - const ALL_CMS_PUBLISH_MODES = Object.values(CMS_PUBLISH_MODE); - if ( - !defaultCmsPublishMode || - !ALL_CMS_PUBLISH_MODES.find(m => m === defaultCmsPublishMode) - ) { - throw new Error( - i18n( - `${i18nKey}.updateDefaultCmsPublishMode.errors.invalidCmsPublishMode`, - { - defaultCmsPublishMode, - validCmsPublishModes: commaSeparatedValues(ALL_CMS_PUBLISH_MODES), - } - ) - ); - } - - this.config.defaultCmsPublishMode = defaultCmsPublishMode; - return this.write(); - } - - /** - * @throws {Error} - */ - updateHttpTimeout(timeout: string): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - const parsedTimeout = parseInt(timeout); - if (isNaN(parsedTimeout) || parsedTimeout < MIN_HTTP_TIMEOUT) { - throw new Error( - i18n(`${i18nKey}.updateHttpTimeout.errors.invalidTimeout`, { - timeout, - minTimeout: MIN_HTTP_TIMEOUT, - }) - ); - } - - this.config.httpTimeout = parsedTimeout; - return this.write(); - } - - /** - * @throws {Error} - */ - updateAllowAutoUpdates(enabled: boolean): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - - this.config.allowAutoUpdates = enabled; - return this.write(); - } - - /** - * @throws {Error} - */ - updateAllowUsageTracking(isEnabled: boolean): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - if (typeof isEnabled !== 'boolean') { - throw new Error( - i18n(`${i18nKey}.updateAllowUsageTracking.errors.invalidInput`, { - isEnabled: `${isEnabled}`, - }) - ); - } - - this.config.allowUsageTracking = isEnabled; - return this.write(); - } - - updateAutoOpenBrowser(isEnabled: boolean): CLIConfig_NEW | null { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - - if (typeof isEnabled !== 'boolean') { - throw new Error( - i18n(`${i18nKey}.updateAutoOpenBrowser.errors.invalidInput`, { - isEnabled: `${isEnabled}`, - }) - ); - } - - this.config.autoOpenBrowser = isEnabled; - return this.write(); - } - - isTrackingAllowed(): boolean { - if (!this.config) { - return true; - } - return this.config.allowUsageTracking !== false; - } - - handleLegacyCmsPublishMode( - config: CLIConfig_NEW | null - ): CLIConfig_NEW | null { - if (config?.defaultMode) { - config.defaultCmsPublishMode = config.defaultMode; - delete config.defaultMode; - } - return config; - } - - hasLocalStateFlag(flag: string): boolean { - if (!this.config) { - return false; - } - - return this.config.flags?.includes(flag) || false; - } - - addLocalStateFlag(flag: string): void { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - - if (!this.hasLocalStateFlag(flag)) { - this.config.flags = [...(this.config.flags || []), flag]; - } - - this.write(); - } - - removeLocalStateFlag(flag: string): void { - if (!this.config) { - throw new Error(i18n(`${i18nKey}.errors.noConfigLoaded`)); - } - - this.config.flags = this.config.flags?.filter(f => f !== flag) || []; - - this.write(); - } -} - -export const CLIConfiguration = new _CLIConfiguration(); diff --git a/config/README.md b/config/README.md index a5b94685..4f68dcbd 100644 --- a/config/README.md +++ b/config/README.md @@ -10,21 +10,38 @@ The config file is named `huspot.config.yml`. There are a handful of standard config utils that anyone working in this library should be familiar with. -#### getAndLoadConfigIfNeeded() +#### getConfig() -Locates, parses, and stores the `hubspot.config.yml` file in memory. This should be the first thing that you do if you plan to access any of the config file values. If the config has already been loaded, this function will simply return the already-parsed config values. +Locates and parses the hubspot config file. This function will automatically find the correct config file using the following criteria: +1. Checks to see if a config was specified via environment variables (see below) +2. If no environment variables are present, looks for a global config file (located at `~/.hscli/config.yml`) +3. If no global config file is found, looks up the file tree for a local config file. +4. If no local config file is found, throws an error -#### updateAccountConfig() -Safely writes updated values to the `hubspot.config.yml` file. This will also refresh the in-memory values that have been stored for the targeted account. +##### Custom config location environment variables +- `HUBSPOT_CONFIG_PATH` - specify a file path to a specific config file +- `USE_ENVIRONMENT_HUBSPOT_CONFIG` - load config account from environment variables. The following environment variables are supported: + - `HUBSPOT_PERSONAL_ACCESS_KEY` + - `HUBSPOT_ACCOUNT_ID` + - `HTTP_TIMEOUT` + - `DEFAULT_CMS_PUBLISH_MODE` -#### getAccountConfig() +#### updateConfigAccount() -Returns config data for a specific account, given the account's ID. +Safely writes updated values to the HubSpot config file. + +#### getConfigAccountById() and getConfigAccountByName() + +Returns config data for a specific account, given the account's ID or name. Errors if an account is not found. + +#### getAccountIfExist + +Returns config data for a specific account, given either a name or an ID. Returns null without erroring if an account is not found ## Example config -Here is an example of a basic hubspot config file with a single account configured. +Here is an example of a basic HubSpot config file with a single account configured. ```yml defaultPortal: my-hubspot-account @@ -39,7 +56,3 @@ portals: accountType: STANDARD personalAccessKey: 'my-personal-access-key' ``` - -## config_DEPRECATED.ts explained - -You may notice that we have a few configuration files in our `config/` folder. This is because we are in the middle of exploring a new method for storing account information. Despite its naming, config_DEPRECATED.ts is still the configuration file that handles all of our config logic. We have a proxy file named `config/index.ts` that will always choose to use the soon-to-be deprecated configuration file. This proxy file will enable us to slowly port config functionality over to the new pattern (i.e. `config/CLIConfiguration.ts`). For now, it is recommended to use config_DEPRECATED.ts and the utils it provides. We ask that any updates made to config_DEPRECATED.ts are also made to the newer CLIConfiguration.ts file whenever applicable. diff --git a/config/__tests__/CLIConfiguration.test.ts b/config/__tests__/CLIConfiguration.test.ts deleted file mode 100644 index 35c42806..00000000 --- a/config/__tests__/CLIConfiguration.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { HUBSPOT_ACCOUNT_TYPES } from '../../constants/config'; -import { ENVIRONMENTS } from '../../constants/environments'; -import { CLIConfiguration as config } from '../CLIConfiguration'; - -describe('config/CLIConfiguration', () => { - afterAll(() => { - config.setActive(false); - }); - - describe('constructor()', () => { - it('initializes correctly', () => { - expect(config).toBeDefined(); - expect(config.options).toBeDefined(); - expect(config.useEnvConfig).toBe(false); - expect(config.config).toBe(null); - expect(config.active).toBe(false); - }); - }); - - describe('isActive()', () => { - it('returns true when the class is being used', () => { - expect(config.isActive()).toBe(false); - config.setActive(true); - expect(config.isActive()).toBe(true); - }); - }); - - describe('getAccount()', () => { - afterEach(() => { - config.config = null; - }); - - it('returns null when no config is loaded', () => { - expect(config.getAccount('account-name')).toBe(null); - }); - - it('returns null when config.accounts is undefined', () => { - config.config = {}; - expect(config.getAccount('account-name')).toBe(null); - expect(config.getAccount(123)).toBe(null); - }); - }); - - describe('isConfigFlagEnabled()', () => { - it('returns default value when no config is loaded', () => { - expect(config.isConfigFlagEnabled('allowUsageTracking', false)).toBe( - false - ); - }); - }); - - describe('getAccountId()', () => { - it('returns null when it cannot find the account in the config', () => { - expect(config.getAccountId('account-name')).toBe(null); - }); - }); - - describe('getDefaultAccount()', () => { - it('returns null when no config is loaded', () => { - expect(config.getDefaultAccount()).toBe(null); - }); - }); - - describe('getAccountIndex()', () => { - afterEach(() => { - config.config = null; - }); - - it('returns -1 when no config is loaded', () => { - expect(config.getAccountIndex(123)).toBe(-1); - }); - - it('returns -1 when config.accounts is undefined', () => { - config.config = {}; - expect(config.getAccountIndex(123)).toBe(-1); - }); - - it('returns -1 when config.accounts is null', () => { - // @ts-expect-error making mistake on purpose - config.config = { accounts: null }; - expect(config.getAccountIndex(123)).toBe(-1); - }); - - it('returns -1 when config.accounts is not an array', () => { - // @ts-expect-error making mistake on purpose - config.config = { accounts: 'not-an-array' }; - expect(config.getAccountIndex(123)).toBe(-1); - }); - }); - - describe('isAccountInConfig()', () => { - it('returns false when no config is loaded', () => { - expect(config.isAccountInConfig(123)).toBe(false); - }); - }); - - describe('getEnv()', () => { - it('returns PROD when no config is loaded', () => { - expect(config.getEnv(123)).toBe(ENVIRONMENTS.PROD); - }); - }); - - describe('getAccountType()', () => { - it('returns STANDARD when no accountType or sandboxAccountType is specified', () => { - expect(config.getAccountType()).toBe(HUBSPOT_ACCOUNT_TYPES.STANDARD); - }); - it('handles sandboxAccountType transforms correctly', () => { - expect(config.getAccountType(undefined, 'DEVELOPER')).toBe( - HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX - ); - expect(config.getAccountType(undefined, 'STANDARD')).toBe( - HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX - ); - }); - it('handles accountType arg correctly', () => { - expect( - config.getAccountType(HUBSPOT_ACCOUNT_TYPES.STANDARD, 'DEVELOPER') - ).toBe(HUBSPOT_ACCOUNT_TYPES.STANDARD); - }); - }); - - describe('updateDefaultAccount()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.updateDefaultAccount('account-name'); - }).toThrow(); - }); - }); - - describe('renameAccount()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.renameAccount('account-name', 'new-account-name'); - }).toThrow(); - }); - }); - - describe('removeAccountFromConfig()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.removeAccountFromConfig('account-name'); - }).toThrow(); - }); - }); - - describe('updateDefaultCmsPublishMode()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.updateDefaultCmsPublishMode('draft'); - }).toThrow(); - }); - }); - - describe('updateHttpTimeout()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.updateHttpTimeout('1000'); - }).toThrow(); - }); - }); - - describe('updateAllowUsageTracking()', () => { - it('throws when no config is loaded', () => { - expect(() => { - config.updateAllowUsageTracking(true); - }).toThrow(); - }); - }); - - describe('isTrackingAllowed()', () => { - it('returns true when no config is loaded', () => { - expect(config.isTrackingAllowed()).toBe(true); - }); - }); - - describe('hasLocalStateFlag()', () => { - it('returns false when no config is loaded', () => { - expect(config.hasLocalStateFlag('test-flag')).toBe(false); - }); - - it('returns false when flag is not in config flags array', () => { - config.config = { accounts: [], flags: ['other-flag'] }; - expect(config.hasLocalStateFlag('test-flag')).toBe(false); - }); - - it('returns true when flag is in config flags array', () => { - config.config = { accounts: [], flags: ['test-flag', 'other-flag'] }; - expect(config.hasLocalStateFlag('test-flag')).toBe(true); - }); - }); - - describe('addLocalStateFlag()', () => { - beforeEach(() => { - // Mock the write method to prevent actual file operations - jest.spyOn(config, 'write').mockImplementation(() => null); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('throws when no config is loaded', () => { - config.config = null; - expect(() => { - config.addLocalStateFlag('test-flag'); - }).toThrow(); - }); - - it('adds flag when flags array does not exist', () => { - config.config = { accounts: [] }; - config.addLocalStateFlag('test-flag'); - - expect(config.config.flags).toEqual(['test-flag']); - expect(config.write).toHaveBeenCalled(); - }); - - it('adds flag to existing flags array', () => { - config.config = { accounts: [], flags: ['existing-flag'] }; - config.addLocalStateFlag('test-flag'); - - expect(config.config.flags).toEqual(['existing-flag', 'test-flag']); - expect(config.write).toHaveBeenCalled(); - }); - }); - - describe('removeLocalStateFlag()', () => { - beforeEach(() => { - // Mock the write method to prevent actual file operations - jest.spyOn(config, 'write').mockImplementation(() => null); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('throws when no config is loaded', () => { - config.config = null; - expect(() => { - config.removeLocalStateFlag('test-flag'); - }).toThrow(); - }); - - it('removes flag from flags array', () => { - config.config = { accounts: [], flags: ['test-flag', 'other-flag'] }; - config.removeLocalStateFlag('test-flag'); - - expect(config.config.flags).toEqual(['other-flag']); - expect(config.write).toHaveBeenCalled(); - }); - - it('handles removing non-existent flag gracefully', () => { - config.config = { accounts: [], flags: ['existing-flag'] }; - config.removeLocalStateFlag('non-existent-flag'); - - expect(config.config.flags).toEqual(['existing-flag']); - expect(config.write).toHaveBeenCalled(); - }); - }); -}); diff --git a/config/__tests__/config.test.ts b/config/__tests__/config.test.ts index 541ec298..b6062924 100644 --- a/config/__tests__/config.test.ts +++ b/config/__tests__/config.test.ts @@ -1,851 +1,598 @@ +import findup from 'findup-sync'; import fs from 'fs-extra'; +import yaml from 'js-yaml'; + import { - setConfig, - getAndLoadConfigIfNeeded, + localConfigFileExists, + globalConfigFileExists, + getConfigFilePath, getConfig, - getAccountType, - getConfigPath, - getAccountConfig, - getAccountId, - updateDefaultAccount, - updateAccountConfig, - updateAutoOpenBrowser, validateConfig, - deleteEmptyConfigFile, - setConfigPath, createEmptyConfigFile, - configFileExists, + deleteConfigFileIfEmpty, + getConfigAccountById, + getConfigAccountByName, + getConfigDefaultAccount, + getAllConfigAccounts, + getConfigAccountEnvironment, + addConfigAccount, + updateConfigAccount, + setConfigAccountAsDefault, + renameConfigAccount, + removeAccountFromConfig, + updateHttpTimeout, + updateAllowUsageTracking, + updateDefaultCmsPublishMode, + isConfigFlagEnabled, + getGlobalConfigFilePath, + getLocalConfigFilePathIfExists, } from '../index'; -import { getAccountIdentifier } from '../getAccountIdentifier'; -import { getAccounts, getDefaultAccount } from '../../utils/accounts'; -import { ENVIRONMENTS } from '../../constants/environments'; -import { HUBSPOT_ACCOUNT_TYPES } from '../../constants/config'; -import { CLIConfig, CLIConfig_DEPRECATED } from '../../types/Config'; +import { HubSpotConfigAccount } from '../../types/Accounts'; +import { HubSpotConfig } from '../../types/Config'; import { - APIKeyAccount_DEPRECATED, - AuthType, - CLIAccount, - OAuthAccount, - OAuthAccount_DEPRECATED, - APIKeyAccount, - PersonalAccessKeyAccount, - PersonalAccessKeyAccount_DEPRECATED, + PersonalAccessKeyConfigAccount, + OAuthConfigAccount, + APIKeyConfigAccount, } from '../../types/Accounts'; -import * as configFile from '../configFile'; -import * as config_DEPRECATED from '../config_DEPRECATED'; - -const CONFIG_PATHS = { - none: null, - default: '/Users/fakeuser/hubspot.config.yml', - nonStandard: '/Some/non-standard.config.yml', - cwd: `${process.cwd()}/hubspot.config.yml`, - hidden: '/Users/fakeuser/config.yml', -}; - -let mockedConfigPath: string | null = CONFIG_PATHS.default; - -jest.mock('findup-sync', () => { - return jest.fn(() => mockedConfigPath); -}); - -jest.mock('../../lib/logger'); - -const fsReadFileSyncSpy = jest.spyOn(fs, 'readFileSync'); -const fsWriteFileSyncSpy = jest.spyOn(fs, 'writeFileSync'); - -jest.mock('../configFile', () => ({ - getConfigFilePath: jest.fn(), - configFileExists: jest.fn(), -})); - -const API_KEY_CONFIG: APIKeyAccount_DEPRECATED = { - portalId: 1111, - name: 'API', - authType: 'apikey', - apiKey: 'secret', - env: ENVIRONMENTS.QA, -}; - -const OAUTH2_CONFIG: OAuthAccount_DEPRECATED = { - name: 'OAUTH2', - portalId: 2222, - authType: 'oauth2', +import { + PERSONAL_ACCESS_KEY_AUTH_METHOD, + OAUTH_AUTH_METHOD, + API_KEY_AUTH_METHOD, +} from '../../constants/auth'; +import { getLocalConfigDefaultFilePath, formatConfigForWrite } from '../utils'; +import { getDefaultAccountOverrideAccountId } from '../defaultAccountOverride'; +import { + CONFIG_FLAGS, + ENVIRONMENT_VARIABLES, + HUBSPOT_CONFIGURATION_FOLDER, +} from '../../constants/config'; +import * as utils from '../utils'; +import { CmsPublishMode } from '../../types/Files'; +import { i18n } from '../../utils/lang'; + +jest.mock('findup-sync'); +jest.mock('../../lib/path'); +jest.mock('fs-extra'); +jest.mock('../defaultAccountOverride'); + +const mockFindup = findup as jest.MockedFunction; +const mockFs = fs as jest.Mocked; +const mockGetDefaultAccountOverrideAccountId = + getDefaultAccountOverrideAccountId as jest.MockedFunction< + typeof getDefaultAccountOverrideAccountId + >; + +const PAK_ACCOUNT: PersonalAccessKeyConfigAccount = { + name: 'test-account', + accountId: 123, + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'test-key', + env: 'qa', auth: { - clientId: 'fakeClientId', - clientSecret: 'fakeClientSecret', - scopes: ['content'], - tokenInfo: { - expiresAt: '2020-01-01T00:00:00.000Z', - refreshToken: 'fakeOauthRefreshToken', - accessToken: 'fakeOauthAccessToken', - }, + tokenInfo: {}, }, - env: ENVIRONMENTS.QA, + accountType: 'STANDARD', }; -const PERSONAL_ACCESS_KEY_CONFIG: PersonalAccessKeyAccount_DEPRECATED = { - name: 'PERSONALACCESSKEY', - authType: 'personalaccesskey', +const OAUTH_ACCOUNT: OAuthConfigAccount = { + accountId: 234, + env: 'qa', + name: '234', + authType: OAUTH_AUTH_METHOD.value, + accountType: undefined, auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', tokenInfo: { - expiresAt: '2020-01-01T00:00:00.000Z', - accessToken: 'fakePersonalAccessKeyAccessToken', + refreshToken: 'test-refresh-token', }, + scopes: ['content', 'hubdb', 'files'], }, - personalAccessKey: 'fakePersonalAccessKey', - env: ENVIRONMENTS.QA, - portalId: 1, }; -const PORTALS = [API_KEY_CONFIG, OAUTH2_CONFIG, PERSONAL_ACCESS_KEY_CONFIG]; +const API_KEY_ACCOUNT: APIKeyConfigAccount = { + accountId: 345, + env: 'qa', + name: 'api-key-account', + authType: API_KEY_AUTH_METHOD.value, + apiKey: 'test-api-key', + accountType: 'STANDARD', +}; -const CONFIG: CLIConfig_DEPRECATED = { - defaultPortal: PORTALS[0].name, - portals: PORTALS, +const CONFIG: HubSpotConfig = { + defaultAccount: PAK_ACCOUNT.accountId, + accounts: [PAK_ACCOUNT], + defaultCmsPublishMode: 'publish', + httpTimeout: 1000, + httpUseLocalhost: true, + allowUsageTracking: true, }; -function getAccountByAuthType( - config: CLIConfig | undefined | null, - authType: AuthType -): CLIAccount { - return getAccounts(config).filter(portal => portal.authType === authType)[0]; +function cleanup() { + Object.keys(ENVIRONMENT_VARIABLES).forEach(key => { + delete process.env[key]; + }); + mockFs.existsSync.mockReset(); + mockFs.readFileSync.mockReset(); + mockFs.writeFileSync.mockReset(); + mockFs.unlinkSync.mockReset(); + mockFindup.mockReset(); + jest.restoreAllMocks(); } -describe('config/config', () => { - const globalConsole = global.console; - beforeAll(() => { - global.console.error = jest.fn(); - global.console.debug = jest.fn(); - }); - afterAll(() => { - global.console = globalConsole; - }); +function mockConfig(config = CONFIG) { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValueOnce('test-config-content'); + jest.spyOn(utils, 'parseConfig').mockReturnValueOnce(structuredClone(config)); +} - describe('setConfig()', () => { - beforeEach(() => { - setConfig(CONFIG); - }); +describe('config/index', () => { + afterEach(() => { + cleanup(); + }); - it('sets the config properly', () => { - expect(getConfig()).toEqual(CONFIG); + describe('getGlobalConfigFilePath()', () => { + it('returns the global config file path', () => { + const globalConfigFilePath = getGlobalConfigFilePath(); + expect(globalConfigFilePath).toBeDefined(); + expect(globalConfigFilePath).toContain( + `${HUBSPOT_CONFIGURATION_FOLDER}/config.yml` + ); }); }); - describe('getAccountId()', () => { - beforeEach(() => { - process.env = {}; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: PORTALS, - }); + describe('getLocalConfigFilePathIfExists()', () => { + it('returns the nearest config file path', () => { + const mockConfigPath = '/mock/path/hubspot.config.yml'; + mockFindup.mockReturnValue(mockConfigPath); + + const localConfigPath = getLocalConfigFilePathIfExists(); + expect(localConfigPath).toBe(mockConfigPath); }); - it('returns portalId from config when a name is passed', () => { - expect(getAccountId(OAUTH2_CONFIG.name)).toEqual(OAUTH2_CONFIG.portalId); + it('returns null if no config file found', () => { + mockFindup.mockReturnValue(null); + const localConfigPath = getLocalConfigFilePathIfExists(); + expect(localConfigPath).toBeNull(); }); + }); - it('returns portalId from config when a numeric id is passed', () => { - expect(getAccountId(OAUTH2_CONFIG.portalId)).toEqual( - OAUTH2_CONFIG.portalId - ); + describe('localConfigFileExists()', () => { + it('returns true when local config exists', () => { + mockFindup.mockReturnValueOnce(getLocalConfigDefaultFilePath()); + expect(localConfigFileExists()).toBe(true); }); - it('returns defaultPortal from config', () => { - expect(getAccountId() || undefined).toEqual( - PERSONAL_ACCESS_KEY_CONFIG.portalId - ); + it('returns false when local config does not exist', () => { + mockFindup.mockReturnValueOnce(null); + expect(localConfigFileExists()).toBe(false); }); + }); - describe('when defaultPortal is a portalId', () => { - beforeEach(() => { - process.env = {}; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.portalId, - portals: PORTALS, - }); - }); + describe('globalConfigFileExists()', () => { + it('returns true when global config exists', () => { + mockFs.existsSync.mockReturnValueOnce(true); + expect(globalConfigFileExists()).toBe(true); + }); - it('returns defaultPortal from config', () => { - expect(getAccountId() || undefined).toEqual( - PERSONAL_ACCESS_KEY_CONFIG.portalId - ); - }); + it('returns false when global config does not exist', () => { + mockFs.existsSync.mockReturnValueOnce(false); + expect(globalConfigFileExists()).toBe(false); }); }); - describe('updateDefaultAccount()', () => { - const myPortalName = 'Foo'; + describe('getConfigFilePath()', () => { + it('returns environment path when set', () => { + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CONFIG_PATH] = + 'test-environment-path'; + expect(getConfigFilePath()).toBe('test-environment-path'); + }); - beforeEach(() => { - updateDefaultAccount(myPortalName); + it('returns global path when exists', () => { + mockFs.existsSync.mockReturnValueOnce(true); + expect(getConfigFilePath()).toBe(getGlobalConfigFilePath()); }); - it('sets the defaultPortal in the config', () => { - const config = getConfig(); - expect(config ? getDefaultAccount(config) : null).toEqual(myPortalName); + it('returns local path when global does not exist', () => { + mockFs.existsSync.mockReturnValueOnce(false); + mockFindup.mockReturnValueOnce(getLocalConfigDefaultFilePath()); + expect(getConfigFilePath()).toBe(getLocalConfigDefaultFilePath()); }); }); - describe('updateAutoOpenBrowser()', () => { - beforeEach(() => { - setConfig({ - defaultPortal: 'test', - portals: [], + describe('getConfig()', () => { + it('returns environment config when enabled', () => { + process.env[ENVIRONMENT_VARIABLES.USE_ENVIRONMENT_HUBSPOT_CONFIG] = + 'true'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '234'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] = 'qa'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_API_KEY] = 'test-api-key'; + expect(getConfig()).toEqual({ + defaultAccount: 234, + accounts: [ + { + accountId: 234, + name: '234', + env: 'qa', + apiKey: 'test-api-key', + authType: API_KEY_AUTH_METHOD.value, + }, + ], }); }); - it('sets autoOpenBrowser to true in the config', () => { - updateAutoOpenBrowser(true); - const config = getConfig(); - expect(config?.autoOpenBrowser).toBe(true); + it('returns parsed config from file', () => { + mockConfig(); + expect(getConfig()).toEqual(CONFIG); }); + }); + + describe('validateConfig()', () => { + it('returns true for valid config', () => { + mockConfig(); - it('sets autoOpenBrowser to false in the config', () => { - updateAutoOpenBrowser(false); - const config = getConfig(); - expect(config?.autoOpenBrowser).toBe(false); + expect(validateConfig()).toEqual({ isValid: true, errors: [] }); }); - it('overwrites existing autoOpenBrowser value', () => { - // First set to true - updateAutoOpenBrowser(true); - let config = getConfig(); - expect(config?.autoOpenBrowser).toBe(true); + it('returns false for config with no accounts', () => { + mockConfig({ accounts: [] }); - // Then set to false - updateAutoOpenBrowser(false); - config = getConfig(); - expect(config?.autoOpenBrowser).toBe(false); + expect(validateConfig()).toEqual({ + isValid: false, + errors: [i18n('config.validateConfig.missingAccounts')], + }); }); - it('maintains other config properties when updating autoOpenBrowser', () => { - const testConfig = { - defaultPortal: 'test-portal', - portals: PORTALS, - allowUsageTracking: false, - }; - setConfig(testConfig); - - updateAutoOpenBrowser(true); + it('returns false for config with duplicate account ids', () => { + mockConfig({ accounts: [PAK_ACCOUNT, PAK_ACCOUNT] }); - const updatedConfig = getConfig(); - expect(updatedConfig?.allowUsageTracking).toBe(false); - expect(updatedConfig?.autoOpenBrowser).toBe(true); + expect(validateConfig()).toEqual({ + isValid: false, + errors: [ + i18n('config.validateConfig.duplicateAccountIds', { + accountId: PAK_ACCOUNT.accountId, + }), + i18n('config.validateConfig.duplicateAccountNames', { + accountName: PAK_ACCOUNT.name, + }), + ], + }); }); }); - describe('deleteEmptyConfigFile()', () => { - it('does not delete config file if there are contents', () => { - jest - .spyOn(fs, 'readFileSync') - .mockImplementation(() => 'defaultPortal: "test"'); - jest.spyOn(fs, 'existsSync').mockImplementation(() => true); - fs.unlinkSync = jest.fn(); + describe('createEmptyConfigFile()', () => { + it('creates global config when specified', () => { + mockFs.existsSync.mockReturnValueOnce(true); + createEmptyConfigFile(true); - deleteEmptyConfigFile(); - expect(fs.unlinkSync).not.toHaveBeenCalled(); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getGlobalConfigFilePath(), + yaml.dump({ accounts: [] }) + ); }); - it('deletes config file if empty', () => { - jest.spyOn(fs, 'readFileSync').mockImplementation(() => ''); - jest.spyOn(fs, 'existsSync').mockImplementation(() => true); - fs.unlinkSync = jest.fn(); + it('creates local config by default', () => { + mockFs.existsSync.mockReturnValueOnce(true); + createEmptyConfigFile(false); - deleteEmptyConfigFile(); - expect(fs.unlinkSync).toHaveBeenCalled(); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getLocalConfigDefaultFilePath(), + yaml.dump({ accounts: [] }) + ); }); }); - describe('updateAccountConfig()', () => { - const CONFIG = { - defaultPortal: PORTALS[0].name, - portals: PORTALS, - }; + describe('deleteConfigFileIfEmpty()', () => { + it('deletes the config file if it is empty', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValueOnce(yaml.dump({ accounts: [] })); + jest + .spyOn(utils, 'parseConfig') + .mockReturnValueOnce({ accounts: [] } as HubSpotConfig); + deleteConfigFileIfEmpty(); - beforeEach(() => { - setConfig(CONFIG); + expect(mockFs.unlinkSync).toHaveBeenCalledWith(getConfigFilePath()); }); - it('sets the env in the config if specified', () => { - const environment = ENVIRONMENTS.QA; - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - environment, - }; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + it('does not delete the config file if it is not empty', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValueOnce(yaml.dump(CONFIG)); + jest + .spyOn(utils, 'parseConfig') + .mockReturnValueOnce(structuredClone(CONFIG)); + deleteConfigFileIfEmpty(); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).env - ).toEqual(environment); - }); - - it('sets the env in the config if it was preexisting', () => { - const env = ENVIRONMENTS.QA; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: [{ ...PERSONAL_ACCESS_KEY_CONFIG, env }], - }); - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - env: undefined, - }; + expect(mockFs.unlinkSync).not.toHaveBeenCalled(); + }); + }); - updateAccountConfig(modifiedPersonalAccessKeyConfig); + describe('getConfigAccountById()', () => { + it('returns account when found', () => { + mockConfig(); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).env - ).toEqual(env); - }); - - it('overwrites the existing env in the config if specified as environment', () => { - // NOTE: the config now uses "env", but this is to support legacy behavior - const previousEnv = ENVIRONMENTS.PROD; - const newEnv = ENVIRONMENTS.QA; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: [{ ...PERSONAL_ACCESS_KEY_CONFIG, env: previousEnv }], - }); - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - environment: newEnv, - }; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + expect(getConfigAccountById(123)).toEqual(PAK_ACCOUNT); + }); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).env - ).toEqual(newEnv); - }); - - it('overwrites the existing env in the config if specified as env', () => { - const previousEnv = ENVIRONMENTS.PROD; - const newEnv = ENVIRONMENTS.QA; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: [{ ...PERSONAL_ACCESS_KEY_CONFIG, env: previousEnv }], - }); - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - env: newEnv, - }; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + it('throws when account not found', () => { + mockConfig(); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).env - ).toEqual(newEnv); + expect(() => getConfigAccountById(456)).toThrow(); }); + }); - it('sets the name in the config if specified', () => { - const name = 'MYNAME'; - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - name, - }; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + describe('getConfigAccountByName()', () => { + it('returns account when found', () => { + mockConfig(); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).name - ).toEqual(name); - }); - - it('sets the name in the config if it was preexisting', () => { - const name = 'PREEXISTING'; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: [{ ...PERSONAL_ACCESS_KEY_CONFIG, name }], - }); - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - }; - delete modifiedPersonalAccessKeyConfig.name; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + expect(getConfigAccountByName('test-account')).toEqual(PAK_ACCOUNT); + }); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).name - ).toEqual(name); - }); - - it('overwrites the existing name in the config if specified', () => { - const previousName = 'PREVIOUSNAME'; - const newName = 'NEWNAME'; - setConfig({ - defaultPortal: PERSONAL_ACCESS_KEY_CONFIG.name, - portals: [{ ...PERSONAL_ACCESS_KEY_CONFIG, name: previousName }], - }); - const modifiedPersonalAccessKeyConfig = { - ...PERSONAL_ACCESS_KEY_CONFIG, - name: newName, - }; - updateAccountConfig(modifiedPersonalAccessKeyConfig); + it('throws when account not found', () => { + mockConfig(); - expect( - getAccountByAuthType( - getConfig(), - modifiedPersonalAccessKeyConfig.authType - ).name - ).toEqual(newName); + expect(() => getConfigAccountByName('non-existent-account')).toThrow(); }); }); - describe('validateConfig()', () => { - const DEFAULT_PORTAL = PORTALS[0].name; + describe('getConfigDefaultAccount()', () => { + it('returns default account when set', () => { + mockConfig(); - it('allows valid config', () => { - setConfig({ - defaultPortal: DEFAULT_PORTAL, - portals: PORTALS, - }); - expect(validateConfig()).toEqual(true); + expect(getConfigDefaultAccount()).toEqual(PAK_ACCOUNT); }); - it('does not allow duplicate portalIds', () => { - setConfig({ - defaultPortal: DEFAULT_PORTAL, - portals: [...PORTALS, PORTALS[0]], - }); - expect(validateConfig()).toEqual(false); - }); + it('throws when no default account', () => { + mockConfig({ accounts: [] }); - it('does not allow duplicate names', () => { - setConfig({ - defaultPortal: DEFAULT_PORTAL, - portals: [ - ...PORTALS, - { - ...PORTALS[0], - portalId: 123456789, - }, - ], - }); - expect(validateConfig()).toEqual(false); + expect(() => getConfigDefaultAccount()).toThrow(); }); - it('does not allow names with spaces', () => { - setConfig({ - defaultPortal: DEFAULT_PORTAL, - portals: [ - { - ...PORTALS[0], - name: 'A NAME WITH SPACES', - }, - ], - }); - expect(validateConfig()).toEqual(false); - }); + it('returns the correct account when default account override is set', () => { + mockConfig({ accounts: [PAK_ACCOUNT, OAUTH_ACCOUNT] }); + mockGetDefaultAccountOverrideAccountId.mockReturnValueOnce( + OAUTH_ACCOUNT.accountId + ); - it('allows multiple portals with no name', () => { - setConfig({ - defaultPortal: DEFAULT_PORTAL, - portals: [ - { - ...PORTALS[0], - name: undefined, - }, - { - ...PORTALS[1], - name: undefined, - }, - ], - }); - expect(validateConfig()).toEqual(true); + expect(getConfigDefaultAccount()).toEqual(OAUTH_ACCOUNT); }); }); - describe('getAndLoadConfigIfNeeded()', () => { - beforeEach(() => { - setConfig(undefined); - process.env = {}; - }); - - it('loads a config from file if no combination of environment variables is sufficient', () => { - const readFileSyncSpy = jest.spyOn(fs, 'readFileSync'); - - getAndLoadConfigIfNeeded(); - expect(fs.readFileSync).toHaveBeenCalled(); - readFileSyncSpy.mockReset(); - }); - - describe('oauth environment variable config', () => { - const { - portalId, - auth: { clientId, clientSecret }, - } = OAUTH2_CONFIG; - const refreshToken = OAUTH2_CONFIG.auth.tokenInfo?.refreshToken || ''; - let portalConfig: OAuthAccount | null; - - beforeEach(() => { - process.env = { - HUBSPOT_ACCOUNT_ID: `${portalId}`, - HUBSPOT_CLIENT_ID: clientId, - HUBSPOT_CLIENT_SECRET: clientSecret, - HUBSPOT_REFRESH_TOKEN: refreshToken, - }; - getAndLoadConfigIfNeeded({ useEnv: true }); - portalConfig = getAccountConfig(portalId) as OAuthAccount; - fsReadFileSyncSpy.mockReset(); - }); + describe('getAllConfigAccounts()', () => { + it('returns all accounts', () => { + mockConfig(); - afterEach(() => { - // Clean up environment variable config so subsequent tests don't break - process.env = {}; - setConfig(undefined); - getAndLoadConfigIfNeeded(); - }); - - it('does not load a config from file', () => { - expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); - }); - - it('creates a portal config', () => { - expect(portalConfig).toBeTruthy(); - }); - - it('properly loads portal id value', () => { - expect(getAccountIdentifier(portalConfig)).toEqual(portalId); - }); - - it('properly loads client id value', () => { - expect(portalConfig?.auth.clientId).toEqual(clientId); - }); - - it('properly loads client secret value', () => { - expect(portalConfig?.auth.clientSecret).toEqual(clientSecret); - }); - - it('properly loads refresh token value', () => { - expect(portalConfig?.auth?.tokenInfo?.refreshToken).toEqual( - refreshToken - ); - }); + expect(getAllConfigAccounts()).toEqual([PAK_ACCOUNT]); }); + }); - describe('apikey environment variable config', () => { - const { portalId, apiKey } = API_KEY_CONFIG; - let portalConfig: APIKeyAccount; - - beforeEach(() => { - process.env = { - HUBSPOT_ACCOUNT_ID: `${portalId}`, - HUBSPOT_API_KEY: apiKey, - }; - getAndLoadConfigIfNeeded({ useEnv: true }); - portalConfig = getAccountConfig(portalId) as APIKeyAccount; - fsReadFileSyncSpy.mockReset(); - }); - - afterEach(() => { - // Clean up environment variable config so subsequent tests don't break - process.env = {}; - setConfig(undefined); - getAndLoadConfigIfNeeded(); - }); - - it('does not load a config from file', () => { - expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); - }); - - it('creates a portal config', () => { - expect(portalConfig).toBeTruthy(); - }); - - it('properly loads portal id value', () => { - expect(getAccountIdentifier(portalConfig)).toEqual(portalId); - }); + describe('getConfigAccountEnvironment()', () => { + it('returns environment for specified account', () => { + mockConfig(); - it('properly loads api key value', () => { - expect(portalConfig.apiKey).toEqual(apiKey); - }); + expect(getConfigAccountEnvironment(123)).toEqual('qa'); }); + }); - describe('personalaccesskey environment variable config', () => { - const { portalId, personalAccessKey } = PERSONAL_ACCESS_KEY_CONFIG; - let portalConfig: PersonalAccessKeyAccount | null; + describe('addConfigAccount()', () => { + it('adds valid account to config', () => { + mockConfig(); + mockFs.writeFileSync.mockImplementationOnce(() => undefined); + addConfigAccount(OAUTH_ACCOUNT); + + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump( + formatConfigForWrite({ + ...CONFIG, + accounts: [PAK_ACCOUNT, OAUTH_ACCOUNT], + }) + ) + ); + }); - beforeEach(() => { - process.env = { - HUBSPOT_ACCOUNT_ID: `${portalId}`, - HUBSPOT_PERSONAL_ACCESS_KEY: personalAccessKey, - }; - getAndLoadConfigIfNeeded({ useEnv: true }); - portalConfig = getAccountConfig(portalId) as PersonalAccessKeyAccount; - fsReadFileSyncSpy.mockReset(); - }); + it('throws for invalid account', () => { + expect(() => + addConfigAccount({ + ...PAK_ACCOUNT, + personalAccessKey: null, + } as unknown as HubSpotConfigAccount) + ).toThrow(); + }); - afterEach(() => { - // Clean up environment variable config so subsequent tests don't break - process.env = {}; - setConfig(undefined); - getAndLoadConfigIfNeeded(); - }); + it('throws when account already exists', () => { + mockConfig(); - it('does not load a config from file', () => { - expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); - }); + expect(() => addConfigAccount(PAK_ACCOUNT)).toThrow(); + }); + }); - it('creates a portal config', () => { - expect(portalConfig).toBeTruthy(); - }); + describe('updateConfigAccount()', () => { + it('updates existing account', () => { + mockConfig(); - it('properly loads portal id value', () => { - expect(getAccountIdentifier(portalConfig)).toEqual(portalId); - }); + const newAccount = { ...PAK_ACCOUNT, name: 'new-name' }; - it('properly loads personal access key value', () => { - expect(portalConfig?.personalAccessKey).toEqual(personalAccessKey); - }); - }); - }); + updateConfigAccount(newAccount); - describe('getAccountType()', () => { - it('returns STANDARD when no accountType or sandboxAccountType is specified', () => { - expect(getAccountType()).toBe(HUBSPOT_ACCOUNT_TYPES.STANDARD); - }); - it('handles sandboxAccountType transforms correctly', () => { - expect(getAccountType(undefined, 'DEVELOPER')).toBe( - HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX - ); - expect(getAccountType(undefined, 'STANDARD')).toBe( - HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump(formatConfigForWrite({ ...CONFIG, accounts: [newAccount] })) ); }); - it('handles accountType arg correctly', () => { - expect(getAccountType(HUBSPOT_ACCOUNT_TYPES.STANDARD, 'DEVELOPER')).toBe( - HUBSPOT_ACCOUNT_TYPES.STANDARD - ); + it('throws for invalid account', () => { + expect(() => + updateConfigAccount({ + ...PAK_ACCOUNT, + personalAccessKey: null, + } as unknown as HubSpotConfigAccount) + ).toThrow(); }); - }); - describe('getConfigPath()', () => { - let fsExistsSyncSpy: jest.SpyInstance; + it('throws when account not found', () => { + mockConfig(); - beforeAll(() => { - fsExistsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation(() => { - return false; - }); + expect(() => updateConfigAccount(OAUTH_ACCOUNT)).toThrow(); }); + }); - afterAll(() => { - fsExistsSyncSpy.mockRestore(); - }); + describe('setConfigAccountAsDefault()', () => { + it('sets account as default by id', () => { + const config = { ...CONFIG, accounts: [PAK_ACCOUNT, API_KEY_ACCOUNT] }; + mockConfig(config); - describe('when a standard config is present', () => { - it('returns the standard config path when useHiddenConfig is false', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue( - CONFIG_PATHS.default - ); - const configPath = getConfigPath('', false); - expect(configPath).toBe(CONFIG_PATHS.default); - }); + setConfigAccountAsDefault(345); - it('returns the hidden config path when useHiddenConfig is true', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue( - CONFIG_PATHS.hidden - ); - const hiddenConfigPath = getConfigPath(undefined, true); - expect(hiddenConfigPath).toBe(CONFIG_PATHS.hidden); - }); - }); - - describe('when passed a path', () => { - it('returns the path when useHiddenConfig is false', () => { - const randomConfigPath = '/some/random/path.config.yml'; - const configPath = getConfigPath(randomConfigPath, false); - expect(configPath).toBe(randomConfigPath); - }); - - it('returns the hidden config path when useHiddenConfig is true, ignoring the passed path', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue( - CONFIG_PATHS.hidden - ); - const hiddenConfigPath = getConfigPath( - '/some/random/path.config.yml', - true - ); - expect(hiddenConfigPath).toBe(CONFIG_PATHS.hidden); - }); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump(formatConfigForWrite({ ...config, defaultAccount: 345 })) + ); }); - describe('when no config is present', () => { - beforeAll(() => { - fsExistsSyncSpy.mockReturnValue(false); - }); + it('sets account as default by name', () => { + const config = { ...CONFIG, accounts: [PAK_ACCOUNT, API_KEY_ACCOUNT] }; + mockConfig(config); - it('returns default directory when useHiddenConfig is false', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue(null); - const configPath = getConfigPath(undefined, false); - expect(configPath).toBe(CONFIG_PATHS.default); - }); + setConfigAccountAsDefault('api-key-account'); - it('returns null when useHiddenConfig is true and no hidden config exists', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue(null); - const hiddenConfigPath = getConfigPath(undefined, true); - expect(hiddenConfigPath).toBeNull(); - }); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump(formatConfigForWrite({ ...config, defaultAccount: 345 })) + ); }); - describe('when a non-standard config is present', () => { - beforeAll(() => { - fsExistsSyncSpy.mockReturnValue(true); - (configFile.getConfigFilePath as jest.Mock).mockReturnValue( - CONFIG_PATHS.nonStandard - ); - }); - - it('returns the hidden config path when useHiddenConfig is true', () => { - (configFile.getConfigFilePath as jest.Mock).mockReturnValue( - CONFIG_PATHS.hidden - ); - const hiddenConfigPath = getConfigPath(undefined, true); - expect(hiddenConfigPath).toBe(CONFIG_PATHS.hidden); - }); + it('throws when account not found', () => { + expect(() => setConfigAccountAsDefault('non-existent-account')).toThrow(); }); }); - describe('createEmptyConfigFile()', () => { - describe('when no config is present', () => { - let fsExistsSyncSpy: jest.SpyInstance; - - beforeEach(() => { - setConfigPath(CONFIG_PATHS.none); - mockedConfigPath = CONFIG_PATHS.none; - fsExistsSyncSpy = jest - .spyOn(fs, 'existsSync') - .mockImplementation(() => { - return false; - }); - }); - - afterAll(() => { - setConfigPath(CONFIG_PATHS.default); - mockedConfigPath = CONFIG_PATHS.default; - fsExistsSyncSpy.mockRestore(); - }); + describe('renameConfigAccount()', () => { + it('renames existing account', () => { + mockConfig(); - it('writes a new config file', () => { - createEmptyConfigFile(); + renameConfigAccount('test-account', 'new-name'); - expect(fsWriteFileSyncSpy).toHaveBeenCalled(); - }); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump( + formatConfigForWrite({ + ...CONFIG, + accounts: [{ ...PAK_ACCOUNT, name: 'new-name' }], + }) + ) + ); }); - describe('when a config is present', () => { - let fsExistsSyncAndReturnTrueSpy: jest.SpyInstance; - - beforeAll(() => { - setConfigPath(CONFIG_PATHS.cwd); - mockedConfigPath = CONFIG_PATHS.cwd; - fsExistsSyncAndReturnTrueSpy = jest - .spyOn(fs, 'existsSync') - .mockImplementation(pathToCheck => { - if (pathToCheck === CONFIG_PATHS.cwd) { - return true; - } - - return false; - }); - }); - - afterAll(() => { - fsExistsSyncAndReturnTrueSpy.mockRestore(); - }); - - it('does nothing', () => { - createEmptyConfigFile(); - - expect(fsWriteFileSyncSpy).not.toHaveBeenCalled(); - }); + it('throws when account not found', () => { + expect(() => + renameConfigAccount('non-existent-account', 'new-name') + ).toThrow(); }); - describe('when passed a path', () => { - beforeAll(() => { - setConfigPath(CONFIG_PATHS.none); - mockedConfigPath = CONFIG_PATHS.none; - }); - - it('creates a config at the specified path', () => { - const specifiedPath = '/some/path/that/has/never/been/used.config.yml'; - createEmptyConfigFile({ path: specifiedPath }); + it('throws when new name already exists', () => { + const config = { ...CONFIG, accounts: [PAK_ACCOUNT, API_KEY_ACCOUNT] }; + mockConfig(config); - expect(fsWriteFileSyncSpy).not.toHaveBeenCalledWith(specifiedPath); - }); + expect(() => + renameConfigAccount('test-account', 'api-key-account') + ).toThrow(); }); }); - describe('configFileExists', () => { - let getConfigPathSpy: jest.SpyInstance; - - beforeAll(() => { - getConfigPathSpy = jest.spyOn(config_DEPRECATED, 'getConfigPath'); + describe('removeAccountFromConfig()', () => { + it('removes existing account', () => { + mockConfig(); + + removeAccountFromConfig(123); + + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump( + formatConfigForWrite({ + ...CONFIG, + accounts: [], + defaultAccount: undefined, + }) + ) + ); }); - beforeEach(() => { - jest.clearAllMocks(); - }); + it('throws when account not found', () => { + mockConfig(); - afterAll(() => { - getConfigPathSpy.mockRestore(); + expect(() => removeAccountFromConfig(456)).toThrow(); }); + }); - it('returns true when useHiddenConfig is true and newConfigFileExists returns true', () => { - (configFile.configFileExists as jest.Mock).mockReturnValue(true); + describe('updateHttpTimeout()', () => { + it('updates timeout value', () => { + mockConfig(); - const result = configFileExists(true); + updateHttpTimeout(4000); - expect(configFile.configFileExists).toHaveBeenCalled(); - expect(result).toBe(true); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump(formatConfigForWrite({ ...CONFIG, httpTimeout: 4000 })) + ); }); - it('returns false when useHiddenConfig is true and newConfigFileExists returns false', () => { - (configFile.configFileExists as jest.Mock).mockReturnValue(false); + it('throws for invalid timeout', () => { + expect(() => updateHttpTimeout('invalid-timeout')).toThrow(); + }); + }); - const result = configFileExists(true); + describe('updateAllowUsageTracking()', () => { + it('updates tracking setting', () => { + mockConfig(); + updateAllowUsageTracking(false); - expect(configFile.configFileExists).toHaveBeenCalled(); - expect(result).toBe(false); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump( + formatConfigForWrite({ ...CONFIG, allowUsageTracking: false }) + ) + ); }); + }); - it('returns true when useHiddenConfig is false and config_DEPRECATED.getConfigPath returns a valid path', () => { - getConfigPathSpy.mockReturnValue(CONFIG_PATHS.default); + describe('updateDefaultCmsPublishMode()', () => { + it('updates publish mode', () => { + mockConfig(); - const result = configFileExists(false); + updateDefaultCmsPublishMode('draft'); - expect(getConfigPathSpy).toHaveBeenCalled(); - expect(result).toBe(true); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + getConfigFilePath(), + yaml.dump( + formatConfigForWrite({ ...CONFIG, defaultCmsPublishMode: 'draft' }) + ) + ); }); - it('returns false when useHiddenConfig is false and config_DEPRECATED.getConfigPath returns an empty path', () => { - getConfigPathSpy.mockReturnValue(''); + it('throws for invalid mode', () => { + expect(() => + updateDefaultCmsPublishMode('invalid-mode' as unknown as CmsPublishMode) + ).toThrow(); + }); + }); - const result = configFileExists(false); + describe('isConfigFlagEnabled()', () => { + it('returns flag value when set', () => { + mockConfig({ + ...CONFIG, + [CONFIG_FLAGS.USE_CUSTOM_OBJECT_HUBFILE]: true, + }); - expect(getConfigPathSpy).toHaveBeenCalled(); - expect(result).toBe(false); + expect(isConfigFlagEnabled(CONFIG_FLAGS.USE_CUSTOM_OBJECT_HUBFILE)).toBe( + true + ); }); - it('defaults to useHiddenConfig as false when not provided', () => { - getConfigPathSpy.mockReturnValue(CONFIG_PATHS.default); + it('returns default value when not set', () => { + mockConfig(); - const result = configFileExists(); - - expect(getConfigPathSpy).toHaveBeenCalled(); - expect(result).toBe(true); + expect( + isConfigFlagEnabled(CONFIG_FLAGS.USE_CUSTOM_OBJECT_HUBFILE, true) + ).toBe(true); }); }); }); diff --git a/config/__tests__/configFile.test.ts b/config/__tests__/configFile.test.ts deleted file mode 100644 index 32e6f67b..00000000 --- a/config/__tests__/configFile.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import fs from 'fs-extra'; -import os from 'os'; -import yaml from 'js-yaml'; -import { - getConfigFilePath, - configFileExists, - configFileIsBlank, - deleteConfigFile, - readConfigFile, - parseConfig, - loadConfigFromFile, - writeConfigToFile, -} from '../configFile'; -import { - HUBSPOT_CONFIGURATION_FILE, - HUBSPOT_CONFIGURATION_FOLDER, -} from '../../constants/config'; -import { CLIConfig_NEW } from '../../types/Config'; - -// fs spy -const existsSyncSpy = jest.spyOn(fs, 'existsSync'); -const readFileSyncSpy = jest.spyOn(fs, 'readFileSync'); -const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync'); -const ensureFileSyncSpy = jest.spyOn(fs, 'ensureFileSync'); -const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync'); - -// yamp spy -const loadSpy = jest.spyOn(yaml, 'load'); -const dumpSpy = jest.spyOn(yaml, 'dump'); - -const CONFIG = { - defaultAccount: '', - accounts: [], -} as CLIConfig_NEW; - -describe('config/configFile', () => { - describe('getConfigFilePath()', () => { - it('returns the config file path', () => { - const configFilePath = getConfigFilePath(); - const homeDir = os.homedir(); - - const homeDirIndex = configFilePath.indexOf(homeDir); - const folderIndex = configFilePath.indexOf(HUBSPOT_CONFIGURATION_FOLDER); - const fileIndex = configFilePath.indexOf(HUBSPOT_CONFIGURATION_FILE); - - expect(homeDirIndex).toBeGreaterThan(-1); - expect(folderIndex).toBeGreaterThan(-1); - expect(fileIndex).toBeGreaterThan(-1); - expect(folderIndex).toBeGreaterThan(homeDirIndex); - expect(fileIndex).toBeGreaterThan(folderIndex); - }); - }); - - describe('configFileExists()', () => { - it('returns true if config file exists', () => { - existsSyncSpy.mockImplementation(() => true); - const exists = configFileExists(); - - expect(existsSyncSpy).toHaveBeenCalled(); - expect(exists).toBe(true); - }); - }); - - describe('configFileIsBlank()', () => { - it('returns true if config file is blank', () => { - readFileSyncSpy.mockImplementation(() => Buffer.from('')); - const isBlank = configFileIsBlank(); - - expect(readFileSyncSpy).toHaveBeenCalled(); - expect(isBlank).toBe(true); - }); - it('returns false if config file is not blank', () => { - readFileSyncSpy.mockImplementation(() => Buffer.from('content')); - const isBlank = configFileIsBlank(); - - expect(readFileSyncSpy).toHaveBeenCalled(); - expect(isBlank).toBe(false); - }); - }); - - describe('deleteConfigFile()', () => { - it('deletes a file', () => { - unlinkSyncSpy.mockImplementation(() => null); - deleteConfigFile(); - - expect(unlinkSyncSpy).toHaveBeenLastCalledWith(getConfigFilePath()); - }); - }); - - describe('readConfigFile()', () => { - it('reads the config file', () => { - readFileSyncSpy.mockImplementation(() => Buffer.from('content')); - const result = readConfigFile('path/to/config/file'); - - expect(result).toBeDefined(); - }); - it('throws error if it fails to read the config file', () => { - readFileSyncSpy.mockImplementation(() => { - throw new Error('failed to do the thing'); - }); - - expect(() => readConfigFile('path/to/config/file')).toThrow(); - }); - }); - - describe('parseConfig()', () => { - it('parses the config file', () => { - loadSpy.mockImplementation(() => ({})); - const result = parseConfig('config-source'); - - expect(result).toBeDefined(); - }); - it('throws error if it fails to parse the config file', () => { - loadSpy.mockImplementation(() => { - throw new Error('failed to do the thing'); - }); - - expect(() => parseConfig('config-source')).toThrow(); - }); - }); - - describe('loadConfigFromFile()', () => { - it('loads the config from file', () => { - readFileSyncSpy.mockImplementation(() => Buffer.from('content')); - loadSpy.mockImplementation(() => ({})); - const result = loadConfigFromFile(); - - expect(result).toBeDefined(); - }); - it('throws error if it fails to load the config file', () => { - loadSpy.mockImplementation(() => { - throw new Error('Config file could not be read: /testpath'); - }); - - expect(() => loadConfigFromFile()).toThrow(); - }); - }); - - describe('writeConfigToFile()', () => { - it('writes the config to a file', () => { - ensureFileSyncSpy.mockImplementation(() => null); - writeFileSyncSpy.mockImplementation(() => null); - readFileSyncSpy.mockImplementation(() => Buffer.from('content')); - loadSpy.mockImplementation(() => ({})); - - writeConfigToFile(CONFIG); - - expect(ensureFileSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - }); - it('throws error if it fails to parse the config json', () => { - dumpSpy.mockImplementation(() => { - throw new Error('failed to do the thing'); - }); - - expect(() => writeConfigToFile(CONFIG)).toThrow(); - }); - it('throws error if it fails to write the config to a file', () => { - ensureFileSyncSpy.mockImplementation(() => null); - writeFileSyncSpy.mockImplementation(() => { - throw new Error('failed to do the thing'); - }); - - expect(() => writeConfigToFile(CONFIG)).toThrow(); - }); - }); -}); diff --git a/config/__tests__/configUtils.test.ts b/config/__tests__/configUtils.test.ts deleted file mode 100644 index a7b6fc83..00000000 --- a/config/__tests__/configUtils.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - generateConfig, - getOrderedAccount, - getOrderedConfig, -} from '../configUtils'; -import { CLIConfig } from '../../types/Config'; -import { - CLIAccount, - OAuthAccount, - PersonalAccessKeyAccount, -} from '../../types/Accounts'; - -const PAK_ACCOUNT: PersonalAccessKeyAccount = { - accountId: 111, - authType: 'personalaccesskey', - name: 'pak-account-1', - auth: { - tokenInfo: { - accessToken: 'pak-access-token', - expiresAt: '', - }, - }, - personalAccessKey: 'pak-12345', - env: '', -}; - -const OAUTH_ACCOUNT: OAuthAccount = { - accountId: 222, - authType: 'oauth2', - name: 'oauth-account-1', - auth: { - clientId: 'oauth-client-id', - clientSecret: 'oauth-client-secret', - scopes: [], - tokenInfo: { - refreshToken: 'oauth-refresh-token', - }, - }, - env: '', -}; - -const APIKEY_ACCOUNT: CLIAccount = { - accountId: 333, - name: 'apikey-account-1', - authType: 'apikey', - apiKey: 'api-key', - env: '', -}; - -const CONFIG: CLIConfig = { - defaultAccount: PAK_ACCOUNT.name, - accounts: [PAK_ACCOUNT, OAUTH_ACCOUNT, APIKEY_ACCOUNT], -}; - -describe('config/configUtils', () => { - describe('getOrderedAccount()', () => { - it('returns an ordered account', () => { - const orderedAccount = getOrderedAccount(PAK_ACCOUNT); - const keys = Object.keys(orderedAccount); - - expect(keys[0]).toBe('name'); - expect(keys[1]).toBe('accountId'); - }); - }); - - describe('getOrderedConfig()', () => { - it('returns an ordered config', () => { - const orderedConfig = getOrderedConfig(CONFIG); - const keys = Object.keys(orderedConfig); - - expect(keys[0]).toBe('defaultAccount'); - expect(keys[keys.length - 1]).toBe('accounts'); - }); - it('returns a config with accounts ordered', () => { - const orderedConfig = getOrderedConfig(CONFIG); - const accountKeys = Object.keys(orderedConfig.accounts![0]); - - expect(accountKeys[0]).toBe('name'); - expect(accountKeys[1]).toBe('accountId'); - }); - }); - - describe('generateConfig()', () => { - it('returns a personal access key auth account', () => { - const pakConfig = generateConfig('personalaccesskey', { - accountId: 111, - personalAccessKey: 'pak-12345', - env: 'prod', - }); - - expect(pakConfig).toBeDefined(); - if (pakConfig) { - expect(pakConfig.accounts).toBeDefined(); - expect(pakConfig.accounts![0].authType).toBe('personalaccesskey'); - } - }); - it('returns an oauth auth account', () => { - const oauthConfig = generateConfig('oauth2', { - accountId: 111, - clientId: 'client-id', - clientSecret: 'client-secret', - refreshToken: 'refresh-token', - scopes: [], - env: 'prod', - }); - - expect(oauthConfig).toBeDefined(); - if (oauthConfig) { - expect(oauthConfig.accounts).toBeDefined(); - expect(oauthConfig.accounts![0].authType).toBe('oauth2'); - } - }); - it('returns an apikey account', () => { - const apikeyConfig = generateConfig('apikey', { - accountId: 111, - apiKey: 'api-key', - env: 'prod', - }); - - expect(apikeyConfig).toBeDefined(); - if (apikeyConfig) { - expect(apikeyConfig.accounts).toBeDefined(); - expect(apikeyConfig.accounts![0].authType).toBe('apikey'); - } - }); - }); -}); diff --git a/config/__tests__/defaultAccountOverride.test.ts b/config/__tests__/defaultAccountOverride.test.ts new file mode 100644 index 00000000..c6f1db93 --- /dev/null +++ b/config/__tests__/defaultAccountOverride.test.ts @@ -0,0 +1,73 @@ +import fs from 'fs-extra'; +import findup from 'findup-sync'; +import { + getDefaultAccountOverrideAccountId, + getDefaultAccountOverrideFilePath, +} from '../defaultAccountOverride'; +import * as config from '../index'; +import { PersonalAccessKeyConfigAccount } from '../../types/Accounts'; +import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '../../constants/auth'; + +jest.mock('fs-extra'); +jest.mock('findup-sync'); +jest.mock('../index'); + +const mockFs = fs as jest.Mocked; +const mockFindup = findup as jest.MockedFunction; +const mockGetAllConfigAccounts = + config.getAllConfigAccounts as jest.MockedFunction< + typeof config.getAllConfigAccounts + >; + +const PAK_ACCOUNT: PersonalAccessKeyConfigAccount = { + name: 'test-account', + accountId: 123, + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'test-key', + env: 'qa', + auth: { + tokenInfo: {}, + }, + accountType: 'STANDARD', +}; + +describe('defaultAccountOverride', () => { + describe('getDefaultAccountOverrideAccountId()', () => { + it('returns null when override file does not exist', () => { + mockFindup.mockReturnValueOnce(null); + expect(getDefaultAccountOverrideAccountId()).toBeNull(); + }); + + it('throws an error when override file exists but is not a number', () => { + mockFindup.mockReturnValueOnce('.hsaccount'); + mockFs.readFileSync.mockReturnValueOnce('string'); + expect(() => getDefaultAccountOverrideAccountId()).toThrow(); + }); + + it('throws an error when account specified in override file does not exist in config', () => { + mockFindup.mockReturnValueOnce('.hsaccount'); + mockFs.readFileSync.mockReturnValueOnce('234'); + mockGetAllConfigAccounts.mockReturnValueOnce([PAK_ACCOUNT]); + expect(() => getDefaultAccountOverrideAccountId()).toThrow(); + }); + + it('returns the account ID when an account with that ID exists in config', () => { + mockFindup.mockReturnValueOnce('.hsaccount'); + mockFs.readFileSync.mockReturnValueOnce('123'); + mockGetAllConfigAccounts.mockReturnValueOnce([PAK_ACCOUNT]); + expect(getDefaultAccountOverrideAccountId()).toBe(123); + }); + }); + + describe('getDefaultAccountOverrideFilePath()', () => { + it('returns the path to the override file if one exists', () => { + mockFindup.mockReturnValueOnce('.hsaccount'); + expect(getDefaultAccountOverrideFilePath()).toBe('.hsaccount'); + }); + + it('returns null if no override file exists', () => { + mockFindup.mockReturnValueOnce(null); + expect(getDefaultAccountOverrideFilePath()).toBeNull(); + }); + }); +}); diff --git a/config/__tests__/environment.test.ts b/config/__tests__/environment.test.ts deleted file mode 100644 index 62b8015f..00000000 --- a/config/__tests__/environment.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { loadConfigFromEnvironment } from '../environment'; -import { ENVIRONMENT_VARIABLES } from '../../constants/environments'; -import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '../../constants/auth'; - -describe('config/environment', () => { - describe('loadConfigFromEnvironment()', () => { - const INITIAL_ENV = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...INITIAL_ENV }; - }); - - afterAll(() => { - process.env = INITIAL_ENV; - }); - - it('returns null when no accountId exists', () => { - const config = loadConfigFromEnvironment(); - expect(config).toBe(null); - }); - - it('returns null when no env exists', () => { - process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '1234'; - - const config = loadConfigFromEnvironment(); - expect(config).toBe(null); - }); - - it('generates a personal access key config from the env', () => { - process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '1234'; - process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] = 'qa'; - process.env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY] = - 'personal-access-key'; - - const config = loadConfigFromEnvironment(); - expect(config).toMatchObject({ - accounts: [ - { - authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, - accountId: 1234, - env: 'qa', - personalAccessKey: 'personal-access-key', - }, - ], - }); - }); - }); -}); diff --git a/config/__tests__/migrate.test.ts b/config/__tests__/migrate.test.ts index f16ba212..e5f4c25f 100644 --- a/config/__tests__/migrate.test.ts +++ b/config/__tests__/migrate.test.ts @@ -1,540 +1,472 @@ -import * as migrate from '../migrate'; -import * as config_DEPRECATED from '../config_DEPRECATED'; -import { CLIConfiguration } from '../CLIConfiguration'; -import * as configIndex from '../index'; -import * as configFile from '../configFile'; -import { CLIConfig_DEPRECATED, CLIConfig_NEW } from '../../types/Config'; -import { ENVIRONMENTS } from '../../constants/environments'; -import { OAUTH_AUTH_METHOD } from '../../constants/auth'; -import { ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME } from '../../constants/config'; -import { i18n } from '../../utils/lang'; import fs from 'fs'; import path from 'path'; +import os from 'os'; + +import { + getConfigAtPath, + migrateConfigAtPath, + mergeConfigProperties, + mergeConfigAccounts, + archiveConfigAtPath, +} from '../migrate'; +import { HubSpotConfig } from '../../types/Config'; +import { readConfigFile, writeConfigFile } from '../utils'; +import { + DEFAULT_CMS_PUBLISH_MODE, + HTTP_TIMEOUT, + ENV, + HTTP_USE_LOCALHOST, + ALLOW_USAGE_TRACKING, + DEFAULT_ACCOUNT, + ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME, +} from '../../constants/config'; +import { ENVIRONMENTS } from '../../constants/environments'; +import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '../../constants/auth'; +import { PersonalAccessKeyConfigAccount } from '../../types/Accounts'; +import { createEmptyConfigFile, getGlobalConfigFilePath } from '../index'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + unlinkSync: jest.fn(), + renameSync: jest.fn(), +})); + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + readConfigFile: jest.fn(), + writeConfigFile: jest.fn(), +})); + +jest.mock('../index', () => ({ + ...jest.requireActual('../index'), + createEmptyConfigFile: jest.fn(), + getGlobalConfigFilePath: jest.fn(), +})); + +describe('config/migrate', () => { + let mockConfig: HubSpotConfig; + let mockConfigSource: string; + let mockConfigPath: string; + let mockGlobalConfigPath: string; -// Mock dependencies -jest.mock('../config_DEPRECATED'); -jest.mock('../CLIConfiguration'); -jest.mock('../index'); -jest.mock('../configFile'); -jest.mock('../../utils/lang'); -jest.mock('fs'); -jest.mock('path'); - -const mockConfig_DEPRECATED = config_DEPRECATED as jest.Mocked< - typeof config_DEPRECATED ->; -const mockCLIConfiguration = CLIConfiguration as jest.Mocked< - typeof CLIConfiguration ->; -const mockConfigIndex = configIndex as jest.Mocked; -const mockConfigFile = configFile as jest.Mocked; -const mockI18n = i18n as jest.MockedFunction; -const mockFs = fs as jest.Mocked; -const mockPath = path as jest.Mocked; - -describe('migrate', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - describe('getDeprecatedConfig', () => { - it('should return deprecated config when loadConfig succeeds', () => { - const mockDeprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'test-portal', - portals: [], - }; - mockConfig_DEPRECATED.loadConfig.mockReturnValue(mockDeprecatedConfig); - - const result = migrate.getDeprecatedConfig('/test/path'); - - expect(mockConfig_DEPRECATED.loadConfig).toHaveBeenCalledWith( - '/test/path' - ); - expect(result).toBe(mockDeprecatedConfig); - }); - - it('should return null when loadConfig fails', () => { - mockConfig_DEPRECATED.loadConfig.mockReturnValue(null); - - const result = migrate.getDeprecatedConfig(); - - expect(mockConfig_DEPRECATED.loadConfig).toHaveBeenCalledWith(undefined); - expect(result).toBeNull(); - }); - - it('should call loadConfig with undefined when no configPath provided', () => { - mockConfig_DEPRECATED.loadConfig.mockReturnValue(null); - - migrate.getDeprecatedConfig(); - expect(mockConfig_DEPRECATED.loadConfig).toHaveBeenCalledWith(undefined); - }); + mockConfig = { + accounts: [ + { + accountId: 123456, + name: 'Test Account', + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'test-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: {}, + }, + } as PersonalAccessKeyConfigAccount, + ], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, + allowUsageTracking: true, + defaultAccount: 123456, + }; + + mockConfigSource = JSON.stringify(mockConfig); + mockConfigPath = '/path/to/config.yml'; + mockGlobalConfigPath = path.join(os.homedir(), '.hscli', 'config.yml'); + + (readConfigFile as jest.Mock).mockReturnValue(mockConfigSource); + (getGlobalConfigFilePath as jest.Mock).mockReturnValue( + mockGlobalConfigPath + ); }); - describe('getGlobalConfig', () => { - it('should return CLIConfiguration config when active', () => { - const mockConfig: CLIConfig_NEW = { - defaultAccount: 'test-account', - accounts: [], - }; - mockCLIConfiguration.isActive.mockReturnValue(true); - mockCLIConfiguration.config = mockConfig; - - const result = migrate.getGlobalConfig(); - - expect(mockCLIConfiguration.isActive).toHaveBeenCalled(); - expect(result).toBe(mockConfig); - }); - - it('should return null when CLIConfiguration is not active', () => { - mockCLIConfiguration.isActive.mockReturnValue(false); - - const result = migrate.getGlobalConfig(); + describe('getConfigAtPath', () => { + it('should read and parse config from the given path', () => { + const result = getConfigAtPath(mockConfigPath); - expect(mockCLIConfiguration.isActive).toHaveBeenCalled(); - expect(result).toBeNull(); + expect(readConfigFile).toHaveBeenCalledWith(mockConfigPath); + expect(result).toEqual(mockConfig); }); }); - describe('configFileExists', () => { - it('should check new config file when useHiddenConfig is true', () => { - mockConfigFile.configFileExists.mockReturnValue(true); + describe('migrateConfigAtPath', () => { + it('should migrate config from the given path to the global config path', () => { + (createEmptyConfigFile as jest.Mock).mockImplementation(() => undefined); + migrateConfigAtPath(mockConfigPath); - const result = migrate.configFileExists(true); - - expect(mockConfigFile.configFileExists).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should check deprecated config file when useHiddenConfig is false', () => { - const mockConfigPath = '/test/config.yml'; - mockConfig_DEPRECATED.getConfigPath.mockReturnValue(mockConfigPath); - - const result = migrate.configFileExists(false, '/test/path'); - - expect(mockConfig_DEPRECATED.getConfigPath).toHaveBeenCalledWith( - '/test/path' - ); - expect(result).toBe(true); - }); - - it('should return false when deprecated config path is null', () => { - mockConfig_DEPRECATED.getConfigPath.mockReturnValue(null); - - const result = migrate.configFileExists(false); - - expect(result).toBe(false); - }); - - it('should default useHiddenConfig to false', () => { - mockConfig_DEPRECATED.getConfigPath.mockReturnValue(null); - - migrate.configFileExists(); - - expect(mockConfig_DEPRECATED.getConfigPath).toHaveBeenCalledWith( - undefined + expect(createEmptyConfigFile).toHaveBeenCalledWith(true); + expect(readConfigFile).toHaveBeenCalledWith(mockConfigPath); + expect(writeConfigFile).toHaveBeenCalledWith( + mockConfig, + mockGlobalConfigPath ); }); }); - describe('getConfigPath', () => { - it('should return new config file path when useHiddenConfig is true', () => { - const mockPath = '/test/new/config'; - mockConfigFile.getConfigFilePath.mockReturnValue(mockPath); - - const result = migrate.getConfigPath('/test/path', true); - - expect(mockConfigFile.getConfigFilePath).toHaveBeenCalled(); - expect(result).toBe(mockPath); - }); - - it('should return deprecated config path when useHiddenConfig is false', () => { - const mockPath = '/test/deprecated/config'; - mockConfig_DEPRECATED.getConfigPath.mockReturnValue(mockPath); - - const result = migrate.getConfigPath('/test/path', false); - - expect(mockConfig_DEPRECATED.getConfigPath).toHaveBeenCalledWith( - '/test/path' - ); - expect(result).toBe(mockPath); - }); - - it('should default useHiddenConfig to false', () => { - const mockPath = '/test/deprecated/config'; - mockConfig_DEPRECATED.getConfigPath.mockReturnValue(mockPath); + describe('mergeConfigProperties', () => { + it('should merge properties from fromConfig to toConfig without conflicts when force is false', () => { + // Arrange + const toConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'publish', + httpTimeout: 3000, + env: ENVIRONMENTS.QA, + httpUseLocalhost: true, + allowUsageTracking: false, + defaultAccount: 654321, + }; - const result = migrate.getConfigPath('/test/path'); + const fromConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, + allowUsageTracking: true, + defaultAccount: 123456, + }; - expect(mockConfig_DEPRECATED.getConfigPath).toHaveBeenCalledWith( - '/test/path' - ); - expect(result).toBe(mockPath); - }); - }); + const result = mergeConfigProperties(toConfig, fromConfig); - describe('migrateConfig', () => { - beforeEach(() => { - mockI18n.mockImplementation(key => `translated-${key}`); - mockConfigIndex.createEmptyConfigFile.mockImplementation(); - mockConfigIndex.loadConfig.mockImplementation(); - mockConfigIndex.writeConfig.mockImplementation(); - mockConfigIndex.deleteEmptyConfigFile.mockImplementation(); - mockConfig_DEPRECATED.getConfigPath.mockReturnValue('/old/config/path'); - mockPath.dirname.mockReturnValue('/old/config'); - mockPath.join.mockReturnValue( - `/old/config/${ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME}` - ); - mockFs.renameSync.mockImplementation(); + expect(result.configWithMergedProperties).toEqual(toConfig); + expect(result.conflicts).toHaveLength(6); + expect(result.conflicts).toContainEqual({ + property: DEFAULT_CMS_PUBLISH_MODE, + oldValue: 'draft', + newValue: 'publish', + }); + expect(result.conflicts).toContainEqual({ + property: HTTP_TIMEOUT, + oldValue: 5000, + newValue: 3000, + }); + expect(result.conflicts).toContainEqual({ + property: ENV, + oldValue: ENVIRONMENTS.PROD, + newValue: ENVIRONMENTS.QA, + }); + expect(result.conflicts).toContainEqual({ + property: HTTP_USE_LOCALHOST, + oldValue: false, + newValue: true, + }); + expect(result.conflicts).toContainEqual({ + property: ALLOW_USAGE_TRACKING, + oldValue: true, + newValue: false, + }); + expect(result.conflicts).toContainEqual({ + property: DEFAULT_ACCOUNT, + oldValue: 123456, + newValue: 654321, + }); }); - it('should throw error when deprecatedConfig is null', () => { - expect(() => migrate.migrateConfig(null)).toThrow( - 'translated-config.migrate.errors.noDeprecatedConfig' - ); - expect(mockI18n).toHaveBeenCalledWith( - 'config.migrate.errors.noDeprecatedConfig' - ); - }); + it('should merge properties from fromConfig to toConfig without conflicts when force is true', () => { + const toConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'publish', + httpTimeout: 3000, + env: ENVIRONMENTS.QA, + httpUseLocalhost: true, + allowUsageTracking: false, + defaultAccount: 654321, + }; - it('should migrate config successfully', () => { - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'test-portal', - portals: [ - { - portalId: 123, - name: 'test-account', - authType: OAUTH_AUTH_METHOD.value, - env: ENVIRONMENTS.PROD, - }, - ], - httpTimeout: 30000, + const fromConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, allowUsageTracking: true, + defaultAccount: 123456, }; - migrate.migrateConfig(deprecatedConfig); + const result = mergeConfigProperties(toConfig, fromConfig, true); - expect(mockConfigIndex.createEmptyConfigFile).toHaveBeenCalledWith( - {}, - true - ); - expect(mockConfigIndex.loadConfig).toHaveBeenCalledWith(''); - expect(mockConfigIndex.writeConfig).toHaveBeenCalledWith({ - source: JSON.stringify({ - httpTimeout: 30000, - allowUsageTracking: true, - defaultAccount: 'test-portal', - accounts: [ - { - name: 'test-account', - authType: OAUTH_AUTH_METHOD.value, - env: ENVIRONMENTS.PROD, - accountId: 123, - }, - ], - }), + expect(result.configWithMergedProperties).toEqual({ + ...toConfig, + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, + allowUsageTracking: true, + defaultAccount: 123456, }); + expect(result.conflicts).toHaveLength(0); }); - it('should handle portals with undefined portalId', () => { - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'test-portal', - portals: [ - { - portalId: 123, - name: 'valid-account', - env: ENVIRONMENTS.PROD, - }, - { - portalId: undefined, - name: 'invalid-account', - env: ENVIRONMENTS.PROD, - }, - ], + it('should merge properties from fromConfig to toConfig when toConfig has missing properties', () => { + const toConfig: HubSpotConfig = { + accounts: [], }; - migrate.migrateConfig(deprecatedConfig); + const fromConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, + allowUsageTracking: true, + defaultAccount: 123456, + }; - const writeConfigCall = mockConfigIndex.writeConfig.mock.calls[0]?.[0]; - expect(writeConfigCall).toBeDefined(); - expect(writeConfigCall?.source).toBeDefined(); - const parsedConfig = JSON.parse(writeConfigCall!.source!); + const result = mergeConfigProperties(toConfig, fromConfig); - expect(parsedConfig.accounts).toHaveLength(1); - expect(parsedConfig.accounts[0]).toEqual({ - name: 'valid-account', - accountId: 123, + expect(result.configWithMergedProperties).toEqual({ + ...toConfig, + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, env: ENVIRONMENTS.PROD, + httpUseLocalhost: false, + allowUsageTracking: true, + defaultAccount: 123456, }); + expect(result.conflicts).toHaveLength(0); }); - it('should rename old config file after successful migration', () => { - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'test-portal', - portals: [], + it('should merge flags from both configs', () => { + const toConfig: HubSpotConfig = { + accounts: [], + flags: ['flag1', 'flag2'], }; - migrate.migrateConfig(deprecatedConfig); - - expect(mockConfig_DEPRECATED.getConfigPath).toHaveBeenCalled(); - expect(mockPath.dirname).toHaveBeenCalledWith('/old/config/path'); - expect(mockPath.join).toHaveBeenCalledWith( - '/old/config', - ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME - ); - expect(mockFs.renameSync).toHaveBeenCalledWith( - '/old/config/path', - `/old/config/${ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME}` - ); - }); - - it('should handle writeConfig error and clean up', () => { - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'test-portal', - portals: [], + const fromConfig: HubSpotConfig = { + accounts: [], + flags: ['flag2', 'flag3'], }; - const writeError = new Error('Write failed'); - mockConfigIndex.writeConfig.mockImplementation(() => { - throw writeError; - }); - expect(() => migrate.migrateConfig(deprecatedConfig)).toThrow( - 'translated-config.migrate.errors.writeConfig' - ); - expect(mockConfigIndex.deleteEmptyConfigFile).toHaveBeenCalled(); + const result = mergeConfigProperties(toConfig, fromConfig); + + expect(result.configWithMergedProperties.flags).toEqual([ + 'flag1', + 'flag2', + 'flag3', + ]); + expect(result.conflicts).toHaveLength(0); }); - }); - describe('mergeConfigProperties', () => { - it('should merge properties without conflicts when force is true', () => { - const globalConfig: CLIConfig_NEW = { - defaultAccount: 'global-account', + it('should merge autoOpenBrowser and allowAutoUpdates fields', () => { + const toConfig: HubSpotConfig = { accounts: [], - httpTimeout: 10000, }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'deprecated-portal', - portals: [], - httpTimeout: 20000, - allowUsageTracking: false, + + const fromConfig: HubSpotConfig = { + accounts: [], + autoOpenBrowser: true, + allowAutoUpdates: false, }; - const result = migrate.mergeConfigProperties( - globalConfig, - deprecatedConfig, - true - ); + const result = mergeConfigProperties(toConfig, fromConfig); - expect(result.initialConfig).toEqual({ - defaultAccount: 'deprecated-portal', - accounts: [], - httpTimeout: 20000, - allowUsageTracking: false, - }); + expect(result.configWithMergedProperties.autoOpenBrowser).toBe(true); + expect(result.configWithMergedProperties.allowAutoUpdates).toBe(false); expect(result.conflicts).toHaveLength(0); }); - it('should detect conflicts when force is false and values differ', () => { - const globalConfig: CLIConfig_NEW = { - defaultAccount: 'global-account', + it('should detect conflicts for autoOpenBrowser and allowAutoUpdates when values differ', () => { + const toConfig: HubSpotConfig = { accounts: [], - httpTimeout: 10000, + autoOpenBrowser: false, + allowAutoUpdates: true, }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - defaultPortal: 'deprecated-portal', - portals: [], - httpTimeout: 20000, + + const fromConfig: HubSpotConfig = { + accounts: [], + autoOpenBrowser: true, + allowAutoUpdates: false, }; - const result = migrate.mergeConfigProperties( - globalConfig, - deprecatedConfig, - false - ); + const result = mergeConfigProperties(toConfig, fromConfig, false); - expect(result.conflicts).toHaveLength(2); expect(result.conflicts).toContainEqual({ - property: 'httpTimeout', - oldValue: 20000, - newValue: 10000, + property: 'autoOpenBrowser', + oldValue: true, + newValue: false, }); expect(result.conflicts).toContainEqual({ - property: 'defaultAccount', - oldValue: 'deprecated-portal', - newValue: 'global-account', + property: 'allowAutoUpdates', + oldValue: false, + newValue: true, }); }); + }); - it('should merge flags from both configs', () => { - const globalConfig: CLIConfig_NEW = { - accounts: [], - flags: ['flag1', 'flag2'], + describe('mergeConfigAccounts', () => { + it('should merge accounts from fromConfig to toConfig and skip existing accounts', () => { + const existingAccount = { + accountId: 123456, + name: 'Existing Account', + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'existing-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: {}, + }, + } as PersonalAccessKeyConfigAccount; + + const newAccount = { + accountId: 789012, + name: 'New Account', + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'new-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: {}, + }, + } as PersonalAccessKeyConfigAccount; + + const toConfig: HubSpotConfig = { + accounts: [existingAccount], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [], - flags: ['flag2', 'flag3'], + + const fromConfig: HubSpotConfig = { + accounts: [existingAccount, newAccount], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const result = migrate.mergeConfigProperties( - globalConfig, - deprecatedConfig - ); + const result = mergeConfigAccounts(toConfig, fromConfig); - expect(result.initialConfig.flags).toEqual(['flag1', 'flag2', 'flag3']); + expect(result.configWithMergedAccounts.accounts).toHaveLength(2); + expect(result.configWithMergedAccounts.accounts).toContainEqual( + existingAccount + ); + expect(result.configWithMergedAccounts.accounts).toContainEqual( + newAccount + ); + expect(result.skippedAccountIds).toEqual([123456]); + expect(writeConfigFile).toHaveBeenCalledWith( + result.configWithMergedAccounts, + mockGlobalConfigPath + ); }); - it('should handle missing properties gracefully', () => { - const globalConfig: CLIConfig_NEW = { + it('should handle empty accounts arrays', () => { + const toConfig: HubSpotConfig = { accounts: [], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [], - httpTimeout: 15000, - }; - - const result = migrate.mergeConfigProperties( - globalConfig, - deprecatedConfig - ); - - expect(result.initialConfig.httpTimeout).toBe(15000); - expect(result.conflicts).toHaveLength(0); - }); - it('should not create conflicts when values are the same', () => { - const globalConfig: CLIConfig_NEW = { + const fromConfig: HubSpotConfig = { accounts: [], - httpTimeout: 15000, - allowUsageTracking: true, - }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [], - httpTimeout: 15000, - allowUsageTracking: true, + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const result = migrate.mergeConfigProperties( - globalConfig, - deprecatedConfig - ); - - expect(result.conflicts).toHaveLength(0); - }); - }); - - describe('mergeExistingConfigs', () => { - beforeEach(() => { - mockConfigIndex.writeConfig.mockImplementation(); - }); + const result = mergeConfigAccounts(toConfig, fromConfig); - it('should merge accounts and return skipped account IDs', () => { - const globalConfig: CLIConfig_NEW = { - accounts: [ - { - accountId: 123, - name: 'existing-account', - env: ENVIRONMENTS.PROD, - }, - ], - }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [ - { - portalId: 123, - name: 'duplicate-account', - env: ENVIRONMENTS.PROD, - }, - { - portalId: 456, - name: 'new-account', - env: ENVIRONMENTS.PROD, - }, - ], - }; - - const result = migrate.mergeExistingConfigs( - globalConfig, - deprecatedConfig + expect(result.configWithMergedAccounts.accounts).toHaveLength(0); + expect(result.skippedAccountIds).toEqual([]); + expect(writeConfigFile).toHaveBeenCalledWith( + result.configWithMergedAccounts, + mockGlobalConfigPath ); + }); - expect(result.finalConfig.accounts).toHaveLength(2); - expect(result.finalConfig.accounts).toContainEqual({ - accountId: 123, - name: 'existing-account', + it('should handle case when fromConfig has no accounts', () => { + const existingAccount = { + accountId: 123456, + name: 'Existing Account', + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'existing-key', env: ENVIRONMENTS.PROD, - }); - expect(result.finalConfig.accounts).toContainEqual({ - accountId: 456, - name: 'new-account', + auth: { + tokenInfo: {}, + }, + } as PersonalAccessKeyConfigAccount; + + const toConfig: HubSpotConfig = { + accounts: [existingAccount], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, env: ENVIRONMENTS.PROD, - }); - expect(result.skippedAccountIds).toEqual([123]); - }); + }; - it('should handle config without existing accounts', () => { - const globalConfig: CLIConfig_NEW = { + const fromConfig: HubSpotConfig = { accounts: [], - }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [ - { - portalId: 123, - name: 'new-account', - env: ENVIRONMENTS.PROD, - }, - ], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const result = migrate.mergeExistingConfigs( - globalConfig, - deprecatedConfig + const result = mergeConfigAccounts(toConfig, fromConfig); + + expect(result.configWithMergedAccounts.accounts).toHaveLength(1); + expect(result.configWithMergedAccounts.accounts).toContainEqual( + existingAccount + ); + expect(result.skippedAccountIds).toEqual([]); + expect(writeConfigFile).toHaveBeenCalledWith( + result.configWithMergedAccounts, + mockGlobalConfigPath ); + }); - expect(result.finalConfig.accounts).toHaveLength(1); - expect(result.finalConfig.accounts![0]).toEqual({ - name: 'new-account', - accountId: 123, + it('should handle case when toConfig has no accounts', () => { + const newAccount = { + accountId: 789012, + name: 'New Account', + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'new-key', env: ENVIRONMENTS.PROD, - }); - expect(result.skippedAccountIds).toHaveLength(0); - }); + auth: { + tokenInfo: {}, + }, + } as PersonalAccessKeyConfigAccount; - it('should handle config without deprecated portals', () => { - const globalConfig: CLIConfig_NEW = { - accounts: [ - { - accountId: 123, - name: 'existing-account', - env: ENVIRONMENTS.PROD, - }, - ], + const toConfig: HubSpotConfig = { + accounts: [], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [], + + const fromConfig: HubSpotConfig = { + accounts: [newAccount], + defaultCmsPublishMode: 'draft', + httpTimeout: 5000, + env: ENVIRONMENTS.PROD, }; - const result = migrate.mergeExistingConfigs( - globalConfig, - deprecatedConfig - ); + const result = mergeConfigAccounts(toConfig, fromConfig); - expect(result.finalConfig.accounts).toHaveLength(1); - expect(result.skippedAccountIds).toHaveLength(0); + expect(result.configWithMergedAccounts.accounts).toHaveLength(1); + expect(result.configWithMergedAccounts.accounts).toContainEqual( + newAccount + ); + expect(result.skippedAccountIds).toEqual([]); + expect(writeConfigFile).toHaveBeenCalledWith( + result.configWithMergedAccounts, + mockGlobalConfigPath + ); }); + }); - it('should write the final config', () => { - const globalConfig: CLIConfig_NEW = { - accounts: [], - }; - const deprecatedConfig: CLIConfig_DEPRECATED = { - portals: [], - }; + describe('archiveConfigAtPath', () => { + const mockRenameSync = fs.renameSync as jest.Mock; - migrate.mergeExistingConfigs(globalConfig, deprecatedConfig); + it('should rename config file to archived config file', () => { + const configPath = '/home/user/project/hubspot.config.yml'; + const expectedArchivedPath = `/home/user/project/${ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME}`; - expect(mockConfigIndex.writeConfig).toHaveBeenCalledWith({ - source: JSON.stringify(globalConfig), - }); + archiveConfigAtPath(configPath); + + expect(mockRenameSync).toHaveBeenCalledWith( + configPath, + expectedArchivedPath + ); }); }); }); diff --git a/config/__tests__/utils.test.ts b/config/__tests__/utils.test.ts new file mode 100644 index 00000000..758dd521 --- /dev/null +++ b/config/__tests__/utils.test.ts @@ -0,0 +1,388 @@ +import findup from 'findup-sync'; +import fs from 'fs-extra'; +import { + getLocalConfigDefaultFilePath, + getConfigPathEnvironmentVariables, + readConfigFile, + removeUndefinedFieldsFromConfigAccount, + writeConfigFile, + normalizeParsedConfig, + buildConfigFromEnvironment, + getConfigAccountByIdentifier, + getConfigAccountIndexById, + validateConfigAccount, + getAccountIdentifierAndType, +} from '../utils'; +import { HubSpotConfigError } from '../../models/HubSpotConfigError'; +import { getCwd } from '../../lib/path'; +import { + DeprecatedHubSpotConfigAccountFields, + HubSpotConfigAccount, + PersonalAccessKeyConfigAccount, + OAuthConfigAccount, + APIKeyConfigAccount, +} from '../../types/Accounts'; +import { + DeprecatedHubSpotConfigFields, + HubSpotConfig, +} from '../../types/Config'; +import { ENVIRONMENT_VARIABLES } from '../../constants/config'; +import { FileSystemError } from '../../models/FileSystemError'; +import { + PERSONAL_ACCESS_KEY_AUTH_METHOD, + OAUTH_AUTH_METHOD, + API_KEY_AUTH_METHOD, +} from '../../constants/auth'; +import { i18n } from '../../utils/lang'; + +jest.mock('findup-sync'); +jest.mock('../../lib/path'); +jest.mock('fs-extra'); + +const mockFindup = findup as jest.MockedFunction; +const mockCwd = getCwd as jest.MockedFunction; +const mockFs = fs as jest.Mocked; + +const PAK_ACCOUNT: PersonalAccessKeyConfigAccount = { + name: 'test-account', + accountId: 123, + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'test-key', + env: 'qa', + auth: { + tokenInfo: {}, + }, + accountType: 'STANDARD', +}; + +const OAUTH_ACCOUNT: OAuthConfigAccount = { + accountId: 123, + env: 'qa', + name: '123', + authType: OAUTH_AUTH_METHOD.value, + accountType: undefined, + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tokenInfo: { + refreshToken: 'test-refresh-token', + }, + scopes: ['content', 'hubdb', 'files'], + }, +}; + +const API_KEY_ACCOUNT: APIKeyConfigAccount = { + accountId: 123, + env: 'qa', + name: '123', + authType: API_KEY_AUTH_METHOD.value, + accountType: undefined, + apiKey: 'test-api-key', +}; + +const DEPRECATED_ACCOUNT: HubSpotConfigAccount & + DeprecatedHubSpotConfigAccountFields = { + name: 'test-account', + portalId: 123, + accountId: 1, + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + personalAccessKey: 'test-key', + env: 'qa', + auth: { + tokenInfo: {}, + }, + accountType: undefined, +}; + +const CONFIG: HubSpotConfig = { + defaultAccount: PAK_ACCOUNT.accountId, + accounts: [PAK_ACCOUNT], + defaultCmsPublishMode: 'publish', + httpTimeout: 1000, + httpUseLocalhost: true, + allowUsageTracking: true, +}; + +const DEPRECATED_CONFIG: HubSpotConfig & DeprecatedHubSpotConfigFields = { + accounts: [], + defaultAccount: 1, + portals: [DEPRECATED_ACCOUNT], + defaultPortal: DEPRECATED_ACCOUNT.name, + defaultCmsPublishMode: undefined, + defaultMode: 'publish', + httpTimeout: 1000, + httpUseLocalhost: true, + allowUsageTracking: true, +}; + +function cleanupEnvironmentVariables() { + Object.keys(ENVIRONMENT_VARIABLES).forEach(key => { + delete process.env[key]; + }); +} + +describe('config/utils', () => { + beforeEach(() => { + cleanupEnvironmentVariables(); + }); + + afterEach(() => { + cleanupEnvironmentVariables(); + }); + + describe('getLocalConfigDefaultFilePath()', () => { + it('returns the default config path in current directory', () => { + const mockCwdPath = '/mock/cwd'; + mockCwd.mockReturnValue(mockCwdPath); + + const defaultPath = getLocalConfigDefaultFilePath(); + expect(defaultPath).toBe(`${mockCwdPath}/hubspot.config.yml`); + }); + }); + + describe('getConfigPathEnvironmentVariables()', () => { + it('returns environment config settings', () => { + const configPath = 'config/path'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CONFIG_PATH] = configPath; + + const result = getConfigPathEnvironmentVariables(); + expect(result.useEnvironmentConfig).toBe(false); + expect(result.configFilePathFromEnvironment).toBe(configPath); + }); + + it('throws when both environment variables are set', () => { + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CONFIG_PATH] = 'path'; + process.env[ENVIRONMENT_VARIABLES.USE_ENVIRONMENT_HUBSPOT_CONFIG] = + 'true'; + + expect(() => getConfigPathEnvironmentVariables()).toThrow(); + }); + }); + + describe('readConfigFile()', () => { + it('reads and returns file contents', () => { + mockFs.readFileSync.mockReturnValue('config contents'); + const result = readConfigFile('test'); + expect(result).toBe('config contents'); + }); + + it('throws HubSpotConfigError on read failure', () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('Read error'); + }); + + expect(() => readConfigFile('test')).toThrow(HubSpotConfigError); + }); + }); + + describe('removeUndefinedFieldsFromConfigAccount()', () => { + it('removes undefined fields from account', () => { + const accountWithUndefinedFields = { + ...PAK_ACCOUNT, + defaultCmsPublishMode: undefined, + auth: { + ...PAK_ACCOUNT.auth, + tokenInfo: { + refreshToken: undefined, + }, + }, + }; + + const withFieldsRemoved = removeUndefinedFieldsFromConfigAccount( + accountWithUndefinedFields + ); + + expect(withFieldsRemoved).toEqual(PAK_ACCOUNT); + }); + }); + + describe('writeConfigFile()', () => { + it('writes formatted config to file', () => { + mockFs.ensureFileSync.mockImplementation(() => undefined); + mockFs.writeFileSync.mockImplementation(() => undefined); + + writeConfigFile(CONFIG, 'test.yml'); + + expect(mockFs.writeFileSync).toHaveBeenCalled(); + }); + + it('throws FileSystemError on write failure', () => { + mockFs.writeFileSync.mockImplementation(() => { + throw new Error('Write error'); + }); + + expect(() => writeConfigFile(CONFIG, 'test.yml')).toThrow( + FileSystemError + ); + }); + }); + + describe('normalizeParsedConfig()', () => { + it('converts portal fields to account fields', () => { + const normalizedConfig = normalizeParsedConfig(DEPRECATED_CONFIG); + expect(normalizedConfig).toEqual(CONFIG); + }); + }); + + describe('buildConfigFromEnvironment()', () => { + it('builds personal access key config', () => { + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY] = + 'test-key'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '123'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] = 'qa'; + process.env[ENVIRONMENT_VARIABLES.HTTP_TIMEOUT] = '1000'; + process.env[ENVIRONMENT_VARIABLES.HTTP_USE_LOCALHOST] = 'true'; + process.env[ENVIRONMENT_VARIABLES.ALLOW_USAGE_TRACKING] = 'true'; + process.env[ENVIRONMENT_VARIABLES.DEFAULT_CMS_PUBLISH_MODE] = 'publish'; + + const config = buildConfigFromEnvironment(); + + expect(config).toEqual({ + ...CONFIG, + accounts: [{ ...PAK_ACCOUNT, name: '123', accountType: undefined }], + }); + }); + + it('builds OAuth config', () => { + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_ID] = 'test-client-id'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_SECRET] = + 'test-client-secret'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_REFRESH_TOKEN] = + 'test-refresh-token'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '123'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] = 'qa'; + process.env[ENVIRONMENT_VARIABLES.HTTP_TIMEOUT] = '1000'; + process.env[ENVIRONMENT_VARIABLES.HTTP_USE_LOCALHOST] = 'true'; + process.env[ENVIRONMENT_VARIABLES.ALLOW_USAGE_TRACKING] = 'true'; + process.env[ENVIRONMENT_VARIABLES.DEFAULT_CMS_PUBLISH_MODE] = 'publish'; + + const config = buildConfigFromEnvironment(); + + expect(config).toEqual({ + ...CONFIG, + accounts: [OAUTH_ACCOUNT], + }); + }); + + it('throws when required variables missing', () => { + expect(() => { + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] = '123'; + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] = 'qa'; + buildConfigFromEnvironment(); + }).toThrow(); + }); + }); + + describe('getConfigAccountByIdentifier()', () => { + it('finds account by name', () => { + const account = getConfigAccountByIdentifier( + CONFIG.accounts, + 'name', + 'test-account' + ); + + expect(account).toEqual(PAK_ACCOUNT); + }); + + it('finds account by accountId', () => { + const account = getConfigAccountByIdentifier( + CONFIG.accounts, + 'accountId', + 123 + ); + + expect(account).toEqual(PAK_ACCOUNT); + }); + + it('returns undefined when account not found', () => { + const account = getConfigAccountByIdentifier( + CONFIG.accounts, + 'accountId', + 1234 + ); + + expect(account).toBeUndefined(); + }); + }); + + describe('getConfigAccountIndexById()', () => { + it('returns correct index for existing account', () => { + const index = getConfigAccountIndexById(CONFIG.accounts, 123); + expect(index).toBe(0); + }); + + it('returns -1 when account not found', () => { + const index = getConfigAccountIndexById(CONFIG.accounts, 1234); + expect(index).toBe(-1); + }); + }); + + describe('validateConfigAccount()', () => { + it('validates personal access key account', () => { + expect(validateConfigAccount(PAK_ACCOUNT)).toEqual({ + isValid: true, + errors: [], + }); + }); + + it('validates OAuth account', () => { + expect(validateConfigAccount(OAUTH_ACCOUNT)).toEqual({ + isValid: true, + errors: [], + }); + }); + + it('validates API key account', () => { + expect(validateConfigAccount(API_KEY_ACCOUNT)).toEqual({ + isValid: true, + errors: [], + }); + }); + + it('returns false for invalid account', () => { + expect( + validateConfigAccount({ + ...PAK_ACCOUNT, + personalAccessKey: undefined, + }) + ).toEqual({ + isValid: false, + errors: [ + i18n('config.utils.validateConfigAccount.missingPersonalAccessKey', { + accountId: PAK_ACCOUNT.accountId, + }), + ], + }); + expect( + validateConfigAccount({ + ...PAK_ACCOUNT, + accountId: undefined, + }) + ).toEqual({ + isValid: false, + errors: [i18n('config.utils.validateConfigAccount.missingAccountId')], + }); + }); + }); + + describe('getAccountIdentifierAndType()', () => { + it('returns name identifier for string', () => { + const { identifier, identifierType } = + getAccountIdentifierAndType('test-account'); + expect(identifier).toBe('test-account'); + expect(identifierType).toBe('name'); + }); + + it('returns accountId identifier for number', () => { + const { identifier, identifierType } = getAccountIdentifierAndType(123); + expect(identifier).toBe(123); + expect(identifierType).toBe('accountId'); + }); + + it('returns accountId identifier for numeric string', () => { + const { identifier, identifierType } = getAccountIdentifierAndType('123'); + expect(identifier).toBe(123); + expect(identifierType).toBe('accountId'); + }); + }); +}); diff --git a/config/configFile.ts b/config/configFile.ts deleted file mode 100644 index 89d884e7..00000000 --- a/config/configFile.ts +++ /dev/null @@ -1,111 +0,0 @@ -import fs from 'fs-extra'; -import yaml from 'js-yaml'; -import { logger } from '../lib/logger'; -import { GLOBAL_CONFIG_PATH } from '../constants/config'; -import { getOrderedConfig } from './configUtils'; -import { CLIConfig_NEW } from '../types/Config'; -import { i18n } from '../utils/lang'; -import { FileSystemError } from '../models/FileSystemError'; - -const i18nKey = 'config.configFile'; - -export function getConfigFilePath(): string { - return GLOBAL_CONFIG_PATH; -} - -export function configFileExists(): boolean { - const configPath = getConfigFilePath(); - return !!configPath && fs.existsSync(configPath); -} - -export function configFileIsBlank(): boolean { - const configPath = getConfigFilePath(); - return !!configPath && fs.readFileSync(configPath).length === 0; -} - -export function deleteConfigFile(): void { - const configPath = getConfigFilePath(); - fs.unlinkSync(configPath); -} - -/** - * @throws {Error} - */ -export function readConfigFile(configPath: string): string { - let source = ''; - - try { - source = fs.readFileSync(configPath).toString(); - } catch (err) { - logger.debug(i18n(`${i18nKey}.errorReading`, { configPath })); - throw new FileSystemError( - { cause: err }, - { - filepath: configPath, - operation: 'read', - } - ); - } - - return source; -} - -/** - * @throws {Error} - */ -export function parseConfig(configSource: string): CLIConfig_NEW { - let parsed: CLIConfig_NEW; - - try { - parsed = yaml.load(configSource) as CLIConfig_NEW; - } catch (err) { - throw new Error(i18n(`${i18nKey}.errors.parsing`), { cause: err }); - } - - return parsed; -} - -/** - * @throws {Error} - */ -export function loadConfigFromFile(): CLIConfig_NEW | null { - const configPath = getConfigFilePath(); - - if (configPath) { - const source = readConfigFile(configPath); - - if (!source) { - return null; - } - - return parseConfig(source); - } - - logger.debug(i18n(`${i18nKey}.errorLoading`, { configPath })); - - return null; -} - -/** - * @throws {Error} - */ -export function writeConfigToFile(config: CLIConfig_NEW): void { - const source = yaml.dump( - JSON.parse(JSON.stringify(getOrderedConfig(config), null, 2)) - ); - const configPath = getConfigFilePath(); - - try { - fs.ensureFileSync(configPath); - fs.writeFileSync(configPath, source); - logger.debug(i18n(`${i18nKey}.writeSuccess`, { configPath })); - } catch (err) { - throw new FileSystemError( - { cause: err }, - { - filepath: configPath, - operation: 'write', - } - ); - } -} diff --git a/config/configUtils.ts b/config/configUtils.ts deleted file mode 100644 index a32bd18d..00000000 --- a/config/configUtils.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { logger } from '../lib/logger'; -import { - API_KEY_AUTH_METHOD, - OAUTH_AUTH_METHOD, - PERSONAL_ACCESS_KEY_AUTH_METHOD, -} from '../constants/auth'; -import { CLIConfig_NEW } from '../types/Config'; -import { - AuthType, - CLIAccount_NEW, - APIKeyAccount_NEW, - OAuthAccount_NEW, - PersonalAccessKeyAccount_NEW, - PersonalAccessKeyOptions, - OAuthOptions, - APIKeyOptions, -} from '../types/Accounts'; -import { i18n } from '../utils/lang'; - -const i18nKey = 'config.configUtils'; - -export function getOrderedAccount( - unorderedAccount: CLIAccount_NEW -): CLIAccount_NEW { - const { name, accountId, env, authType, ...rest } = unorderedAccount; - - return { - name, - accountId, - env, - authType, - ...rest, - }; -} - -export function getOrderedConfig( - unorderedConfig: CLIConfig_NEW -): CLIConfig_NEW { - const { - defaultAccount, - defaultCmsPublishMode, - httpTimeout, - allowUsageTracking, - accounts, - ...rest - } = unorderedConfig; - - return { - ...(defaultAccount && { defaultAccount }), - defaultCmsPublishMode, - httpTimeout, - allowUsageTracking, - ...rest, - accounts: accounts?.map(getOrderedAccount) || [], - }; -} - -function generatePersonalAccessKeyAccountConfig({ - accountId, - personalAccessKey, - env, -}: PersonalAccessKeyOptions): PersonalAccessKeyAccount_NEW { - return { - authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, - accountId, - personalAccessKey, - env, - }; -} - -function generateOauthAccountConfig({ - accountId, - clientId, - clientSecret, - refreshToken, - scopes, - env, -}: OAuthOptions): OAuthAccount_NEW { - return { - authType: OAUTH_AUTH_METHOD.value, - accountId, - auth: { - clientId, - clientSecret, - scopes, - tokenInfo: { - refreshToken, - }, - }, - env, - }; -} - -function generateApiKeyAccountConfig({ - accountId, - apiKey, - env, -}: APIKeyOptions): APIKeyAccount_NEW { - return { - authType: API_KEY_AUTH_METHOD.value, - accountId, - apiKey, - env, - }; -} - -export function generateConfig( - type: AuthType, - options: PersonalAccessKeyOptions | OAuthOptions | APIKeyOptions -): CLIConfig_NEW | null { - if (!options) { - return null; - } - const config: CLIConfig_NEW = { accounts: [] }; - let configAccount: CLIAccount_NEW; - - switch (type) { - case API_KEY_AUTH_METHOD.value: - configAccount = generateApiKeyAccountConfig(options as APIKeyOptions); - break; - case PERSONAL_ACCESS_KEY_AUTH_METHOD.value: - configAccount = generatePersonalAccessKeyAccountConfig( - options as PersonalAccessKeyOptions - ); - break; - case OAUTH_AUTH_METHOD.value: - configAccount = generateOauthAccountConfig(options as OAuthOptions); - break; - default: - logger.debug(i18n(`${i18nKey}.unknownType`, { type })); - return null; - } - - if (configAccount) { - config.accounts?.push(configAccount); - } - - return config; -} diff --git a/config/config_DEPRECATED.ts b/config/config_DEPRECATED.ts deleted file mode 100644 index 350eb5d7..00000000 --- a/config/config_DEPRECATED.ts +++ /dev/null @@ -1,977 +0,0 @@ -import fs from 'fs-extra'; -import yaml from 'js-yaml'; -import findup from 'findup-sync'; -import { getCwd } from '../lib/path'; -import { - DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, - MIN_HTTP_TIMEOUT, - HUBSPOT_ACCOUNT_TYPES, -} from '../constants/config'; -import { ENVIRONMENTS, ENVIRONMENT_VARIABLES } from '../constants/environments'; -import { - API_KEY_AUTH_METHOD, - OAUTH_AUTH_METHOD, - PERSONAL_ACCESS_KEY_AUTH_METHOD, - OAUTH_SCOPES, -} from '../constants/auth'; -import { CMS_PUBLISH_MODE } from '../constants/files'; -import { getValidEnv } from '../lib/environment'; -import { logger } from '../lib/logger'; -import { - logErrorInstance, - logFileSystemErrorInstance, -} from '../errors/errors_DEPRECATED'; -import { CLIConfig_DEPRECATED, Environment } from '../types/Config'; -import { - APIKeyAccount_DEPRECATED, - AccountType, - CLIAccount_DEPRECATED, - FlatAccountFields_DEPRECATED, - OAuthAccount_DEPRECATED, - UpdateAccountConfigOptions, -} from '../types/Accounts'; -import { BaseError } from '../types/Error'; -import { CmsPublishMode } from '../types/Files'; -import { CLIOptions, WriteConfigOptions } from '../types/CLIOptions'; - -const ALL_CMS_PUBLISH_MODES = Object.values(CMS_PUBLISH_MODE); -let _config: CLIConfig_DEPRECATED | null; -let _configPath: string | null; -let environmentVariableConfigLoaded = false; - -const commaSeparatedValues = ( - arr: Array, - conjunction = 'and', - ifempty = '' -): string => { - const l = arr.length; - if (!l) return ifempty; - if (l < 2) return arr[0]; - if (l < 3) return arr.join(` ${conjunction} `); - arr = arr.slice(); - arr[l - 1] = `${conjunction} ${arr[l - 1]}`; - return arr.join(', '); -}; - -export const getConfig = () => _config; - -export function setConfig( - updatedConfig?: CLIConfig_DEPRECATED -): CLIConfig_DEPRECATED | null { - _config = updatedConfig || null; - return _config; -} - -export function getConfigAccounts( - config?: CLIConfig_DEPRECATED -): Array | undefined { - const __config = config || getConfig(); - if (!__config) return; - return __config.portals; -} - -export function getConfigDefaultAccount( - config?: CLIConfig_DEPRECATED -): string | number | undefined { - const __config = config || getConfig(); - if (!__config) return; - return __config.defaultPortal; -} - -export function getConfigAccountId( - account: CLIAccount_DEPRECATED -): number | undefined { - if (!account) return; - return account.portalId; -} - -export function setConfigPath(path: string | null) { - return (_configPath = path); -} - -export function getConfigPath(path?: string | null): string | null { - return path || (configFileExists() && _configPath) || findConfig(getCwd()); -} - -export function validateConfig(): boolean { - const config = getConfig(); - if (!config) { - logger.error('No config was found'); - return false; - } - const accounts = getConfigAccounts(); - if (!Array.isArray(accounts)) { - logger.error('config.portals[] is not defined'); - return false; - } - - if (accounts.length === 0) { - logger.error('There are no accounts defined in the configuration file'); - return false; - } - - const accountIdsHash: { [id: number]: CLIAccount_DEPRECATED } = {}; - const accountNamesHash: { [name: string]: CLIAccount_DEPRECATED } = {}; - - return accounts.every(cfg => { - if (!cfg) { - logger.error('config.portals[] has an empty entry'); - return false; - } - - const accountId = getConfigAccountId(cfg); - if (!accountId) { - logger.error('config.portals[] has an entry missing portalId'); - return false; - } - if (accountIdsHash[accountId]) { - logger.error( - `config.portals[] has multiple entries with portalId=${accountId}` - ); - return false; - } - - if (cfg.name) { - if (accountNamesHash[cfg.name]) { - logger.error( - `config.name has multiple entries with portalId=${accountId}` - ); - return false; - } - if (/\s+/.test(cfg.name)) { - logger.error(`config.name '${cfg.name}' cannot contain spaces`); - return false; - } - accountNamesHash[cfg.name] = cfg; - } - - if (!cfg.accountType) { - updateAccountConfig({ - ...cfg, - portalId: accountId, - accountType: getAccountType(undefined, cfg.sandboxAccountType), - }); - writeConfig(); - } - - accountIdsHash[accountId] = cfg; - return true; - }); -} - -export function accountNameExistsInConfig(name: string): boolean { - const config = getConfig(); - const accounts = getConfigAccounts(); - - if (!config || !Array.isArray(accounts)) { - return false; - } - - return accounts.some(cfg => cfg.name && cfg.name === name); -} - -export function getOrderedAccount( - unorderedAccount: CLIAccount_DEPRECATED -): CLIAccount_DEPRECATED { - const { name, portalId, env, authType, ...rest } = unorderedAccount; - - return { - name, - ...(portalId && { portalId }), - env, - authType, - ...rest, - }; -} - -export function getOrderedConfig(unorderedConfig: CLIConfig_DEPRECATED) { - const { - defaultPortal, - defaultCmsPublishMode, - httpTimeout, - allowUsageTracking, - portals, - ...rest - } = unorderedConfig; - - return { - ...(defaultPortal && { defaultPortal }), - defaultCmsPublishMode, - httpTimeout, - allowUsageTracking, - ...rest, - portals: portals.map(getOrderedAccount), - }; -} - -export function writeConfig(options: WriteConfigOptions = {}): void { - if (environmentVariableConfigLoaded) { - return; - } - let source; - try { - source = - typeof options.source === 'string' - ? options.source - : yaml.dump( - JSON.parse(JSON.stringify(getOrderedConfig(getConfig()!), null, 2)) - ); - } catch (err) { - logErrorInstance(err as BaseError); - return; - } - const configPath = options.path || _configPath; - try { - logger.debug(`Writing current config to ${configPath}`); - fs.ensureFileSync(configPath || ''); - fs.writeFileSync(configPath || '', source); - setConfig(parseConfig(source).parsed); - } catch (err) { - logFileSystemErrorInstance(err as BaseError, { - filepath: configPath || '', - operation: 'write', - }); - } -} - -function readConfigFile(): { source?: string; error?: BaseError } { - let source; - let error; - if (!_configPath) { - return { source, error }; - } - try { - source = fs.readFileSync(_configPath); - } catch (err) { - error = err as BaseError; - logger.error(`Config file could not be read: ${_configPath}`); - logFileSystemErrorInstance(error, { - filepath: _configPath, - operation: 'read', - }); - } - return { source: source && source.toString(), error }; -} - -function parseConfig(configSource?: string): { - parsed?: CLIConfig_DEPRECATED; - error?: BaseError; -} { - let parsed: CLIConfig_DEPRECATED | undefined = undefined; - let error: BaseError | undefined = undefined; - if (!configSource) { - return { parsed, error }; - } - try { - parsed = yaml.load(configSource) as CLIConfig_DEPRECATED; - } catch (err) { - error = err as BaseError; - logger.error(`Config file could not be parsed: ${_configPath}`); - logErrorInstance(err as BaseError); - } - return { parsed, error }; -} - -function loadConfigFromFile(path?: string, options: CLIOptions = {}) { - setConfigPath(getConfigPath(path)); - if (!_configPath) { - if (!options.silenceErrors) { - logger.error( - `A ${DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME} file could not be found. To create a new config file, use the "hs init" command.` - ); - } else { - logger.debug( - `A ${DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME} file could not be found` - ); - } - return; - } - - logger.debug(`Reading config from ${_configPath}`); - const { source, error: sourceError } = readConfigFile(); - if (sourceError) return; - const { parsed, error: parseError } = parseConfig(source); - if (parseError) return; - setConfig(handleLegacyCmsPublishMode(parsed)); - - if (!getConfig()) { - logger.debug('The config file was empty config'); - logger.debug('Initializing an empty config'); - setConfig({ portals: [] }); - } - - return getConfig(); -} - -export function loadConfig( - path?: string, - options: CLIOptions = { - useEnv: false, - } -): CLIConfig_DEPRECATED | null { - if (options.useEnv && loadEnvironmentVariableConfig(options)) { - logger.debug('Loaded environment variable config'); - environmentVariableConfigLoaded = true; - } else { - path && logger.debug(`Loading config from ${path}`); - loadConfigFromFile(path, options); - environmentVariableConfigLoaded = false; - } - - return getConfig(); -} - -export function isTrackingAllowed(): boolean { - if (!configFileExists() || configFileIsBlank()) { - return true; - } - const { allowUsageTracking } = getAndLoadConfigIfNeeded(); - return allowUsageTracking !== false; -} - -export function getAndLoadConfigIfNeeded( - options = {} -): Partial { - if (!getConfig()) { - loadConfig('', { - silenceErrors: true, - ...options, - }); - } - return getConfig() || { allowUsageTracking: undefined }; -} - -export function findConfig(directory: string): string | null { - return findup( - [ - DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, - DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME.replace('.yml', '.yaml'), - ], - { cwd: directory } - ); -} - -export function getEnv(nameOrId?: string | number): Environment { - let env: Environment = ENVIRONMENTS.PROD; - const config = getAndLoadConfigIfNeeded(); - const accountId = getAccountId(nameOrId); - - if (accountId) { - const accountConfig = getAccountConfig(accountId); - if (accountConfig && accountConfig.env) { - env = accountConfig.env; - } - } else if (config && config.env) { - env = config.env; - } - return env; -} - -// Deprecating sandboxAccountType in favor of accountType -export function getAccountType( - accountType?: AccountType, - sandboxAccountType?: string | null -): AccountType { - if (accountType) { - return accountType; - } - if (typeof sandboxAccountType === 'string') { - if (sandboxAccountType.toUpperCase() === 'DEVELOPER') { - return HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX; - } - if (sandboxAccountType.toUpperCase() === 'STANDARD') { - return HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX; - } - } - return HUBSPOT_ACCOUNT_TYPES.STANDARD; -} - -export function getAccountConfig( - accountId: number | undefined -): CLIAccount_DEPRECATED | undefined { - return getConfigAccounts( - getAndLoadConfigIfNeeded() as CLIConfig_DEPRECATED - )?.find(account => account.portalId === accountId); -} - -/* - * Returns a portalId from the config if it exists, else returns null - */ -export function getAccountId(nameOrId?: string | number): number | undefined { - const config = getAndLoadConfigIfNeeded() as CLIConfig_DEPRECATED; - let name: string | undefined = undefined; - let accountId: number | undefined = undefined; - let account: CLIAccount_DEPRECATED | undefined = undefined; - - function setNameOrAccountFromSuppliedValue( - suppliedValue: string | number - ): void { - if (typeof suppliedValue === 'string') { - name = suppliedValue; - if (/^\d+$/.test(suppliedValue)) { - accountId = parseInt(suppliedValue, 10); - } - } else if (typeof suppliedValue === 'number') { - accountId = suppliedValue; - } - } - - if (!nameOrId) { - const defaultAccount = getConfigDefaultAccount(config); - - if (defaultAccount) { - setNameOrAccountFromSuppliedValue(defaultAccount); - } - } else { - setNameOrAccountFromSuppliedValue(nameOrId); - } - - const accounts = getConfigAccounts(config); - if (accounts) { - if (name) { - account = accounts.find(p => p.name === name); - } - if (accountId && !account) { - account = accounts.find(p => accountId === p.portalId); - } - if (account) { - return account.portalId; - } - } - - return undefined; -} - -/** - * @throws {Error} - */ -export function removeSandboxAccountFromConfig( - nameOrId: string | number -): boolean { - const config = getAndLoadConfigIfNeeded(); - const accountId = getAccountId(nameOrId); - let promptDefaultAccount = false; - - if (!accountId) { - throw new Error(`Unable to find account for ${nameOrId}.`); - } - - const accountConfig = getAccountConfig(accountId); - - const accountType = getAccountType( - accountConfig?.accountType, - accountConfig?.sandboxAccountType - ); - - const isSandboxAccount = - accountType === HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX || - accountType === HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX; - - if (!isSandboxAccount) return promptDefaultAccount; - - if (config.defaultPortal === accountConfig?.name) { - promptDefaultAccount = true; - } - - const accounts = getConfigAccounts(config as CLIConfig_DEPRECATED); - - if (accountConfig && accounts) { - logger.debug(`Deleting config for ${accountId}`); - const index = accounts.indexOf(accountConfig); - accounts.splice(index, 1); - } - - writeConfig(); - - return promptDefaultAccount; -} - -/** - * @throws {Error} - */ -export function updateAccountConfig( - configOptions: UpdateAccountConfigOptions -): FlatAccountFields_DEPRECATED { - const { - accountType, - apiKey, - authType, - clientId, - clientSecret, - defaultCmsPublishMode, - environment, - name, - parentAccountId, - personalAccessKey, - portalId, - sandboxAccountType, - scopes, - tokenInfo, - } = configOptions; - - if (!portalId) { - throw new Error('A portalId is required to update the config'); - } - - const config = getAndLoadConfigIfNeeded() as CLIConfig_DEPRECATED; - const accountConfig = getAccountConfig(portalId); - - let auth: OAuthAccount_DEPRECATED['auth'] | undefined = - accountConfig && accountConfig.auth; - if (clientId || clientSecret || scopes || tokenInfo) { - auth = { - ...(accountConfig ? accountConfig.auth : {}), - clientId, - clientSecret, - scopes, - tokenInfo, - }; - } - - const env = getValidEnv( - environment || - (configOptions && configOptions.env) || - (accountConfig && accountConfig.env) - ); - const cmsPublishMode: CmsPublishMode | undefined = - defaultCmsPublishMode?.toLowerCase() as CmsPublishMode; - const nextAccountConfig: FlatAccountFields_DEPRECATED = { - ...accountConfig, - name: name || (accountConfig && accountConfig.name), - env, - ...(portalId && { portalId }), - authType, - auth, - accountType: getAccountType(accountType, sandboxAccountType), - apiKey, - defaultCmsPublishMode: - cmsPublishMode && Object.hasOwn(CMS_PUBLISH_MODE, cmsPublishMode) - ? cmsPublishMode - : undefined, - personalAccessKey, - sandboxAccountType, - parentAccountId, - }; - - let accounts = getConfigAccounts(config); - if (accountConfig && accounts) { - logger.debug(`Updating config for ${portalId}`); - const index = accounts.indexOf(accountConfig); - accounts[index] = nextAccountConfig; - } else { - logger.debug(`Adding config entry for ${portalId}`); - if (accounts) { - accounts.push(nextAccountConfig); - } else { - accounts = [nextAccountConfig]; - } - } - - return nextAccountConfig; -} - -/** - * @throws {Error} - */ -export function updateDefaultAccount(defaultAccount: string | number): void { - if ( - !defaultAccount || - (typeof defaultAccount !== 'number' && typeof defaultAccount !== 'string') - ) { - throw new Error( - `A 'defaultPortal' with value of number or string is required to update the config` - ); - } - - const config = getAndLoadConfigIfNeeded(); - config.defaultPortal = defaultAccount; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -/** - * @throws {Error} - */ -export function updateDefaultCmsPublishMode( - defaultCmsPublishMode: CmsPublishMode -): void { - if ( - !defaultCmsPublishMode || - !ALL_CMS_PUBLISH_MODES.find(m => m === defaultCmsPublishMode) - ) { - throw new Error( - `The mode ${defaultCmsPublishMode} is invalid. Valid values are ${commaSeparatedValues( - ALL_CMS_PUBLISH_MODES - )}.` - ); - } - - const config = getAndLoadConfigIfNeeded(); - config.defaultCmsPublishMode = defaultCmsPublishMode; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -/** - * @throws {Error} - */ -export function updateHttpTimeout(timeout: string): void { - const parsedTimeout = parseInt(timeout); - if (isNaN(parsedTimeout) || parsedTimeout < MIN_HTTP_TIMEOUT) { - throw new Error( - `The value ${timeout} is invalid. The value must be a number greater than ${MIN_HTTP_TIMEOUT}.` - ); - } - - const config = getAndLoadConfigIfNeeded(); - config.httpTimeout = parsedTimeout; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -/** - * @throws {Error} - */ -export function updateAllowAutoUpdates(enabled: boolean): void { - const config = getAndLoadConfigIfNeeded(); - config.allowAutoUpdates = enabled; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -/** - * @throws {Error} - */ -export function updateAllowUsageTracking(isEnabled: boolean): void { - if (typeof isEnabled !== 'boolean') { - throw new Error( - `Unable to update allowUsageTracking. The value ${isEnabled} is invalid. The value must be a boolean.` - ); - } - - const config = getAndLoadConfigIfNeeded(); - config.allowUsageTracking = isEnabled; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -export function updateAutoOpenBrowser(isEnabled: boolean): void { - if (typeof isEnabled !== 'boolean') { - throw new Error( - `Unable to update autoOpenBrowser. The value ${isEnabled} is invalid. The value must be a boolean.` - ); - } - - const config = getAndLoadConfigIfNeeded(); - config.autoOpenBrowser = isEnabled; - - setDefaultConfigPathIfUnset(); - writeConfig(); -} - -/** - * @throws {Error} - */ -export async function renameAccount( - currentName: string, - newName: string -): Promise { - const accountId = getAccountId(currentName); - const accountConfigToRename = getAccountConfig(accountId); - const defaultAccount = getConfigDefaultAccount(); - - if (!accountConfigToRename) { - throw new Error(`Cannot find account with identifier ${currentName}`); - } - - await updateAccountConfig({ - ...accountConfigToRename, - name: newName, - }); - - if (accountConfigToRename.name === defaultAccount) { - updateDefaultAccount(newName); - } - - return writeConfig(); -} - -/** - * @throws {Error} - */ -export async function deleteAccount(accountName: string): Promise { - const config = getAndLoadConfigIfNeeded() as CLIConfig_DEPRECATED; - const accounts = getConfigAccounts(config); - const accountIdToDelete = getAccountId(accountName); - - if (!accountIdToDelete || !accounts) { - throw new Error(`Cannot find account with identifier ${accountName}`); - } - - setConfig({ - ...config, - defaultPortal: - config.defaultPortal === accountName || - config.defaultPortal === accountIdToDelete - ? undefined - : config.defaultPortal, - portals: accounts.filter(account => account.portalId !== accountIdToDelete), - }); - - return writeConfig(); -} - -function setDefaultConfigPathIfUnset(): void { - if (!_configPath) { - setDefaultConfigPath(); - } -} - -function setDefaultConfigPath(): void { - setConfigPath(`${getCwd()}/${DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME}`); -} - -function configFileExists(): boolean { - return Boolean(_configPath && fs.existsSync(_configPath)); -} - -function configFileIsBlank(): boolean { - return Boolean(_configPath && fs.readFileSync(_configPath).length === 0); -} - -export function createEmptyConfigFile({ path }: { path?: string } = {}): void { - if (!path) { - setDefaultConfigPathIfUnset(); - - if (configFileExists()) { - return; - } - } else { - setConfigPath(path); - } - - writeConfig({ source: '', path }); -} - -export function deleteEmptyConfigFile(): void { - configFileExists() && configFileIsBlank() && fs.unlinkSync(_configPath || ''); -} - -export function deleteConfigFile(): void { - configFileExists() && fs.unlinkSync(_configPath || ''); -} - -function getConfigVariablesFromEnv() { - const env = process.env; - - return { - apiKey: env[ENVIRONMENT_VARIABLES.HUBSPOT_API_KEY], - clientId: env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_ID], - clientSecret: env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_SECRET], - personalAccessKey: env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY], - portalId: - parseInt(env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] || '', 10) || - parseInt(env[ENVIRONMENT_VARIABLES.HUBSPOT_PORTAL_ID] || '', 10), - refreshToken: env[ENVIRONMENT_VARIABLES.HUBSPOT_REFRESH_TOKEN], - httpTimeout: env[ENVIRONMENT_VARIABLES.HTTP_TIMEOUT] - ? parseInt(env[ENVIRONMENT_VARIABLES.HTTP_TIMEOUT] as string) - : undefined, - env: getValidEnv( - env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] as Environment - ), - }; -} - -function generatePersonalAccessKeyConfig( - portalId: number, - personalAccessKey: string, - env: Environment, - httpTimeout?: number -): { portals: Array; httpTimeout?: number } { - return { - portals: [ - { - authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, - portalId, - personalAccessKey, - env, - }, - ], - httpTimeout, - }; -} - -function generateOauthConfig( - portalId: number, - clientId: string, - clientSecret: string, - refreshToken: string, - scopes: Array, - env: Environment, - httpTimeout?: number -): { portals: Array; httpTimeout?: number } { - return { - portals: [ - { - authType: OAUTH_AUTH_METHOD.value, - portalId, - auth: { - clientId, - clientSecret, - scopes, - tokenInfo: { - refreshToken, - }, - }, - env, - }, - ], - httpTimeout, - }; -} - -function generateApiKeyConfig( - portalId: number, - apiKey: string, - env: Environment -): { portals: Array } { - return { - portals: [ - { - authType: API_KEY_AUTH_METHOD.value, - portalId, - apiKey, - env, - }, - ], - }; -} - -export function loadConfigFromEnvironment({ - useEnv = false, -}: { useEnv?: boolean } = {}): - | { portals: Array } - | undefined { - const { - apiKey, - clientId, - clientSecret, - personalAccessKey, - portalId, - refreshToken, - env, - httpTimeout, - } = getConfigVariablesFromEnv(); - const unableToLoadEnvConfigError = - 'Unable to load config from environment variables.'; - - if (!portalId) { - useEnv && logger.error(unableToLoadEnvConfigError); - return; - } - - if (httpTimeout && httpTimeout < MIN_HTTP_TIMEOUT) { - throw new Error( - `The HTTP timeout value ${httpTimeout} is invalid. The value must be a number greater than ${MIN_HTTP_TIMEOUT}.` - ); - } - - if (personalAccessKey) { - return generatePersonalAccessKeyConfig( - portalId, - personalAccessKey, - env, - httpTimeout - ); - } else if (clientId && clientSecret && refreshToken) { - return generateOauthConfig( - portalId, - clientId, - clientSecret, - refreshToken, - OAUTH_SCOPES.map(scope => scope.value), - env, - httpTimeout - ); - } else if (apiKey) { - return generateApiKeyConfig(portalId, apiKey, env); - } else { - useEnv && logger.error(unableToLoadEnvConfigError); - return; - } -} - -function loadEnvironmentVariableConfig(options: { - useEnv?: boolean; -}): CLIConfig_DEPRECATED | null { - const envConfig = loadConfigFromEnvironment(options); - - if (!envConfig) { - return null; - } - const { portalId } = getConfigVariablesFromEnv(); - - logger.debug( - `Loaded config from environment variables for account ${portalId}` - ); - - return setConfig(handleLegacyCmsPublishMode(envConfig)); -} - -export function isConfigFlagEnabled( - flag: keyof CLIConfig_DEPRECATED, - defaultValue = false -): boolean { - if (!configFileExists() || configFileIsBlank()) { - return defaultValue; - } - - const config = getAndLoadConfigIfNeeded(); - - return Boolean(config[flag] || defaultValue); -} - -function handleLegacyCmsPublishMode( - config: CLIConfig_DEPRECATED | undefined -): CLIConfig_DEPRECATED | undefined { - if (config?.defaultMode) { - config.defaultCmsPublishMode = config.defaultMode; - delete config.defaultMode; - } - return config; -} - -export function hasLocalStateFlag(flag: string): boolean { - if (!_config) { - return false; - } - - return _config.flags?.includes(flag) || false; -} - -export function addLocalStateFlag(flag: string): void { - if (!_config) { - throw new Error('No config loaded'); - } - - if (!hasLocalStateFlag(flag)) { - _config.flags = [...(_config.flags || []), flag]; - } - - writeConfig(); -} - -export function removeLocalStateFlag(flag: string): void { - if (!_config) { - throw new Error('No config loaded'); - } - - _config.flags = _config.flags?.filter(f => f !== flag) || []; - - writeConfig(); -} diff --git a/config/defaultAccountOverride.ts b/config/defaultAccountOverride.ts new file mode 100644 index 00000000..7ebe364d --- /dev/null +++ b/config/defaultAccountOverride.ts @@ -0,0 +1,72 @@ +import findup from 'findup-sync'; +import fs from 'fs-extra'; + +import { getCwd } from '../lib/path'; +import { + DEFAULT_ACCOUNT_OVERRIDE_ERROR_INVALID_ID, + DEFAULT_ACCOUNT_OVERRIDE_ERROR_ACCOUNT_NOT_FOUND, + DEFAULT_ACCOUNT_OVERRIDE_FILE_NAME, +} from '../constants/config'; +import { i18n } from '../utils/lang'; +import { FileSystemError } from '../models/FileSystemError'; +import { getAllConfigAccounts } from './index'; + +const i18nKey = 'config.defaultAccountOverride'; + +export function getDefaultAccountOverrideAccountId(): number | null { + const defaultAccountOverrideFilePath = getDefaultAccountOverrideFilePath(); + + if (!defaultAccountOverrideFilePath) { + return null; + } + + let source: string; + try { + source = fs.readFileSync(defaultAccountOverrideFilePath, 'utf8'); + } catch (e) { + throw new FileSystemError( + { cause: e }, + { + filepath: defaultAccountOverrideFilePath, + operation: 'read', + } + ); + } + + const accountId = parseInt(source); + + if (isNaN(accountId)) { + throw new Error( + i18n(`${i18nKey}.getDefaultAccountOverrideAccountId.errorHeader`, { + hsAccountFile: defaultAccountOverrideFilePath, + }), + { + // TODO: This is improper use of cause, we should create a custom error class + cause: DEFAULT_ACCOUNT_OVERRIDE_ERROR_INVALID_ID, + } + ); + } + + const accounts = getAllConfigAccounts(); + + const account = accounts?.find(account => account.accountId === accountId); + if (!account) { + throw new Error( + i18n(`${i18nKey}.getDefaultAccountOverrideAccountId.errorHeader`, { + hsAccountFile: defaultAccountOverrideFilePath, + }), + { + // TODO: This is improper use of cause, we should create a custom error class + cause: DEFAULT_ACCOUNT_OVERRIDE_ERROR_ACCOUNT_NOT_FOUND, + } + ); + } + + return account.accountId; +} + +export function getDefaultAccountOverrideFilePath(): string | null { + return findup([DEFAULT_ACCOUNT_OVERRIDE_FILE_NAME], { + cwd: getCwd(), + }); +} diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index 6062261f..00000000 --- a/config/environment.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - CLIConfig_NEW, - Environment, - EnvironmentConfigVariables, -} from '../types/Config'; -import { logger } from '../lib/logger'; -import { ENVIRONMENT_VARIABLES } from '../constants/environments'; -import { - API_KEY_AUTH_METHOD, - OAUTH_AUTH_METHOD, - PERSONAL_ACCESS_KEY_AUTH_METHOD, - OAUTH_SCOPES, -} from '../constants/auth'; -import { generateConfig } from './configUtils'; -import { getValidEnv } from '../lib/environment'; -import { i18n } from '../utils/lang'; - -const i18nKey = 'config.environment'; - -function getConfigVariablesFromEnv(): EnvironmentConfigVariables { - const env = process.env; - - return { - apiKey: env[ENVIRONMENT_VARIABLES.HUBSPOT_API_KEY], - clientId: env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_ID], - clientSecret: env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_SECRET], - personalAccessKey: env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY], - accountId: parseInt(env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID]!, 10), - refreshToken: env[ENVIRONMENT_VARIABLES.HUBSPOT_REFRESH_TOKEN], - env: getValidEnv( - env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT] as Environment - ), - }; -} - -export function loadConfigFromEnvironment(): CLIConfig_NEW | null { - const { - apiKey, - clientId, - clientSecret, - personalAccessKey, - accountId, - refreshToken, - env, - } = getConfigVariablesFromEnv(); - if (!accountId) { - logger.debug(i18n(`${i18nKey}.loadConfig.missingAccountId`)); - return null; - } - - if (!env) { - logger.debug(i18n(`${i18nKey}.loadConfig.missingEnv`)); - return null; - } - - if (personalAccessKey) { - return generateConfig(PERSONAL_ACCESS_KEY_AUTH_METHOD.value, { - accountId, - personalAccessKey, - env, - }); - } else if (clientId && clientSecret && refreshToken) { - return generateConfig(OAUTH_AUTH_METHOD.value, { - accountId, - clientId, - clientSecret, - refreshToken, - scopes: OAUTH_SCOPES.map((scope: { value: string }) => scope.value), - env, - }); - } else if (apiKey) { - return generateConfig(API_KEY_AUTH_METHOD.value, { - accountId, - apiKey, - env, - }); - } - - logger.debug(i18n(`${i18nKey}.loadConfig.unknownAuthType`)); - return null; -} diff --git a/config/getAccountIdentifier.ts b/config/getAccountIdentifier.ts deleted file mode 100644 index 56b8097b..00000000 --- a/config/getAccountIdentifier.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GenericAccount } from '../types/Accounts'; - -export function getAccountIdentifier( - account?: GenericAccount | null -): number | undefined { - if (!account) { - return undefined; - } else if (Object.hasOwn(account, 'portalId')) { - return account.portalId; - } else if (Object.hasOwn(account, 'accountId')) { - return account.accountId; - } -} diff --git a/config/index.ts b/config/index.ts index d943fad6..ed4b0123 100644 --- a/config/index.ts +++ b/config/index.ts @@ -1,331 +1,648 @@ -import * as config_DEPRECATED from './config_DEPRECATED'; -import { CLIConfiguration } from './CLIConfiguration'; +import fs from 'fs-extra'; +import findup from 'findup-sync'; + import { - configFileExists as newConfigFileExists, - getConfigFilePath, - deleteConfigFile as newDeleteConfigFile, -} from './configFile'; -import { CLIConfig_NEW, CLIConfig } from '../types/Config'; -import { CLIOptions, WriteConfigOptions } from '../types/CLIOptions'; + ACCOUNT_IDENTIFIERS, + DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, + GLOBAL_CONFIG_PATH, + HUBSPOT_CONFIG_OPERATIONS, + MIN_HTTP_TIMEOUT, +} from '../constants/config'; +import { HubSpotConfigAccount } from '../types/Accounts'; import { - AccountType, - CLIAccount, - CLIAccount_NEW, - CLIAccount_DEPRECATED, - FlatAccountFields, -} from '../types/Accounts'; -import { getAccountIdentifier } from './getAccountIdentifier'; + HubSpotConfig, + ConfigFlag, + HubSpotConfigValidationResult, +} from '../types/Config'; import { CmsPublishMode } from '../types/Files'; +import { logger } from '../lib/logger'; +import { + readConfigFile, + parseConfig, + buildConfigFromEnvironment, + writeConfigFile, + getLocalConfigDefaultFilePath, + getConfigAccountByIdentifier, + validateConfigAccount, + getConfigAccountIndexById, + getConfigPathEnvironmentVariables, + getConfigAccountByInferredIdentifier, + handleConfigFileSystemError, + doesConfigFileExistAtPath, +} from './utils'; +import { CMS_PUBLISH_MODE } from '../constants/files'; +import { Environment } from '../types/Config'; +import { i18n } from '../utils/lang'; +import { getDefaultAccountOverrideAccountId } from './defaultAccountOverride'; +import { getValidEnv } from '../lib/environment'; +import { HubSpotConfigError } from '../models/HubSpotConfigError'; +import { HUBSPOT_CONFIG_ERROR_TYPES } from '../constants/config'; +import { isDeepEqual } from '../lib/isDeepEqual'; +import { getCwd } from '../lib/path'; -// Use new config if it exists -export function loadConfig( - path: string, - options: CLIOptions = {} -): CLIConfig | null { - // Attempt to load the root config - if (newConfigFileExists()) { - return CLIConfiguration.init(options); - } - return config_DEPRECATED.loadConfig(path, options); +const EMPTY_CONFIG = { accounts: [] }; + +export function getGlobalConfigFilePath(): string { + return GLOBAL_CONFIG_PATH; } -export function getAndLoadConfigIfNeeded( - options?: CLIOptions -): Partial | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.config; - } - return config_DEPRECATED.getAndLoadConfigIfNeeded(options); +export function getLocalConfigFilePathIfExists(): string | null { + return findup( + [ + DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, + DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME.replace('.yml', '.yaml'), + ], + { cwd: getCwd() } + ); } -export function validateConfig(): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.validate(); - } - return config_DEPRECATED.validateConfig(); +export function localConfigFileExists(): boolean { + return Boolean(getLocalConfigFilePathIfExists()); } -export function loadConfigFromEnvironment(): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.useEnvConfig; - } - return Boolean(config_DEPRECATED.loadConfigFromEnvironment()); +export function globalConfigFileExists(): boolean { + return doesConfigFileExistAtPath(getGlobalConfigFilePath()); } -export function createEmptyConfigFile( - options: { path?: string } = {}, - useHiddenConfig = false -): void { - if (useHiddenConfig) { - CLIConfiguration.write({ accounts: [] }); - } else { - return config_DEPRECATED.createEmptyConfigFile(options); +export function configFileExists(): boolean { + try { + return doesConfigFileExistAtPath(getConfigFilePath()); + } catch (error) { + return false; + } +} + +function getConfigDefaultFilePath(): string { + const globalConfigFilePath = getGlobalConfigFilePath(); + + if (doesConfigFileExistAtPath(globalConfigFilePath)) { + return globalConfigFilePath; + } + + const localConfigFilePath = getLocalConfigFilePathIfExists(); + + if (!localConfigFilePath) { + throw new HubSpotConfigError( + i18n('config.getDefaultConfigFilePath.error'), + HUBSPOT_CONFIG_ERROR_TYPES.CONFIG_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ + ); + } + + return localConfigFilePath; +} + +export function getConfigFilePath(): string { + const { configFilePathFromEnvironment } = getConfigPathEnvironmentVariables(); + + return configFilePathFromEnvironment || getConfigDefaultFilePath(); +} + +export function getConfig(): HubSpotConfig { + let pathToRead: string | undefined; + try { + const { useEnvironmentConfig } = getConfigPathEnvironmentVariables(); + + if (useEnvironmentConfig) { + return buildConfigFromEnvironment(); + } + + pathToRead = getConfigFilePath(); + + logger.debug(i18n('config.getConfig.reading', { path: pathToRead })); + const configFileSource = readConfigFile(pathToRead); + + return parseConfig(configFileSource, pathToRead); + } catch (err) { + throw new HubSpotConfigError( + pathToRead + ? i18n('config.getConfig.errorWithPath', { path: pathToRead }) + : i18n('config.getConfig.error'), + HUBSPOT_CONFIG_ERROR_TYPES.CONFIG_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ, + { cause: err } + ); } } -export function deleteEmptyConfigFile() { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.delete(); +export function validateConfig(): HubSpotConfigValidationResult { + const config = getConfig(); + + if (config.accounts.length === 0) { + return { + isValid: false, + errors: [i18n('config.validateConfig.missingAccounts')], + }; } - return config_DEPRECATED.deleteEmptyConfigFile(); + + const accountIdsMap: { [key: number]: boolean } = {}; + const accountNamesMap: { [key: string]: boolean } = {}; + + const validationErrors: string[] = []; + + config.accounts.forEach(account => { + const accountValidationResult = validateConfigAccount(account); + if (!accountValidationResult.isValid) { + validationErrors.push(...accountValidationResult.errors); + } + if (accountIdsMap[account.accountId]) { + validationErrors.push( + i18n('config.validateConfig.duplicateAccountIds', { + accountId: account.accountId, + }) + ); + } + if (account.name) { + if (accountNamesMap[account.name.toLowerCase()]) { + validationErrors.push( + i18n('config.validateConfig.duplicateAccountNames', { + accountName: account.name, + }) + ); + } + if (/\s+/.test(account.name)) { + validationErrors.push( + i18n('config.validateConfig.invalidAccountName', { + accountName: account.name, + }) + ); + } + accountNamesMap[account.name] = true; + } + + accountIdsMap[account.accountId] = true; + }); + + return { isValid: validationErrors.length === 0, errors: validationErrors }; } -export function getConfig(): CLIConfig | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.config; +export function createEmptyConfigFile(useGlobalConfig = false): void { + const { configFilePathFromEnvironment } = getConfigPathEnvironmentVariables(); + const defaultPath = useGlobalConfig + ? getGlobalConfigFilePath() + : getLocalConfigDefaultFilePath(); + + const pathToWrite = configFilePathFromEnvironment || defaultPath; + + writeConfigFile(EMPTY_CONFIG, pathToWrite); +} + +export function deleteConfigFileIfEmpty(): void { + const pathToDelete = getConfigFilePath(); + + try { + const config = getConfig(); + + if (isDeepEqual(config, EMPTY_CONFIG)) { + fs.unlinkSync(pathToDelete); + } + } catch (error) { + const { message, type } = handleConfigFileSystemError(error, pathToDelete); + + throw new HubSpotConfigError( + message, + type, + HUBSPOT_CONFIG_OPERATIONS.DELETE, + { + cause: error, + } + ); } - return config_DEPRECATED.getConfig(); } -export function writeConfig(options: WriteConfigOptions = {}): void { - if (CLIConfiguration.isActive()) { - const config = options.source - ? (JSON.parse(options.source) as CLIConfig_NEW) - : undefined; - CLIConfiguration.write(config); - } else { - config_DEPRECATED.writeConfig(options); +export function getConfigAccountById(accountId: number): HubSpotConfigAccount { + const { accounts } = getConfig(); + + const account = getConfigAccountByIdentifier( + accounts, + ACCOUNT_IDENTIFIERS.ACCOUNT_ID, + accountId + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.getConfigAccountById.error', { accountId }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ + ); } + + return account; } -export function getConfigPath( - path?: string, - useHiddenConfig = false -): string | null { - if (useHiddenConfig || CLIConfiguration.isActive()) { - return getConfigFilePath(); +export function getConfigAccountByName( + accountName: string +): HubSpotConfigAccount { + const { accounts } = getConfig(); + + const account = getConfigAccountByIdentifier( + accounts, + ACCOUNT_IDENTIFIERS.NAME, + accountName + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.getConfigAccountByName.error', { accountName }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ + ); } - return config_DEPRECATED.getConfigPath(path); + + return account; } -export function configFileExists(useHiddenConfig?: boolean): boolean { - return useHiddenConfig - ? newConfigFileExists() - : Boolean(config_DEPRECATED.getConfigPath()); +export function getConfigAccountIfExists( + identifier: number | string +): HubSpotConfigAccount | undefined { + const config = getConfig(); + return getConfigAccountByInferredIdentifier(config.accounts, identifier); } -export function getAccountConfig(accountId?: number): CLIAccount | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getAccount(accountId); +export function getConfigDefaultAccount(): HubSpotConfigAccount { + const { accounts, defaultAccount } = getConfig(); + + let defaultAccountToUse = defaultAccount; + + const currentConfigPath = getConfigFilePath(); + const globalConfigPath = getGlobalConfigFilePath(); + if (currentConfigPath === globalConfigPath && globalConfigFileExists()) { + const defaultAccountOverrideAccountId = + getDefaultAccountOverrideAccountId(); + defaultAccountToUse = defaultAccountOverrideAccountId || defaultAccount; } - return config_DEPRECATED.getAccountConfig(accountId) || null; -} -export function accountNameExistsInConfig(name: string): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.isAccountInConfig(name); + if (!defaultAccountToUse) { + throw new HubSpotConfigError( + i18n('config.getConfigDefaultAccount.fieldMissingError'), + HUBSPOT_CONFIG_ERROR_TYPES.NO_DEFAULT_ACCOUNT, + HUBSPOT_CONFIG_OPERATIONS.READ + ); } - return config_DEPRECATED.accountNameExistsInConfig(name); -} - -export function updateAccountConfig( - configOptions: Partial -): FlatAccountFields | null { - const accountIdentifier = getAccountIdentifier(configOptions); - if (CLIConfiguration.isActive()) { - return CLIConfiguration.addOrUpdateAccount({ - ...configOptions, - accountId: accountIdentifier, - }); + + const account = getConfigAccountByInferredIdentifier( + accounts, + defaultAccountToUse + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.getConfigDefaultAccount.accountMissingError', { + defaultAccountToUse, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ + ); } - return config_DEPRECATED.updateAccountConfig({ - ...configOptions, - portalId: accountIdentifier, - }); + + return account; } -export function updateDefaultAccount(nameOrId: string | number): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.updateDefaultAccount(nameOrId); - } else { - config_DEPRECATED.updateDefaultAccount(nameOrId); +export function getConfigDefaultAccountIfExists(): + | HubSpotConfigAccount + | undefined { + const { accounts, defaultAccount } = getConfig(); + + let defaultAccountToUse = defaultAccount; + + // Only check for default account override if we're using the global config + const currentConfigPath = getConfigFilePath(); + const globalConfigPath = getGlobalConfigFilePath(); + if (currentConfigPath === globalConfigPath && globalConfigFileExists()) { + const defaultAccountOverrideAccountId = + getDefaultAccountOverrideAccountId(); + defaultAccountToUse = defaultAccountOverrideAccountId || defaultAccount; } -} -export async function renameAccount( - currentName: string, - newName: string -): Promise { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.renameAccount(currentName, newName); - } else { - return config_DEPRECATED.renameAccount(currentName, newName); + if (!defaultAccountToUse) { + return; } + + const account = getConfigAccountByInferredIdentifier( + accounts, + defaultAccountToUse + ); + + return account; } -export function getAccountId(nameOrId?: string | number): number | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getAccountId(nameOrId); - } - return config_DEPRECATED.getAccountId(nameOrId) || null; +export function getAllConfigAccounts(): HubSpotConfigAccount[] { + const { accounts } = getConfig(); + + return accounts; } -export function removeSandboxAccountFromConfig( - nameOrId: string | number -): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.removeAccountFromConfig(nameOrId); +export function getConfigAccountEnvironment( + identifier: number | string +): Environment { + const config = getConfig(); + + const account = getConfigAccountByInferredIdentifier( + config.accounts, + identifier + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.getConfigAccountEnvironment.accountNotFound', { + identifier, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.READ + ); } - return config_DEPRECATED.removeSandboxAccountFromConfig(nameOrId); + + return getValidEnv(account.env); } -export async function deleteAccount( - accountName: string -): Promise { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.removeAccountFromConfig(accountName); - } else { - return config_DEPRECATED.deleteAccount(accountName); +export function addConfigAccount(accountToAdd: HubSpotConfigAccount): void { + if (!validateConfigAccount(accountToAdd)) { + throw new HubSpotConfigError( + i18n('config.addConfigAccount.invalidAccount'), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ACCOUNT, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } -} -export function updateHttpTimeout(timeout: string): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.updateHttpTimeout(timeout); - } else { - config_DEPRECATED.updateHttpTimeout(timeout); + const config = getConfig(); + + const accountInConfig = getConfigAccountByIdentifier( + config.accounts, + ACCOUNT_IDENTIFIERS.ACCOUNT_ID, + accountToAdd.accountId + ); + + if (accountInConfig) { + throw new HubSpotConfigError( + i18n('config.addConfigAccount.duplicateAccount', { + accountId: accountToAdd.accountId, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ACCOUNT, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } + + config.accounts.push(accountToAdd); + + writeConfigFile(config, getConfigFilePath()); } -export function updateAllowAutoUpdates(enabled: boolean): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.updateAllowAutoUpdates(enabled); - } else { - config_DEPRECATED.updateAllowAutoUpdates(enabled); +export function updateConfigAccount( + updatedAccount: HubSpotConfigAccount +): void { + if (!validateConfigAccount(updatedAccount)) { + throw new HubSpotConfigError( + i18n('config.updateConfigAccount.invalidAccount', { + name: updatedAccount.name, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ACCOUNT, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } -} -export function updateAllowUsageTracking(isEnabled: boolean): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.updateAllowUsageTracking(isEnabled); - } else { - config_DEPRECATED.updateAllowUsageTracking(isEnabled); + const config = getConfig(); + + const accountIndex = getConfigAccountIndexById( + config.accounts, + updatedAccount.accountId + ); + + if (accountIndex < 0) { + throw new HubSpotConfigError( + i18n('config.updateConfigAccount.accountNotFound', { + accountId: updatedAccount.accountId, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } + + config.accounts[accountIndex] = updatedAccount; + + writeConfigFile(config, getConfigFilePath()); } -export function updateAutoOpenBrowser(isEnabled: boolean): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.updateAutoOpenBrowser(isEnabled); - } else { - config_DEPRECATED.updateAutoOpenBrowser(isEnabled); +export function setConfigAccountAsDefault(identifier: number | string): void { + const config = getConfig(); + + const account = getConfigAccountByInferredIdentifier( + config.accounts, + identifier + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.setConfigAccountAsDefault.accountNotFound', { + identifier, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } + + config.defaultAccount = account.accountId; + writeConfigFile(config, getConfigFilePath()); } -export function deleteConfigFile(): void { - if (CLIConfiguration.isActive()) { - newDeleteConfigFile(); - } else { - config_DEPRECATED.deleteConfigFile(); +export function renameConfigAccount( + currentName: string, + newName: string +): void { + const config = getConfig(); + + const account = getConfigAccountByIdentifier( + config.accounts, + ACCOUNT_IDENTIFIERS.NAME, + currentName + ); + + if (!account) { + throw new HubSpotConfigError( + i18n('config.renameConfigAccount.accountNotFound', { + currentName, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } -} -export function isConfigFlagEnabled( - flag: keyof CLIConfig, - defaultValue = false -): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.isConfigFlagEnabled(flag, defaultValue); + const duplicateAccount = getConfigAccountByIdentifier( + config.accounts, + ACCOUNT_IDENTIFIERS.NAME, + newName + ); + + if (duplicateAccount) { + throw new HubSpotConfigError( + i18n('config.renameConfigAccount.duplicateAccount', { + currentName, + newName, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ACCOUNT, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.isConfigFlagEnabled(flag, defaultValue); + + account.name = newName; + + writeConfigFile(config, getConfigFilePath()); } -export function isTrackingAllowed() { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.isTrackingAllowed(); +export function removeAccountFromConfig(accountId: number): void { + const config = getConfig(); + + const index = getConfigAccountIndexById(config.accounts, accountId); + + if (index < 0) { + throw new HubSpotConfigError( + i18n('config.removeAccountFromConfig.accountNotFound', { + accountId, + }), + HUBSPOT_CONFIG_ERROR_TYPES.ACCOUNT_NOT_FOUND, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.isTrackingAllowed(); -} -export function getEnv(nameOrId?: string | number) { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getEnv(nameOrId); + config.accounts.splice(index, 1); + + if (config.defaultAccount === accountId) { + delete config.defaultAccount; } - return config_DEPRECATED.getEnv(nameOrId); + + writeConfigFile(config, getConfigFilePath()); } -export function getAccountType( - accountType?: AccountType, - sandboxAccountType?: string | null -): AccountType { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getAccountType(accountType, sandboxAccountType); +export function updateHttpTimeout(timeout: string | number): void { + const parsedTimeout = + typeof timeout === 'string' ? parseInt(timeout) : timeout; + + if (isNaN(parsedTimeout) || parsedTimeout < MIN_HTTP_TIMEOUT) { + throw new HubSpotConfigError( + i18n('config.updateHttpTimeout.invalidTimeout', { + minTimeout: MIN_HTTP_TIMEOUT, + timeout: parsedTimeout, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_FIELD, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.getAccountType(accountType, sandboxAccountType); + + const config = getConfig(); + + config.httpTimeout = parsedTimeout; + + writeConfigFile(config, getConfigFilePath()); } -export function getConfigDefaultAccount(): string | number | null | undefined { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getDefaultAccount(); +export function updateAllowUsageTracking(isAllowed: boolean): void { + if (typeof isAllowed !== 'boolean') { + throw new HubSpotConfigError( + i18n('config.updateAllowUsageTracking.invalidInput', { + isAllowed: `${isAllowed}`, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_FIELD, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.getConfigDefaultAccount(); + + const config = getConfig(); + + config.allowUsageTracking = isAllowed; + + writeConfigFile(config, getConfigFilePath()); } -export function getDisplayDefaultAccount(): string | number | null | undefined { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.config?.defaultAccount; +export function updateAllowAutoUpdates(isEnabled: boolean): void { + if (typeof isEnabled !== 'boolean') { + throw new HubSpotConfigError( + i18n('config.updateAllowAutoUpdates.invalidInput', { + isEnabled: `${isEnabled}`, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_FIELD, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.getConfigDefaultAccount(); + const config = getConfig(); + + config.allowAutoUpdates = isEnabled; + + writeConfigFile(config, getConfigFilePath()); } -export function getConfigAccounts(): - | Array - | Array - | null - | undefined { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getConfigAccounts(); +export function updateAutoOpenBrowser(isEnabled: boolean): void { + if (typeof isEnabled !== 'boolean') { + throw new HubSpotConfigError( + i18n('config.updateAutoOpenBrowser.invalidInput', { + isEnabled: `${isEnabled}`, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_FIELD, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.getConfigAccounts(); + + const config = getConfig(); + + config.autoOpenBrowser = isEnabled; + + writeConfigFile(config, getConfigFilePath()); } export function updateDefaultCmsPublishMode( cmsPublishMode: CmsPublishMode -): void | CLIConfig_NEW | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.updateDefaultCmsPublishMode(cmsPublishMode); +): void { + if ( + !cmsPublishMode || + !Object.values(CMS_PUBLISH_MODE).includes(cmsPublishMode) + ) { + throw new HubSpotConfigError( + i18n('config.updateDefaultCmsPublishMode.invalidCmsPublishMode', { + cmsPublishMode, + }), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_FIELD, + HUBSPOT_CONFIG_OPERATIONS.WRITE + ); } - return config_DEPRECATED.updateDefaultCmsPublishMode(cmsPublishMode); -} -export function getCWDAccountOverride() { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getCWDAccountOverride(); - } + const config = getConfig(); + + config.defaultCmsPublishMode = cmsPublishMode; + + writeConfigFile(config, getConfigFilePath()); } -export function getDefaultAccountOverrideFilePath() { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.getDefaultAccountOverrideFilePath(); +export function isConfigFlagEnabled( + flag: ConfigFlag, + defaultValue?: boolean +): boolean { + const config = getConfig(); + + if (typeof config[flag] === 'undefined') { + return defaultValue || false; } + + return Boolean(config[flag]); } export function hasLocalStateFlag(flag: string): boolean { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.hasLocalStateFlag(flag); - } - return config_DEPRECATED.hasLocalStateFlag(flag); + const config = getConfig(); + + return config.flags?.includes(flag) || false; } export function addLocalStateFlag(flag: string): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.addLocalStateFlag(flag); - } else { - config_DEPRECATED.addLocalStateFlag(flag); + const config = getConfig(); + + if (!hasLocalStateFlag(flag)) { + config.flags = [...(config.flags || []), flag]; } + + writeConfigFile(config, getConfigFilePath()); } export function removeLocalStateFlag(flag: string): void { - if (CLIConfiguration.isActive()) { - CLIConfiguration.removeLocalStateFlag(flag); - } else { - config_DEPRECATED.removeLocalStateFlag(flag); - } -} + const config = getConfig(); + + config.flags = config.flags?.filter(f => f !== flag) || []; -// These functions are not supported with the new config setup -export const getConfigAccountId = config_DEPRECATED.getConfigAccountId; -export const getOrderedAccount = config_DEPRECATED.getOrderedAccount; -export const getOrderedConfig = config_DEPRECATED.getOrderedConfig; -export const setConfig = config_DEPRECATED.setConfig; -export const setConfigPath = config_DEPRECATED.setConfigPath; -export const findConfig = config_DEPRECATED.findConfig; + writeConfigFile(config, getConfigFilePath()); +} diff --git a/config/migrate.ts b/config/migrate.ts index 18710ea4..9d2989d1 100644 --- a/config/migrate.ts +++ b/config/migrate.ts @@ -1,246 +1,162 @@ -import * as config_DEPRECATED from './config_DEPRECATED'; -import { CLIConfiguration } from './CLIConfiguration'; -import { - CLIConfig, - CLIConfig_DEPRECATED, - CLIConfig_NEW, - Environment, -} from '../types/Config'; -import { CmsPublishMode } from '../types/Files'; -import { - writeConfig, - createEmptyConfigFile, - loadConfig, - deleteEmptyConfigFile, -} from './index'; -import { - getConfigFilePath, - configFileExists as newConfigFileExists, -} from './configFile'; +import fs from 'fs'; + +import { HubSpotConfig } from '../types/Config'; +import { createEmptyConfigFile, getGlobalConfigFilePath } from './index'; import { - GLOBAL_CONFIG_PATH, DEFAULT_CMS_PUBLISH_MODE, HTTP_TIMEOUT, ENV, HTTP_USE_LOCALHOST, ALLOW_USAGE_TRACKING, DEFAULT_ACCOUNT, - DEFAULT_PORTAL, - ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME, AUTO_OPEN_BROWSER, ALLOW_AUTO_UPDATES, + ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME, } from '../constants/config'; -import { i18n } from '../utils/lang'; -import fs from 'fs'; +import { parseConfig, readConfigFile, writeConfigFile } from './utils'; +import { ValueOf } from '../types/Utils'; import path from 'path'; -const i18nKey = 'config.migrate'; - -export function getDeprecatedConfig( - configPath?: string -): CLIConfig_DEPRECATED | null { - return config_DEPRECATED.loadConfig(configPath); -} +export function getConfigAtPath(path: string): HubSpotConfig { + const configFileSource = readConfigFile(path); -export function getGlobalConfig(): CLIConfig_NEW | null { - if (CLIConfiguration.isActive()) { - return CLIConfiguration.config; - } - return null; + return parseConfig(configFileSource, path); } -export function configFileExists( - useHiddenConfig = false, - configPath?: string -): boolean { - return useHiddenConfig - ? newConfigFileExists() - : Boolean(config_DEPRECATED.getConfigPath(configPath)); +export function migrateConfigAtPath(path: string): void { + createEmptyConfigFile(true); + const configToMigrate = getConfigAtPath(path); + writeConfigFile(configToMigrate, getGlobalConfigFilePath()); } -export function getConfigPath( - configPath?: string, - useHiddenConfig = false -): string | null { - if (useHiddenConfig) { - return getConfigFilePath(); - } - return config_DEPRECATED.getConfigPath(configPath); -} - -function writeGlobalConfigFile( - updatedConfig: CLIConfig_NEW, - isMigrating = false -): void { - const updatedConfigJson = JSON.stringify(updatedConfig); - if (isMigrating) { - createEmptyConfigFile({}, true); - } - loadConfig(''); - - try { - writeConfig({ source: updatedConfigJson }); - const oldConfigPath = config_DEPRECATED.getConfigPath(); - if (oldConfigPath) { - const dir = path.dirname(oldConfigPath); - const newConfigPath = path.join( - dir, - ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME - ); - fs.renameSync(oldConfigPath, newConfigPath); - } - } catch (error) { - deleteEmptyConfigFile(); - throw new Error( - i18n(`${i18nKey}.errors.writeConfig`, { configPath: GLOBAL_CONFIG_PATH }), - { cause: error } - ); - } -} - -export function migrateConfig( - deprecatedConfig: CLIConfig_DEPRECATED | null -): void { - if (!deprecatedConfig) { - throw new Error(i18n(`${i18nKey}.errors.noDeprecatedConfig`)); - } - const { defaultPortal, portals, ...rest } = deprecatedConfig; - const updatedConfig = { - ...rest, - defaultAccount: defaultPortal, - accounts: portals - .filter(({ portalId }) => portalId !== undefined) - .map(({ portalId, ...rest }) => ({ - ...rest, - accountId: portalId!, - })), - }; - writeGlobalConfigFile(updatedConfig, true); -} - -type ConflictValue = boolean | string | number | CmsPublishMode | Environment; export type ConflictProperty = { - property: keyof CLIConfig_NEW; - oldValue: ConflictValue; - newValue: ConflictValue; + property: keyof HubSpotConfig; + oldValue: ValueOf; + newValue: ValueOf>; }; export function mergeConfigProperties( - globalConfig: CLIConfig_NEW, - deprecatedConfig: CLIConfig_DEPRECATED, + toConfig: HubSpotConfig, + fromConfig: HubSpotConfig, force?: boolean ): { - initialConfig: CLIConfig_NEW; + configWithMergedProperties: HubSpotConfig; conflicts: Array; } { - const propertiesToCheck: Array> = [ - DEFAULT_CMS_PUBLISH_MODE, - HTTP_TIMEOUT, - ENV, - HTTP_USE_LOCALHOST, - ALLOW_USAGE_TRACKING, - AUTO_OPEN_BROWSER, - ALLOW_AUTO_UPDATES, - ]; const conflicts: Array = []; - propertiesToCheck.forEach(prop => { - if (prop in deprecatedConfig) { + if (force) { + toConfig.defaultCmsPublishMode = fromConfig.defaultCmsPublishMode; + toConfig.httpTimeout = fromConfig.httpTimeout; + toConfig.env = fromConfig.env; + toConfig.httpUseLocalhost = fromConfig.httpUseLocalhost; + toConfig.allowUsageTracking = fromConfig.allowUsageTracking; + toConfig.autoOpenBrowser = fromConfig.autoOpenBrowser; + toConfig.allowAutoUpdates = fromConfig.allowAutoUpdates; + toConfig.defaultAccount = fromConfig.defaultAccount; + } else { + toConfig.defaultCmsPublishMode ||= fromConfig.defaultCmsPublishMode; + toConfig.httpTimeout ||= fromConfig.httpTimeout; + toConfig.env ||= fromConfig.env; + toConfig.httpUseLocalhost = + toConfig.httpUseLocalhost === undefined + ? fromConfig.httpUseLocalhost + : toConfig.httpUseLocalhost; + toConfig.allowUsageTracking = + toConfig.allowUsageTracking === undefined + ? fromConfig.allowUsageTracking + : toConfig.allowUsageTracking; + toConfig.autoOpenBrowser = + toConfig.autoOpenBrowser === undefined + ? fromConfig.autoOpenBrowser + : toConfig.autoOpenBrowser; + toConfig.allowAutoUpdates = + toConfig.allowAutoUpdates === undefined + ? fromConfig.allowAutoUpdates + : toConfig.allowAutoUpdates; + toConfig.defaultAccount ||= fromConfig.defaultAccount; + + const propertiesToCheck = [ + DEFAULT_CMS_PUBLISH_MODE, + HTTP_TIMEOUT, + ENV, + HTTP_USE_LOCALHOST, + ALLOW_USAGE_TRACKING, + AUTO_OPEN_BROWSER, + ALLOW_AUTO_UPDATES, + DEFAULT_ACCOUNT, + ] as const; + + propertiesToCheck.forEach(prop => { if ( - force || - !(prop in globalConfig) || - globalConfig[prop] === deprecatedConfig[prop] + toConfig[prop] !== undefined && + fromConfig[prop] !== undefined && + toConfig[prop] !== fromConfig[prop] ) { - // @ts-expect-error Cannot reconcile CLIConfig_NEW and CLIConfig_DEPRECATED types - globalConfig[prop] = deprecatedConfig[prop]; - } else { conflicts.push({ property: prop, - oldValue: deprecatedConfig[prop]!, - newValue: globalConfig[prop]!, + oldValue: fromConfig[prop], + newValue: toConfig[prop]!, }); } - } - }); - - if (globalConfig.flags || deprecatedConfig.flags) { - globalConfig.flags = Array.from( - new Set([ - ...(globalConfig.flags || []), - ...(deprecatedConfig.flags || []), - ]) - ); + }); } - if ( - DEFAULT_ACCOUNT in globalConfig && - DEFAULT_PORTAL in deprecatedConfig && - globalConfig.defaultAccount !== deprecatedConfig.defaultPortal - ) { - if (force) { - globalConfig.defaultAccount = deprecatedConfig.defaultPortal; - } else { - conflicts.push({ - property: DEFAULT_ACCOUNT, - oldValue: deprecatedConfig.defaultPortal!, - newValue: globalConfig.defaultAccount!, - }); - } - } else if (DEFAULT_PORTAL in deprecatedConfig) { - globalConfig.defaultAccount = deprecatedConfig.defaultPortal; + // Merge flags + if (toConfig.flags || fromConfig.flags) { + toConfig.flags = Array.from( + new Set([...(toConfig.flags || []), ...(fromConfig.flags || [])]) + ); } - return { initialConfig: globalConfig, conflicts }; + return { configWithMergedProperties: toConfig, conflicts }; } -function mergeAccounts( - globalConfig: CLIConfig_NEW, - deprecatedConfig: CLIConfig_DEPRECATED +function buildConfigWithMergedAccounts( + toConfig: HubSpotConfig, + fromConfig: HubSpotConfig ): { - finalConfig: CLIConfig_NEW; - skippedAccountIds: Array; + configWithMergedAccounts: HubSpotConfig; + skippedAccountIds: Array; } { - let existingAccountIds: Array = []; - const skippedAccountIds: Array = []; - - if (globalConfig.accounts && deprecatedConfig.portals) { - existingAccountIds = globalConfig.accounts.map( - account => account.accountId - ); + const existingAccountIds = toConfig.accounts.map( + ({ accountId }) => accountId + ); + const skippedAccountIds: Array = []; - const newAccounts = deprecatedConfig.portals - .filter(portal => { - const isExisting = existingAccountIds.includes(portal.portalId!); - if (isExisting) { - skippedAccountIds.push(portal.portalId!); - } - return !isExisting; - }) - .map(({ portalId, ...rest }) => ({ - ...rest, - accountId: portalId!, - })); - - if (newAccounts.length > 0) { - globalConfig.accounts.push(...newAccounts); + fromConfig.accounts.forEach(account => { + if (existingAccountIds.includes(account.accountId)) { + skippedAccountIds.push(account.accountId); + } else { + toConfig.accounts.push(account); } - } + }); return { - finalConfig: globalConfig, + configWithMergedAccounts: toConfig, skippedAccountIds, }; } -export function mergeExistingConfigs( - globalConfig: CLIConfig_NEW, - deprecatedConfig: CLIConfig_DEPRECATED -): { finalConfig: CLIConfig_NEW; skippedAccountIds: Array } { - const { finalConfig, skippedAccountIds } = mergeAccounts( - globalConfig, - deprecatedConfig - ); +export function mergeConfigAccounts( + toConfig: HubSpotConfig, + fromConfig: HubSpotConfig +): { + configWithMergedAccounts: HubSpotConfig; + skippedAccountIds: Array; +} { + const { configWithMergedAccounts, skippedAccountIds } = + buildConfigWithMergedAccounts(toConfig, fromConfig); - writeGlobalConfigFile(finalConfig); - return { finalConfig, skippedAccountIds }; + writeConfigFile(configWithMergedAccounts, getGlobalConfigFilePath()); + return { configWithMergedAccounts, skippedAccountIds }; +} + +export function archiveConfigAtPath(configPath: string): void { + const dir = path.dirname(configPath); + const archivedConfigPath = path.join( + dir, + ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME + ); + fs.renameSync(configPath, archivedConfigPath); } diff --git a/config/utils.ts b/config/utils.ts new file mode 100644 index 00000000..d2dc3aa9 --- /dev/null +++ b/config/utils.ts @@ -0,0 +1,511 @@ +import fs from 'fs-extra'; +import yaml from 'js-yaml'; + +import { + HUBSPOT_ACCOUNT_TYPES, + DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, + ENVIRONMENT_VARIABLES, + ACCOUNT_IDENTIFIERS, + HUBSPOT_CONFIG_ERROR_TYPES, + HUBSPOT_CONFIG_OPERATIONS, +} from '../constants/config'; +import { + PERSONAL_ACCESS_KEY_AUTH_METHOD, + API_KEY_AUTH_METHOD, + OAUTH_AUTH_METHOD, + OAUTH_SCOPES, +} from '../constants/auth'; +import { + HubSpotConfig, + DeprecatedHubSpotConfigFields, + HubSpotConfigErrorType, + HubSpotConfigValidationResult, +} from '../types/Config'; +import { FileSystemError } from '../models/FileSystemError'; +import { + HubSpotConfigAccount, + OAuthConfigAccount, + AccountType, + TokenInfo, +} from '../types/Accounts'; +import { getValidEnv } from '../lib/environment'; +import { getCwd } from '../lib/path'; +import { CMS_PUBLISH_MODE } from '../constants/files'; +import { i18n } from '../utils/lang'; +import { ValueOf } from '../types/Utils'; +import { HubSpotConfigError } from '../models/HubSpotConfigError'; + +export function getLocalConfigDefaultFilePath(): string { + return `${getCwd()}/${DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME}`; +} + +export function getConfigPathEnvironmentVariables(): { + useEnvironmentConfig: boolean; + configFilePathFromEnvironment: string | undefined; +} { + const configFilePathFromEnvironment = + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CONFIG_PATH]; + const useEnvironmentConfig = + process.env[ENVIRONMENT_VARIABLES.USE_ENVIRONMENT_HUBSPOT_CONFIG] === + 'true'; + + if (configFilePathFromEnvironment && useEnvironmentConfig) { + throw new HubSpotConfigError( + i18n( + 'config.utils.getConfigPathEnvironmentVariables.invalidEnvironmentVariables' + ), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ENVIRONMENT_VARIABLES, + HUBSPOT_CONFIG_OPERATIONS.READ + ); + } + + return { + configFilePathFromEnvironment, + useEnvironmentConfig, + }; +} + +export function doesConfigFileExistAtPath(path: string): boolean { + try { + return fs.existsSync(path); + } catch (error) { + const { message, type } = handleConfigFileSystemError(error, path); + throw new HubSpotConfigError( + message, + type, + HUBSPOT_CONFIG_OPERATIONS.READ, + { cause: error } + ); + } +} + +export function readConfigFile(configPath: string): string { + try { + return fs.readFileSync(configPath).toString(); + } catch (err) { + const { message, type } = handleConfigFileSystemError(err, configPath); + throw new HubSpotConfigError( + message, + type, + HUBSPOT_CONFIG_OPERATIONS.READ, + { cause: err } + ); + } +} + +export function removeUndefinedFieldsFromConfigAccount< + T extends + | HubSpotConfigAccount + | Partial = HubSpotConfigAccount, +>(account: T): T { + Object.keys(account).forEach(k => { + const key = k as keyof T; + if (account[key] === undefined) { + delete account[key]; + } + }); + + if ('auth' in account && account.auth) { + if (account.authType === OAUTH_AUTH_METHOD.value) { + Object.keys(account.auth).forEach(k => { + const key = k as keyof OAuthConfigAccount['auth']; + if (account.auth?.[key] === undefined) { + delete account.auth?.[key]; + } + }); + } + + if ( + 'tokenInfo' in account.auth && + typeof account.auth.tokenInfo === 'object' + ) { + Object.keys(account.auth.tokenInfo).forEach(k => { + const key = k as keyof TokenInfo; + if (account.auth?.tokenInfo[key] === undefined) { + delete account.auth?.tokenInfo[key]; + } + }); + } + } + + return account; +} + +// Ensure written config files have fields in a consistent order +export function formatConfigForWrite(config: HubSpotConfig) { + const { + defaultAccount, + defaultCmsPublishMode, + httpTimeout, + allowUsageTracking, + accounts, + ...rest + } = config; + + const orderedConfig = { + ...(defaultAccount && { defaultAccount }), + defaultCmsPublishMode, + httpTimeout, + allowUsageTracking, + ...rest, + accounts: accounts.map(account => { + const { name, accountId, env, authType, ...rest } = account; + + return { + name, + accountId, + env, + authType, + ...rest, + }; + }), + }; + + return removeUndefinedFieldsFromConfigAccount(orderedConfig); +} + +export function writeConfigFile( + config: HubSpotConfig, + configPath: string +): void { + const source = yaml.dump(formatConfigForWrite(config)); + + try { + fs.ensureFileSync(configPath); + fs.writeFileSync(configPath, source); + } catch (err) { + throw new FileSystemError( + { cause: err }, + { + filepath: configPath, + operation: 'write', + } + ); + } +} + +function getAccountType(sandboxAccountType?: string): AccountType { + if (sandboxAccountType) { + if (sandboxAccountType.toUpperCase() === 'DEVELOPER') { + return HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX; + } + if (sandboxAccountType.toUpperCase() === 'STANDARD') { + return HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX; + } + } + return HUBSPOT_ACCOUNT_TYPES.STANDARD; +} + +export function normalizeParsedConfig( + parsedConfig: HubSpotConfig & DeprecatedHubSpotConfigFields +): HubSpotConfig { + if (!parsedConfig.portals && !parsedConfig.accounts) { + parsedConfig.accounts = []; + } + + if (parsedConfig.portals) { + parsedConfig.accounts = parsedConfig.portals.map(account => { + if (account.portalId) { + account.accountId = account.portalId; + delete account.portalId; + } + if (!account.accountType) { + account.accountType = getAccountType(account.sandboxAccountType); + delete account.sandboxAccountType; + } + return account; + }); + delete parsedConfig.portals; + } + + if (parsedConfig.defaultPortal) { + const defaultAccount = getConfigAccountByInferredIdentifier( + parsedConfig.accounts, + parsedConfig.defaultPortal + ); + + if (defaultAccount) { + parsedConfig.defaultAccount = defaultAccount.accountId; + } + delete parsedConfig.defaultPortal; + } + + if (parsedConfig.defaultMode) { + parsedConfig.defaultCmsPublishMode = parsedConfig.defaultMode; + delete parsedConfig.defaultMode; + } + + return parsedConfig; +} + +export function parseConfig( + configSource: string, + configPath: string +): HubSpotConfig { + let parsedYaml: HubSpotConfig & DeprecatedHubSpotConfigFields; + + try { + parsedYaml = yaml.load(configSource) as HubSpotConfig & + DeprecatedHubSpotConfigFields; + } catch (err) { + throw new HubSpotConfigError( + i18n('config.utils.parseConfig.error', { configPath: configPath }), + HUBSPOT_CONFIG_ERROR_TYPES.YAML_PARSING, + HUBSPOT_CONFIG_OPERATIONS.READ, + { cause: err } + ); + } + + return normalizeParsedConfig(parsedYaml); +} + +export function buildConfigFromEnvironment(): HubSpotConfig { + const apiKey = process.env[ENVIRONMENT_VARIABLES.HUBSPOT_API_KEY]; + const clientId = process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_ID]; + const clientSecret = process.env[ENVIRONMENT_VARIABLES.HUBSPOT_CLIENT_SECRET]; + const personalAccessKey = + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_PERSONAL_ACCESS_KEY]; + const accountIdVar = + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ACCOUNT_ID] || + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_PORTAL_ID]; + const refreshToken = process.env[ENVIRONMENT_VARIABLES.HUBSPOT_REFRESH_TOKEN]; + const hubspotEnvironment = + process.env[ENVIRONMENT_VARIABLES.HUBSPOT_ENVIRONMENT]; + const httpTimeoutVar = process.env[ENVIRONMENT_VARIABLES.HTTP_TIMEOUT]; + const httpUseLocalhostVar = + process.env[ENVIRONMENT_VARIABLES.HTTP_USE_LOCALHOST]; + const allowUsageTrackingVar = + process.env[ENVIRONMENT_VARIABLES.ALLOW_USAGE_TRACKING]; + const defaultCmsPublishModeVar = + process.env[ENVIRONMENT_VARIABLES.DEFAULT_CMS_PUBLISH_MODE]; + + if (!accountIdVar) { + throw new HubSpotConfigError( + i18n('config.utils.buildConfigFromEnvironment.missingAccountId'), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ENVIRONMENT_VARIABLES, + HUBSPOT_CONFIG_OPERATIONS.READ + ); + } + + const accountId = parseInt(accountIdVar); + const httpTimeout = httpTimeoutVar ? parseInt(httpTimeoutVar) : undefined; + const httpUseLocalhost = httpUseLocalhostVar + ? httpUseLocalhostVar === 'true' + : undefined; + const allowUsageTracking = allowUsageTrackingVar + ? allowUsageTrackingVar === 'true' + : undefined; + const defaultCmsPublishMode = + defaultCmsPublishModeVar === CMS_PUBLISH_MODE.draft || + defaultCmsPublishModeVar === CMS_PUBLISH_MODE.publish + ? defaultCmsPublishModeVar + : undefined; + + const env = getValidEnv(hubspotEnvironment); + + let account: HubSpotConfigAccount; + + if (personalAccessKey) { + account = { + authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, + accountId, + personalAccessKey, + env, + name: accountIdVar, + auth: { + tokenInfo: {}, + }, + }; + } else if (clientId && clientSecret && refreshToken) { + account = { + authType: OAUTH_AUTH_METHOD.value, + accountId, + auth: { + clientId, + clientSecret, + scopes: OAUTH_SCOPES.map((scope: { value: string }) => scope.value), + tokenInfo: { + refreshToken, + }, + }, + env, + name: accountIdVar, + }; + } else if (apiKey) { + account = { + authType: API_KEY_AUTH_METHOD.value, + accountId, + apiKey, + env, + name: accountIdVar, + }; + } else { + throw new HubSpotConfigError( + i18n('config.utils.buildConfigFromEnvironment.invalidAuthType'), + HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ENVIRONMENT_VARIABLES, + HUBSPOT_CONFIG_OPERATIONS.READ + ); + } + + return { + accounts: [account], + defaultAccount: accountId, + httpTimeout, + httpUseLocalhost, + allowUsageTracking, + defaultCmsPublishMode, + }; +} + +export function getAccountIdentifierAndType( + accountIdentifier: string | number +): { + identifier: string | number; + identifierType: ValueOf; +} { + const identifierAsNumber = + typeof accountIdentifier === 'number' + ? accountIdentifier + : parseInt(accountIdentifier); + const isId = !isNaN(identifierAsNumber); + + return { + identifier: isId ? identifierAsNumber : accountIdentifier, + identifierType: isId + ? ACCOUNT_IDENTIFIERS.ACCOUNT_ID + : ACCOUNT_IDENTIFIERS.NAME, + }; +} + +export function getConfigAccountByIdentifier( + accounts: Array, + identifierFieldName: ValueOf, + identifier: string | number +): HubSpotConfigAccount | undefined { + return accounts.find(account => account[identifierFieldName] === identifier); +} + +export function getConfigAccountByInferredIdentifier( + accounts: Array, + accountIdentifier: string | number +): HubSpotConfigAccount | undefined { + const { identifier, identifierType } = + getAccountIdentifierAndType(accountIdentifier); + + const account = getConfigAccountByIdentifier( + accounts, + identifierType, + identifier + ); + + if (account) { + return account; + } + + // Fallback to handle accounts with numbers as names + return getConfigAccountByIdentifier( + accounts, + ACCOUNT_IDENTIFIERS.NAME, + String(accountIdentifier) + ); +} + +export function getConfigAccountIndexById( + accounts: Array, + id: number +): number { + return accounts.findIndex(account => account.accountId === id); +} + +export function validateConfigAccount( + account: Partial +): HubSpotConfigValidationResult { + const validationErrors = []; + if (!account || typeof account !== 'object') { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingAccount') + ); + return { isValid: false, errors: validationErrors }; + } + + if (!account.accountId) { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingAccountId') + ); + return { isValid: false, errors: validationErrors }; + } + + if (!account.authType) { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingAuthType', { + accountId: account.accountId, + }) + ); + return { isValid: false, errors: validationErrors }; + } + + if (account.authType === PERSONAL_ACCESS_KEY_AUTH_METHOD.value) { + const isValidPersonalAccessKeyAccount = + 'personalAccessKey' in account && Boolean(account.personalAccessKey); + + if (!isValidPersonalAccessKeyAccount) { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingPersonalAccessKey', { + accountId: account.accountId, + }) + ); + } + } + + if (account.authType === OAUTH_AUTH_METHOD.value) { + const isValidOAuthAccount = 'auth' in account && Boolean(account.auth); + + if (!isValidOAuthAccount) { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingAuth', { + accountId: account.accountId, + }) + ); + } + } + + if (account.authType === API_KEY_AUTH_METHOD.value) { + const isValidAPIKeyAccount = 'apiKey' in account && Boolean(account.apiKey); + + if (!isValidAPIKeyAccount) { + validationErrors.push( + i18n('config.utils.validateConfigAccount.missingApiKey', { + accountId: account.accountId, + }) + ); + } + } + + return { isValid: validationErrors.length === 0, errors: validationErrors }; +} + +export function handleConfigFileSystemError( + error: unknown, + path: string +): { message?: string; type: HubSpotConfigErrorType } { + let message; + let type: HubSpotConfigErrorType = HUBSPOT_CONFIG_ERROR_TYPES.UNKNOWN; + + if (error instanceof Error && 'code' in error) { + if (error.code === 'ENOENT') { + message = i18n( + 'config.utils.handleConfigFileSystemError.configNotFoundError', + { path } + ); + type = HUBSPOT_CONFIG_ERROR_TYPES.CONFIG_NOT_FOUND; + } else if (error.code === 'EACCES') { + message = i18n( + 'config.utils.handleConfigFileSystemError.insufficientPermissionsError', + { path } + ); + type = HUBSPOT_CONFIG_ERROR_TYPES.INSUFFICIENT_PERMISSIONS; + } + } + + return { message, type }; +} diff --git a/constants/config.ts b/constants/config.ts index 14aa6d52..c0f6b836 100644 --- a/constants/config.ts +++ b/constants/config.ts @@ -57,3 +57,48 @@ export const HUBSPOT_ACCOUNT_TYPE_STRINGS = { APP_DEVELOPER: i18n('lib.accountTypes.appDeveloper'), STANDARD: i18n('lib.accountTypes.standard'), } as const; + +export const CONFIG_FLAGS = { + USE_CUSTOM_OBJECT_HUBFILE: 'useCustomObjectHubfile', + HTTP_USE_LOCALHOST: 'httpUseLocalhost', +} as const; + +export const ENVIRONMENT_VARIABLES = { + HUBSPOT_API_KEY: 'HUBSPOT_API_KEY', + HUBSPOT_CLIENT_ID: 'HUBSPOT_CLIENT_ID', + HUBSPOT_CLIENT_SECRET: 'HUBSPOT_CLIENT_SECRET', + HUBSPOT_PERSONAL_ACCESS_KEY: 'HUBSPOT_PERSONAL_ACCESS_KEY', + HUBSPOT_ACCOUNT_ID: 'HUBSPOT_ACCOUNT_ID', + HUBSPOT_PORTAL_ID: 'HUBSPOT_PORTAL_ID', + HUBSPOT_REFRESH_TOKEN: 'HUBSPOT_REFRESH_TOKEN', + HUBSPOT_ENVIRONMENT: 'HUBSPOT_ENVIRONMENT', + HTTP_TIMEOUT: 'HTTP_TIMEOUT', + HTTP_USE_LOCALHOST: 'HTTP_USE_LOCALHOST', + ALLOW_USAGE_TRACKING: 'ALLOW_USAGE_TRACKING', + DEFAULT_CMS_PUBLISH_MODE: 'DEFUALT_CMS_PUBLISH_MODE', + USE_ENVIRONMENT_HUBSPOT_CONFIG: 'USE_ENVIRONMENT_HUBSPOT_CONFIG', + HUBSPOT_CONFIG_PATH: 'HUBSPOT_CONFIG_PATH', +} as const; + +export const ACCOUNT_IDENTIFIERS = { + ACCOUNT_ID: 'accountId', + NAME: 'name', +} as const; + +export const HUBSPOT_CONFIG_ERROR_TYPES = { + CONFIG_NOT_FOUND: 'CONFIG_NOT_FOUND', + INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS', + ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND', + NO_DEFAULT_ACCOUNT: 'NO_DEFAULT_ACCOUNT', + INVALID_ENVIRONMENT_VARIABLES: 'ENVIRONMENT_VARIABLES', + YAML_PARSING: 'YAML_PARSING', + INVALID_ACCOUNT: 'INVALID_ACCOUNT', + INVALID_FIELD: 'INVALID_FIELD', + UNKNOWN: 'UNKNOWN', +} as const; + +export const HUBSPOT_CONFIG_OPERATIONS = { + READ: 'READ', + WRITE: 'WRITE', + DELETE: 'DELETE', +} as const; diff --git a/constants/environments.ts b/constants/environments.ts index cb704fa2..a8bff37b 100644 --- a/constants/environments.ts +++ b/constants/environments.ts @@ -2,15 +2,3 @@ export const ENVIRONMENTS = { PROD: 'prod', QA: 'qa', } as const; - -export const ENVIRONMENT_VARIABLES = { - HUBSPOT_API_KEY: 'HUBSPOT_API_KEY', - HUBSPOT_CLIENT_ID: 'HUBSPOT_CLIENT_ID', - HUBSPOT_CLIENT_SECRET: 'HUBSPOT_CLIENT_SECRET', - HUBSPOT_PERSONAL_ACCESS_KEY: 'HUBSPOT_PERSONAL_ACCESS_KEY', - HUBSPOT_ACCOUNT_ID: 'HUBSPOT_ACCOUNT_ID', - HUBSPOT_PORTAL_ID: 'HUBSPOT_PORTAL_ID', - HUBSPOT_REFRESH_TOKEN: 'HUBSPOT_REFRESH_TOKEN', - HUBSPOT_ENVIRONMENT: 'HUBSPOT_ENVIRONMENT', - HTTP_TIMEOUT: 'HTTP_TIMEOUT', -} as const; diff --git a/errors/index.ts b/errors/index.ts index 9840fbde..77ba5612 100644 --- a/errors/index.ts +++ b/errors/index.ts @@ -1,6 +1,7 @@ import { HubSpotHttpError } from '../models/HubSpotHttpError'; import { BaseError } from '../types/Error'; import { FileSystemError } from '../models/FileSystemError'; +import { HubSpotConfigError } from '../models/HubSpotConfigError'; export function isSpecifiedError( err: unknown, @@ -97,3 +98,7 @@ export function isSystemError(err: unknown): err is BaseError { export function isFileSystemError(err: unknown): err is FileSystemError { return err instanceof FileSystemError; } + +export function isHubSpotConfigError(err: unknown): err is HubSpotConfigError { + return err instanceof HubSpotConfigError; +} diff --git a/http/__tests__/getAxiosConfig.test.ts b/http/__tests__/getAxiosConfig.test.ts index f3cbc1f1..6cddcddd 100644 --- a/http/__tests__/getAxiosConfig.test.ts +++ b/http/__tests__/getAxiosConfig.test.ts @@ -1,19 +1,16 @@ -import { getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded } from '../../config'; +import { getConfig as __getConfig } from '../../config'; import { ENVIRONMENTS } from '../../constants/environments'; import { getAxiosConfig } from '../getAxiosConfig'; jest.mock('../../config'); -const getAndLoadConfigIfNeeded = - __getAndLoadConfigIfNeeded as jest.MockedFunction< - typeof __getAndLoadConfigIfNeeded - >; +const getConfig = __getConfig as jest.MockedFunction; const url = 'https://app.hubspot.com'; describe('http/getAxiosConfig', () => { it('constructs baseURL as expected based on environment', () => { - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [], }); @@ -25,7 +22,7 @@ describe('http/getAxiosConfig', () => { }); }); it('supports httpUseLocalhost config option to construct baseURL for local HTTP services', () => { - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ httpUseLocalhost: true, accounts: [], }); diff --git a/http/__tests__/index.test.ts b/http/__tests__/index.test.ts index f86fc84d..6015167a 100644 --- a/http/__tests__/index.test.ts +++ b/http/__tests__/index.test.ts @@ -2,13 +2,13 @@ import axios, { AxiosError } from 'axios'; import fs from 'fs-extra'; import moment from 'moment'; import { - getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded, - getAccountConfig as __getAccountConfig, + getConfig as __getConfig, + getConfigAccountById as __getConfigAccountById, } from '../../config'; import { ENVIRONMENTS } from '../../constants/environments'; import { http } from '../'; import { version } from '../../package.json'; -import { AuthType } from '../../types/Accounts'; +import { HubSpotConfigAccount } from '../../types/Accounts'; jest.mock('fs-extra'); jest.mock('axios'); @@ -28,14 +28,19 @@ jest.mock('https', () => ({ })); const mockedAxios = jest.mocked(axios); -const getAndLoadConfigIfNeeded = - __getAndLoadConfigIfNeeded as jest.MockedFunction< - typeof __getAndLoadConfigIfNeeded - >; -const getAccountConfig = __getAccountConfig as jest.MockedFunction< - typeof __getAccountConfig +const getConfig = __getConfig as jest.MockedFunction; +const getConfigAccountById = __getConfigAccountById as jest.MockedFunction< + typeof __getConfigAccountById >; +const ACCOUNT: HubSpotConfigAccount = { + name: 'test-account', + accountId: 123, + apiKey: 'abc', + env: ENVIRONMENTS.QA, + authType: 'apikey', +}; + fs.createWriteStream = jest.fn().mockReturnValue({ on: jest.fn((event, callback) => { if (event === 'close') { @@ -46,27 +51,17 @@ fs.createWriteStream = jest.fn().mockReturnValue({ describe('http/index', () => { afterEach(() => { - getAndLoadConfigIfNeeded.mockReset(); - getAccountConfig.mockReset(); + getConfig.mockReset(); + getConfigAccountById.mockReset(); }); describe('http.getOctetStream()', () => { beforeEach(() => { - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ httpTimeout: 1000, - accounts: [ - { - accountId: 123, - apiKey: 'abc', - env: ENVIRONMENTS.QA, - }, - ], - }); - getAccountConfig.mockReturnValue({ - accountId: 123, - apiKey: 'abc', - env: ENVIRONMENTS.QA, + accounts: [ACCOUNT], }); + getConfigAccountById.mockReturnValue(ACCOUNT); }); it('makes a get request', async () => { @@ -126,10 +121,11 @@ describe('http/index', () => { describe('http.get()', () => { it('adds authorization header when using OAuth2 with valid access token', async () => { const accessToken = 'let-me-in'; - const account = { + const account: HubSpotConfigAccount = { + name: 'test-account', accountId: 123, env: ENVIRONMENTS.PROD, - authType: 'oauth2' as AuthType, + authType: 'oauth2', auth: { clientId: 'd996372f-2b53-30d3-9c3b-4fdde4bce3a2', clientSecret: 'f90a6248-fbc0-3b03-b0db-ec58c95e791', @@ -141,10 +137,10 @@ describe('http/index', () => { }, }, }; - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [account], }); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReturnValue(account); await http.get(123, { url: 'some/endpoint/path' }); @@ -172,10 +168,11 @@ describe('http/index', () => { }); it('adds authorization header when using a user token', async () => { const accessToken = 'let-me-in'; - const account = { + const account: HubSpotConfigAccount = { + name: 'test-account', accountId: 123, env: ENVIRONMENTS.PROD, - authType: 'personalaccesskey' as AuthType, + authType: 'personalaccesskey', personalAccessKey: 'some-secret', auth: { tokenInfo: { @@ -184,10 +181,10 @@ describe('http/index', () => { }, }, }; - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [account], }); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReturnValue(account); await http.get(123, { url: 'some/endpoint/path' }); @@ -215,26 +212,16 @@ describe('http/index', () => { }); it('supports setting a custom timeout', async () => { - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ httpTimeout: 1000, - accounts: [ - { - accountId: 123, - apiKey: 'abc', - env: ENVIRONMENTS.PROD, - }, - ], - }); - getAccountConfig.mockReturnValue({ - accountId: 123, - apiKey: 'abc', - env: ENVIRONMENTS.PROD, + accounts: [ACCOUNT], }); + getConfigAccountById.mockReturnValue(ACCOUNT); await http.get(123, { url: 'some/endpoint/path' }); expect(mockedAxios).toHaveBeenCalledWith({ - baseURL: `https://api.hubapi.com`, + baseURL: `https://api.hubapiqa.com`, url: 'some/endpoint/path', headers: { 'User-Agent': `HubSpot Local Dev Lib/${version}`, diff --git a/http/getAxiosConfig.ts b/http/getAxiosConfig.ts index 2f4ad454..856045c1 100644 --- a/http/getAxiosConfig.ts +++ b/http/getAxiosConfig.ts @@ -1,7 +1,8 @@ import { version } from '../package.json'; -import { getAndLoadConfigIfNeeded } from '../config'; +import { getConfig } from '../config'; import { getHubSpotApiOrigin } from '../lib/urls'; import { HttpOptions } from '../types/Http'; +import { HubSpotConfig } from '../types/Config'; import { AxiosRequestConfig } from 'axios'; import https from 'https'; import http from 'http'; @@ -48,7 +49,12 @@ const DEFAULT_TRANSITIONAL = { export function getAxiosConfig(options: HttpOptions): AxiosRequestConfig { const { env, localHostOverride, headers, ...rest } = options; - const config = getAndLoadConfigIfNeeded(); + let config: HubSpotConfig | null; + try { + config = getConfig(); + } catch (e) { + config = null; + } let httpTimeout = 15000; let httpUseLocalhost = false; diff --git a/http/index.ts b/http/index.ts index e2e4731a..d1d47e90 100644 --- a/http/index.ts +++ b/http/index.ts @@ -8,16 +8,21 @@ import axios, { isAxiosError, } from 'axios'; -import { getAccountConfig } from '../config'; +import { getConfigAccountById } from '../config'; import { USER_AGENTS, getAxiosConfig } from './getAxiosConfig'; import { addQueryParams } from './addQueryParams'; import { accessTokenForPersonalAccessKey } from '../lib/personalAccessKey'; import { getOauthManager } from '../lib/oauth'; -import { FlatAccountFields } from '../types/Accounts'; import { HttpOptions, HubSpotPromise } from '../types/Http'; import { logger } from '../lib/logger'; import { i18n } from '../utils/lang'; import { HubSpotHttpError } from '../models/HubSpotHttpError'; +import { OAuthConfigAccount } from '../types/Accounts'; +import { + PERSONAL_ACCESS_KEY_AUTH_METHOD, + OAUTH_AUTH_METHOD, + API_KEY_AUTH_METHOD, +} from '../constants/auth'; import { LOCALDEVAUTH_ACCESS_TOKEN_PATH } from '../api/localDevAuth'; import * as util from 'util'; import { CMS_CLI_USAGE_PATH, VSCODE_USAGE_PATH } from '../lib/trackUsage'; @@ -89,15 +94,16 @@ export function addUserAgentHeader(key: string, value: string) { } async function withOauth( - accountId: number, - accountConfig: FlatAccountFields, + account: OAuthConfigAccount, axiosConfig: AxiosRequestConfig ): Promise { const { headers } = axiosConfig; - const oauth = getOauthManager(accountId, accountConfig); + const oauth = getOauthManager(account); if (!oauth) { - throw new Error(i18n(`${i18nKey}.errors.withOauth`, { accountId })); + throw new Error( + i18n(`${i18nKey}.errors.withOauth`, { accountId: account.accountId }) + ); } const accessToken = await oauth.accessToken(); @@ -144,34 +150,40 @@ async function withAuth( accountId: number, options: HttpOptions ): Promise { - const accountConfig = getAccountConfig(accountId); - - if (!accountConfig) { - throw new Error(i18n(`${i18nKey}.errors.withAuth`, { accountId })); - } + const account = getConfigAccountById(accountId); - const { env, authType, apiKey } = accountConfig; + const { env, authType } = account; const axiosConfig = withPortalId( accountId, getAxiosConfig({ env, ...options }) ); - if (authType === 'personalaccesskey') { + if (authType === PERSONAL_ACCESS_KEY_AUTH_METHOD.value) { return withPersonalAccessKey(accountId, axiosConfig); } - if (authType === 'oauth2') { - return withOauth(accountId, accountConfig, axiosConfig); + if (authType === OAUTH_AUTH_METHOD.value) { + return withOauth(account, axiosConfig); } - const { params } = axiosConfig; - return { - ...axiosConfig, - params: { - ...params, - hapikey: apiKey, - }, - }; + if (authType === API_KEY_AUTH_METHOD.value) { + const { params } = axiosConfig; + + return { + ...axiosConfig, + params: { + ...params, + hapikey: account.apiKey, + }, + }; + } + + throw new Error( + i18n(`${i18nKey}.errors.invalidAuthType`, { + accountId, + authType, + }) + ); } async function getRequest( diff --git a/lang/en.json b/lang/en.json index 121c8931..2a1e0240 100644 --- a/lang/en.json +++ b/lang/en.json @@ -75,7 +75,8 @@ "personalAccessKey": { "errors": { "accountNotFound": "Account with id {{ accountId }} does not exist.", - "invalidPersonalAccessKey": "Error while retrieving new access token: {{ errorMessage }}" + "invalidPersonalAccessKey": "Error while retrieving new access token: {{ errorMessage }}", + "invalidAuthType": "Error fetching access token: account {{ accountId }} uses an auth type other than personalaccesskey" } }, "crm": { @@ -242,73 +243,96 @@ } }, "config": { - "cliConfiguration": { - "errors": { - "noConfigLoaded": "No config loaded." - }, - "load": { - "configFromEnv": "Loaded config from environment variables for {{ accountId }}", - "configFromFile": "Loaded config from configuration file.", - "empty": "The config file was empty. Initializing an empty config." + "getDefaultConfigFilePath": { + "error": "Attempted to get config file path, but no config file was found." + }, + "getConfig": { + "reading": "Reading config from {{ path }}", + "error": "No config file found.", + "errorWithPath": "No config file found at {{ path }}." + }, + "validateConfig": { + "missingAccounts": "No accounts found", + "duplicateAccountIds": "Multiple accounts with accountId: {{ accountId }}", + "duplicateAccountNames": "Multiple accounts with name: {{ accountName }}", + "invalidAccountName": "Account name {{ accountName }} contains spaces" + }, + "getConfigAccountById": { + "error": "No account with id {{ accountId }} exists in config" + }, + "getConfigAccountByName": { + "error": "No account with name {{ accountName }} exists in config" + }, + "getConfigDefaultAccount": { + "fieldMissingError": "Attempted to get default account, but no default account was found in config", + "accountMissingError": "Default account is set to {{ defaultAccount }}, but no account with that id exists in config" + }, + "addConfigAccount": { + "invalidAccount": "Attempting to add account, but account is invalid", + "duplicateAccount": "Attempting to add account, but account with id {{ accountId }} already exists in config" + }, + "updateConfigAccount": { + "invalidAccount": "Attempting to update account {{ name }}, but account is invalid", + "accountNotFound": "Attempting to update account with id {{ id }}, but that account was not found in config" + }, + "setConfigAccountAsDefault": { + "accountNotFound": "Attempted to set account with identifier {{ identifier }} as default, but that account was not found in config" + }, + "renameConfigAccount": { + "accountNotFound": "Attempted to rename account with name {{ currentName }}, but that account was not found in config", + "duplicateAccount": "Attempted to rename account {{ currentName }} to {{ newName }}, but an account with that name already exists in config" + }, + "removeAccountFromConfig": { + "accountNotFound": "Attempted to remove account with id {{ accountId }}, but that account was not found in config" + }, + "updateHttpTimeout": { + "invalidTimeout": "HTTP timeout must be greater than {{ minTimeout }}. Received {{ timeout }}" + }, + "updateDefaultCmsPublishMode": { + "invalidCmsPublishMode": "Error updating config default CMS publish mode: CMS publish can only be set to 'draft' or 'publish'" + }, + "getConfigAccountEnvironment": { + "accountNotFound": "Attempted to get environment for account with identifier {{ identifier }}, but that account was not found in config" + }, + "utils": { + "handleConfigFileSystemError": { + "configNotFoundError": "No config file found at {{ path }}.", + "insufficientPermissionsError": "Insufficient permissions to access config file at {{ path }}" }, - "validate": { - "noConfig": "Validation failed: No config was found.", - "noConfigAccounts": "Validation failed: config.accounts[] is not defined.", - "emptyAccountConfig": "Validation failed: config.accounts[] has an empty entry.", - "noAccountId": "Validation failed: config.accounts[] has an entry missing accountId.", - "duplicateAccountIds": "Validation failed: config.accounts[] has multiple entries with {{ accountId }}.", - "duplicateAccountNames": "Validation failed: config.accounts[] has multiple entries with {{ accountName }}.", - "nameContainsSpaces": "Validation failed: config.name {{ accountName }} cannot contain spaces." + "validateConfigAccount": { + "missingAccount": "At least one account in config is missing data", + "missingAuthType": "Account {{ accountId }} has no authType", + "missingAccountId": "At least one account in config is missing accountId", + "missingApiKey": "Account {{ accountId }} has authType of apikey but is missing the apiKey field", + "missingAuth": "Account {{ accountId }} has authtype of oauth2 but is missing auth data", + "missingPersonalAccessKey": "Account {{ accountId }} has authType of personalAccessKey but is missing the personalAccessKey field" }, - "updateAccount": { - "noConfigToUpdate": "No config to update.", - "updating": "Updating account config for {{ accountId }}", - "addingConfigEntry": "Adding account config entry for {{ accountId }}", - "errors": { - "accountIdRequired": "An accountId is required to update the config" - } + "getConfigPathEnvironmentVariables": { + "invalidEnvironmentVariables": "USE_ENVIRONMENT_HUBSPOT_CONFIG and HUBSPOT_CONFIG_PATH cannot both be set simultaneously" }, - "updateDefaultAccount": { - "errors": { - "invalidInput": "A 'defaultAccount' with value of number or string is required to update the config." - } + "parseConfig": { + "error": "File could not be parsed. Confirm that your config file {{ configPath }} is valid YAML." }, - "getCWDAccountOverride": { + "buildConfigFromEnvironment": { + "missingAccountId": "HUBSPOT_ACCOUNT_ID is required, but not currently set", + "invalidAuthType": "Auth type is invalid. Use HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, and HUBSPOT_REFRESH_TOKEN to authenticate with Oauth2, PERSONAL_ACCESS_KEY to authenticate with Personal Access Key, or API_KEY to authenticate with API Key." + } + }, + "defaultAccountOverride": { + "getDefaultAccountOverrideAccountId": { "errorHeader": "Error in {{ hsAccountFile }}", "readFileError": "Error reading account override file." - }, - "renameAccount": { - "errors": { - "invalidName": "Cannot find account with identifier {{ currentName }}" - } - }, - "removeAccountFromConfig": { - "deleting": "Deleting config for {{ accountId }}", - "errors": { - "invalidId": "Unable to find account for {{ nameOrId }}." - } - }, - "updateDefaultCmsPublishMode": { - "errors": { - "invalidCmsPublishMode": "The CMS publish mode {{ defaultCmsPublishMode }} is invalid. Valid values are {{ validCmsPublishModes }}." - } - }, - "updateHttpTimeout": { - "errors": { - "invalidTimeout": "The value {{ timeout }} is invalid. The value must be a number greater than {{ minTimeout }}." - } - }, - "updateAllowUsageTracking": { - "errors": { - "invalidInput": "Unable to update allowUsageTracking. The value {{ isEnabled }} is invalid. The value must be a boolean." - } - }, - "updateAutoOpenBrowser": { - "errors": { - "invalidInput": "Unable to update autoOpenBrowser. The value {{ isEnabled }} is invalid. The value must be a boolean." - } } }, + "updateAllowUsageTracking": { + "invalidInput": "Unable to update allowUsageTracking. The value {{ isAllowed }} is invalid. The value must be a boolean." + }, + "updateAllowAutoUpdates": { + "invalidInput": "Unable to update allowAutoUpdates. The value {{ isEnabled }} is invalid. The value must be a boolean." + }, + "updateAutoOpenBrowser": { + "invalidInput": "Unable to update autoOpenBrowser. The value {{ isEnabled }} is invalid. The value must be a boolean." + }, "configFile": { "errorReading": "Config file could not be read: {{ configPath }}", "writeSuccess": "Successfully wrote updated config data to {{ configPath }}", @@ -363,6 +387,9 @@ "missingRefreshToken": "The account {{ accountId }} has not been authenticated with Oauth2", "auth": "Error while retrieving new token: {{ token }}" } + }, + "HubSpotConfigError": { + "baseMessage": "An error occurred while {{ operation }} your HubSpot config {{ configType }}{{ message }}" } }, "utils": { @@ -402,7 +429,8 @@ }, "errors": { "withOauth": "Oauth manager for account {{ accountId }} not found.", - "withAuth": "Account with id {{ accountId }} not found." + "withAuth": "Account with id {{ accountId }} not found.", + "invalidAuthType": "Error authenticating HTTP request: account {{ accountId }} has an invalid auth type {{ authType }}" } } }, diff --git a/lib/__tests__/archive.test.ts b/lib/__tests__/archive.test.ts index 24e51d04..737b821d 100644 --- a/lib/__tests__/archive.test.ts +++ b/lib/__tests__/archive.test.ts @@ -1,11 +1,13 @@ -import os from 'os'; - jest.mock('fs-extra'); jest.mock('extract-zip'); -jest.mock('os'); +jest.mock('os', () => ({ + tmpdir: jest.fn(() => '/tmp'), + homedir: jest.fn(() => '/home/user'), +})); jest.mock('../logger'); jest.mock('../fs'); +import os from 'os'; import { extractZipArchive } from '../archive'; import { logger } from '../logger'; import fs from 'fs-extra'; diff --git a/lib/__tests__/environment.test.ts b/lib/__tests__/environment.test.ts index ebce2670..48fceeee 100644 --- a/lib/__tests__/environment.test.ts +++ b/lib/__tests__/environment.test.ts @@ -10,7 +10,6 @@ describe('lib/environment', () => { }); it('should return prod when the provided env is not equal to QA', () => { - // @ts-expect-error purposefully causing an error expect(getValidEnv('notQA')).toEqual(PROD); }); diff --git a/lib/__tests__/oauth.test.ts b/lib/__tests__/oauth.test.ts index 62a6f87c..820c985e 100644 --- a/lib/__tests__/oauth.test.ts +++ b/lib/__tests__/oauth.test.ts @@ -1,62 +1,62 @@ import { addOauthToAccountConfig, getOauthManager } from '../oauth'; -jest.mock('../../config/getAccountIdentifier'); jest.mock('../../config'); jest.mock('../logger'); jest.mock('../../errors'); +jest.mock('../../models/OAuth2Manager'); -import { updateAccountConfig, writeConfig } from '../../config'; -import { OAuth2Manager } from '../../models/OAuth2Manager'; -import { FlatAccountFields_NEW } from '../../types/Accounts'; +import { updateConfigAccount } from '../../config'; +import * as OAuth2ManagerModule from '../../models/OAuth2Manager'; import { ENVIRONMENTS } from '../../constants/environments'; import { AUTH_METHODS } from '../../constants/auth'; import { logger } from '../logger'; +import { HubSpotConfigAccount } from '../../types/Accounts'; -const OAuth2ManagerFromConfigMock = jest.spyOn(OAuth2Manager, 'fromConfig'); +const UnmockedOAuth2Manager = jest.requireActual('../../models/OAuth2Manager'); +const OAuth2Manager = UnmockedOAuth2Manager.OAuth2Manager; + +const OAuth2ManagerMock = jest.spyOn(OAuth2ManagerModule, 'OAuth2Manager'); describe('lib/oauth', () => { const accountId = 123; - const accountConfig: FlatAccountFields_NEW = { + const account: HubSpotConfigAccount = { + name: 'my-account', accountId, env: ENVIRONMENTS.QA, - clientId: 'my-client-id', - clientSecret: "shhhh, it's a secret", - scopes: [], - apiKey: '', - personalAccessKey: '', - }; - const account = { - name: 'my-account', + authType: AUTH_METHODS.oauth.value, + auth: { + clientId: 'my-client-id', + clientSecret: "shhhh, it's a secret", + scopes: [], + tokenInfo: {}, + }, }; + describe('getOauthManager', () => { it('should create a OAuth2Manager for accounts that are not cached', () => { - getOauthManager(accountId, accountConfig); - expect(OAuth2ManagerFromConfigMock).toHaveBeenCalledTimes(1); - expect(OAuth2ManagerFromConfigMock).toHaveBeenCalledWith( - accountConfig, + getOauthManager(account); + expect(OAuth2ManagerMock).toHaveBeenCalledTimes(1); + expect(OAuth2ManagerMock).toHaveBeenCalledWith( + account, expect.any(Function) ); }); it('should use the cached OAuth2Manager if it exists', () => { - getOauthManager(accountId, accountConfig); - expect(OAuth2ManagerFromConfigMock).not.toHaveBeenCalled(); + getOauthManager(account); + expect(OAuth2ManagerMock).not.toHaveBeenCalled(); }); + + jest.clearAllMocks(); }); describe('addOauthToAccountConfig', () => { it('should update the config', () => { - addOauthToAccountConfig(new OAuth2Manager(account)); - expect(updateAccountConfig).toHaveBeenCalledTimes(1); - expect(updateAccountConfig).toHaveBeenCalledWith({ - ...account, - authType: AUTH_METHODS.oauth.value, - }); - }); - - it('should write the updated config', () => { - addOauthToAccountConfig(new OAuth2Manager(account)); - expect(writeConfig).toHaveBeenCalledTimes(1); + const oauthManager = new OAuth2Manager(account, () => null); + console.log('oauthManager', oauthManager.account); + addOauthToAccountConfig(oauthManager); + expect(updateConfigAccount).toHaveBeenCalledTimes(1); + expect(updateConfigAccount).toHaveBeenCalledWith(account); }); it('should log messages letting the user know the status of the operation', () => { diff --git a/lib/__tests__/personalAccessKey.test.ts b/lib/__tests__/personalAccessKey.test.ts index 028c9838..f736f71e 100644 --- a/lib/__tests__/personalAccessKey.test.ts +++ b/lib/__tests__/personalAccessKey.test.ts @@ -1,8 +1,12 @@ import moment from 'moment'; import { - getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded, - getAccountConfig as __getAccountConfig, - updateAccountConfig as __updateAccountConfig, + getConfig as __getConfig, + getConfigAccountById as __getConfigAccountById, + updateConfigAccount as __updateConfigAccount, + addConfigAccount as __addConfigAccount, + setConfigAccountAsDefault as __setConfigAccountAsDefault, + getConfigAccountIfExists as __getConfigAccountIfExists, + getConfigDefaultAccountIfExists as __getConfigDefaultAccountIfExists, } from '../../config'; import { fetchAccessToken as __fetchAccessToken } from '../../api/localDevAuth'; import { fetchSandboxHubData as __fetchSandboxHubData } from '../../api/sandboxHubs'; @@ -14,7 +18,7 @@ import { getAccessToken, updateConfigWithAccessToken, } from '../personalAccessKey'; -import { AuthType } from '../../types/Accounts'; +import { HubSpotConfigAccount } from '../../types/Accounts'; import { mockAxiosResponse } from './__utils__/mockAxiosResponse'; jest.mock('../../config'); @@ -23,16 +27,28 @@ jest.mock('../../api/localDevAuth'); jest.mock('../../api/sandboxHubs'); jest.mock('../../api/developerTestAccounts'); -const updateAccountConfig = __updateAccountConfig as jest.MockedFunction< - typeof __updateAccountConfig +const updateConfigAccount = __updateConfigAccount as jest.MockedFunction< + typeof __updateConfigAccount >; -const getAccountConfig = __getAccountConfig as jest.MockedFunction< - typeof __getAccountConfig +const addConfigAccount = __addConfigAccount as jest.MockedFunction< + typeof __addConfigAccount >; -const getAndLoadConfigIfNeeded = - __getAndLoadConfigIfNeeded as jest.MockedFunction< - typeof __getAndLoadConfigIfNeeded +const setConfigAccountAsDefault = + __setConfigAccountAsDefault as jest.MockedFunction< + typeof __setConfigAccountAsDefault >; +const getConfigAccountIfExists = + __getConfigAccountIfExists as jest.MockedFunction< + typeof __getConfigAccountIfExists + >; +const getConfigDefaultAccountIfExists = + __getConfigDefaultAccountIfExists as jest.MockedFunction< + typeof __getConfigDefaultAccountIfExists + >; +const getConfigAccountById = __getConfigAccountById as jest.MockedFunction< + typeof __getConfigAccountById +>; +const getConfig = __getConfig as jest.MockedFunction; const fetchAccessToken = __fetchAccessToken as jest.MockedFunction< typeof __fetchAccessToken >; @@ -48,16 +64,20 @@ describe('lib/personalAccessKey', () => { describe('accessTokenForPersonalAccessKey()', () => { it('refreshes access token when access token is missing', async () => { const accountId = 123; - const account = { + const account: HubSpotConfigAccount = { + name: 'test-account', accountId, - authType: 'personalaccesskey' as AuthType, + authType: 'personalaccesskey', personalAccessKey: 'let-me-in', env: ENVIRONMENTS.QA, + auth: { + tokenInfo: {}, + }, }; - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [account], }); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReturnValue(account); const freshAccessToken = 'fresh-token'; fetchAccessToken.mockResolvedValue( @@ -77,16 +97,20 @@ describe('lib/personalAccessKey', () => { }); it('uses accountId when refreshing token', async () => { const accountId = 123; - const account = { + const account: HubSpotConfigAccount = { accountId, - authType: 'personalaccesskey' as AuthType, + name: 'test-account', + authType: 'personalaccesskey', personalAccessKey: 'let-me-in-2', env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: {}, + }, }; - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [account], }); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReturnValue(account); await accessTokenForPersonalAccessKey(accountId); expect(fetchAccessToken).toHaveBeenCalledWith( @@ -97,9 +121,10 @@ describe('lib/personalAccessKey', () => { }); it('refreshes access token when the existing token is expired', async () => { const accountId = 123; - const account = { + const account: HubSpotConfigAccount = { + name: 'test-account', accountId, - authType: 'personalaccesskey' as AuthType, + authType: 'personalaccesskey', personalAccessKey: 'let-me-in-3', auth: { tokenInfo: { @@ -109,10 +134,10 @@ describe('lib/personalAccessKey', () => { }, env: ENVIRONMENTS.QA, }; - getAndLoadConfigIfNeeded.mockReturnValue({ + getConfig.mockReturnValue({ accounts: [account], }); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReturnValue(account); const freshAccessToken = 'fresh-token'; fetchAccessToken.mockResolvedValue( @@ -134,26 +159,32 @@ describe('lib/personalAccessKey', () => { const accountId = 123; const accessKey = 'let-me-in-4'; const userId = 456; - const mockAccount = (expiresAt: string, accessToken: string) => ({ - accountId, - authType: 'personalaccesskey' as AuthType, - personalAccessKey: accessKey, - auth: { - tokenInfo: { - expiresAt, - accessToken, + function mockAccount( + expiresAt: string, + accessToken: string + ): HubSpotConfigAccount { + return { + name: 'test-account', + accountId, + authType: 'personalaccesskey', + personalAccessKey: accessKey, + auth: { + tokenInfo: { + expiresAt, + accessToken, + }, }, - }, - env: ENVIRONMENTS.QA, - }); + env: ENVIRONMENTS.QA, + }; + } const initialAccountConfig = mockAccount( moment().subtract(2, 'hours').toISOString(), 'test-token' ); - getAndLoadConfigIfNeeded.mockReturnValueOnce({ + getConfig.mockReturnValueOnce({ accounts: [initialAccountConfig], }); - getAccountConfig.mockReturnValueOnce(initialAccountConfig); + getConfigAccountById.mockReturnValueOnce(initialAccountConfig); const firstAccessToken = 'fresh-token'; const expiresAtMillis = moment().subtract(1, 'hours').valueOf(); @@ -177,10 +208,10 @@ describe('lib/personalAccessKey', () => { moment(expiresAtMillis).toISOString(), firstAccessToken ); - getAndLoadConfigIfNeeded.mockReturnValueOnce({ + getConfig.mockReturnValueOnce({ accounts: [updatedAccountConfig], }); - getAccountConfig.mockReturnValueOnce(updatedAccountConfig); + getConfigAccountById.mockReturnValueOnce(updatedAccountConfig); const secondAccessToken = 'another-fresh-token'; fetchAccessToken.mockResolvedValue( @@ -204,6 +235,21 @@ describe('lib/personalAccessKey', () => { describe('updateConfigWithPersonalAccessKey()', () => { it('updates the config with the new account', async () => { + const existingAccount = { + accountId: 123, + name: 'account-name', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.QA, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + const freshAccessToken = 'fresh-token'; fetchAccessToken.mockResolvedValue( mockAxiosResponse({ @@ -227,7 +273,7 @@ describe('lib/personalAccessKey', () => { 'account-name' ); - expect(updateAccountConfig).toHaveBeenCalledWith( + expect(updateConfigAccount).toHaveBeenCalledWith( expect.objectContaining({ accountId: 123, accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, @@ -239,6 +285,21 @@ describe('lib/personalAccessKey', () => { }); it('updates the config with the new account for sandbox accounts', async () => { + const existingAccount = { + accountId: 123, + name: 'account-name', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.QA, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + fetchSandboxHubData.mockResolvedValue( mockAxiosResponse({ type: 'DEVELOPER', @@ -269,7 +330,7 @@ describe('lib/personalAccessKey', () => { 'account-name' ); - expect(updateAccountConfig).toHaveBeenCalledWith( + expect(updateConfigAccount).toHaveBeenCalledWith( expect.objectContaining({ accountId: 123, accountType: HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX, @@ -282,6 +343,21 @@ describe('lib/personalAccessKey', () => { }); it('updates the config with the new account for developer test accounts', async () => { + const existingAccount = { + accountId: 123, + name: 'Dev test portal', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.QA, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + fetchSandboxHubData.mockRejectedValue(new Error('Not a sandbox')); fetchDeveloperTestAccountData.mockResolvedValue( mockAxiosResponse({ @@ -317,7 +393,7 @@ describe('lib/personalAccessKey', () => { 'Dev test portal' ); - expect(updateAccountConfig).toHaveBeenCalledWith( + expect(updateConfigAccount).toHaveBeenCalledWith( expect.objectContaining({ accountId: 123, accountType: HUBSPOT_ACCOUNT_TYPES.DEVELOPER_TEST, @@ -328,5 +404,346 @@ describe('lib/personalAccessKey', () => { }) ); }); + + it('adds a new account when account does not exist', async () => { + getConfigAccountIfExists.mockReturnValue(undefined); + getConfigDefaultAccountIfExists.mockReturnValue(undefined); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-6', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + ENVIRONMENTS.QA, + 'new-account' + ); + + expect(addConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 123, + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + personalAccessKey: 'pak_123', + name: 'new-account', + authType: 'personalaccesskey', + }) + ); + expect(updateConfigAccount).not.toHaveBeenCalled(); + }); + + it('updates existing account when account exists', async () => { + const existingAccount = { + accountId: 123, + name: 'existing-account', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-7', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + ENVIRONMENTS.QA, + 'existing-account' + ); + + expect(updateConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 123, + personalAccessKey: 'pak_123', + name: 'existing-account', + }) + ); + expect(addConfigAccount).not.toHaveBeenCalled(); + }); + + it('sets account as default when makeDefault is true', async () => { + getConfigAccountIfExists.mockReturnValue(undefined); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-8', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + ENVIRONMENTS.QA, + 'default-account', + true + ); + + expect(setConfigAccountAsDefault).toHaveBeenCalledWith(123); + }); + + it('does not set account as default when makeDefault is false', async () => { + getConfigAccountIfExists.mockReturnValue(undefined); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-9', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + ENVIRONMENTS.QA, + 'not-default-account', + false + ); + + expect(setConfigAccountAsDefault).not.toHaveBeenCalled(); + }); + + it('defaults environment to PROD when not provided and no existing account', async () => { + getConfigAccountIfExists.mockReturnValue(undefined); + getConfigDefaultAccountIfExists.mockReturnValue(undefined); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-10', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', undefined, 123); + + await updateConfigWithAccessToken(token, 'pak_123', undefined, 'account'); + + expect(addConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + env: ENVIRONMENTS.PROD, + }) + ); + }); + + it('uses existing account environment when env not provided', async () => { + const existingAccount = { + accountId: 123, + name: 'existing-account', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.QA, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-11', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'test-hub', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.PROD, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + undefined, + 'existing-account' + ); + + expect(updateConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + env: ENVIRONMENTS.QA, + }) + ); + }); + + it('creates account with undefined name when name not provided and no existing account', async () => { + getConfigAccountIfExists.mockReturnValue(undefined); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-12', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'hub-from-token', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken(token, 'pak_123', ENVIRONMENTS.QA); + + expect(addConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 123, + name: undefined, + }) + ); + }); + + it('uses provided name when updating existing account found by portalId', async () => { + const existingAccount = { + accountId: 123, + name: 'old-name', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-13', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'hub-from-token', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken( + token, + 'pak_123', + ENVIRONMENTS.QA, + 'new-name' + ); + + expect(updateConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 123, + name: 'new-name', + }) + ); + }); + + it('uses existing account name when no name provided and account exists', async () => { + const existingAccount = { + accountId: 123, + name: 'existing-name', + authType: 'personalaccesskey' as const, + personalAccessKey: 'old-key', + env: ENVIRONMENTS.PROD, + auth: { + tokenInfo: { + accessToken: 'old-token', + expiresAt: moment().add(1, 'hours').toISOString(), + }, + }, + }; + getConfigAccountIfExists.mockReturnValue(existingAccount); + + const freshAccessToken = 'fresh-token'; + fetchAccessToken.mockResolvedValue( + mockAxiosResponse({ + oauthAccessToken: freshAccessToken, + expiresAtMillis: moment().add(1, 'hours').valueOf(), + encodedOAuthRefreshToken: 'let-me-in-13', + scopeGroups: ['content'], + hubId: 123, + userId: 456, + hubName: 'hub-from-token', + accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD, + }) + ); + + const token = await getAccessToken('pak_123', ENVIRONMENTS.QA, 123); + + await updateConfigWithAccessToken(token, 'pak_123', ENVIRONMENTS.QA); + + expect(updateConfigAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 123, + name: 'existing-name', + }) + ); + }); }); }); diff --git a/lib/__tests__/themes.test.ts b/lib/__tests__/themes.test.ts index 391d536c..e222c48a 100644 --- a/lib/__tests__/themes.test.ts +++ b/lib/__tests__/themes.test.ts @@ -1,7 +1,7 @@ import findup from 'findup-sync'; import { getHubSpotWebsiteOrigin } from '../urls'; import { getThemeJSONPath, getThemePreviewUrl } from '../cms/themes'; -import { getEnv } from '../../config'; +import { getConfigAccountEnvironment } from '../../config'; import { ENVIRONMENTS } from '../../constants/environments'; jest.mock('findup-sync'); @@ -15,7 +15,10 @@ jest.mock('../../constants/environments', () => ({ })); const mockedFindup = findup as jest.MockedFunction; -const mockedGetEnv = getEnv as jest.MockedFunction; +const mockedGetConfigAccountEnvironment = + getConfigAccountEnvironment as jest.MockedFunction< + typeof getConfigAccountEnvironment + >; const mockedGetHubSpotWebsiteOrigin = getHubSpotWebsiteOrigin as jest.MockedFunction< typeof getHubSpotWebsiteOrigin @@ -51,12 +54,12 @@ describe('lib/cms/themes', () => { describe('getThemePreviewUrl', () => { it('should return the correct theme preview URL for PROD environment', () => { mockedFindup.mockReturnValue('/src/my-theme/theme.json'); - mockedGetEnv.mockReturnValue('prod'); + mockedGetConfigAccountEnvironment.mockReturnValue('prod'); mockedGetHubSpotWebsiteOrigin.mockReturnValue('https://prod.hubspot.com'); const result = getThemePreviewUrl('/path/to/file', 12345); - expect(getEnv).toHaveBeenCalledWith(12345); + expect(getConfigAccountEnvironment).toHaveBeenCalledWith(12345); expect(getHubSpotWebsiteOrigin).toHaveBeenCalledWith(ENVIRONMENTS.PROD); expect(result).toBe( 'https://prod.hubspot.com/theme-previewer/12345/edit/my-theme' @@ -65,12 +68,12 @@ describe('lib/cms/themes', () => { it('should return the correct theme preview URL for QA environment', () => { mockedFindup.mockReturnValue('/src/my-theme/theme.json'); - mockedGetEnv.mockReturnValue('qa'); + mockedGetConfigAccountEnvironment.mockReturnValue('qa'); mockedGetHubSpotWebsiteOrigin.mockReturnValue('https://qa.hubspot.com'); const result = getThemePreviewUrl('/path/to/file', 12345); - expect(getEnv).toHaveBeenCalledWith(12345); + expect(getConfigAccountEnvironment).toHaveBeenCalledWith(12345); expect(getHubSpotWebsiteOrigin).toHaveBeenCalledWith(ENVIRONMENTS.QA); expect(result).toBe( 'https://qa.hubspot.com/theme-previewer/12345/edit/my-theme' diff --git a/lib/__tests__/trackUsage.test.ts b/lib/__tests__/trackUsage.test.ts index aecbca42..fcf31bdb 100644 --- a/lib/__tests__/trackUsage.test.ts +++ b/lib/__tests__/trackUsage.test.ts @@ -5,10 +5,10 @@ import { VSCODE_USAGE_PATH, } from '../trackUsage'; import { - getAccountConfig as __getAccountConfig, - getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded, + getConfigAccountById as __getConfigAccountById, + getConfig as __getConfig, } from '../../config'; -import { AuthType } from '../../types/Accounts'; +import { HubSpotConfigAccount } from '../../types/Accounts'; import { ENVIRONMENTS } from '../../constants/environments'; import { FILE_MAPPER_API_PATH } from '../../api/fileMapper'; import { logger } from '../logger'; @@ -22,20 +22,18 @@ jest.mock('../../http'); const mockedAxios = jest.mocked(axios); const mockedLogger = jest.mocked(logger); const mockedHttp = jest.mocked(http); -const getAccountConfig = __getAccountConfig as jest.MockedFunction< - typeof __getAccountConfig +const getConfigAccountById = __getConfigAccountById as jest.MockedFunction< + typeof __getConfigAccountById >; -const getAndLoadConfigIfNeeded = - __getAndLoadConfigIfNeeded as jest.MockedFunction< - typeof __getAndLoadConfigIfNeeded - >; +const getConfig = __getConfig as jest.MockedFunction; mockedAxios.mockResolvedValue({}); -getAndLoadConfigIfNeeded.mockReturnValue({}); +getConfig.mockReturnValue({ accounts: [] }); -const account = { +const account: HubSpotConfigAccount = { + name: 'test-account', accountId: 12345, - authType: 'personalaccesskey' as AuthType, + authType: 'personalaccesskey', personalAccessKey: 'let-me-in-3', auth: { tokenInfo: { @@ -54,8 +52,8 @@ const usageTrackingMeta = { describe('lib/trackUsage', () => { describe('trackUsage()', () => { beforeEach(() => { - getAccountConfig.mockReset(); - getAccountConfig.mockReturnValue(account); + getConfigAccountById.mockReset(); + getConfigAccountById.mockReturnValue(account); mockedAxios.mockReset(); mockedLogger.debug.mockReset(); mockedHttp.post.mockReset(); @@ -70,7 +68,7 @@ describe('lib/trackUsage', () => { expect(mockedAxios).toHaveBeenCalled(); expect(requestArgs!.data.eventName).toEqual('test-action'); expect(requestArgs!.url.includes('authenticated')).toBeFalsy(); - expect(getAccountConfig).not.toHaveBeenCalled(); + expect(getConfigAccountById).not.toHaveBeenCalled(); }); it('tracks correctly for authenticated accounts', async () => { @@ -86,7 +84,7 @@ describe('lib/trackUsage', () => { }, resolveWithFullResponse: true, }); - expect(getAccountConfig).toHaveBeenCalled(); + expect(getConfigAccountById).toHaveBeenCalled(); }); describe('eventName routing - unauthenticated requests', () => { @@ -159,7 +157,7 @@ describe('lib/trackUsage', () => { }, resolveWithFullResponse: true, }); - expect(getAccountConfig).toHaveBeenCalledWith(12345); + expect(getConfigAccountById).toHaveBeenCalledWith(12345); expect(mockedLogger.debug).not.toHaveBeenCalledWith( expect.stringContaining('invalidEvent') ); @@ -183,7 +181,7 @@ describe('lib/trackUsage', () => { }, resolveWithFullResponse: true, }); - expect(getAccountConfig).toHaveBeenCalledWith(12345); + expect(getConfigAccountById).toHaveBeenCalledWith(12345); expect(mockedLogger.debug).not.toHaveBeenCalledWith( expect.stringContaining('invalidEvent') ); @@ -208,7 +206,7 @@ describe('lib/trackUsage', () => { }, resolveWithFullResponse: true, }); - expect(getAccountConfig).toHaveBeenCalledWith(12345); + expect(getConfigAccountById).toHaveBeenCalledWith(12345); expect(mockedLogger.debug).toHaveBeenCalledWith( `Usage tracking event ${unknownEventName} is not a valid event type.` ); diff --git a/lib/cms/themes.ts b/lib/cms/themes.ts index 9f03b479..3ecf08f1 100644 --- a/lib/cms/themes.ts +++ b/lib/cms/themes.ts @@ -1,7 +1,7 @@ import findup from 'findup-sync'; import { getHubSpotWebsiteOrigin } from '../urls'; import { ENVIRONMENTS } from '../../constants/environments'; -import { getEnv } from '../../config'; +import { getConfigAccountEnvironment } from '../../config'; export function getThemeJSONPath(path: string): string | null { return findup('theme.json', { @@ -26,7 +26,9 @@ export function getThemePreviewUrl( if (!themeName) return; const baseUrl = getHubSpotWebsiteOrigin( - getEnv(accountId) === 'qa' ? ENVIRONMENTS.QA : ENVIRONMENTS.PROD + getConfigAccountEnvironment(accountId) === 'qa' + ? ENVIRONMENTS.QA + : ENVIRONMENTS.PROD ); return `${baseUrl}/theme-previewer/${accountId}/edit/${encodeURIComponent( diff --git a/lib/environment.ts b/lib/environment.ts index 6654d944..b3b36f06 100644 --- a/lib/environment.ts +++ b/lib/environment.ts @@ -2,7 +2,7 @@ import { ENVIRONMENTS } from '../constants/environments'; import { Environment } from '../types/Config'; export function getValidEnv( - env?: Environment | null, + env?: string | null, maskedProductionValue?: Environment ): Environment { const prodValue = diff --git a/lib/oauth.ts b/lib/oauth.ts index f4ea4881..00823c76 100644 --- a/lib/oauth.ts +++ b/lib/oauth.ts @@ -1,47 +1,33 @@ import { OAuth2Manager } from '../models/OAuth2Manager'; -import { AUTH_METHODS } from '../constants/auth'; -import { FlatAccountFields } from '../types/Accounts'; +import { OAuthConfigAccount } from '../types/Accounts'; import { logger } from './logger'; -import { getAccountIdentifier } from '../config/getAccountIdentifier'; -import { updateAccountConfig, writeConfig } from '../config'; +import { updateConfigAccount } from '../config'; import { i18n } from '../utils/lang'; const i18nKey = 'lib.oauth'; const oauthManagers = new Map(); -function writeOauthTokenInfo(accountConfig: FlatAccountFields): void { - const accountId = getAccountIdentifier(accountConfig); - +function writeOauthTokenInfo(account: OAuthConfigAccount): void { logger.debug( - i18n(`${i18nKey}.writeTokenInfo`, { portalId: accountId || '' }) + i18n(`${i18nKey}.writeTokenInfo`, { portalId: account.accountId }) ); - updateAccountConfig(accountConfig); - writeConfig(); + updateConfigAccount(account); } -export function getOauthManager( - accountId: number, - accountConfig: FlatAccountFields -) { - if (!oauthManagers.has(accountId)) { +export function getOauthManager(account: OAuthConfigAccount) { + if (!oauthManagers.has(account.accountId)) { oauthManagers.set( - accountId, - OAuth2Manager.fromConfig(accountConfig, () => - writeOauthTokenInfo(accountConfig) - ) + account.accountId, + new OAuth2Manager(account, () => writeOauthTokenInfo(account)) ); } - return oauthManagers.get(accountId); + return oauthManagers.get(account.accountId); } export function addOauthToAccountConfig(oauth: OAuth2Manager) { logger.log(i18n(`${i18nKey}.addOauthToAccountConfig.init`)); - updateAccountConfig({ - ...oauth.account, - authType: AUTH_METHODS.oauth.value, - }); - writeConfig(); + updateConfigAccount(oauth.account); logger.success(i18n(`${i18nKey}.addOauthToAccountConfig.success`)); } diff --git a/lib/personalAccessKey.ts b/lib/personalAccessKey.ts index c982a6e2..59fb8700 100644 --- a/lib/personalAccessKey.ts +++ b/lib/personalAccessKey.ts @@ -7,22 +7,20 @@ import { } from '../api/localDevAuth'; import { fetchSandboxHubData } from '../api/sandboxHubs'; import { - CLIAccount, - PersonalAccessKeyAccount, + PersonalAccessKeyConfigAccount, ScopeGroupAuthorization, } from '../types/Accounts'; import { Environment } from '../types/Config'; import { - getAccountConfig, - updateAccountConfig, - writeConfig, - getEnv, - updateDefaultAccount, + getConfigAccountById, + getConfigAccountIfExists, + updateConfigAccount, + addConfigAccount, + setConfigAccountAsDefault, } from '../config'; import { HUBSPOT_ACCOUNT_TYPES } from '../constants/config'; import { fetchDeveloperTestAccountData } from '../api/developerTestAccounts'; import { logger } from './logger'; -import { CLIConfiguration } from '../config/CLIConfiguration'; import { i18n } from '../utils/lang'; import { isHubSpotHttpError } from '../errors'; import { AccessToken } from '../types/Accounts'; @@ -60,49 +58,40 @@ export async function getAccessToken( } async function refreshAccessToken( - personalAccessKey: string, - env: Environment = ENVIRONMENTS.PROD, - accountId: number + account: PersonalAccessKeyConfigAccount ): Promise { + const { personalAccessKey, env, accountId } = account; const accessTokenResponse = await getAccessToken( personalAccessKey, env, accountId ); const { accessToken, expiresAt } = accessTokenResponse; - const config = getAccountConfig(accountId); - updateAccountConfig({ - env, - ...config, - accountId, - tokenInfo: { - accessToken, - expiresAt: expiresAt, + updateConfigAccount({ + ...account, + auth: { + tokenInfo: { + accessToken, + expiresAt: expiresAt, + }, }, }); - writeConfig(); return accessTokenResponse; } async function getNewAccessToken( - accountId: number, - personalAccessKey: string, - expiresAt: string | undefined, - env: Environment + account: PersonalAccessKeyConfigAccount ): Promise { - const key = getRefreshKey(personalAccessKey, expiresAt); + const { personalAccessKey, auth } = account; + const key = getRefreshKey(personalAccessKey, auth.tokenInfo.expiresAt); if (refreshRequests.has(key)) { return refreshRequests.get(key); } let accessTokenResponse: AccessToken; try { - const refreshAccessPromise = refreshAccessToken( - personalAccessKey, - env, - accountId - ); + const refreshAccessPromise = refreshAccessToken(account); if (key) { refreshRequests.set(key, refreshAccessPromise); } @@ -119,18 +108,19 @@ async function getNewAccessToken( async function getNewAccessTokenByAccountId( accountId: number ): Promise { - const account = getAccountConfig(accountId) as PersonalAccessKeyAccount; + const account = getConfigAccountById(accountId); if (!account) { throw new Error(i18n(`${i18nKey}.errors.accountNotFound`, { accountId })); } - const { auth, personalAccessKey, env } = account; + if (account.authType !== PERSONAL_ACCESS_KEY_AUTH_METHOD.value) { + throw new Error( + i18n(`${i18nKey}.errors.invalidAuthType`, { + accountId, + }) + ); + } - const accessTokenResponse = await getNewAccessToken( - accountId, - personalAccessKey, - auth?.tokenInfo?.expiresAt, - env - ); + const accessTokenResponse = await getNewAccessToken(account); return accessTokenResponse; } @@ -138,11 +128,19 @@ export async function accessTokenForPersonalAccessKey( accountId: number, forceRefresh = false ): Promise { - const account = getAccountConfig(accountId) as PersonalAccessKeyAccount; + const account = getConfigAccountById(accountId); if (!account) { throw new Error(i18n(`${i18nKey}.errors.accountNotFound`, { accountId })); } - const { auth, personalAccessKey, env } = account; + if (account.authType !== PERSONAL_ACCESS_KEY_AUTH_METHOD.value) { + throw new Error( + i18n(`${i18nKey}.errors.invalidAuthType`, { + accountId, + }) + ); + } + + const { auth } = account; const authTokenInfo = auth && auth.tokenInfo; const authDataExists = authTokenInfo && auth?.tokenInfo?.accessToken; @@ -151,15 +149,10 @@ export async function accessTokenForPersonalAccessKey( forceRefresh || moment().add(5, 'minutes').isAfter(moment(authTokenInfo.expiresAt)) ) { - return getNewAccessToken( - accountId, - personalAccessKey, - authTokenInfo && authTokenInfo.expiresAt, - env - ).then(tokenInfo => tokenInfo.accessToken); + return getNewAccessToken(account).then(tokenInfo => tokenInfo.accessToken); } - return auth?.tokenInfo?.accessToken; + return auth.tokenInfo?.accessToken; } export async function enabledFeaturesForPersonalAccessKey( @@ -187,9 +180,10 @@ export async function updateConfigWithAccessToken( env?: Environment, name?: string, makeDefault = false -): Promise { +): Promise { const { portalId, accessToken, expiresAt, accountType } = token; - const accountEnv = env || getEnv(name); + const account = getConfigAccountIfExists(portalId); + const accountEnv = env || account?.env || ENVIRONMENTS.PROD; let parentAccountId; try { @@ -230,22 +224,26 @@ export async function updateConfigWithAccessToken( logger.debug(err); } - const updatedAccount = updateAccountConfig({ + const updatedAccount = { accountId: portalId, accountType, personalAccessKey, - name, + name: name || account?.name, authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value, - tokenInfo: { accessToken, expiresAt }, + auth: { tokenInfo: { accessToken, expiresAt } }, parentAccountId, env: accountEnv, - }); - if (!CLIConfiguration.isActive()) { - writeConfig(); + } as PersonalAccessKeyConfigAccount; // Account may temporarily not have a name before prompted to add one in the CLI + + // Add new account if it doesn't exist, otherwise update existing account + if (account) { + updateConfigAccount(updatedAccount); + } else { + addConfigAccount(updatedAccount); } - if (makeDefault && name) { - updateDefaultAccount(name); + if (makeDefault) { + setConfigAccountAsDefault(updatedAccount.accountId); } return updatedAccount; diff --git a/lib/trackUsage.ts b/lib/trackUsage.ts index a626dcc2..facc90f0 100644 --- a/lib/trackUsage.ts +++ b/lib/trackUsage.ts @@ -2,9 +2,10 @@ import axios from 'axios'; import { getAxiosConfig } from '../http/getAxiosConfig'; import { logger } from './logger'; import { http } from '../http'; -import { getAccountConfig, getEnv } from '../config'; +import { getConfigAccountById, getConfigAccountEnvironment } from '../config'; import { FILE_MAPPER_API_PATH } from '../api/fileMapper'; import { i18n } from '../utils/lang'; +import { getValidEnv } from './environment'; const i18nKey = 'lib.trackUsage'; export const CMS_CLI_USAGE_PATH = `${FILE_MAPPER_API_PATH}/cms-cli-usage`; @@ -40,9 +41,9 @@ export async function trackUsage( logger.debug(i18n(`${i18nKey}.invalidEvent`, { eventName })); } - const accountConfig = accountId && getAccountConfig(accountId); + const account = accountId && getConfigAccountById(accountId); - if (accountConfig && accountConfig.authType === 'personalaccesskey') { + if (account && account.authType === 'personalaccesskey') { logger.debug(i18n(`${i18nKey}.sendingEventAuthenticated`)); try { await http.post(accountId, { @@ -56,7 +57,9 @@ export async function trackUsage( } } - const env = getEnv(accountId); + const env = accountId + ? getConfigAccountEnvironment(accountId) + : getValidEnv(); const axiosConfig = getAxiosConfig({ env, url: path, diff --git a/models/HubSpotConfigError.ts b/models/HubSpotConfigError.ts new file mode 100644 index 00000000..9b6cf8ac --- /dev/null +++ b/models/HubSpotConfigError.ts @@ -0,0 +1,50 @@ +import { + HUBSPOT_CONFIG_ERROR_TYPES, + HUBSPOT_CONFIG_OPERATIONS, +} from '../constants/config'; +import { + HubSpotConfigErrorType, + HubSpotConfigOperation, +} from '../types/Config'; +import { i18n } from '../utils/lang'; + +const NAME = 'HubSpotConfigError'; + +const OPERATION_TEXT = { + [HUBSPOT_CONFIG_OPERATIONS.READ]: 'reading', + [HUBSPOT_CONFIG_OPERATIONS.WRITE]: 'writing to', + [HUBSPOT_CONFIG_OPERATIONS.DELETE]: 'deleting', +}; + +function isEnvironmentError(type: HubSpotConfigErrorType): boolean { + return type === HUBSPOT_CONFIG_ERROR_TYPES.INVALID_ENVIRONMENT_VARIABLES; +} + +export class HubSpotConfigError extends Error { + public type: HubSpotConfigErrorType; + public operation: HubSpotConfigOperation; + + constructor( + message: string | undefined, + type: HubSpotConfigErrorType, + operation: HubSpotConfigOperation, + options?: ErrorOptions + ) { + const configType = isEnvironmentError(type) + ? 'environment variables' + : 'file'; + + const operationText = OPERATION_TEXT[operation]; + + const withBaseMessage = i18n('models.HubSpotConfigError.baseMessage', { + configType, + message: message ? `: ${message}` : '', + operation: operationText, + }); + + super(withBaseMessage, options); + this.name = NAME; + this.type = type; + this.operation = operation; + } +} diff --git a/models/OAuth2Manager.ts b/models/OAuth2Manager.ts index c32c6d17..78793c65 100644 --- a/models/OAuth2Manager.ts +++ b/models/OAuth2Manager.ts @@ -4,26 +4,23 @@ import moment from 'moment'; import { getHubSpotApiOrigin } from '../lib/urls'; import { getValidEnv } from '../lib/environment'; import { - FlatAccountFields, - OAuth2ManagerAccountConfig, + OAuthConfigAccount, WriteTokenInfoFunction, RefreshTokenResponse, ExchangeProof, } from '../types/Accounts'; import { logger } from '../lib/logger'; -import { getAccountIdentifier } from '../config/getAccountIdentifier'; -import { AUTH_METHODS } from '../constants/auth'; import { i18n } from '../utils/lang'; const i18nKey = 'models.OAuth2Manager'; export class OAuth2Manager { - account: OAuth2ManagerAccountConfig; + account: OAuthConfigAccount; writeTokenInfo?: WriteTokenInfoFunction; refreshTokenRequest: Promise | null; constructor( - account: OAuth2ManagerAccountConfig, + account: OAuthConfigAccount, writeTokenInfo?: WriteTokenInfoFunction ) { this.writeTokenInfo = writeTokenInfo; @@ -36,30 +33,30 @@ export class OAuth2Manager { } async accessToken(): Promise { - if (!this.account.tokenInfo?.refreshToken) { + if (!this.account.auth.tokenInfo.refreshToken) { throw new Error( i18n(`${i18nKey}.errors.missingRefreshToken`, { - accountId: getAccountIdentifier(this.account)!, + accountId: this.account.accountId, }) ); } if ( - !this.account.tokenInfo?.accessToken || + !this.account.auth.tokenInfo.accessToken || moment() .add(5, 'minutes') - .isAfter(moment(new Date(this.account.tokenInfo.expiresAt || ''))) + .isAfter(moment(new Date(this.account.auth.tokenInfo.expiresAt || ''))) ) { await this.refreshAccessToken(); } - return this.account.tokenInfo.accessToken; + return this.account.auth.tokenInfo.accessToken; } async fetchAccessToken(exchangeProof: ExchangeProof): Promise { logger.debug( i18n(`${i18nKey}.fetchingAccessToken`, { - accountId: getAccountIdentifier(this.account)!, - clientId: this.account.clientId || '', + accountId: this.account.accountId, + clientId: this.account.auth.clientId, }) ); @@ -79,22 +76,22 @@ export class OAuth2Manager { access_token: accessToken, expires_in: expiresIn, } = data; - if (!this.account.tokenInfo) { - this.account.tokenInfo = {}; + if (!this.account.auth.tokenInfo) { + this.account.auth.tokenInfo = {}; } - this.account.tokenInfo.refreshToken = refreshToken; - this.account.tokenInfo.accessToken = accessToken; - this.account.tokenInfo.expiresAt = moment() + this.account.auth.tokenInfo.refreshToken = refreshToken; + this.account.auth.tokenInfo.accessToken = accessToken; + this.account.auth.tokenInfo.expiresAt = moment() .add(Math.round(parseInt(expiresIn) * 0.75), 'seconds') .toString(); if (this.writeTokenInfo) { logger.debug( i18n(`${i18nKey}.updatingTokenInfo`, { - accountId: getAccountIdentifier(this.account)!, - clientId: this.account.clientId || '', + accountId: this.account.accountId, + clientId: this.account.auth.clientId, }) ); - this.writeTokenInfo(this.account.tokenInfo); + this.writeTokenInfo(this.account.auth.tokenInfo); } } finally { this.refreshTokenRequest = null; @@ -105,8 +102,8 @@ export class OAuth2Manager { if (this.refreshTokenRequest) { logger.debug( i18n(`${i18nKey}.refreshingAccessToken`, { - accountId: getAccountIdentifier(this.account)!, - clientId: this.account.clientId || '', + accountId: this.account.accountId, + clientId: this.account.auth.clientId, }) ); await this.refreshTokenRequest; @@ -118,24 +115,10 @@ export class OAuth2Manager { async refreshAccessToken(): Promise { const refreshTokenProof = { grant_type: 'refresh_token', - client_id: this.account.clientId, - client_secret: this.account.clientSecret, - refresh_token: this.account.tokenInfo?.refreshToken, + client_id: this.account.auth.clientId, + client_secret: this.account.auth.clientSecret, + refresh_token: this.account.auth.tokenInfo.refreshToken, }; await this.exchangeForTokens(refreshTokenProof); } - - static fromConfig( - accountConfig: FlatAccountFields, - writeTokenInfo: WriteTokenInfoFunction - ) { - return new OAuth2Manager( - { - ...accountConfig, - authType: AUTH_METHODS.oauth.value, - ...(accountConfig.auth || {}), - }, - writeTokenInfo - ); - } } diff --git a/models/__tests__/OAuth2Manager.test.ts b/models/__tests__/OAuth2Manager.test.ts index 7e6f5e08..8d539fa8 100644 --- a/models/__tests__/OAuth2Manager.test.ts +++ b/models/__tests__/OAuth2Manager.test.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import moment from 'moment'; import { OAuth2Manager } from '../OAuth2Manager'; import { ENVIRONMENTS } from '../../constants/environments'; +import { HubSpotConfigAccount } from '../../types/Accounts'; jest.mock('axios'); @@ -19,10 +20,11 @@ const axiosSpy = axiosMock.mockResolvedValue({ const initialRefreshToken = '84d22710-4cb7-5581-ba05-35f9945e5e8e'; -const oauthAccount = { +const oauthAccount: HubSpotConfigAccount = { + name: 'my-account', accountId: 123, env: ENVIRONMENTS.PROD, - authType: 'oauth2' as const, + authType: 'oauth2', auth: { clientId: 'd996372f-2b53-30d3-9c3b-4fdde4bce3a2', clientSecret: 'f90a6248-fbc0-3b03-b0db-ec58c95e791', @@ -46,10 +48,7 @@ describe('models/Oauth2Manager', () => { describe('fromConfig()', () => { it('initializes an oauth manager instance', async () => { - const oauthManager = OAuth2Manager.fromConfig( - oauthAccount, - () => undefined - ); + const oauthManager = new OAuth2Manager(oauthAccount, () => undefined); expect(oauthManager.refreshTokenRequest).toBe(null); expect(oauthManager.account).toMatchObject(oauthAccount); @@ -58,10 +57,7 @@ describe('models/Oauth2Manager', () => { describe('refreshAccessToken()', () => { it('refreshes the oauth access token', async () => { - const oauthManager = OAuth2Manager.fromConfig( - oauthAccount, - () => undefined - ); + const oauthManager = new OAuth2Manager(oauthAccount, () => undefined); await oauthManager.refreshAccessToken(); @@ -76,10 +72,10 @@ describe('models/Oauth2Manager', () => { method: 'post', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); - expect(oauthManager.account.tokenInfo?.refreshToken).toBe( + expect(oauthManager.account.auth.tokenInfo?.refreshToken).toBe( mockRefreshTokenResponse.refresh_token ); - expect(oauthManager.account.tokenInfo?.accessToken).toBe( + expect(oauthManager.account.auth.tokenInfo?.accessToken).toBe( mockRefreshTokenResponse.access_token ); }); diff --git a/package.json b/package.json index 9374235d..0f8db77f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "./errors/*": "./errors/*.js", "./http": "./http/index.js", "./http/*": "./http/*.js", - "./config/getAccountIdentifier": "./config/getAccountIdentifier.js", + "./config/defaultAccountOverride": "./config/defaultAccountOverride.js", "./config/migrate": "./config/migrate.js", "./config/state": "./config/state.js", "./config": "./config/index.js", diff --git a/types/Accounts.ts b/types/Accounts.ts index 4765eec4..9cf16c78 100644 --- a/types/Accounts.ts +++ b/types/Accounts.ts @@ -2,50 +2,26 @@ import { HUBSPOT_ACCOUNT_TYPES } from '../constants/config'; import { CmsPublishMode } from './Files'; import { Environment } from './Config'; import { ValueOf } from './Utils'; - +import { + PERSONAL_ACCESS_KEY_AUTH_METHOD, + OAUTH_AUTH_METHOD, + API_KEY_AUTH_METHOD, +} from '../constants/auth'; export type AuthType = 'personalaccesskey' | 'apikey' | 'oauth2'; -export interface CLIAccount_NEW { - name?: string; +interface BaseHubSpotConfigAccount { + name: string; accountId: number; accountType?: AccountType; defaultCmsPublishMode?: CmsPublishMode; env: Environment; - authType?: AuthType; - auth?: { - tokenInfo?: TokenInfo; - clientId?: string; - clientSecret?: string; - }; - sandboxAccountType?: string | null; - parentAccountId?: number | null; - apiKey?: string; - personalAccessKey?: string; + authType: AuthType; + parentAccountId?: number; } -export interface CLIAccount_DEPRECATED { - name?: string; - portalId?: number; - defaultCmsPublishMode?: CmsPublishMode; - env: Environment; - accountType?: AccountType; - authType?: AuthType; - auth?: { - tokenInfo?: TokenInfo; - clientId?: string; - clientSecret?: string; - }; - sandboxAccountType?: string | null; - parentAccountId?: number | null; - apiKey?: string; - personalAccessKey?: string; -} - -export type CLIAccount = CLIAccount_NEW | CLIAccount_DEPRECATED; - -export type GenericAccount = { +export type DeprecatedHubSpotConfigAccountFields = { portalId?: number; - accountId?: number; + sandboxAccountType?: string; }; export type AccountType = ValueOf; @@ -56,76 +32,34 @@ export type TokenInfo = { refreshToken?: string; }; -export interface PersonalAccessKeyAccount_NEW extends CLIAccount_NEW { - authType: 'personalaccesskey'; +export interface PersonalAccessKeyConfigAccount + extends BaseHubSpotConfigAccount { + authType: typeof PERSONAL_ACCESS_KEY_AUTH_METHOD.value; personalAccessKey: string; -} - -export interface PersonalAccessKeyAccount_DEPRECATED - extends CLIAccount_DEPRECATED { - authType: 'personalaccesskey'; - personalAccessKey: string; -} - -export type PersonalAccessKeyAccount = - | PersonalAccessKeyAccount_NEW - | PersonalAccessKeyAccount_DEPRECATED; - -export interface OAuthAccount_NEW extends CLIAccount_NEW { - authType: 'oauth2'; auth: { - clientId?: string; - clientSecret?: string; - scopes?: Array; - tokenInfo?: TokenInfo; + tokenInfo: TokenInfo; }; } -export interface OAuthAccount_DEPRECATED extends CLIAccount_DEPRECATED { - authType: 'oauth2'; +export interface OAuthConfigAccount extends BaseHubSpotConfigAccount { + authType: typeof OAUTH_AUTH_METHOD.value; auth: { - clientId?: string; - clientSecret?: string; - scopes?: Array; - tokenInfo?: TokenInfo; + clientId: string; + clientSecret: string; + scopes: Array; + tokenInfo: TokenInfo; }; } -export type OAuthAccount = OAuthAccount_NEW | OAuthAccount_DEPRECATED; - -export interface APIKeyAccount_NEW extends CLIAccount_NEW { - authType: 'apikey'; +export interface APIKeyConfigAccount extends BaseHubSpotConfigAccount { + authType: typeof API_KEY_AUTH_METHOD.value; apiKey: string; } -export interface APIKeyAccount_DEPRECATED extends CLIAccount_DEPRECATED { - authType: 'apikey'; - apiKey: string; -} - -export type APIKeyAccount = APIKeyAccount_NEW | APIKeyAccount_DEPRECATED; - -export interface FlatAccountFields_NEW extends CLIAccount_NEW { - tokenInfo?: TokenInfo; - clientId?: string; - clientSecret?: string; - scopes?: Array; - apiKey?: string; - personalAccessKey?: string; -} - -export interface FlatAccountFields_DEPRECATED extends CLIAccount_DEPRECATED { - tokenInfo?: TokenInfo; - clientId?: string; - clientSecret?: string; - scopes?: Array; - apiKey?: string; - personalAccessKey?: string; -} - -export type FlatAccountFields = - | FlatAccountFields_NEW - | FlatAccountFields_DEPRECATED; +export type HubSpotConfigAccount = + | PersonalAccessKeyConfigAccount + | OAuthConfigAccount + | APIKeyConfigAccount; export type ScopeData = { portalScopesInGroup: Array; @@ -164,32 +98,6 @@ export type EnabledFeaturesResponse = { enabledFeatures: { [key: string]: boolean }; }; -export type UpdateAccountConfigOptions = - Partial & { - environment?: Environment; - }; - -export type PersonalAccessKeyOptions = { - accountId: number; - personalAccessKey: string; - env: Environment; -}; - -export type OAuthOptions = { - accountId: number; - clientId: string; - clientSecret: string; - refreshToken: string; - scopes: Array; - env: Environment; -}; - -export type APIKeyOptions = { - accountId: number; - apiKey: string; - env: Environment; -}; - export type AccessToken = { portalId: number; accessToken: string; @@ -201,18 +109,6 @@ export type AccessToken = { accountType: ValueOf; }; -export type OAuth2ManagerAccountConfig = { - name?: string; - accountId?: number; - clientId?: string; - clientSecret?: string; - scopes?: Array; - env?: Environment; - environment?: Environment; - tokenInfo?: TokenInfo; - authType?: 'oauth2'; -}; - export type WriteTokenInfoFunction = (tokenInfo: TokenInfo) => void; export type RefreshTokenResponse = { diff --git a/types/Config.ts b/types/Config.ts index 91e5fb54..d0e64681 100644 --- a/types/Config.ts +++ b/types/Config.ts @@ -1,56 +1,56 @@ +import { + CONFIG_FLAGS, + HUBSPOT_CONFIG_ERROR_TYPES, + HUBSPOT_CONFIG_OPERATIONS, +} from '../constants/config'; import { ENVIRONMENTS } from '../constants/environments'; -import { CLIAccount_NEW, CLIAccount_DEPRECATED } from './Accounts'; +import { + DeprecatedHubSpotConfigAccountFields, + HubSpotConfigAccount, +} from './Accounts'; import { CmsPublishMode } from './Files'; import { ValueOf } from './Utils'; -export interface CLIConfig_NEW { - accounts?: Array; +export interface HubSpotConfig { + accounts: Array; allowUsageTracking?: boolean; allowAutoUpdates?: boolean; - defaultAccount?: string | number; + defaultAccount?: number; defaultMode?: CmsPublishMode; // Deprecated - left in to handle existing configs with this field defaultCmsPublishMode?: CmsPublishMode; httpTimeout?: number; env?: Environment; httpUseLocalhost?: boolean; autoOpenBrowser?: boolean; + useCustomObjectHubfile?: boolean; flags?: Array; } -export interface CLIConfig_DEPRECATED { - portals: Array; - allowUsageTracking?: boolean; - allowAutoUpdates?: boolean; - defaultPortal?: string | number; - defaultMode?: CmsPublishMode; // Deprecated - left in to handle existing configs with this field - defaultCmsPublishMode?: CmsPublishMode; - httpTimeout?: number; - env?: Environment; - httpUseLocalhost?: boolean; - autoOpenBrowser?: boolean; - flags?: Array; -} - -export type CLIConfig = CLIConfig_NEW | CLIConfig_DEPRECATED; +export type DeprecatedHubSpotConfigFields = { + portals?: Array; + defaultPortal?: string; + defaultMode?: CmsPublishMode; +}; export type Environment = ValueOf | ''; -export type EnvironmentConfigVariables = { - apiKey?: string; - clientId?: string; - clientSecret?: string; - personalAccessKey?: string; - accountId?: number; - refreshToken?: string; - env?: Environment; -}; - export type GitInclusionResult = { inGit: boolean; configIgnored: boolean; gitignoreFiles: Array; }; +export type ConfigFlag = ValueOf; + export type HubSpotState = { mcpTotalToolCalls: number; }; + +export type HubSpotConfigErrorType = ValueOf; + +export type HubSpotConfigOperation = ValueOf; + +export type HubSpotConfigValidationResult = { + isValid: boolean; + errors: Array; +}; diff --git a/utils/accounts.ts b/utils/accounts.ts deleted file mode 100644 index a3fa8a84..00000000 --- a/utils/accounts.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { CLIAccount } from '../types/Accounts'; -import { - CLIConfig, - CLIConfig_DEPRECATED, - CLIConfig_NEW, -} from '../types/Config'; - -export function getAccounts(config?: CLIConfig | null): Array { - if (!config) { - return []; - } else if ('portals' in config && Array.isArray(config.portals)) { - return config.portals; - } else if ('accounts' in config && Array.isArray(config.accounts)) { - return config.accounts; - } - return []; -} - -export function getDefaultAccount( - config?: CLIConfig | null -): string | number | undefined { - if (!config) { - return undefined; - } else if (Object.hasOwn(config, 'defaultPortal')) { - return (config as CLIConfig_DEPRECATED).defaultPortal; - } else if (Object.hasOwn(config, 'defaultAccount')) { - return (config as CLIConfig_NEW).defaultAccount; - } -}