@hubspot/cli 8.0.1-experimental.0 → 8.0.3-experimental.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 (109) hide show
  1. package/bin/cli.js +8 -5
  2. package/commands/__tests__/getStarted.test.js +12 -0
  3. package/commands/__tests__/project.test.js +30 -0
  4. package/commands/account/auth.js +8 -97
  5. package/commands/account/use.js +19 -4
  6. package/commands/cms/module/marketplace-validate.js +23 -5
  7. package/commands/cms/theme/marketplace-validate.js +25 -6
  8. package/commands/getStarted.d.ts +2 -1
  9. package/commands/getStarted.js +38 -15
  10. package/commands/mcp/__tests__/start.test.js +1 -67
  11. package/commands/mcp/setup.js +1 -2
  12. package/commands/mcp/start.js +1 -19
  13. package/commands/mcp.js +1 -2
  14. package/commands/project.js +22 -1
  15. package/lang/en.d.ts +53 -7
  16. package/lang/en.js +59 -13
  17. package/lib/CLIWebSocketServer.d.ts +28 -0
  18. package/lib/CLIWebSocketServer.js +91 -0
  19. package/lib/__tests__/CLIWebSocketServer.test.d.ts +1 -0
  20. package/lib/__tests__/CLIWebSocketServer.test.js +252 -0
  21. package/lib/__tests__/accountAuth.test.d.ts +1 -0
  22. package/lib/__tests__/accountAuth.test.js +258 -0
  23. package/lib/__tests__/commandSuggestion.test.d.ts +1 -0
  24. package/lib/__tests__/commandSuggestion.test.js +119 -0
  25. package/lib/accountAuth.d.ts +10 -0
  26. package/lib/accountAuth.js +105 -0
  27. package/lib/app/urls.d.ts +1 -0
  28. package/lib/app/urls.js +4 -0
  29. package/lib/commandSuggestion.d.ts +3 -0
  30. package/lib/commandSuggestion.js +45 -0
  31. package/lib/constants.d.ts +0 -1
  32. package/lib/constants.js +0 -1
  33. package/lib/errors/ProjectErrors.d.ts +15 -0
  34. package/lib/errors/ProjectErrors.js +30 -0
  35. package/lib/getStarted/getStartedV2.d.ts +7 -0
  36. package/lib/getStarted/getStartedV2.js +18 -0
  37. package/lib/getStartedV2Actions.d.ts +37 -0
  38. package/lib/getStartedV2Actions.js +146 -0
  39. package/lib/marketplaceValidate.d.ts +1 -1
  40. package/lib/marketplaceValidate.js +23 -41
  41. package/lib/mcp/__tests__/setup.test.js +0 -17
  42. package/lib/mcp/setup.d.ts +0 -1
  43. package/lib/mcp/setup.js +59 -103
  44. package/lib/projects/ProjectLogsManager.d.ts +12 -3
  45. package/lib/projects/ProjectLogsManager.js +70 -12
  46. package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +43 -175
  47. package/lib/projects/__tests__/ProjectLogsManager.test.js +131 -18
  48. package/lib/projects/__tests__/platformVersion.test.js +37 -1
  49. package/lib/projects/__tests__/projects.test.js +6 -2
  50. package/lib/projects/components.d.ts +6 -0
  51. package/lib/projects/components.js +1 -1
  52. package/lib/projects/config.js +9 -2
  53. package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -7
  54. package/lib/projects/localDev/LocalDevWebsocketServer.js +51 -98
  55. package/lib/projects/localDev/helpers/project.d.ts +4 -1
  56. package/lib/projects/localDev/helpers/project.js +13 -8
  57. package/lib/projects/localDev/localDevWebsocketServerUtils.d.ts +8 -7
  58. package/lib/projects/platformVersion.d.ts +8 -0
  59. package/lib/projects/platformVersion.js +31 -2
  60. package/lib/prompts/accountsPrompt.d.ts +2 -1
  61. package/lib/prompts/accountsPrompt.js +10 -2
  62. package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +20 -3
  63. package/mcp-server/tools/project/AddFeatureToProjectTool.js +6 -10
  64. package/mcp-server/tools/project/CreateProjectTool.d.ts +24 -4
  65. package/mcp-server/tools/project/CreateProjectTool.js +5 -10
  66. package/mcp-server/tools/project/GetApiUsagePatternsByAppIdTool.js +5 -8
  67. package/mcp-server/tools/project/GetBuildLogsTool.d.ts +2 -2
  68. package/mcp-server/tools/project/GetBuildLogsTool.js +3 -4
  69. package/mcp-server/tools/project/GetBuildStatusTool.d.ts +1 -1
  70. package/mcp-server/tools/project/GetBuildStatusTool.js +3 -4
  71. package/mcp-server/tools/project/GuidedWalkthroughTool.d.ts +6 -1
  72. package/mcp-server/tools/project/GuidedWalkthroughTool.js +1 -6
  73. package/mcp-server/tools/project/constants.d.ts +12 -1
  74. package/mcp-server/tools/project/constants.js +12 -16
  75. package/mcp-server/utils/__tests__/project.test.js +0 -125
  76. package/mcp-server/utils/project.js +0 -8
  77. package/package.json +10 -5
  78. package/types/LocalDev.d.ts +0 -4
  79. package/ui/components/ActionSection.d.ts +12 -0
  80. package/ui/components/ActionSection.js +25 -0
  81. package/ui/components/BoxWithTitle.d.ts +4 -2
  82. package/ui/components/BoxWithTitle.js +2 -2
  83. package/ui/components/FullScreen.d.ts +6 -0
  84. package/ui/components/FullScreen.js +13 -0
  85. package/ui/components/InputField.d.ts +10 -0
  86. package/ui/components/InputField.js +10 -0
  87. package/ui/components/SelectInput.d.ts +11 -0
  88. package/ui/components/SelectInput.js +59 -0
  89. package/ui/components/StatusIcon.d.ts +9 -0
  90. package/ui/components/StatusIcon.js +17 -0
  91. package/ui/components/getStarted/GetStartedFlow.d.ts +8 -0
  92. package/ui/components/getStarted/GetStartedFlow.js +136 -0
  93. package/ui/components/getStarted/reducer.d.ts +59 -0
  94. package/ui/components/getStarted/reducer.js +72 -0
  95. package/ui/components/getStarted/screens/ProjectSetupScreen.d.ts +16 -0
  96. package/ui/components/getStarted/screens/ProjectSetupScreen.js +39 -0
  97. package/ui/components/getStarted/screens/UploadScreen.d.ts +7 -0
  98. package/ui/components/getStarted/screens/UploadScreen.js +43 -0
  99. package/ui/components/getStarted/selectors.d.ts +2 -0
  100. package/ui/components/getStarted/selectors.js +1 -0
  101. package/ui/constants.d.ts +6 -0
  102. package/ui/constants.js +6 -0
  103. package/ui/lib/constants.d.ts +16 -0
  104. package/ui/lib/constants.js +16 -0
  105. package/ui/playground/fixtures.js +47 -0
  106. package/ui/render.d.ts +4 -0
  107. package/ui/render.js +31 -0
  108. package/ui/styles.d.ts +3 -0
  109. package/ui/styles.js +3 -0
@@ -0,0 +1,252 @@
1
+ import { WebSocketServer } from 'ws';
2
+ import { isPortManagerServerRunning, requestPorts, } from '@hubspot/local-dev-lib/portManager';
3
+ import { uiLogger } from '../ui/logger.js';
4
+ import CLIWebSocketServer from '../CLIWebSocketServer.js';
5
+ import { lib } from '../../lang/en.js';
6
+ import { pkg } from '../jsonLoader.js';
7
+ vi.mock('ws');
8
+ vi.mock('@hubspot/local-dev-lib/portManager');
9
+ const INSTANCE_ID = 'test-websocket-server';
10
+ const LOG_PREFIX = '[TestWebSocketServer]';
11
+ const PORT = 1234;
12
+ describe('CLIWebSocketServer', () => {
13
+ let mockWebSocket;
14
+ let mockWebSocketServer;
15
+ let server;
16
+ beforeEach(() => {
17
+ mockWebSocket = {
18
+ on: vi.fn(),
19
+ send: vi.fn(),
20
+ close: vi.fn(),
21
+ };
22
+ mockWebSocketServer = {
23
+ on: vi.fn(),
24
+ close: vi.fn(),
25
+ };
26
+ WebSocketServer.mockImplementation(() => mockWebSocketServer);
27
+ server = new CLIWebSocketServer({
28
+ instanceId: INSTANCE_ID,
29
+ logPrefix: LOG_PREFIX,
30
+ debug: true,
31
+ });
32
+ });
33
+ describe('start()', () => {
34
+ it('should throw error if port manager is not running', async () => {
35
+ isPortManagerServerRunning.mockResolvedValue(false);
36
+ await expect(server.start({})).rejects.toThrow(lib.CLIWebsocketServer.errors.portManagerNotRunning(LOG_PREFIX));
37
+ });
38
+ it('should start websocket server on the assigned port', async () => {
39
+ isPortManagerServerRunning.mockResolvedValue(true);
40
+ requestPorts.mockResolvedValue({
41
+ [INSTANCE_ID]: PORT,
42
+ });
43
+ await server.start({});
44
+ expect(WebSocketServer).toHaveBeenCalledWith({ port: PORT });
45
+ expect(mockWebSocketServer.on).toHaveBeenCalledWith('connection', expect.any(Function));
46
+ });
47
+ it('should log startup message when debug is enabled', async () => {
48
+ isPortManagerServerRunning.mockResolvedValue(true);
49
+ requestPorts.mockResolvedValue({
50
+ [INSTANCE_ID]: PORT,
51
+ });
52
+ await server.start({});
53
+ expect(uiLogger.log).toHaveBeenCalledWith(expect.stringContaining(String(PORT)));
54
+ });
55
+ });
56
+ describe('origin validation', () => {
57
+ const validOrigins = [
58
+ 'https://app.hubspot.com',
59
+ 'https://app.hubspotqa.com',
60
+ 'https://local.hubspot.com',
61
+ 'https://local.hubspotqa.com',
62
+ 'https://app-na2.hubspot.com',
63
+ 'https://app-na2.hubspotqa.com',
64
+ 'https://app-na3.hubspot.com',
65
+ 'https://app-na3.hubspotqa.com',
66
+ 'https://app-ap1.hubspot.com',
67
+ 'https://app-ap1.hubspotqa.com',
68
+ 'https://app-eu1.hubspot.com',
69
+ 'https://app-eu1.hubspotqa.com',
70
+ ];
71
+ let connectionCallback;
72
+ beforeEach(async () => {
73
+ isPortManagerServerRunning.mockResolvedValue(true);
74
+ requestPorts.mockResolvedValue({
75
+ [INSTANCE_ID]: PORT,
76
+ });
77
+ await server.start({});
78
+ connectionCallback = mockWebSocketServer.on.mock
79
+ .calls[0][1];
80
+ });
81
+ validOrigins.forEach(origin => {
82
+ it(`should accept connection from ${origin}`, () => {
83
+ connectionCallback(mockWebSocket, { headers: { origin } });
84
+ expect(mockWebSocket.close).not.toHaveBeenCalled();
85
+ expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
86
+ });
87
+ });
88
+ const invalidOrigins = [
89
+ 'https://malicious-site.com',
90
+ 'https://app.malicious-site.com',
91
+ 'https://app.hubspot.com.evil.com',
92
+ ];
93
+ invalidOrigins.forEach(origin => {
94
+ it(`should reject connection from "${origin}"`, () => {
95
+ connectionCallback(mockWebSocket, { headers: { origin } });
96
+ expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.CLIWebsocketServer.errors.originNotAllowed(origin));
97
+ expect(mockWebSocket.on).not.toHaveBeenCalled();
98
+ });
99
+ });
100
+ it('should reject connection with no origin header', () => {
101
+ connectionCallback(mockWebSocket, { headers: {} });
102
+ expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.CLIWebsocketServer.errors.originNotAllowed());
103
+ expect(mockWebSocket.on).not.toHaveBeenCalled();
104
+ });
105
+ });
106
+ describe('sendCliMetadata', () => {
107
+ let connectionCallback;
108
+ beforeEach(async () => {
109
+ isPortManagerServerRunning.mockResolvedValue(true);
110
+ requestPorts.mockResolvedValue({
111
+ [INSTANCE_ID]: PORT,
112
+ });
113
+ });
114
+ it('should send cliVersion on connection', async () => {
115
+ await server.start({});
116
+ connectionCallback = mockWebSocketServer.on.mock
117
+ .calls[0][1];
118
+ connectionCallback(mockWebSocket, {
119
+ headers: { origin: 'https://app.hubspot.com' },
120
+ });
121
+ expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({
122
+ type: 'server:cliMetadata',
123
+ data: { cliVersion: pkg.version },
124
+ }));
125
+ });
126
+ it('should merge additional metadata when provided', async () => {
127
+ await server.start({
128
+ metadata: { customField: 'customValue', version: 2 },
129
+ });
130
+ connectionCallback = mockWebSocketServer.on.mock
131
+ .calls[0][1];
132
+ connectionCallback(mockWebSocket, {
133
+ headers: { origin: 'https://app.hubspot.com' },
134
+ });
135
+ expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({
136
+ type: 'server:cliMetadata',
137
+ data: {
138
+ cliVersion: pkg.version,
139
+ customField: 'customValue',
140
+ version: 2,
141
+ },
142
+ }));
143
+ });
144
+ });
145
+ describe('callbacks', () => {
146
+ let connectionCallback;
147
+ beforeEach(async () => {
148
+ isPortManagerServerRunning.mockResolvedValue(true);
149
+ requestPorts.mockResolvedValue({
150
+ [INSTANCE_ID]: PORT,
151
+ });
152
+ });
153
+ it('should call onConnection for valid connections', async () => {
154
+ const onConnection = vi.fn();
155
+ await server.start({ onConnection });
156
+ connectionCallback = mockWebSocketServer.on.mock
157
+ .calls[0][1];
158
+ connectionCallback(mockWebSocket, {
159
+ headers: { origin: 'https://app.hubspot.com' },
160
+ });
161
+ expect(onConnection).toHaveBeenCalledWith(mockWebSocket);
162
+ });
163
+ it('should not call onConnection for rejected connections', async () => {
164
+ const onConnection = vi.fn();
165
+ await server.start({ onConnection });
166
+ connectionCallback = mockWebSocketServer.on.mock
167
+ .calls[0][1];
168
+ connectionCallback(mockWebSocket, {
169
+ headers: { origin: 'https://malicious.com' },
170
+ });
171
+ expect(onConnection).not.toHaveBeenCalled();
172
+ });
173
+ it('should call onMessage when a valid message is received', async () => {
174
+ const onMessage = vi.fn().mockReturnValue(true);
175
+ await server.start({ onMessage });
176
+ connectionCallback = mockWebSocketServer.on.mock
177
+ .calls[0][1];
178
+ connectionCallback(mockWebSocket, {
179
+ headers: { origin: 'https://app.hubspot.com' },
180
+ });
181
+ const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
182
+ messageCallback(JSON.stringify({ type: 'test:action', data: { key: 'value' } }));
183
+ expect(onMessage).toHaveBeenCalledWith(mockWebSocket, {
184
+ type: 'test:action',
185
+ data: { key: 'value' },
186
+ });
187
+ });
188
+ it('should log error when onMessage returns false', async () => {
189
+ const onMessage = vi.fn().mockReturnValue(false);
190
+ await server.start({ onMessage });
191
+ connectionCallback = mockWebSocketServer.on.mock
192
+ .calls[0][1];
193
+ connectionCallback(mockWebSocket, {
194
+ headers: { origin: 'https://app.hubspot.com' },
195
+ });
196
+ const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
197
+ messageCallback(JSON.stringify({ type: 'UNKNOWN_TYPE' }));
198
+ expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('UNKNOWN_TYPE'));
199
+ });
200
+ it('should call onClose when server closes', async () => {
201
+ const onClose = vi.fn();
202
+ await server.start({ onClose });
203
+ const closeCallback = mockWebSocketServer.on.mock.calls.find(call => call[0] === 'close')[1];
204
+ closeCallback();
205
+ expect(onClose).toHaveBeenCalled();
206
+ });
207
+ });
208
+ describe('message parsing', () => {
209
+ let messageCallback;
210
+ beforeEach(async () => {
211
+ isPortManagerServerRunning.mockResolvedValue(true);
212
+ requestPorts.mockResolvedValue({
213
+ [INSTANCE_ID]: PORT,
214
+ });
215
+ await server.start({});
216
+ const connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
217
+ connectionCallback(mockWebSocket, {
218
+ headers: { origin: 'https://app.hubspot.com' },
219
+ });
220
+ messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
221
+ });
222
+ it('should log error for messages missing a type field', () => {
223
+ messageCallback(JSON.stringify({}));
224
+ expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('Missing type field'));
225
+ });
226
+ it('should log error for invalid JSON', () => {
227
+ messageCallback('not valid json');
228
+ expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON'));
229
+ });
230
+ });
231
+ describe('sendMessage()', () => {
232
+ it('should serialize and send a message to the websocket', () => {
233
+ const message = { type: 'test:message', data: { foo: 'bar' } };
234
+ server.sendMessage(mockWebSocket, message);
235
+ expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message));
236
+ });
237
+ });
238
+ describe('shutdown()', () => {
239
+ it('should close the websocket server', async () => {
240
+ isPortManagerServerRunning.mockResolvedValue(true);
241
+ requestPorts.mockResolvedValue({
242
+ [INSTANCE_ID]: PORT,
243
+ });
244
+ await server.start({});
245
+ server.shutdown();
246
+ expect(mockWebSocketServer.close).toHaveBeenCalled();
247
+ });
248
+ it('should handle shutdown when server was never started', () => {
249
+ expect(() => server.shutdown()).not.toThrow();
250
+ });
251
+ });
252
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,258 @@
1
+ import { updateConfigAccount, createEmptyConfigFile, getConfigFilePath, localConfigFileExists, globalConfigFileExists, setConfigAccountAsDefault, } from '@hubspot/local-dev-lib/config';
2
+ import { getAccessToken, updateConfigWithAccessToken, } from '@hubspot/local-dev-lib/personalAccessKey';
3
+ import { toKebabCase } from '@hubspot/local-dev-lib/text';
4
+ import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '@hubspot/local-dev-lib/constants/auth';
5
+ import { handleMerge, handleMigration } from '../configMigrate.js';
6
+ import { personalAccessKeyPrompt } from '../prompts/personalAccessKeyPrompt.js';
7
+ import { cliAccountNamePrompt } from '../prompts/accountNamePrompt.js';
8
+ import { setAsDefaultAccountPrompt } from '../prompts/setAsDefaultAccountPrompt.js';
9
+ import { authenticateNewAccount } from '../accountAuth.js';
10
+ vi.mock('@hubspot/local-dev-lib/config');
11
+ vi.mock('@hubspot/local-dev-lib/personalAccessKey');
12
+ vi.mock('@hubspot/local-dev-lib/text');
13
+ vi.mock('../configMigrate.js');
14
+ vi.mock('../errorHandlers/index.js');
15
+ vi.mock('../prompts/personalAccessKeyPrompt.js');
16
+ vi.mock('../prompts/accountNamePrompt.js');
17
+ vi.mock('../prompts/setAsDefaultAccountPrompt.js');
18
+ vi.mock('../ui/logger.js', () => ({
19
+ uiLogger: {
20
+ log: vi.fn(),
21
+ error: vi.fn(),
22
+ success: vi.fn(),
23
+ },
24
+ }));
25
+ const mockedUpdateConfigAccount = updateConfigAccount;
26
+ const mockedCreateEmptyConfigFile = createEmptyConfigFile;
27
+ const mockedGetConfigFilePath = getConfigFilePath;
28
+ const mockedLocalConfigFileExists = localConfigFileExists;
29
+ const mockedGlobalConfigFileExists = globalConfigFileExists;
30
+ const mockedSetConfigAccountAsDefault = setConfigAccountAsDefault;
31
+ const mockedGetAccessToken = getAccessToken;
32
+ const mockedUpdateConfigWithAccessToken = updateConfigWithAccessToken;
33
+ const mockedToKebabCase = toKebabCase;
34
+ const mockedHandleMerge = handleMerge;
35
+ const mockedHandleMigration = handleMigration;
36
+ const mockedPersonalAccessKeyPrompt = personalAccessKeyPrompt;
37
+ const mockedCliAccountNamePrompt = cliAccountNamePrompt;
38
+ const mockedSetAsDefaultAccountPrompt = setAsDefaultAccountPrompt;
39
+ describe('lib/accountAuth', () => {
40
+ describe('authenticateNewAccount()', () => {
41
+ const mockAccessToken = {
42
+ portalId: 123456,
43
+ accessToken: 'test-token',
44
+ expiresAt: '2025-01-01',
45
+ scopeGroups: ['test-scope'],
46
+ enabledFeatures: { 'test-feature': 1 },
47
+ encodedOAuthRefreshToken: 'test-refresh-token',
48
+ hubName: 'Test Hub',
49
+ accountType: 'STANDARD',
50
+ };
51
+ const mockAccountConfig = {
52
+ name: 'test-hub',
53
+ accountId: 123456,
54
+ env: 'prod',
55
+ accountType: 'STANDARD',
56
+ authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value,
57
+ personalAccessKey: 'test-key',
58
+ auth: {
59
+ tokenInfo: {
60
+ accessToken: 'test-token',
61
+ expiresAt: '2025-01-01',
62
+ },
63
+ },
64
+ };
65
+ beforeEach(() => {
66
+ vi.clearAllMocks();
67
+ mockedLocalConfigFileExists.mockReturnValue(false);
68
+ mockedGlobalConfigFileExists.mockReturnValue(false);
69
+ mockedGetAccessToken.mockResolvedValue(mockAccessToken);
70
+ mockedUpdateConfigWithAccessToken.mockResolvedValue(mockAccountConfig);
71
+ mockedToKebabCase.mockReturnValue('test-hub');
72
+ mockedCliAccountNamePrompt.mockResolvedValue({ name: 'test-hub' });
73
+ mockedGetConfigFilePath.mockReturnValue('/path/to/config');
74
+ mockedPersonalAccessKeyPrompt.mockResolvedValue({
75
+ personalAccessKey: 'test-key',
76
+ });
77
+ });
78
+ it('should create config file if it does not exist', async () => {
79
+ mockedGlobalConfigFileExists.mockReturnValue(false);
80
+ await authenticateNewAccount({
81
+ env: 'prod',
82
+ providedPersonalAccessKey: 'test-key',
83
+ accountId: 123456,
84
+ });
85
+ expect(mockedCreateEmptyConfigFile).toHaveBeenCalledWith(true);
86
+ });
87
+ it('should not create config file if it already exists', async () => {
88
+ mockedGlobalConfigFileExists.mockReturnValue(true);
89
+ await authenticateNewAccount({
90
+ env: 'prod',
91
+ providedPersonalAccessKey: 'test-key',
92
+ accountId: 123456,
93
+ });
94
+ expect(mockedCreateEmptyConfigFile).not.toHaveBeenCalled();
95
+ });
96
+ it('should use provided personal access key without prompting', async () => {
97
+ await authenticateNewAccount({
98
+ env: 'prod',
99
+ providedPersonalAccessKey: 'test-key',
100
+ accountId: 123456,
101
+ });
102
+ expect(mockedPersonalAccessKeyPrompt).not.toHaveBeenCalled();
103
+ expect(mockedGetAccessToken).toHaveBeenCalledWith('test-key', 'prod');
104
+ });
105
+ it('should prompt for personal access key if not provided', async () => {
106
+ await authenticateNewAccount({
107
+ env: 'prod',
108
+ accountId: 123456,
109
+ });
110
+ expect(mockedPersonalAccessKeyPrompt).toHaveBeenCalledWith({
111
+ env: 'prod',
112
+ account: 123456,
113
+ });
114
+ expect(mockedGetAccessToken).toHaveBeenCalledWith('test-key', 'prod');
115
+ });
116
+ it('should prompt for account name if config does not exist', async () => {
117
+ mockedGlobalConfigFileExists.mockReturnValue(false);
118
+ await authenticateNewAccount({
119
+ env: 'prod',
120
+ providedPersonalAccessKey: 'test-key',
121
+ accountId: 123456,
122
+ });
123
+ expect(mockedCliAccountNamePrompt).toHaveBeenCalledWith('test-hub');
124
+ });
125
+ it('should not prompt for account name if config already exists', async () => {
126
+ mockedGlobalConfigFileExists.mockReturnValue(true);
127
+ await authenticateNewAccount({
128
+ env: 'prod',
129
+ providedPersonalAccessKey: 'test-key',
130
+ accountId: 123456,
131
+ });
132
+ expect(mockedCliAccountNamePrompt).not.toHaveBeenCalled();
133
+ });
134
+ it('should set account as default when setAsDefaultAccount is true', async () => {
135
+ mockedGlobalConfigFileExists.mockReturnValue(true);
136
+ await authenticateNewAccount({
137
+ env: 'prod',
138
+ providedPersonalAccessKey: 'test-key',
139
+ accountId: 123456,
140
+ setAsDefaultAccount: true,
141
+ });
142
+ expect(mockedSetConfigAccountAsDefault).toHaveBeenCalledWith('test-hub');
143
+ });
144
+ it('should prompt to set as default when setAsDefaultAccount is not provided and config exists', async () => {
145
+ mockedGlobalConfigFileExists.mockReturnValue(true);
146
+ await authenticateNewAccount({
147
+ env: 'prod',
148
+ providedPersonalAccessKey: 'test-key',
149
+ accountId: 123456,
150
+ });
151
+ expect(mockedSetAsDefaultAccountPrompt).toHaveBeenCalledWith('test-hub');
152
+ });
153
+ it('should return the updated account config on success', async () => {
154
+ const result = await authenticateNewAccount({
155
+ env: 'prod',
156
+ providedPersonalAccessKey: 'test-key',
157
+ accountId: 123456,
158
+ });
159
+ expect(result).toEqual(mockAccountConfig);
160
+ });
161
+ it('should return null if access token fetch fails', async () => {
162
+ mockedGetAccessToken.mockRejectedValue(new Error('Invalid token'));
163
+ const result = await authenticateNewAccount({
164
+ env: 'prod',
165
+ providedPersonalAccessKey: 'test-key',
166
+ accountId: 123456,
167
+ });
168
+ expect(result).toBeNull();
169
+ });
170
+ it('should return null if config update fails', async () => {
171
+ mockedUpdateConfigWithAccessToken.mockResolvedValue(null);
172
+ const result = await authenticateNewAccount({
173
+ env: 'prod',
174
+ providedPersonalAccessKey: 'test-key',
175
+ accountId: 123456,
176
+ });
177
+ expect(result).toBeNull();
178
+ });
179
+ it('should handle missing account name and prompt for it', async () => {
180
+ mockedGlobalConfigFileExists.mockReturnValue(true);
181
+ mockedUpdateConfigWithAccessToken.mockResolvedValue({
182
+ ...mockAccountConfig,
183
+ name: undefined,
184
+ });
185
+ mockedCliAccountNamePrompt.mockResolvedValue({
186
+ name: 'new-account-name',
187
+ });
188
+ await authenticateNewAccount({
189
+ env: 'prod',
190
+ providedPersonalAccessKey: 'test-key',
191
+ accountId: 123456,
192
+ });
193
+ expect(mockedCliAccountNamePrompt).toHaveBeenCalledWith('test-hub');
194
+ expect(mockedUpdateConfigAccount).toHaveBeenCalledWith(expect.objectContaining({
195
+ name: 'new-account-name',
196
+ }));
197
+ });
198
+ describe('config migration', () => {
199
+ it('should handle migration when local config exists and global does not', async () => {
200
+ mockedLocalConfigFileExists.mockReturnValue(true);
201
+ mockedGlobalConfigFileExists.mockReturnValue(false);
202
+ mockedHandleMigration.mockResolvedValue(true);
203
+ await authenticateNewAccount({
204
+ env: 'prod',
205
+ providedPersonalAccessKey: 'test-key',
206
+ accountId: 123456,
207
+ });
208
+ expect(mockedHandleMigration).toHaveBeenCalled();
209
+ expect(mockedHandleMerge).not.toHaveBeenCalled();
210
+ });
211
+ it('should handle merge when both local and global configs exist', async () => {
212
+ mockedLocalConfigFileExists.mockReturnValue(true);
213
+ mockedGlobalConfigFileExists.mockReturnValue(true);
214
+ mockedHandleMerge.mockResolvedValue(true);
215
+ await authenticateNewAccount({
216
+ env: 'prod',
217
+ providedPersonalAccessKey: 'test-key',
218
+ accountId: 123456,
219
+ });
220
+ expect(mockedHandleMerge).toHaveBeenCalled();
221
+ expect(mockedHandleMigration).not.toHaveBeenCalled();
222
+ });
223
+ it('should return null if migration is not confirmed', async () => {
224
+ mockedLocalConfigFileExists.mockReturnValue(true);
225
+ mockedGlobalConfigFileExists.mockReturnValue(false);
226
+ mockedHandleMigration.mockResolvedValue(false);
227
+ const result = await authenticateNewAccount({
228
+ env: 'prod',
229
+ providedPersonalAccessKey: 'test-key',
230
+ accountId: 123456,
231
+ });
232
+ expect(result).toBeNull();
233
+ });
234
+ it('should return null if merge is not confirmed', async () => {
235
+ mockedLocalConfigFileExists.mockReturnValue(true);
236
+ mockedGlobalConfigFileExists.mockReturnValue(true);
237
+ mockedHandleMerge.mockResolvedValue(false);
238
+ const result = await authenticateNewAccount({
239
+ env: 'prod',
240
+ providedPersonalAccessKey: 'test-key',
241
+ accountId: 123456,
242
+ });
243
+ expect(result).toBeNull();
244
+ });
245
+ it('should return null if migration throws error', async () => {
246
+ mockedLocalConfigFileExists.mockReturnValue(true);
247
+ mockedGlobalConfigFileExists.mockReturnValue(false);
248
+ mockedHandleMigration.mockRejectedValue(new Error('Migration failed'));
249
+ const result = await authenticateNewAccount({
250
+ env: 'prod',
251
+ providedPersonalAccessKey: 'test-key',
252
+ accountId: 123456,
253
+ });
254
+ expect(result).toBeNull();
255
+ });
256
+ });
257
+ });
258
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import yargs from 'yargs';
2
+ import { addCommandSuggestions, commandSuggestionMappings, } from '../commandSuggestion.js';
3
+ import { uiLogger } from '../ui/logger.js';
4
+ import { uiCommandReference } from '../ui/index.js';
5
+ import { EXIT_CODES } from '../enums/exitCodes.js';
6
+ vi.mock('../ui/logger.js');
7
+ vi.mock('../ui/index.js');
8
+ const commandSpy = vi
9
+ .spyOn(yargs, 'command')
10
+ .mockReturnValue(yargs);
11
+ describe('lib/commandSuggestion', () => {
12
+ const uiLoggerErrorMock = uiLogger.error;
13
+ const uiCommandReferenceMock = uiCommandReference;
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ commandSpy.mockClear();
17
+ uiCommandReferenceMock.mockImplementation((command) => `\`${command}\``);
18
+ });
19
+ describe('addCommandSuggestions', () => {
20
+ it('adds all command suggestions to yargs instance', () => {
21
+ addCommandSuggestions(yargs);
22
+ expect(commandSpy).toHaveBeenCalledTimes(Object.keys(commandSuggestionMappings).length);
23
+ });
24
+ it('registers each mapping from commandSuggestionMappings', () => {
25
+ addCommandSuggestions(yargs);
26
+ Object.entries(commandSuggestionMappings).forEach(([oldCommand]) => {
27
+ expect(commandSpy).toHaveBeenCalledWith(expect.objectContaining({
28
+ command: oldCommand,
29
+ }));
30
+ });
31
+ });
32
+ it('returns the yargs instance', () => {
33
+ const yargsInstance = yargs;
34
+ const result = addCommandSuggestions(yargsInstance);
35
+ expect(result).toBe(yargsInstance);
36
+ });
37
+ it('creates command modules with handler that logs error and exits', () => {
38
+ vi.spyOn(process, 'exit').mockImplementation(() => {
39
+ throw new Error(`process.exit called with ${EXIT_CODES.ERROR}`);
40
+ });
41
+ addCommandSuggestions(yargs);
42
+ // Get the first registered command module
43
+ const firstCall = commandSpy.mock.calls[0];
44
+ const commandModule = firstCall[0];
45
+ const firstMapping = Object.entries(commandSuggestionMappings)[0];
46
+ const [, newCommand] = firstMapping;
47
+ // Verify the command module structure
48
+ expect(commandModule).toHaveProperty('command');
49
+ expect(commandModule).toHaveProperty('handler');
50
+ expect(commandModule).toHaveProperty('builder');
51
+ // Invoke the handler to verify behavior
52
+ expect(() => {
53
+ commandModule.handler({});
54
+ }).toThrow('process.exit called');
55
+ expect(uiCommandReferenceMock).toHaveBeenCalledWith(newCommand);
56
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith(`Did you mean \`${newCommand}\`?`);
57
+ expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR);
58
+ vi.restoreAllMocks();
59
+ });
60
+ it('creates command modules with builder that sets strict mode to false', async () => {
61
+ const yargsInstance = yargs;
62
+ const commandSpyForTest = vi
63
+ .spyOn(yargsInstance, 'command')
64
+ .mockReturnValue(yargsInstance);
65
+ addCommandSuggestions(yargsInstance);
66
+ // Get a command module and verify it has a builder
67
+ const firstCall = commandSpyForTest.mock.calls[0];
68
+ const commandModule = firstCall[0];
69
+ expect(commandModule).toHaveProperty('builder');
70
+ expect(typeof commandModule.builder).toBe('function');
71
+ // Create a mock yargs builder with strict method
72
+ const mockYargsBuilder = {
73
+ strict: vi.fn().mockReturnThis(),
74
+ };
75
+ await commandModule.builder(mockYargsBuilder);
76
+ expect(mockYargsBuilder.strict).toHaveBeenCalledWith(false);
77
+ commandSpyForTest.mockRestore();
78
+ });
79
+ it('handles commands with multiple words', () => {
80
+ vi.spyOn(process, 'exit').mockImplementation(() => {
81
+ throw new Error(`process.exit called with ${EXIT_CODES.ERROR}`);
82
+ });
83
+ // Find a multi-word command
84
+ const multiWordCommand = Object.entries(commandSuggestionMappings).find(([oldCommand]) => oldCommand.includes(' '));
85
+ expect(multiWordCommand).toBeDefined();
86
+ if (multiWordCommand) {
87
+ const [oldCommand, newCommand] = multiWordCommand;
88
+ const yargsInstance = yargs;
89
+ const commandSpyForTest = vi
90
+ .spyOn(yargsInstance, 'command')
91
+ .mockReturnValue(yargsInstance);
92
+ addCommandSuggestions(yargsInstance);
93
+ // Find the call for this multi-word command
94
+ const call = commandSpyForTest.mock.calls.find(call => call[0]
95
+ .command === oldCommand);
96
+ expect(call).toBeDefined();
97
+ const commandModule = call[0];
98
+ expect(commandModule).toHaveProperty('handler');
99
+ // Invoke handler to verify it works with multi-word commands
100
+ expect(() => {
101
+ commandModule.handler({});
102
+ }).toThrow('process.exit called');
103
+ expect(uiCommandReferenceMock).toHaveBeenCalledWith(newCommand);
104
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith(`Did you mean \`${newCommand}\`?`);
105
+ commandSpyForTest.mockRestore();
106
+ }
107
+ vi.restoreAllMocks();
108
+ });
109
+ });
110
+ describe('commandSuggestionMappings', () => {
111
+ it('all mappings point to valid new commands', () => {
112
+ Object.entries(commandSuggestionMappings).forEach(([, newCommand]) => {
113
+ expect(newCommand).toMatch(/^hs /);
114
+ expect(typeof newCommand).toBe('string');
115
+ expect(newCommand.length).toBeGreaterThan(0);
116
+ });
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,10 @@
1
+ import { Environment } from '@hubspot/local-dev-lib/types/Accounts';
2
+ import { HubSpotConfigAccount } from '@hubspot/local-dev-lib/types/Accounts';
3
+ type AuthenticateNewAccountOptions = {
4
+ env: Environment;
5
+ providedPersonalAccessKey?: string;
6
+ accountId?: number;
7
+ setAsDefaultAccount?: boolean;
8
+ };
9
+ export declare function authenticateNewAccount({ env, providedPersonalAccessKey, accountId, setAsDefaultAccount, }: AuthenticateNewAccountOptions): Promise<HubSpotConfigAccount | null>;
10
+ export {};