@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.
- package/commands/config/set.d.ts +1 -1
- package/commands/config/set.js +65 -33
- package/commands/init.js +0 -1
- package/commands/project/__tests__/validate.test.d.ts +1 -0
- package/commands/project/__tests__/validate.test.js +98 -0
- package/commands/project/validate.js +4 -4
- package/commands/testAccount/__tests__/delete.test.js +2 -4
- package/commands/testAccount/delete.d.ts +4 -3
- package/commands/testAccount/delete.js +155 -14
- package/lang/en.d.ts +37 -8
- package/lang/en.js +49 -20
- package/lib/__tests__/yargsUtils.test.js +83 -9
- package/lib/configOptions.js +7 -0
- package/lib/constants.d.ts +6 -0
- package/lib/constants.js +10 -0
- package/lib/doctor/DiagnosticInfoBuilder.js +7 -6
- package/lib/projects/__tests__/AppDevModeInterface.test.js +2 -0
- package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +64 -33
- package/lib/projects/__tests__/localDevProjectHelpers.test.js +2 -0
- package/lib/projects/__tests__/upload.test.d.ts +1 -0
- package/lib/projects/__tests__/upload.test.js +82 -0
- package/lib/projects/localDev/AppDevModeInterface.d.ts +2 -0
- package/lib/projects/localDev/AppDevModeInterface.js +17 -8
- package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +0 -1
- package/lib/projects/localDev/LocalDevWebsocketServer.js +4 -7
- package/lib/projects/structure.js +4 -4
- package/lib/projects/upload.d.ts +1 -1
- package/lib/projects/upload.js +15 -6
- package/lib/prompts/createDeveloperTestAccountConfigPrompt.js +10 -1
- package/lib/prompts/promptUtils.js +8 -5
- package/lib/yargsUtils.d.ts +1 -1
- package/lib/yargsUtils.js +12 -5
- 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(
|
|
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: () =>
|
|
1123
|
-
failure: () =>
|
|
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(
|
|
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(
|
|
1163
|
-
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
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) => `
|
|
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) => `
|
|
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) => `
|
|
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
|
|
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: '
|
|
2904
|
-
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,
|
|
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('
|
|
40
|
-
it('should
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
});
|
package/lib/configOptions.js
CHANGED
|
@@ -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;
|
package/lib/constants.d.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
'
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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://
|
|
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.
|
|
80
|
+
// this.isStaticAuthApp()
|
|
75
81
|
// );
|
|
76
82
|
// }
|
|
77
83
|
async getAppInstallUrl() {
|
|
78
|
-
if (this.appNode
|
|
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) {
|