@hubspot/cli 8.0.10-experimental.1 → 8.0.10-experimental.2

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 (71) hide show
  1. package/commands/cms/__tests__/watch.test.js +0 -8
  2. package/commands/cms/function/logs.js +1 -0
  3. package/commands/cms/theme/preview.js +64 -11
  4. package/commands/cms/watch.d.ts +0 -1
  5. package/commands/cms/watch.js +2 -8
  6. package/commands/feedback.js +1 -1
  7. package/commands/mcp/__tests__/start.test.js +8 -1
  8. package/commands/mcp/setup.js +1 -9
  9. package/commands/mcp/start.js +0 -1
  10. package/commands/project/__tests__/create.test.js +1 -1
  11. package/commands/project/create.js +2 -2
  12. package/commands/project/watch.js +15 -2
  13. package/lang/en.d.ts +2 -6
  14. package/lang/en.js +2 -6
  15. package/lib/__tests__/serverlessLogs.test.js +71 -65
  16. package/lib/constants.d.ts +1 -1
  17. package/lib/constants.js +1 -1
  18. package/lib/generateSelectors.js +1 -2
  19. package/lib/mcp/__tests__/setup.test.js +357 -28
  20. package/lib/mcp/setup.d.ts +1 -0
  21. package/lib/mcp/setup.js +77 -30
  22. package/lib/projects/create/__tests__/legacy.test.js +6 -24
  23. package/lib/projects/create/index.js +1 -4
  24. package/lib/projects/create/legacy.js +3 -8
  25. package/lib/projects/create/v2.js +1 -9
  26. package/lib/projects/ensureProjectExists.js +1 -2
  27. package/lib/projects/pollProjectBuildAndDeploy.js +90 -85
  28. package/lib/projects/upload.d.ts +1 -0
  29. package/lib/projects/upload.js +37 -46
  30. package/lib/projects/watch.d.ts +2 -1
  31. package/lib/projects/watch.js +32 -24
  32. package/lib/serverlessLogs.js +50 -44
  33. package/mcp-server/tools/cms/HsCreateFunctionTool.js +1 -1
  34. package/mcp-server/tools/cms/HsCreateModuleTool.js +1 -1
  35. package/mcp-server/tools/cms/HsCreateTemplateTool.js +1 -1
  36. package/mcp-server/tools/cms/HsFunctionLogsTool.js +1 -1
  37. package/mcp-server/tools/cms/HsListFunctionsTool.js +1 -1
  38. package/mcp-server/tools/cms/HsListTool.js +1 -1
  39. package/mcp-server/tools/cms/__tests__/HsCreateFunctionTool.test.js +1 -2
  40. package/mcp-server/tools/cms/__tests__/HsCreateModuleTool.test.js +1 -2
  41. package/mcp-server/tools/cms/__tests__/HsCreateTemplateTool.test.js +1 -2
  42. package/mcp-server/tools/cms/__tests__/HsFunctionLogsTool.test.js +1 -2
  43. package/mcp-server/tools/cms/__tests__/HsListFunctionsTool.test.js +1 -2
  44. package/mcp-server/tools/cms/__tests__/HsListTool.test.js +1 -2
  45. package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +1 -1
  46. package/mcp-server/tools/project/AddFeatureToProjectTool.js +1 -1
  47. package/mcp-server/tools/project/CreateProjectTool.d.ts +1 -1
  48. package/mcp-server/tools/project/CreateProjectTool.js +1 -1
  49. package/mcp-server/tools/project/CreateTestAccountTool.js +1 -1
  50. package/mcp-server/tools/project/DeployProjectTool.js +1 -1
  51. package/mcp-server/tools/project/UploadProjectTools.js +1 -1
  52. package/mcp-server/tools/project/ValidateProjectTool.js +1 -1
  53. package/mcp-server/tools/project/__tests__/AddFeatureToProjectTool.test.js +1 -2
  54. package/mcp-server/tools/project/__tests__/CreateProjectTool.test.js +1 -2
  55. package/mcp-server/tools/project/__tests__/CreateTestAccountTool.test.js +1 -2
  56. package/mcp-server/tools/project/__tests__/DeployProjectTool.test.js +1 -2
  57. package/mcp-server/tools/project/__tests__/UploadProjectTools.test.js +10 -2
  58. package/mcp-server/tools/project/__tests__/ValidateProjectTool.test.js +2 -2
  59. package/mcp-server/tools/project/constants.d.ts +1 -1
  60. package/mcp-server/utils/__tests__/command.test.js +233 -3
  61. package/mcp-server/utils/__tests__/feedbackTracking.test.js +9 -64
  62. package/mcp-server/utils/command.d.ts +5 -0
  63. package/mcp-server/utils/command.js +24 -0
  64. package/mcp-server/utils/feedbackTracking.js +2 -17
  65. package/package.json +4 -4
  66. package/lib/cms/devServerProcess.d.ts +0 -13
  67. package/lib/cms/devServerProcess.js +0 -200
  68. package/mcp-server/utils/__tests__/project.test.d.ts +0 -1
  69. package/mcp-server/utils/__tests__/project.test.js +0 -140
  70. package/mcp-server/utils/project.d.ts +0 -5
  71. package/mcp-server/utils/project.js +0 -18
@@ -1,37 +1,24 @@
1
1
  import { execAsync } from '../../../mcp-server/utils/command.js';
2
- import { setupCodex, setupGemini, supportedTools } from '../setup.js';
2
+ import { setupCodex, setupGemini, setupClaudeCode, setupCursor, setupWindsurf, setupVsCode, addMcpServerToConfig, supportedTools, } from '../setup.js';
3
3
  import SpinniesManager from '../../ui/SpinniesManager.js';
4
4
  import { logError } from '../../errorHandlers/index.js';
5
+ import { uiLogger } from '../../ui/logger.js';
6
+ import { promptUser } from '../../prompts/promptUtils.js';
5
7
  import { commands } from '../../../lang/en.js';
8
+ import fs from 'fs-extra';
9
+ import { existsSync } from 'fs';
10
+ import os from 'os';
11
+ import path from 'path';
6
12
  // Mock dependencies
7
13
  vi.mock('../../../mcp-server/utils/command.js');
8
14
  vi.mock('../../ui/SpinniesManager.js');
9
15
  vi.mock('../../errorHandlers/index.js');
10
- vi.mock('../../../lang/en.js', () => ({
11
- commands: {
12
- mcp: {
13
- setup: {
14
- codex: 'Codex CLI',
15
- claudeCode: 'Claude Code',
16
- cursor: 'Cursor',
17
- gemini: 'Gemini CLI',
18
- vsCode: 'VS Code',
19
- windsurf: 'Windsurf',
20
- success: vi.fn(targets => `Success message for ${targets.join(', ')}`),
21
- spinners: {
22
- configuringCodex: 'Configuring Codex...',
23
- configuredCodex: 'Configured Codex',
24
- codexNotFound: 'Codex command not found - skipping configuration',
25
- codexInstallFailed: 'Failed to configure Codex',
26
- configuringGemini: 'Configuring Gemini CLI...',
27
- configuredGemini: 'Configured Gemini CLI',
28
- geminiNotFound: 'Gemini CLI not found - skipping configuration',
29
- geminiInstallFailed: 'Failed to configure Gemini CLI',
30
- },
31
- },
32
- },
33
- },
34
- }));
16
+ vi.mock('../../ui/logger.js');
17
+ vi.mock('../../prompts/promptUtils.js');
18
+ vi.mock('fs-extra');
19
+ vi.mock('fs');
20
+ vi.mock('os');
21
+ vi.mock('path');
35
22
  const mockedExecAsync = vi.mocked(execAsync);
36
23
  const mockedSpinniesManager = vi.mocked(SpinniesManager);
37
24
  const mockedLogError = vi.mocked(logError);
@@ -132,6 +119,21 @@ describe('lib/mcp/setup', () => {
132
119
  });
133
120
  expect(mockedLogError).toHaveBeenCalledWith(error);
134
121
  });
122
+ it('should pass through environment variables in command', async () => {
123
+ const mockMcpCommandWithEnv = {
124
+ command: 'test-command',
125
+ args: ['--arg1'],
126
+ env: { HUBSPOT_MCP_STANDALONE: 'true' },
127
+ };
128
+ mockedExecAsync.mockResolvedValueOnce({
129
+ stdout: 'codex version 1.0.0',
130
+ stderr: '',
131
+ });
132
+ mockedExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
133
+ const result = await setupCodex(mockMcpCommandWithEnv);
134
+ expect(result).toBe(true);
135
+ expect(mockedExecAsync).toHaveBeenCalledWith('codex mcp add "HubSpotDev" --env HUBSPOT_MCP_STANDALONE="true" -- test-command --arg1 --ai-agent codex');
136
+ });
135
137
  });
136
138
  describe('setupGemini', () => {
137
139
  const mockMcpCommand = {
@@ -189,6 +191,333 @@ describe('lib/mcp/setup', () => {
189
191
  expect(mockedLogError).toHaveBeenCalledWith(error);
190
192
  });
191
193
  });
192
- // Note: addMcpServerToConfig integration tests would require mocking many dependencies
193
- // and complex setup. The setupCodex function tests above cover the new functionality.
194
+ describe('setupClaudeCode', () => {
195
+ const mockMcpCommand = {
196
+ command: 'test-command',
197
+ args: ['--arg1', '--arg2'],
198
+ };
199
+ it('should successfully configure Claude Code when command is available', async () => {
200
+ mockedExecAsync
201
+ .mockResolvedValueOnce({ stdout: 'claude version 1.0.0', stderr: '' })
202
+ .mockResolvedValueOnce({ stdout: '', stderr: '' })
203
+ .mockResolvedValueOnce({ stdout: '', stderr: '' });
204
+ const result = await setupClaudeCode(mockMcpCommand);
205
+ expect(result).toBe(true);
206
+ expect(mockedSpinniesManager.add).toHaveBeenCalledWith('claudeCode', {
207
+ text: commands.mcp.setup.spinners.configuringClaudeCode,
208
+ });
209
+ expect(mockedExecAsync).toHaveBeenCalledWith('claude --version');
210
+ expect(mockedExecAsync).toHaveBeenCalledWith('claude mcp list');
211
+ expect(mockedSpinniesManager.succeed).toHaveBeenCalledWith('claudeCode', {
212
+ text: commands.mcp.setup.spinners.configuredClaudeCode,
213
+ });
214
+ });
215
+ it('should remove and re-add when server is already installed', async () => {
216
+ mockedExecAsync
217
+ .mockResolvedValueOnce({ stdout: 'claude version 1.0.0', stderr: '' })
218
+ .mockResolvedValueOnce({ stdout: 'HubSpotDev some-config', stderr: '' })
219
+ .mockResolvedValueOnce({ stdout: '', stderr: '' })
220
+ .mockResolvedValueOnce({ stdout: '', stderr: '' });
221
+ const result = await setupClaudeCode(mockMcpCommand);
222
+ expect(result).toBe(true);
223
+ expect(mockedSpinniesManager.update).toHaveBeenCalledWith('claudeCode', {
224
+ text: commands.mcp.setup.spinners.alreadyInstalled,
225
+ });
226
+ expect(mockedExecAsync).toHaveBeenCalledWith('claude mcp remove "HubSpotDev" --scope user');
227
+ });
228
+ it('should use default mcp command when none provided', async () => {
229
+ mockedExecAsync
230
+ .mockResolvedValueOnce({ stdout: 'claude version 1.0.0', stderr: '' })
231
+ .mockResolvedValueOnce({ stdout: '', stderr: '' })
232
+ .mockResolvedValueOnce({ stdout: '', stderr: '' });
233
+ const result = await setupClaudeCode();
234
+ expect(result).toBe(true);
235
+ expect(mockedExecAsync).toHaveBeenCalledWith(expect.stringContaining('claude mcp add-json "HubSpotDev"'));
236
+ });
237
+ it('should return false when claude command is not found', async () => {
238
+ mockedExecAsync.mockRejectedValueOnce(new Error('claude: command not found'));
239
+ const result = await setupClaudeCode(mockMcpCommand);
240
+ expect(result).toBe(false);
241
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('claudeCode', {
242
+ text: commands.mcp.setup.spinners.claudeCodeNotFound,
243
+ });
244
+ });
245
+ it('should return false and log error when mcp add fails', async () => {
246
+ const error = new Error('mcp add failed');
247
+ mockedExecAsync
248
+ .mockResolvedValueOnce({ stdout: 'claude version 1.0.0', stderr: '' })
249
+ .mockResolvedValueOnce({ stdout: '', stderr: '' })
250
+ .mockRejectedValueOnce(error);
251
+ const result = await setupClaudeCode(mockMcpCommand);
252
+ expect(result).toBe(false);
253
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('claudeCode', {
254
+ text: commands.mcp.setup.spinners.claudeCodeInstallFailed,
255
+ });
256
+ expect(mockedLogError).toHaveBeenCalledWith(error);
257
+ });
258
+ });
259
+ describe('setupCursor', () => {
260
+ const mockedFs = vi.mocked(fs);
261
+ const mockedExistsSync = vi.mocked(existsSync);
262
+ const mockMcpCommand = {
263
+ command: 'test-command',
264
+ args: ['--arg1'],
265
+ };
266
+ beforeEach(() => {
267
+ vi.mocked(os.homedir).mockReturnValue('/home/user');
268
+ vi.mocked(path.join).mockImplementation((...parts) => parts.join('/'));
269
+ });
270
+ it('should successfully configure Cursor when config file exists', () => {
271
+ mockedExistsSync.mockReturnValue(true);
272
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ mcpServers: { existingServer: {} } }));
273
+ const result = setupCursor(mockMcpCommand);
274
+ expect(result).toBe(true);
275
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('.cursor/mcp.json'), expect.stringContaining('HubSpotDev'));
276
+ expect(mockedSpinniesManager.succeed).toHaveBeenCalledWith('spinner', {
277
+ text: commands.mcp.setup.spinners.configuredCursor,
278
+ });
279
+ });
280
+ it('should create config file when it does not exist', () => {
281
+ mockedExistsSync.mockReturnValue(false);
282
+ mockedFs.readFileSync.mockReturnValue('{}');
283
+ const result = setupCursor(mockMcpCommand);
284
+ expect(result).toBe(true);
285
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('.cursor/mcp.json'), JSON.stringify({}, null, 2));
286
+ });
287
+ it('should handle empty config file', () => {
288
+ mockedExistsSync.mockReturnValue(true);
289
+ mockedFs.readFileSync.mockReturnValue(' ');
290
+ const result = setupCursor(mockMcpCommand);
291
+ expect(result).toBe(true);
292
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
293
+ expect(writeCall).toBeDefined();
294
+ });
295
+ it('should return false when config file has invalid JSON', () => {
296
+ mockedExistsSync.mockReturnValue(true);
297
+ mockedFs.readFileSync.mockReturnValue('not valid json {{{');
298
+ const result = setupCursor(mockMcpCommand);
299
+ expect(result).toBe(false);
300
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('spinner', {
301
+ text: commands.mcp.setup.spinners.failedToConfigureCursor,
302
+ });
303
+ });
304
+ it('should return false when reading config file fails', () => {
305
+ const error = new Error('Permission denied');
306
+ mockedExistsSync.mockReturnValue(true);
307
+ mockedFs.readFileSync.mockImplementation(() => {
308
+ throw error;
309
+ });
310
+ const result = setupCursor(mockMcpCommand);
311
+ expect(result).toBe(false);
312
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('spinner', {
313
+ text: commands.mcp.setup.spinners.failedToConfigureCursor,
314
+ });
315
+ expect(mockedLogError).toHaveBeenCalledWith(error);
316
+ });
317
+ it('should use default mcp command when none provided', () => {
318
+ mockedExistsSync.mockReturnValue(true);
319
+ mockedFs.readFileSync.mockReturnValue('{}');
320
+ const result = setupCursor();
321
+ expect(result).toBe(true);
322
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
323
+ const written = JSON.parse(writeCall[1]);
324
+ expect(written.mcpServers.HubSpotDev.command).toBe('hs');
325
+ });
326
+ it('should initialize mcpServers when missing from existing config', () => {
327
+ mockedExistsSync.mockReturnValue(true);
328
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ someOtherKey: true }));
329
+ const result = setupCursor(mockMcpCommand);
330
+ expect(result).toBe(true);
331
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
332
+ const written = JSON.parse(writeCall[1]);
333
+ expect(written.mcpServers).toBeDefined();
334
+ expect(written.mcpServers.HubSpotDev).toBeDefined();
335
+ });
336
+ });
337
+ describe('setupWindsurf', () => {
338
+ const mockedFs = vi.mocked(fs);
339
+ const mockedExistsSync = vi.mocked(existsSync);
340
+ const mockMcpCommand = {
341
+ command: 'test-command',
342
+ args: ['--arg1'],
343
+ };
344
+ beforeEach(() => {
345
+ vi.mocked(os.homedir).mockReturnValue('/home/user');
346
+ vi.mocked(path.join).mockImplementation((...parts) => parts.join('/'));
347
+ });
348
+ it('should successfully configure Windsurf', () => {
349
+ mockedExistsSync.mockReturnValue(true);
350
+ mockedFs.readFileSync.mockReturnValue('{}');
351
+ const result = setupWindsurf(mockMcpCommand);
352
+ expect(result).toBe(true);
353
+ expect(mockedSpinniesManager.add).toHaveBeenCalledWith('spinner', {
354
+ text: commands.mcp.setup.spinners.configuringWindsurf,
355
+ });
356
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('.codeium/windsurf/mcp_config.json'), expect.stringContaining('HubSpotDev'));
357
+ expect(mockedSpinniesManager.succeed).toHaveBeenCalledWith('spinner', {
358
+ text: commands.mcp.setup.spinners.configuredWindsurf,
359
+ });
360
+ });
361
+ it('should create config file when it does not exist', () => {
362
+ mockedExistsSync.mockReturnValue(false);
363
+ mockedFs.readFileSync.mockReturnValue('{}');
364
+ const result = setupWindsurf(mockMcpCommand);
365
+ expect(result).toBe(true);
366
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('.codeium/windsurf/mcp_config.json'), JSON.stringify({}, null, 2));
367
+ });
368
+ it('should return false on invalid JSON', () => {
369
+ mockedExistsSync.mockReturnValue(true);
370
+ mockedFs.readFileSync.mockReturnValue('{ invalid json');
371
+ const result = setupWindsurf(mockMcpCommand);
372
+ expect(result).toBe(false);
373
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('spinner', {
374
+ text: commands.mcp.setup.spinners.failedToConfigureWindsurf,
375
+ });
376
+ });
377
+ it('should use default mcp command when none provided', () => {
378
+ mockedExistsSync.mockReturnValue(true);
379
+ mockedFs.readFileSync.mockReturnValue('{}');
380
+ const result = setupWindsurf();
381
+ expect(result).toBe(true);
382
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
383
+ const written = JSON.parse(writeCall[1]);
384
+ expect(written.mcpServers.HubSpotDev.command).toBe('hs');
385
+ });
386
+ });
387
+ describe('setupVsCode', () => {
388
+ const mockMcpCommand = {
389
+ command: 'test-command',
390
+ args: ['--arg1'],
391
+ };
392
+ it('should successfully configure VS Code', async () => {
393
+ mockedExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
394
+ const result = await setupVsCode(mockMcpCommand);
395
+ expect(result).toBe(true);
396
+ expect(mockedSpinniesManager.add).toHaveBeenCalledWith('vsCode', {
397
+ text: commands.mcp.setup.spinners.configuringVsCode,
398
+ });
399
+ expect(mockedExecAsync).toHaveBeenCalledWith(expect.stringContaining('code --add-mcp'));
400
+ expect(mockedSpinniesManager.succeed).toHaveBeenCalledWith('vsCode', {
401
+ text: commands.mcp.setup.spinners.configuredVsCode,
402
+ });
403
+ });
404
+ it('should use default mcp command when none provided', async () => {
405
+ mockedExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
406
+ const result = await setupVsCode();
407
+ expect(result).toBe(true);
408
+ expect(mockedExecAsync).toHaveBeenCalledWith(expect.stringContaining('code --add-mcp'));
409
+ });
410
+ it('should return false when code command is not found', async () => {
411
+ mockedExecAsync.mockRejectedValueOnce(new Error('code: command not found'));
412
+ const result = await setupVsCode(mockMcpCommand);
413
+ expect(result).toBe(false);
414
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('vsCode', {
415
+ text: commands.mcp.setup.spinners.vsCodeNotFound,
416
+ });
417
+ expect(mockedLogError).not.toHaveBeenCalled();
418
+ });
419
+ it('should return false and log error on other failures', async () => {
420
+ const error = new Error('Unexpected failure');
421
+ mockedExecAsync.mockRejectedValueOnce(error);
422
+ const result = await setupVsCode(mockMcpCommand);
423
+ expect(result).toBe(false);
424
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('vsCode', {
425
+ text: commands.mcp.setup.spinners.failedToConfigureVsCode,
426
+ });
427
+ expect(mockedLogError).toHaveBeenCalledWith(error);
428
+ });
429
+ });
430
+ describe('addMcpServerToConfig', () => {
431
+ const mockedPromptUser = vi.mocked(promptUser);
432
+ const mockedExistsSync = vi.mocked(existsSync);
433
+ const mockedFs = vi.mocked(fs);
434
+ const mockedUiLogger = vi.mocked(uiLogger);
435
+ beforeEach(() => {
436
+ vi.mocked(os.homedir).mockReturnValue('/home/user');
437
+ vi.mocked(path.join).mockImplementation((...parts) => parts.join('/'));
438
+ mockedExistsSync.mockReturnValue(true);
439
+ mockedFs.readFileSync.mockReturnValue('{}');
440
+ });
441
+ it('should use provided targets without prompting', async () => {
442
+ mockedPromptUser.mockResolvedValueOnce({ useStandaloneMode: false });
443
+ mockedExecAsync
444
+ .mockResolvedValueOnce({ stdout: '', stderr: '' })
445
+ .mockResolvedValueOnce({ stdout: '', stderr: '' });
446
+ const result = await addMcpServerToConfig(['cursor']);
447
+ expect(result).toEqual(['cursor']);
448
+ expect(mockedPromptUser).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'selectedTargets' }));
449
+ });
450
+ it('should prompt for targets when none provided', async () => {
451
+ mockedPromptUser
452
+ .mockResolvedValueOnce({ selectedTargets: ['cursor'] })
453
+ .mockResolvedValueOnce({ useStandaloneMode: false });
454
+ mockedExistsSync.mockReturnValue(true);
455
+ mockedFs.readFileSync.mockReturnValue('{}');
456
+ const result = await addMcpServerToConfig(undefined);
457
+ expect(result).toEqual(['cursor']);
458
+ expect(mockedPromptUser).toHaveBeenCalledWith(expect.objectContaining({ name: 'selectedTargets' }));
459
+ });
460
+ it('should prompt for targets when empty array provided', async () => {
461
+ mockedPromptUser
462
+ .mockResolvedValueOnce({ selectedTargets: ['windsurf'] })
463
+ .mockResolvedValueOnce({ useStandaloneMode: false });
464
+ mockedExistsSync.mockReturnValue(true);
465
+ mockedFs.readFileSync.mockReturnValue('{}');
466
+ const result = await addMcpServerToConfig([]);
467
+ expect(result).toEqual(['windsurf']);
468
+ expect(mockedPromptUser).toHaveBeenCalledWith(expect.objectContaining({ name: 'selectedTargets' }));
469
+ });
470
+ it('should use npx command in standalone mode', async () => {
471
+ mockedPromptUser
472
+ .mockResolvedValueOnce({ useStandaloneMode: true })
473
+ .mockResolvedValueOnce({ cliVersion: '' });
474
+ mockedExistsSync.mockReturnValue(true);
475
+ mockedFs.readFileSync.mockReturnValue('{}');
476
+ const result = await addMcpServerToConfig(['cursor']);
477
+ expect(result).toEqual(['cursor']);
478
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
479
+ const written = JSON.parse(writeCall[1]);
480
+ expect(written.mcpServers.HubSpotDev.command).toBe('npx');
481
+ expect(written.mcpServers.HubSpotDev.env?.HUBSPOT_MCP_STANDALONE).toBe('true');
482
+ });
483
+ it('should pin version in standalone mode when version is provided', async () => {
484
+ mockedPromptUser
485
+ .mockResolvedValueOnce({ useStandaloneMode: true })
486
+ .mockResolvedValueOnce({ cliVersion: '8.0.1' });
487
+ mockedExistsSync.mockReturnValue(true);
488
+ mockedFs.readFileSync.mockReturnValue('{}');
489
+ const result = await addMcpServerToConfig(['cursor']);
490
+ expect(result).toEqual(['cursor']);
491
+ const writeCall = mockedFs.writeFileSync.mock.calls.find(c => c[1].includes('HubSpotDev'));
492
+ const written = JSON.parse(writeCall[1]);
493
+ expect(written.mcpServers.HubSpotDev.args).toContain('@hubspot/cli@8.0.1');
494
+ expect(written.mcpServers.HubSpotDev.env?.HUBSPOT_CLI_VERSION).toBe('8.0.1');
495
+ });
496
+ it('should call success logger after all targets are configured', async () => {
497
+ mockedPromptUser.mockResolvedValueOnce({ useStandaloneMode: false });
498
+ mockedExistsSync.mockReturnValue(true);
499
+ mockedFs.readFileSync.mockReturnValue('{}');
500
+ await addMcpServerToConfig(['cursor', 'windsurf']);
501
+ expect(mockedUiLogger.info).toHaveBeenCalledWith(commands.mcp.setup.success(['cursor', 'windsurf']));
502
+ });
503
+ it('should throw and fail spinner when setup function returns false', async () => {
504
+ mockedPromptUser.mockResolvedValueOnce({ useStandaloneMode: false });
505
+ const error = new Error('Permission denied');
506
+ mockedExistsSync.mockReturnValue(true);
507
+ mockedFs.readFileSync.mockImplementation(() => {
508
+ throw error;
509
+ });
510
+ await expect(addMcpServerToConfig(['cursor'])).rejects.toThrow();
511
+ expect(mockedSpinniesManager.fail).toHaveBeenCalledWith('mcpSetup', {
512
+ text: commands.mcp.setup.spinners.failedToConfigure,
513
+ });
514
+ });
515
+ it('should configure multiple targets', async () => {
516
+ mockedPromptUser.mockResolvedValueOnce({ useStandaloneMode: false });
517
+ mockedExistsSync.mockReturnValue(true);
518
+ mockedFs.readFileSync.mockReturnValue('{}');
519
+ const result = await addMcpServerToConfig(['cursor', 'windsurf']);
520
+ expect(result).toEqual(['cursor', 'windsurf']);
521
+ });
522
+ });
194
523
  });
@@ -5,6 +5,7 @@ export declare const supportedTools: {
5
5
  interface McpCommand {
6
6
  command: string;
7
7
  args: string[];
8
+ env?: Record<string, string>;
8
9
  }
9
10
  export declare function addMcpServerToConfig(targets: string[] | undefined): Promise<string[]>;
10
11
  export declare function setupVsCode(mcpCommand?: McpCommand): Promise<boolean>;
package/lib/mcp/setup.js CHANGED
@@ -47,23 +47,56 @@ export async function addMcpServerToConfig(targets) {
47
47
  else {
48
48
  derivedTargets = targets;
49
49
  }
50
+ // Prompt for standalone mode
51
+ const { useStandaloneMode } = await promptUser({
52
+ name: 'useStandaloneMode',
53
+ type: 'confirm',
54
+ message: commands.mcp.setup.prompts.standaloneMode,
55
+ default: false,
56
+ });
57
+ const { cliVersion } = useStandaloneMode
58
+ ? await promptUser({
59
+ name: 'cliVersion',
60
+ type: 'input',
61
+ message: commands.mcp.setup.prompts.cliVersion,
62
+ validate: (v) => !v || /^[\d]+\.[\d]+\.[\d]+([-+][\w.]+)?$/.test(v.trim())
63
+ ? true
64
+ : 'Please enter a valid semver version (e.g. 8.0.1) or leave blank for latest.',
65
+ })
66
+ : { cliVersion: '' };
67
+ const cliPackage = cliVersion
68
+ ? `@hubspot/cli@${cliVersion}`
69
+ : '@hubspot/cli';
70
+ const standaloneEnv = {
71
+ HUBSPOT_MCP_STANDALONE: 'true',
72
+ };
73
+ if (cliVersion) {
74
+ standaloneEnv.HUBSPOT_CLI_VERSION = cliVersion;
75
+ }
76
+ const mcpCommand = useStandaloneMode
77
+ ? {
78
+ command: 'npx',
79
+ args: ['-y', '-p', cliPackage, 'hs', 'mcp', 'start'],
80
+ env: standaloneEnv,
81
+ }
82
+ : defaultMcpCommand;
50
83
  if (derivedTargets.includes(claudeCode)) {
51
- await runSetupFunction(setupClaudeCode);
84
+ await runSetupFunction(() => setupClaudeCode(mcpCommand));
52
85
  }
53
86
  if (derivedTargets.includes(cursor)) {
54
- await runSetupFunction(setupCursor);
87
+ await runSetupFunction(() => setupCursor(mcpCommand));
55
88
  }
56
89
  if (derivedTargets.includes(windsurf)) {
57
- await runSetupFunction(setupWindsurf);
90
+ await runSetupFunction(() => setupWindsurf(mcpCommand));
58
91
  }
59
92
  if (derivedTargets.includes(vscode)) {
60
- await runSetupFunction(setupVsCode);
93
+ await runSetupFunction(() => setupVsCode(mcpCommand));
61
94
  }
62
95
  if (derivedTargets.includes(codex)) {
63
- await runSetupFunction(setupCodex);
96
+ await runSetupFunction(() => setupCodex(mcpCommand));
64
97
  }
65
98
  if (derivedTargets.includes(gemini)) {
66
- await runSetupFunction(setupGemini);
99
+ await runSetupFunction(() => setupGemini(mcpCommand));
67
100
  }
68
101
  uiLogger.info(commands.mcp.setup.success(derivedTargets));
69
102
  return derivedTargets;
@@ -121,10 +154,7 @@ function setupMcpConfigFile(config) {
121
154
  if (!mcpConfig.mcpServers) {
122
155
  mcpConfig.mcpServers = {};
123
156
  }
124
- // Add or update HubSpot CLI MCP server
125
- mcpConfig.mcpServers[mcpServerName] = {
126
- ...config.mcpCommand,
127
- };
157
+ mcpConfig.mcpServers[mcpServerName] = config.mcpCommand;
128
158
  // Write the updated config
129
159
  fs.writeFileSync(config.configPath, JSON.stringify(mcpConfig, null, 2));
130
160
  SpinniesManager.succeed('spinner', {
@@ -145,10 +175,12 @@ export async function setupVsCode(mcpCommand = defaultMcpCommand) {
145
175
  SpinniesManager.add('vsCode', {
146
176
  text: commands.mcp.setup.spinners.configuringVsCode,
147
177
  });
148
- const mcpConfig = JSON.stringify({
178
+ const commandWithAgent = buildCommandWithAgentString(mcpCommand, vscode);
179
+ const configObject = {
149
180
  name: mcpServerName,
150
- ...buildCommandWithAgentString(mcpCommand, vscode),
151
- });
181
+ ...commandWithAgent,
182
+ };
183
+ const mcpConfig = JSON.stringify(configObject);
152
184
  await execAsync(`code --add-mcp ${JSON.stringify(mcpConfig)}`);
153
185
  SpinniesManager.succeed('vsCode', {
154
186
  text: commands.mcp.setup.spinners.configuredVsCode,
@@ -172,25 +204,27 @@ export async function setupVsCode(mcpCommand = defaultMcpCommand) {
172
204
  }
173
205
  }
174
206
  export async function setupClaudeCode(mcpCommand = defaultMcpCommand) {
207
+ SpinniesManager.add('claudeCode', {
208
+ text: commands.mcp.setup.spinners.configuringClaudeCode,
209
+ });
175
210
  try {
176
- SpinniesManager.add('claudeCode', {
177
- text: commands.mcp.setup.spinners.configuringClaudeCode,
211
+ // Check if claude command is available
212
+ await execAsync('claude --version');
213
+ }
214
+ catch (e) {
215
+ SpinniesManager.fail('claudeCode', {
216
+ text: commands.mcp.setup.spinners.claudeCodeNotFound,
178
217
  });
179
- try {
180
- // Check if claude command is available
181
- await execAsync('claude --version');
182
- }
183
- catch (e) {
184
- SpinniesManager.fail('claudeCode', {
185
- text: commands.mcp.setup.spinners.claudeCodeNotFound,
186
- });
187
- return false;
188
- }
218
+ return false;
219
+ }
220
+ try {
189
221
  // Run claude mcp add command
190
- const mcpConfig = JSON.stringify({
222
+ const commandWithAgent = buildCommandWithAgentString(mcpCommand, claudeCode);
223
+ const configObject = {
191
224
  type: 'stdio',
192
- ...buildCommandWithAgentString(mcpCommand, claudeCode),
193
- });
225
+ ...commandWithAgent,
226
+ };
227
+ const mcpConfig = JSON.stringify(configObject);
194
228
  const { stdout } = await execAsync('claude mcp list');
195
229
  if (stdout.includes(mcpServerName)) {
196
230
  SpinniesManager.update('claudeCode', {
@@ -248,7 +282,7 @@ export async function setupCodex(mcpCommand = defaultMcpCommand) {
248
282
  return false;
249
283
  }
250
284
  const mcpCommandWithAgent = buildCommandWithAgentString(mcpCommand, codex);
251
- await execAsync(`codex mcp add "${mcpServerName}" -- ${mcpCommandWithAgent.command} ${mcpCommandWithAgent.args.join(' ')}`);
285
+ await execAsync(`codex mcp add "${mcpServerName}"${buildEnvFlagString(mcpCommand)} -- ${mcpCommandWithAgent.command} ${mcpCommandWithAgent.args.join(' ')}`);
252
286
  SpinniesManager.succeed('codexSpinner', {
253
287
  text: commands.mcp.setup.spinners.configuredCodex,
254
288
  });
@@ -277,7 +311,7 @@ export async function setupGemini(mcpCommand = defaultMcpCommand) {
277
311
  return false;
278
312
  }
279
313
  const mcpCommandWithAgent = buildCommandWithAgentString(mcpCommand, gemini);
280
- await execAsync(`gemini mcp add -s user "${mcpServerName}" ${mcpCommandWithAgent.command} ${mcpCommandWithAgent.args.join(' ')}`);
314
+ await execAsync(`gemini mcp add -s user${buildEnvFlagString(mcpCommand)} "${mcpServerName}" ${mcpCommandWithAgent.command} ${mcpCommandWithAgent.args.join(' ')}`);
281
315
  SpinniesManager.succeed('geminiSpinner', {
282
316
  text: commands.mcp.setup.spinners.configuredGemini,
283
317
  });
@@ -296,3 +330,16 @@ function buildCommandWithAgentString(mcpCommand, agent) {
296
330
  mcpCommandCopy.args.push('--ai-agent', agent);
297
331
  return mcpCommandCopy;
298
332
  }
333
+ function buildEnvFlagString(mcpCommand) {
334
+ const envFlags = [];
335
+ if (mcpCommand.env) {
336
+ const env = Object.entries(mcpCommand.env);
337
+ env.forEach(([key, value]) => {
338
+ envFlags.push(`--env ${key}="${value}"`);
339
+ });
340
+ }
341
+ if (envFlags.length === 0) {
342
+ return '';
343
+ }
344
+ return ` ${envFlags.join(' ')}`;
345
+ }
@@ -1,6 +1,4 @@
1
- import { uiLogger } from '../../../ui/logger.js';
2
1
  import * as github from '@hubspot/local-dev-lib/api/github';
3
- import { EXIT_CODES } from '../../../enums/exitCodes.js';
4
2
  import { getProjectComponentListFromRepo, getProjectTemplateListFromRepo, } from '../legacy.js';
5
3
  import { PROJECT_COMPONENT_TYPES, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, } from '../../../constants.js';
6
4
  vi.mock('@hubspot/local-dev-lib/api/github');
@@ -38,16 +36,6 @@ describe('lib/projects/create/legacy', () => {
38
36
  });
39
37
  });
40
38
  describe('getProjectTemplateListFromRepo()', () => {
41
- let exitMock;
42
- beforeEach(() => {
43
- // @ts-expect-error - Mocking process.exit
44
- exitMock = vi
45
- .spyOn(process, 'exit')
46
- .mockImplementation(() => undefined);
47
- });
48
- afterEach(() => {
49
- exitMock.mockRestore();
50
- });
51
39
  it('returns a list of project templates', async () => {
52
40
  // @ts-expect-error - Mocking AxiosResponse
53
41
  mockedFetchRepoFile.mockResolvedValue({
@@ -56,20 +44,16 @@ describe('lib/projects/create/legacy', () => {
56
44
  const templates = await getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref');
57
45
  expect(templates).toEqual(repoConfig[PROJECT_COMPONENT_TYPES.PROJECTS]);
58
46
  });
59
- it('Logs an error and exits the process if the request for the template list fails', async () => {
47
+ it('throws an error if the request for the template list fails', async () => {
60
48
  mockedFetchRepoFile.mockRejectedValue(new Error('Not found'));
61
- await getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref');
62
- expect(uiLogger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to fetch the config.json file from the target repository/));
63
- expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
49
+ await expect(getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref')).rejects.toThrow('Failed to fetch the config.json file from the target repository');
64
50
  });
65
- it('Logs an error and exits the process if there are no projects listed in the repo config', async () => {
51
+ it('throws an error if there are no projects listed in the repo config', async () => {
66
52
  // @ts-expect-error - Mocking AxiosResponse
67
53
  mockedFetchRepoFile.mockResolvedValue({});
68
- await getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref');
69
- expect(uiLogger.error).toHaveBeenCalledWith(expect.stringMatching(/Unable to find any projects in the target repository's config.json file/));
70
- expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
54
+ await expect(getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref')).rejects.toThrow('Unable to find any projects in the target repository');
71
55
  });
72
- it('Logs an error and exits the process if any of the projects in the repo config are missing required properties', async () => {
56
+ it('throws an error if any of the projects in the repo config are missing required properties', async () => {
73
57
  // @ts-expect-error - Mocking AxiosResponse
74
58
  mockedFetchRepoFile.mockResolvedValue({
75
59
  data: {
@@ -82,9 +66,7 @@ describe('lib/projects/create/legacy', () => {
82
66
  ],
83
67
  },
84
68
  });
85
- await getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref');
86
- expect(uiLogger.error).toHaveBeenCalledWith(expect.stringMatching(/Found misconfigured projects in the target repository's config.json file/));
87
- expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
69
+ await expect(getProjectTemplateListFromRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, 'gh-ref')).rejects.toThrow('Found misconfigured projects in the target repository');
88
70
  });
89
71
  });
90
72
  });
@@ -4,9 +4,7 @@ import { DEFAULT_PROJECT_TEMPLATE_BRANCH, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH
4
4
  import { isV2Project } from '../platformVersion.js';
5
5
  import { v2ComponentFlow } from './v2.js';
6
6
  import { getProjectTemplateListFromRepo } from './legacy.js';
7
- import { uiLogger } from '../../ui/logger.js';
8
7
  import { commands } from '../../../lang/en.js';
9
- import { EXIT_CODES } from '../../enums/exitCodes.js';
10
8
  export async function handleProjectCreationFlow(args) {
11
9
  const { platformVersion, templateSource, projectBase, auth: providedAuth, distribution: providedDistribution, } = args;
12
10
  const repo = templateSource || HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH;
@@ -25,8 +23,7 @@ export async function handleProjectCreationFlow(args) {
25
23
  }
26
24
  const projectTemplates = await getProjectTemplateListFromRepo(repo, DEFAULT_PROJECT_TEMPLATE_BRANCH);
27
25
  if (!projectTemplates.length) {
28
- uiLogger.error(commands.project.create.errors.failedToFetchProjectList);
29
- process.exit(EXIT_CODES.ERROR);
26
+ throw new Error(commands.project.create.errors.failedToFetchProjectList);
30
27
  }
31
28
  const selectProjectTemplatePromptResponse = await selectProjectTemplatePrompt(args, projectTemplates);
32
29
  return {