@hubspot/cli 7.6.2-beta.0 → 7.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/commands/config/set.d.ts +1 -1
  2. package/commands/config/set.js +65 -33
  3. package/commands/init.js +0 -1
  4. package/commands/project/__tests__/validate.test.d.ts +1 -0
  5. package/commands/project/__tests__/validate.test.js +98 -0
  6. package/commands/project/validate.js +4 -4
  7. package/commands/testAccount/__tests__/delete.test.js +2 -4
  8. package/commands/testAccount/delete.d.ts +4 -3
  9. package/commands/testAccount/delete.js +155 -14
  10. package/lang/en.d.ts +37 -8
  11. package/lang/en.js +49 -20
  12. package/lib/__tests__/yargsUtils.test.js +83 -9
  13. package/lib/configOptions.js +7 -0
  14. package/lib/constants.d.ts +6 -0
  15. package/lib/constants.js +10 -0
  16. package/lib/doctor/DiagnosticInfoBuilder.js +7 -6
  17. package/lib/projects/__tests__/AppDevModeInterface.test.js +2 -0
  18. package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +64 -33
  19. package/lib/projects/__tests__/localDevProjectHelpers.test.js +2 -0
  20. package/lib/projects/__tests__/upload.test.d.ts +1 -0
  21. package/lib/projects/__tests__/upload.test.js +82 -0
  22. package/lib/projects/localDev/AppDevModeInterface.d.ts +2 -0
  23. package/lib/projects/localDev/AppDevModeInterface.js +17 -8
  24. package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +0 -1
  25. package/lib/projects/localDev/LocalDevWebsocketServer.js +4 -7
  26. package/lib/projects/structure.js +4 -4
  27. package/lib/projects/upload.d.ts +1 -1
  28. package/lib/projects/upload.js +15 -6
  29. package/lib/prompts/createDeveloperTestAccountConfigPrompt.js +10 -1
  30. package/lib/prompts/promptUtils.js +8 -5
  31. package/lib/yargsUtils.d.ts +1 -1
  32. package/lib/yargsUtils.js +12 -5
  33. package/package.json +2 -2
package/lang/en.js CHANGED
@@ -6,7 +6,7 @@ import { ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME, GLOBAL_CONFIG_PATH, DEFAULT_HUB
6
6
  import { uiAccountDescription, uiBetaTag, uiCommandReference, uiLink, UI_COLORS, } from '../lib/ui/index.js';
7
7
  import { getProjectDetailUrl, getProjectSettingsUrl, getLocalDevUiUrl, getAppAllowlistUrl, } from '../lib/projects/urls.js';
8
8
  import { getProductUpdatesUrl } from '../lib/links.js';
9
- import { APP_DISTRIBUTION_TYPES, APP_AUTH_TYPES, PROJECT_CONFIG_FILE, PROJECT_WITH_APP, } from '../lib/constants.js';
9
+ import { APP_DISTRIBUTION_TYPES, APP_AUTH_TYPES, PROJECT_CONFIG_FILE, PROJECT_WITH_APP, LEGACY_PUBLIC_APP_FILE, } from '../lib/constants.js';
10
10
  import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountIdentifier';
11
11
  export const commands = {
12
12
  generalErrors: {
@@ -262,6 +262,10 @@ export const commands = {
262
262
  describe: 'Enable or disable automatic opening of the browser',
263
263
  },
264
264
  },
265
+ errors: {
266
+ invalidBoolean: (commandName, value) => `Invalid boolean value "${value}" for --${commandName}. Valid values are: true, false`,
267
+ invalidHTTPTimeout: `Invalid HTTP timeout value. Must be a number greater than 3000.`,
268
+ },
265
269
  },
266
270
  },
267
271
  },
@@ -1117,15 +1121,15 @@ export const commands = {
1117
1121
  },
1118
1122
  deprecationWarning: (oldCommand, newCommand) => `The ${oldCommand} command is deprecated and will be removed. Use ${newCommand} going forward.`,
1119
1123
  migrationStatus: {
1120
- inProgress: () => `Converting app configuration to ${chalk.bold('public-app.json')} component definition ...`,
1124
+ inProgress: () => `Converting app configuration to ${chalk.bold(LEGACY_PUBLIC_APP_FILE)} component definition ...`,
1121
1125
  success: () => `${chalk.bold('Your app was converted and build #1 is deployed')}`,
1122
- done: () => 'Converting app configuration to public-app.json component definition ... DONE',
1123
- failure: () => 'Converting app configuration to public-app.json component definition ... FAILED',
1126
+ done: () => `Converting app configuration to ${LEGACY_PUBLIC_APP_FILE} component definition ... DONE`,
1127
+ failure: () => `Converting app configuration to ${LEGACY_PUBLIC_APP_FILE} component definition ... FAILED`,
1124
1128
  },
1125
1129
  warning: {
1126
1130
  title: () => `${chalk.bold('You are about to migrate an app to the projects framework')}`,
1127
1131
  projectConversion: () => `${chalk.bold('The selected app will be converted to a project component.')}`,
1128
- appConfig: () => `All supported app configuration will be moved to the ${chalk.bold('public-app.json')} component definition file. Future updates to those features must be made through the project build and deploy pipeline, not the developer account UI.`,
1132
+ appConfig: () => `All supported app configuration will be moved to the ${chalk.bold(LEGACY_PUBLIC_APP_FILE)} component definition file. Future updates to those features must be made through the project build and deploy pipeline, not the developer account UI.`,
1129
1133
  buildAndDeploy: 'This will create a new project with a single app component and immediately build and deploy it to your developer account (build #1).',
1130
1134
  existingApps: () => `${chalk.bold('This will not affect existing app users or installs.')}`,
1131
1135
  copyApp: 'We strongly recommend making a copy of your app to test this process in a development app before replacing production.',
@@ -1159,10 +1163,10 @@ export const commands = {
1159
1163
  },
1160
1164
  },
1161
1165
  cloneStatus: {
1162
- inProgress: () => `Cloning app configuration to ${chalk.bold('public-app.json')} component definition ...`,
1163
- done: 'Cloning app configuration to public-app.json component definition ... DONE',
1166
+ inProgress: () => `Cloning app configuration to ${chalk.bold(LEGACY_PUBLIC_APP_FILE)} component definition ...`,
1167
+ done: `Cloning app configuration to ${LEGACY_PUBLIC_APP_FILE} component definition ... DONE`,
1164
1168
  success: (dest) => `Your cloned project was created in ${dest}`,
1165
- failure: 'Cloning app configuration to public-app.json component definition ... FAILED',
1169
+ failure: `Cloning app configuration to ${LEGACY_PUBLIC_APP_FILE} component definition ... FAILED`,
1166
1170
  },
1167
1171
  errors: {
1168
1172
  invalidAccountTypeTitle: () => `${chalk.bold('Developer account not targeted')}`,
@@ -1905,18 +1909,41 @@ export const commands = {
1905
1909
  example: (name) => `Create a test account config file with the name "${name}"`,
1906
1910
  },
1907
1911
  delete: {
1908
- describe: 'Delete a test account config file.',
1912
+ describe: 'Delete a test account from your HubSpot account and CLI config',
1909
1913
  pathPrompt: '[--path] What is the path to the test account config?',
1914
+ info: {
1915
+ deletionCanceled: 'Deletion canceled by user',
1916
+ accountNotFoundWithId: (id) => `No account was found with ID ${id}`,
1917
+ replaceDefaultAccount: (testAccountId, parentAccountName) => `The removed test account ${chalk.bold(testAccountId)} was the default account. Replaced default account to parent account: ${chalk.bold(parentAccountName)}`,
1918
+ },
1919
+ prompts: {
1920
+ selectTestAccounts: 'Select test account(s) to delete',
1921
+ confirmDeletion: 'All data for the account will be permanently deleted. Any connected apps will have their access tokens revoked. Do you wish to proceed?',
1922
+ },
1910
1923
  errors: {
1911
- failedToDelete: 'Failed to delete test account',
1924
+ failedToDelete: (testAccountToDelete) => `Failed to delete test account with ID ${testAccountToDelete}`,
1925
+ failedToSelectAccount: 'Failed to select a test account to delete',
1926
+ noAccountsToDelete: (accountId) => `There are no test accounts associated with ${uiAccountDescription(accountId)} to delete. Try running ${uiCommandReference('hs account use')} to change your default account`,
1927
+ failedToDeleteFromConfig: (testAccountToDelete) => `Failed to delete test account with ID ${testAccountToDelete} from the CLI config`,
1928
+ failedToFetchTestAccounts: 'Failed to fetch developer test accounts',
1929
+ testAccountNotFound: (nameOrId) => `Test account${nameOrId ? ` ${chalk.bold(nameOrId)}` : ''} not found in config. \nTry running ${uiCommandReference('hs account auth')} to add the account to config or visit ${uiLink('developer test accounts', 'https://app.hubspot.com/l/developer-test-accounts/')} to delete the test account.`,
1930
+ parentAccountNotFound: (testAccountId) => `Parent account of test account ${chalk.bold(testAccountId)} not found in config. \nTry running ${uiCommandReference('hs account auth')} to add the parent account to config or visit ${uiLink('developer test accounts', 'https://app.hubspot.com/l/developer-test-accounts/')} to delete the test account.`,
1912
1931
  },
1913
1932
  success: {
1914
- testAccountDeleted: (testAccountId) => `Test account with id ${testAccountId} successfully deleted`,
1933
+ testAccountDeletedFromHubSpot: (testAccountToDelete) => `Successfully deleted test account with ID ${testAccountToDelete}`,
1934
+ testAccountDeletedFromConfig: (accountId) => `Successfully deleted test account with ID ${accountId} from the CLI config`,
1935
+ },
1936
+ options: {
1937
+ name: 'The name of the test account (in your CLI config) to delete',
1938
+ id: 'The id of the test account',
1915
1939
  },
1916
- positionals: {
1917
- testAccountId: 'The id of the test account',
1940
+ examples: {
1941
+ withPositionalID: (testAccountToDelete) => `Delete a test account with id "${testAccountToDelete}" using positional argument`,
1942
+ withPositionalName: (testAccountToDelete) => `Delete a test account with name "${testAccountToDelete}" using positional argument`,
1943
+ withID: (testAccountToDelete) => `Delete a test account with the id "${testAccountToDelete}"`,
1944
+ withName: (testAccountToDelete) => `Delete a test account with the name "${testAccountToDelete}"`,
1945
+ withoutId: 'Delete a test account via a prompt',
1918
1946
  },
1919
- example: (testAccountId) => `Delete a test account with the id "${testAccountId}"`,
1920
1947
  },
1921
1948
  },
1922
1949
  secrets: {
@@ -2765,6 +2792,7 @@ export const lib = {
2765
2792
  compressed: (byteCount) => `Project files compressed: ${byteCount} bytes`,
2766
2793
  compressing: (path) => `Compressing build files to "${path}"`,
2767
2794
  fileFiltered: (filename) => `Ignore rule triggered for "${filename}"`,
2795
+ legacyFileDetected: (filename, platformVersion) => `The ${chalk.bold(filename)} file is not supported on platform version ${chalk.bold(platformVersion)} and will be ignored.`,
2768
2796
  },
2769
2797
  },
2770
2798
  boxen: {
@@ -2883,25 +2911,26 @@ export const lib = {
2883
2911
  },
2884
2912
  setAllowUsageTracking: {
2885
2913
  fieldName: 'usage tracking',
2886
- success: (isEnabled) => `Allow usage tracking set to: "${isEnabled}"`,
2914
+ success: (isEnabled) => `Successfully updated ${chalk.bold('allow usage tracking')} to ${chalk.bold(isEnabled)}`,
2887
2915
  },
2888
2916
  setAllowAutoUpdates: {
2889
2917
  fieldName: 'auto updates',
2890
- success: (isEnabled) => `Allow auto updates set to: "${isEnabled}"`,
2918
+ success: (isEnabled) => `Successfully updated ${chalk.bold('allow auto updates')} to ${chalk.bold(isEnabled)}`,
2891
2919
  },
2892
2920
  setDefaultCmsPublishMode: {
2893
2921
  promptMessage: 'Select CMS publish mode to be used as the default',
2894
2922
  error: (validModes) => `The provided CMS publish mode is invalid. Valid values are ${validModes}.`,
2895
- success: (mode) => `Default mode updated to: ${mode}`,
2923
+ success: (mode) => `Successfully updated ${chalk.bold('default CMS publish mode')} to ${chalk.bold(mode)}`,
2896
2924
  },
2897
2925
  setHttpTimeout: {
2898
2926
  promptMessage: 'Enter http timeout duration',
2899
- success: (timeout) => `HTTP timeout set to: ${timeout}`,
2927
+ success: (timeout) => `Successfully updated ${chalk.bold('HTTP timeout')} to ${chalk.bold(timeout)}`,
2928
+ error: (timeout) => `Invalid HTTP timeout value "${timeout}". Must be a number greater than 3000.`,
2900
2929
  },
2901
2930
  setAutoOpenBrowser: {
2902
2931
  fieldName: 'auto open browser',
2903
- enabled: 'Auto opening your browser has been enabled',
2904
- disabled: 'Auto opening your browser has been disabled',
2932
+ enabled: `Successfully updated ${chalk.bold('auto open browser')} to ${chalk.bold('enabled')}`,
2933
+ disabled: `Successfully updated ${chalk.bold('auto open browser')} to ${chalk.bold('disabled')}`,
2905
2934
  },
2906
2935
  },
2907
2936
  commonOpts: {
@@ -1,6 +1,19 @@
1
- import { hasFlag, makeYargsBuilder, getExclusiveConflicts, } from '../yargsUtils.js';
1
+ import { hasFlag, makeYargsBuilder, strictEnforceBoolean, } from '../yargsUtils.js';
2
2
  import * as commonOpts from '../commonOpts.js';
3
3
  vi.mock('../commonOpts');
4
+ vi.mock('../../lang/en.js', () => ({
5
+ commands: {
6
+ config: {
7
+ subcommands: {
8
+ set: {
9
+ errors: {
10
+ invalidBoolean: (option, value) => `Invalid boolean value "${value}" for --${option}. Valid values are: true, false`,
11
+ },
12
+ },
13
+ },
14
+ },
15
+ },
16
+ }));
4
17
  const argvWithFlag = ['hs', 'command', '--test'];
5
18
  const argvWithoutFlag = ['hs', 'command'];
6
19
  describe('lib/yargsUtils', () => {
@@ -36,15 +49,76 @@ describe('lib/yargsUtils', () => {
36
49
  expect(commonOpts.addCustomHelpOutput).toHaveBeenCalled();
37
50
  });
38
51
  });
39
- describe('getExclusiveConflicts()', () => {
40
- it('should return an object where each option conflicts with all others', () => {
41
- const options = ['option1', 'option2', 'option3'];
42
- const result = getExclusiveConflicts(options);
43
- expect(result).toEqual({
44
- option1: ['option2', 'option3'],
45
- option2: ['option1', 'option3'],
46
- option3: ['option1', 'option2'],
52
+ describe('strictEnforceBoolean()', () => {
53
+ it('should validate valid boolean values (true/false, case-insensitive)', () => {
54
+ const validArgs = [
55
+ ['hs', 'config', 'set', '--allow-usage-tracking=true'],
56
+ ['hs', 'config', 'set', '--allow-usage-tracking=false'],
57
+ ['hs', 'config', 'set', '--allow-usage-tracking=TRUE'],
58
+ ['hs', 'config', 'set', '--allow-usage-tracking=False'],
59
+ ];
60
+ validArgs.forEach(args => {
61
+ expect(() => strictEnforceBoolean(args, ['allow-usage-tracking'])).not.toThrow();
62
+ expect(strictEnforceBoolean(args, ['allow-usage-tracking'])).toBe(true);
47
63
  });
48
64
  });
65
+ it('should reject invalid boolean values with descriptive error messages', () => {
66
+ const invalidCases = [
67
+ {
68
+ args: ['hs', 'config', 'set', '--allow-usage-tracking=yes'],
69
+ value: 'yes',
70
+ },
71
+ {
72
+ args: ['hs', 'config', 'set', '--allow-usage-tracking=1'],
73
+ value: '1',
74
+ },
75
+ {
76
+ args: ['hs', 'config', 'set', '--auto-open-browser=maybe'],
77
+ value: 'maybe',
78
+ },
79
+ ];
80
+ invalidCases.forEach(({ args, value }) => {
81
+ const option = args[3].split('=')[0].replace('--', '');
82
+ expect(() => strictEnforceBoolean(args, [option])).toThrow(`Invalid boolean value "${value}" for --${option}. Valid values are: true, false`);
83
+ });
84
+ });
85
+ it('should support multiple config values in a single command', () => {
86
+ const args = [
87
+ 'hs',
88
+ 'config',
89
+ 'set',
90
+ '--allow-usage-tracking=true',
91
+ '--auto-open-browser=false',
92
+ '--allow-auto-updates=true',
93
+ '--http-timeout=5000',
94
+ '--default-cms-publish-mode=draft',
95
+ ];
96
+ const booleanOptions = [
97
+ 'allow-usage-tracking',
98
+ 'auto-open-browser',
99
+ 'allow-auto-updates',
100
+ ];
101
+ expect(() => strictEnforceBoolean(args, booleanOptions)).not.toThrow();
102
+ expect(strictEnforceBoolean(args, booleanOptions)).toBe(true);
103
+ });
104
+ it('should error when one of multiple values is invalid', () => {
105
+ const args = [
106
+ 'hs',
107
+ 'config',
108
+ 'set',
109
+ '--allow-usage-tracking=true',
110
+ '--auto-open-browser=invalid',
111
+ '--http-timeout=5000',
112
+ ];
113
+ const booleanOptions = ['allow-usage-tracking', 'auto-open-browser'];
114
+ expect(() => strictEnforceBoolean(args, booleanOptions)).toThrow('Invalid boolean value "invalid" for --auto-open-browser. Valid values are: true, false');
115
+ });
116
+ it('should maintain backwards compatability', () => {
117
+ expect(() => strictEnforceBoolean(['hs', 'config', 'set', '--allow-usage-tracking=true'], ['allow-usage-tracking'])).not.toThrow();
118
+ // No values provided (interactive mode)
119
+ expect(() => strictEnforceBoolean(['hs', 'config', 'set'], ['allow-usage-tracking'])).not.toThrow();
120
+ // Flag without equals sign (succeed with true)
121
+ expect(() => strictEnforceBoolean(['hs', 'config', 'set', '--allow-usage-tracking'], ['allow-usage-tracking'])).not.toThrow();
122
+ });
49
123
  });
50
124
  });
@@ -79,6 +79,13 @@ async function enterTimeout() {
79
79
  message: lib.configOptions.setHttpTimeout.promptMessage,
80
80
  type: 'input',
81
81
  default: 30000,
82
+ validate: (timeout) => {
83
+ const timeoutNum = parseInt(timeout, 10);
84
+ if (isNaN(timeoutNum) || timeoutNum < 3000) {
85
+ return lib.configOptions.setHttpTimeout.error(timeout);
86
+ }
87
+ return true;
88
+ },
82
89
  },
83
90
  ]);
84
91
  return timeout;
@@ -125,3 +125,9 @@ export declare const CONFIG_LOCAL_STATE_FLAGS: {
125
125
  };
126
126
  export declare const EMPTY_PROJECT = "empty";
127
127
  export declare const PROJECT_WITH_APP = "app";
128
+ export declare const LEGACY_SERVERLESS_FILE = "serverless.json";
129
+ export declare const LEGACY_PUBLIC_APP_FILE = "public-app.json";
130
+ export declare const LEGACY_PRIVATE_APP_FILE = "app.json";
131
+ export declare const THEME_FILE = "theme.json";
132
+ export declare const CMS_ASSETS_FILE = "cms-assets.json";
133
+ export declare const LEGACY_CONFIG_FILES: string[];
package/lib/constants.js CHANGED
@@ -117,3 +117,13 @@ export const CONFIG_LOCAL_STATE_FLAGS = {
117
117
  };
118
118
  export const EMPTY_PROJECT = 'empty';
119
119
  export const PROJECT_WITH_APP = 'app';
120
+ export const LEGACY_SERVERLESS_FILE = 'serverless.json';
121
+ export const LEGACY_PUBLIC_APP_FILE = 'public-app.json';
122
+ export const LEGACY_PRIVATE_APP_FILE = 'app.json';
123
+ export const THEME_FILE = 'theme.json';
124
+ export const CMS_ASSETS_FILE = 'cms-assets.json';
125
+ export const LEGACY_CONFIG_FILES = [
126
+ LEGACY_SERVERLESS_FILE,
127
+ LEGACY_PRIVATE_APP_FILE,
128
+ LEGACY_PUBLIC_APP_FILE,
129
+ ];
@@ -9,15 +9,16 @@ import { getAccessToken } from '@hubspot/local-dev-lib/personalAccessKey';
9
9
  import { walk } from '@hubspot/local-dev-lib/fs';
10
10
  import util from 'util';
11
11
  import { exec as execAsync } from 'node:child_process';
12
+ import { CMS_ASSETS_FILE, LEGACY_PRIVATE_APP_FILE, LEGACY_PUBLIC_APP_FILE, LEGACY_SERVERLESS_FILE, PROJECT_CONFIG_FILE, THEME_FILE, } from '../constants.js';
12
13
  // This needs to be hardcoded since we are using it in the TS type
13
14
  const hubspotCli = '@hubspot/cli';
14
15
  const configFiles = [
15
- 'serverless.json',
16
- 'hsproject.json',
17
- 'app.json',
18
- 'public-app.json',
19
- 'theme.json',
20
- 'cms-assets.json',
16
+ LEGACY_SERVERLESS_FILE,
17
+ PROJECT_CONFIG_FILE,
18
+ LEGACY_PRIVATE_APP_FILE,
19
+ LEGACY_PUBLIC_APP_FILE,
20
+ THEME_FILE,
21
+ CMS_ASSETS_FILE,
21
22
  ];
22
23
  export class DiagnosticInfoBuilder {
23
24
  accountId;
@@ -67,6 +67,8 @@ describe('AppDevModeInterface', () => {
67
67
  componentRoot: '/test/path',
68
68
  componentConfigPath: '/test/path/config.json',
69
69
  configUpdatedSinceLastUpload: false,
70
+ removed: false,
71
+ parsingErrors: [],
70
72
  },
71
73
  componentDeps: {},
72
74
  metaFilePath: '/test/path',
@@ -61,37 +61,66 @@ describe('LocalDevWebsocketServer', () => {
61
61
  expect(mockWebSocketServer.on).toHaveBeenCalledWith('connection', expect.any(Function));
62
62
  expect(logger.log).toHaveBeenCalled();
63
63
  });
64
- it('should accept connection from valid origin', async () => {
65
- isPortManagerServerRunning.mockResolvedValue(true);
66
- requestPorts.mockResolvedValue({
67
- 'local-dev-ui-websocket-server': 1234,
64
+ describe('valid origins', () => {
65
+ const validOrigins = [
66
+ 'https://app.hubspot.com',
67
+ 'https://app.hubspotqa.com',
68
+ 'https://local.hubspot.com',
69
+ 'https://local.hubspotqa.com',
70
+ 'https://app-na2.hubspot.com',
71
+ 'https://app-na2.hubspotqa.com',
72
+ 'https://app-na3.hubspot.com',
73
+ 'https://app-na3.hubspotqa.com',
74
+ 'https://app-ap1.hubspot.com',
75
+ 'https://app-ap1.hubspotqa.com',
76
+ 'https://app-eu1.hubspot.com',
77
+ 'https://app-eu1.hubspotqa.com',
78
+ ];
79
+ validOrigins.forEach(origin => {
80
+ it(`should accept connection from ${origin}`, async () => {
81
+ isPortManagerServerRunning.mockResolvedValue(true);
82
+ requestPorts.mockResolvedValue({
83
+ 'local-dev-ui-websocket-server': 1234,
84
+ });
85
+ await server.start();
86
+ // Get the connection callback
87
+ const connectionCallback = mockWebSocketServer.on.mock
88
+ .calls[0][1];
89
+ // Simulate connection from valid origin
90
+ connectionCallback(mockWebSocket, {
91
+ headers: { origin },
92
+ });
93
+ expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
94
+ expect(mockLocalDevProcess.addStateListener).toHaveBeenCalledWith('projectNodes', expect.any(Function));
95
+ expect(mockWebSocket.close).not.toHaveBeenCalled();
96
+ });
68
97
  });
69
- await server.start();
70
- // Get the connection callback
71
- const connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
72
- // Simulate connection from valid origin
73
- connectionCallback(mockWebSocket, {
74
- headers: { origin: 'https://app.hubspot.com' },
75
- });
76
- expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
77
- expect(mockLocalDevProcess.addStateListener).toHaveBeenCalledWith('projectNodes', expect.any(Function));
78
- expect(mockWebSocket.close).not.toHaveBeenCalled();
79
98
  });
80
- it('should reject connection from invalid origin', async () => {
81
- isPortManagerServerRunning.mockResolvedValue(true);
82
- requestPorts.mockResolvedValue({
83
- 'local-dev-ui-websocket-server': 1234,
84
- });
85
- await server.start();
86
- // Get the connection callback
87
- const connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
88
- // Simulate connection from invalid origin
89
- connectionCallback(mockWebSocket, {
90
- headers: { origin: 'https://malicious-site.com' },
99
+ describe('invalid origins', () => {
100
+ const invalidOrigins = [
101
+ 'https://malicious-site.com',
102
+ 'https://app.malicious-site.com',
103
+ 'https://app.hubspot.com.evil.com',
104
+ ];
105
+ invalidOrigins.forEach(origin => {
106
+ it(`should reject connection from "${origin}"`, async () => {
107
+ isPortManagerServerRunning.mockResolvedValue(true);
108
+ requestPorts.mockResolvedValue({
109
+ 'local-dev-ui-websocket-server': 1234,
110
+ });
111
+ await server.start();
112
+ // Get the connection callback
113
+ const connectionCallback = mockWebSocketServer.on.mock
114
+ .calls[0][1];
115
+ // Simulate connection from invalid origin
116
+ connectionCallback(mockWebSocket, {
117
+ headers: { origin },
118
+ });
119
+ expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.LocalDevWebsocketServer.errors.originNotAllowed(origin));
120
+ expect(mockWebSocket.on).not.toHaveBeenCalled();
121
+ expect(mockLocalDevProcess.addStateListener).not.toHaveBeenCalled();
122
+ });
91
123
  });
92
- expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.LocalDevWebsocketServer.errors.originNotAllowed('https://malicious-site.com'));
93
- expect(mockWebSocket.on).not.toHaveBeenCalled();
94
- expect(mockLocalDevProcess.addStateListener).not.toHaveBeenCalled();
95
124
  });
96
125
  it('should reject connection with no origin header', async () => {
97
126
  isPortManagerServerRunning.mockResolvedValue(true);
@@ -119,7 +148,7 @@ describe('LocalDevWebsocketServer', () => {
119
148
  const connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
120
149
  // Simulate connection from valid origin
121
150
  connectionCallback(mockWebSocket, {
122
- headers: { origin: 'https://app.hubspot.com' },
151
+ headers: { origin: 'https://app-na3.hubspot.com' },
123
152
  });
124
153
  expect(mockLocalDevProcess.sendDevServerMessage).toHaveBeenCalledWith(LOCAL_DEV_SERVER_MESSAGE_TYPES.WEBSOCKET_SERVER_CONNECTED);
125
154
  });
@@ -213,7 +242,7 @@ describe('LocalDevWebsocketServer', () => {
213
242
  headers: { origin: 'https://app.hubspot.com' },
214
243
  });
215
244
  connectionCallback(mockWebSocket2, {
216
- headers: { origin: 'https://app.hubspotqa.com' },
245
+ headers: { origin: 'https://app-na2.hubspotqa.com' },
217
246
  });
218
247
  connectionCallback(mockWebSocket3, {
219
248
  headers: { origin: 'https://local.hubspot.com' },
@@ -238,7 +267,7 @@ describe('LocalDevWebsocketServer', () => {
238
267
  headers: { origin: 'https://app.hubspot.com' },
239
268
  });
240
269
  connectionCallback(mockWebSocket2, {
241
- headers: { origin: 'https://app.hubspotqa.com' },
270
+ headers: { origin: 'https://app-eu1.hubspotqa.com' },
242
271
  });
243
272
  // Each websocket should receive project data
244
273
  expect(mockWebSocket1.send).toHaveBeenCalledWith(JSON.stringify({
@@ -270,7 +299,7 @@ describe('LocalDevWebsocketServer', () => {
270
299
  headers: { origin: 'https://app.hubspot.com' },
271
300
  });
272
301
  connectionCallback(mockWebSocket2, {
273
- headers: { origin: 'https://app.hubspotqa.com' },
302
+ headers: { origin: 'https://app-ap1.hubspotqa.com' },
274
303
  });
275
304
  // Get all the close callbacks for both connections (there should be 2 per connection)
276
305
  const closeCallbacks1 = mockWebSocket1.on.mock.calls
@@ -296,7 +325,7 @@ describe('LocalDevWebsocketServer', () => {
296
325
  headers: { origin: 'https://app.hubspot.com' },
297
326
  });
298
327
  connectionCallback(mockWebSocket2, {
299
- headers: { origin: 'https://app.hubspotqa.com' },
328
+ headers: { origin: 'https://local.hubspotqa.com' },
300
329
  });
301
330
  // Get the projectNodes listeners that were registered
302
331
  const projectNodesListeners = mockLocalDevProcess.addStateListener.mock.calls
@@ -312,6 +341,8 @@ describe('LocalDevWebsocketServer', () => {
312
341
  componentRoot: '/test/path',
313
342
  componentConfigPath: '/test/path/config.json',
314
343
  configUpdatedSinceLastUpload: false,
344
+ removed: false,
345
+ parsingErrors: [],
315
346
  },
316
347
  componentDeps: {},
317
348
  metaFilePath: '/test/path',
@@ -30,6 +30,8 @@ describe('isDeployedProjectUpToDateWithLocal', () => {
30
30
  componentRoot: '/local/path',
31
31
  componentConfigPath: '/local/path/config.json',
32
32
  configUpdatedSinceLastUpload: false,
33
+ removed: false,
34
+ parsingErrors: [],
33
35
  },
34
36
  componentDeps: {},
35
37
  metaFilePath: '/local/path',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { vi } from 'vitest';
5
+ import { validateSourceDirectory } from '../upload.js';
6
+ import { uiLogger } from '../../ui/logger.js';
7
+ import { lib } from '../../../lang/en.js';
8
+ import { useV3Api } from '../platformVersion.js';
9
+ import ProjectValidationError from '../../errors/ProjectValidationError.js';
10
+ import { walk } from '@hubspot/local-dev-lib/fs';
11
+ // Mock dependencies
12
+ vi.mock('../../ui/logger.js');
13
+ vi.mock('../platformVersion.js');
14
+ vi.mock('@hubspot/local-dev-lib/fs');
15
+ describe('lib/projects/upload', () => {
16
+ describe('validateSourceDirectory', () => {
17
+ let tempDir;
18
+ let srcDir;
19
+ let projectConfig;
20
+ beforeEach(() => {
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-test-'));
22
+ srcDir = path.join(tempDir, 'src');
23
+ fs.mkdirSync(srcDir, { recursive: true });
24
+ projectConfig = {
25
+ name: 'test-project',
26
+ srcDir: 'src',
27
+ platformVersion: '2025.2',
28
+ };
29
+ vi.clearAllMocks();
30
+ });
31
+ afterEach(() => {
32
+ fs.removeSync(tempDir);
33
+ });
34
+ it('should throw ProjectValidationError when source directory is empty', async () => {
35
+ vi.mocked(walk).mockResolvedValue([]);
36
+ await expect(validateSourceDirectory(srcDir, projectConfig, tempDir)).rejects.toThrow(ProjectValidationError);
37
+ expect(walk).toHaveBeenCalledWith(srcDir, ['node_modules']);
38
+ });
39
+ it('should warn about legacy files in V3 projects', async () => {
40
+ vi.mocked(useV3Api).mockReturnValue(true);
41
+ const legacyFilePath = path.join(srcDir, 'app', 'serverless.json');
42
+ vi.mocked(walk).mockResolvedValue([legacyFilePath]);
43
+ await validateSourceDirectory(srcDir, projectConfig, tempDir);
44
+ expect(uiLogger.warn).toHaveBeenCalledWith(lib.projectUpload.handleProjectUpload.legacyFileDetected('src/app/serverless.json', '2025.2'));
45
+ });
46
+ it('should warn about multiple legacy files', async () => {
47
+ vi.mocked(useV3Api).mockReturnValue(true);
48
+ const filePaths = [
49
+ path.join(srcDir, 'app1', 'serverless.json'),
50
+ path.join(srcDir, 'app2', 'app.json'),
51
+ path.join(srcDir, 'app3', 'public-app.json'),
52
+ ];
53
+ vi.mocked(walk).mockResolvedValue(filePaths);
54
+ await validateSourceDirectory(srcDir, projectConfig, tempDir);
55
+ expect(uiLogger.warn).toHaveBeenCalledTimes(3);
56
+ expect(uiLogger.warn).toHaveBeenCalledWith(lib.projectUpload.handleProjectUpload.legacyFileDetected('src/app1/serverless.json', '2025.2'));
57
+ expect(uiLogger.warn).toHaveBeenCalledWith(lib.projectUpload.handleProjectUpload.legacyFileDetected('src/app2/app.json', '2025.2'));
58
+ expect(uiLogger.warn).toHaveBeenCalledWith(lib.projectUpload.handleProjectUpload.legacyFileDetected('src/app3/public-app.json', '2025.2'));
59
+ });
60
+ it('should not warn about non-legacy files', async () => {
61
+ vi.mocked(useV3Api).mockReturnValue(true);
62
+ const filePaths = [
63
+ path.join(srcDir, 'component.js'),
64
+ path.join(srcDir, 'config.json'),
65
+ ];
66
+ vi.mocked(walk).mockResolvedValue(filePaths);
67
+ await validateSourceDirectory(srcDir, projectConfig, tempDir);
68
+ expect(uiLogger.warn).not.toHaveBeenCalled();
69
+ });
70
+ it('should not warn about legacy files in non-V3 projects', async () => {
71
+ vi.mocked(useV3Api).mockReturnValue(false);
72
+ projectConfig.platformVersion = '2025.1';
73
+ const filePaths = [
74
+ path.join(srcDir, 'app', 'serverless.json'),
75
+ path.join(srcDir, 'app', 'app.json'),
76
+ ];
77
+ vi.mocked(walk).mockResolvedValue(filePaths);
78
+ await validateSourceDirectory(srcDir, projectConfig, tempDir);
79
+ expect(uiLogger.warn).not.toHaveBeenCalled();
80
+ });
81
+ });
82
+ });
@@ -15,6 +15,8 @@ declare class AppDevModeInterface {
15
15
  private get appNode();
16
16
  private get appData();
17
17
  private set appData(value);
18
+ private isStaticAuthApp;
19
+ private isOAuthApp;
18
20
  private getAppInstallUrl;
19
21
  private fetchAppData;
20
22
  private checkMarketplaceAppInstalls;
@@ -54,6 +54,12 @@ class AppDevModeInterface {
54
54
  }
55
55
  this.localDevState.setAppDataForUid(this.appNode.uid, appData);
56
56
  }
57
+ isStaticAuthApp() {
58
+ return (this.appNode?.config.auth.type.toLowerCase() === APP_AUTH_TYPES.STATIC);
59
+ }
60
+ isOAuthApp() {
61
+ return (this.appNode?.config.auth.type.toLowerCase() === APP_AUTH_TYPES.OAUTH);
62
+ }
57
63
  // @TODO: Restore test account auto install functionality
58
64
  // private isAutomaticallyInstallable(): boolean {
59
65
  // const targetTestingAccount = getAccountConfig(
@@ -71,11 +77,11 @@ class AppDevModeInterface {
71
77
  // return (
72
78
  // isTestAccount &&
73
79
  // hasCorrectParent &&
74
- // this.appNode?.config.auth.type === APP_AUTH_TYPES.STATIC
80
+ // this.isStaticAuthApp()
75
81
  // );
76
82
  // }
77
83
  async getAppInstallUrl() {
78
- if (this.appNode?.config.auth.type.toLowerCase() === APP_AUTH_TYPES.OAUTH) {
84
+ if (this.appNode && this.isOAuthApp()) {
79
85
  return getOauthAppInstallUrl({
80
86
  targetAccountId: this.localDevState.targetTestingAccountId,
81
87
  env: this.localDevState.env,
@@ -177,13 +183,16 @@ class AppDevModeInterface {
177
183
  // );
178
184
  // }
179
185
  // }
186
+ const staticAuthInstallOptions = this.isStaticAuthApp()
187
+ ? {
188
+ testingAccountId: this.localDevState.targetTestingAccountId,
189
+ projectAccountId: this.localDevState.targetProjectAccountId,
190
+ projectName: this.localDevState.projectConfig.name,
191
+ appUid: this.appNode.uid,
192
+ }
193
+ : undefined;
180
194
  const installUrl = await this.getAppInstallUrl();
181
- await installAppBrowserPrompt(installUrl, isReinstall, {
182
- testingAccountId: this.localDevState.targetTestingAccountId,
183
- projectAccountId: this.localDevState.targetProjectAccountId,
184
- projectName: this.localDevState.projectConfig.name,
185
- appUid: this.appNode.uid,
186
- });
195
+ await installAppBrowserPrompt(installUrl, isReinstall, staticAuthInstallOptions);
187
196
  }
188
197
  async checkTestAccountAppInstallation() {
189
198
  if (!this.appNode || !this.appData) {
@@ -3,7 +3,6 @@ declare class LocalDevWebsocketServer {
3
3
  private server?;
4
4
  private debug?;
5
5
  private localDevProcess;
6
- private ALLOWED_ORIGINS;
7
6
  constructor(localDevProcess: LocalDevProcess, debug?: boolean);
8
7
  private log;
9
8
  private logError;