@hubspot/cli 8.1.0-beta.0 → 8.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) 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 +9 -64
  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 +17 -6
  14. package/lang/en.js +18 -7
  15. package/lib/__tests__/commandSuggestion.test.js +2 -0
  16. package/lib/__tests__/serverlessLogs.test.js +79 -64
  17. package/lib/commandSuggestion.js +1 -7
  18. package/lib/constants.d.ts +1 -1
  19. package/lib/constants.js +1 -1
  20. package/lib/generateSelectors.js +1 -2
  21. package/lib/getStartedV2Actions.d.ts +13 -0
  22. package/lib/getStartedV2Actions.js +53 -0
  23. package/lib/mcp/__tests__/setup.test.js +357 -28
  24. package/lib/mcp/setup.d.ts +1 -0
  25. package/lib/mcp/setup.js +77 -30
  26. package/lib/projects/create/__tests__/legacy.test.js +6 -24
  27. package/lib/projects/create/index.js +1 -4
  28. package/lib/projects/create/legacy.js +3 -8
  29. package/lib/projects/create/v2.js +1 -9
  30. package/lib/projects/ensureProjectExists.js +1 -2
  31. package/lib/projects/pollProjectBuildAndDeploy.js +90 -85
  32. package/lib/projects/upload.d.ts +1 -0
  33. package/lib/projects/upload.js +37 -46
  34. package/lib/projects/watch.d.ts +2 -1
  35. package/lib/projects/watch.js +32 -24
  36. package/lib/serverlessLogs.js +50 -44
  37. package/lib/theme/cmsDevServerProcess.d.ts +12 -0
  38. package/lib/theme/cmsDevServerProcess.js +148 -0
  39. package/lib/theme/cmsDevServerRunner.d.ts +14 -0
  40. package/lib/theme/cmsDevServerRunner.js +90 -0
  41. package/lib/usageTracking.js +8 -5
  42. package/mcp-server/tools/cms/HsCreateFunctionTool.js +1 -1
  43. package/mcp-server/tools/cms/HsCreateModuleTool.js +1 -1
  44. package/mcp-server/tools/cms/HsCreateTemplateTool.js +1 -1
  45. package/mcp-server/tools/cms/HsFunctionLogsTool.js +1 -1
  46. package/mcp-server/tools/cms/HsListFunctionsTool.js +1 -1
  47. package/mcp-server/tools/cms/HsListTool.js +1 -1
  48. package/mcp-server/tools/cms/__tests__/HsCreateFunctionTool.test.js +1 -2
  49. package/mcp-server/tools/cms/__tests__/HsCreateModuleTool.test.js +1 -2
  50. package/mcp-server/tools/cms/__tests__/HsCreateTemplateTool.test.js +1 -2
  51. package/mcp-server/tools/cms/__tests__/HsFunctionLogsTool.test.js +1 -2
  52. package/mcp-server/tools/cms/__tests__/HsListFunctionsTool.test.js +1 -2
  53. package/mcp-server/tools/cms/__tests__/HsListTool.test.js +1 -2
  54. package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +20 -3
  55. package/mcp-server/tools/project/AddFeatureToProjectTool.js +7 -11
  56. package/mcp-server/tools/project/CreateProjectTool.d.ts +24 -4
  57. package/mcp-server/tools/project/CreateProjectTool.js +6 -11
  58. package/mcp-server/tools/project/CreateTestAccountTool.js +1 -1
  59. package/mcp-server/tools/project/DeployProjectTool.js +1 -1
  60. package/mcp-server/tools/project/GetApiUsagePatternsByAppIdTool.js +5 -8
  61. package/mcp-server/tools/project/GetBuildLogsTool.d.ts +2 -2
  62. package/mcp-server/tools/project/GetBuildLogsTool.js +6 -7
  63. package/mcp-server/tools/project/GetBuildStatusTool.d.ts +1 -1
  64. package/mcp-server/tools/project/GetBuildStatusTool.js +3 -4
  65. package/mcp-server/tools/project/GuidedWalkthroughTool.d.ts +6 -1
  66. package/mcp-server/tools/project/GuidedWalkthroughTool.js +1 -6
  67. package/mcp-server/tools/project/UploadProjectTools.js +1 -1
  68. package/mcp-server/tools/project/ValidateProjectTool.js +1 -1
  69. package/mcp-server/tools/project/__tests__/AddFeatureToProjectTool.test.js +1 -2
  70. package/mcp-server/tools/project/__tests__/CreateProjectTool.test.js +1 -2
  71. package/mcp-server/tools/project/__tests__/CreateTestAccountTool.test.js +1 -2
  72. package/mcp-server/tools/project/__tests__/DeployProjectTool.test.js +1 -2
  73. package/mcp-server/tools/project/__tests__/GetApiUsagePatternsByAppIdTool.test.js +0 -32
  74. package/mcp-server/tools/project/__tests__/UploadProjectTools.test.js +10 -2
  75. package/mcp-server/tools/project/__tests__/ValidateProjectTool.test.js +2 -2
  76. package/mcp-server/tools/project/constants.d.ts +12 -1
  77. package/mcp-server/tools/project/constants.js +12 -16
  78. package/mcp-server/utils/__tests__/command.test.js +233 -3
  79. package/mcp-server/utils/__tests__/feedbackTracking.test.js +9 -64
  80. package/mcp-server/utils/command.d.ts +5 -0
  81. package/mcp-server/utils/command.js +24 -0
  82. package/mcp-server/utils/feedbackTracking.js +2 -17
  83. package/package.json +4 -5
  84. package/ui/components/getStarted/GetStartedFlow.js +79 -2
  85. package/ui/components/getStarted/reducer.d.ts +20 -0
  86. package/ui/components/getStarted/reducer.js +36 -0
  87. package/ui/components/getStarted/screens/InstallationScreen.d.ts +7 -0
  88. package/ui/components/getStarted/screens/InstallationScreen.js +16 -0
  89. package/ui/components/getStarted/screens/ProjectSetupScreen.js +2 -1
  90. package/ui/lib/constants.d.ts +1 -0
  91. package/ui/lib/constants.js +1 -0
  92. package/mcp-server/utils/__tests__/project.test.d.ts +0 -1
  93. package/mcp-server/utils/__tests__/project.test.js +0 -140
  94. package/mcp-server/utils/project.d.ts +0 -5
  95. package/mcp-server/utils/project.js +0 -18
@@ -18,12 +18,6 @@ export const commandSuggestionMappings = {
18
18
  'theme generate-selectors': 'hs cms theme generate-selectors',
19
19
  'theme marketplace-validate': 'hs cms theme marketplace-validate',
20
20
  'theme preview': 'hs cms theme preview',
21
- 'custom-object schema create': 'hs custom-object create-schema',
22
- 'custom-object schema delete': 'hs custom-object delete-schema',
23
- 'custom-object schema fetch-all': 'hs custom-object fetch-all-schemas',
24
- 'custom-object schema fetch': 'hs custom-object fetch-schema',
25
- 'custom-object schema list': 'hs custom-object list-schemas',
26
- 'custom-object schema update': 'hs custom-object update-schema',
27
21
  };
28
22
  function createCommandSuggestionHandler(newCommand) {
29
23
  return () => {
@@ -35,7 +29,7 @@ function createCommandSuggestion(oldCommand, newCommand) {
35
29
  return {
36
30
  command: oldCommand,
37
31
  builder: async (yargs) => {
38
- return yargs.strict(false);
32
+ return yargs.strict(false).help(false).version(false);
39
33
  },
40
34
  handler: createCommandSuggestionHandler(newCommand),
41
35
  };
@@ -81,7 +81,6 @@ export declare const FEATURES: {
81
81
  readonly SANDBOXES_V2_CLI: "sandboxes:v2:cliEnabled";
82
82
  readonly APP_EVENTS: "Developers:UnifiedApps:AppEventsAccess";
83
83
  readonly APPS_HOME: "UIE:AppHome";
84
- readonly MCP_ACCESS: "Developers:CLIMCPAccess";
85
84
  readonly THEME_MIGRATION_2025_2: "Developers:ProjectThemeMigrations:2025.2";
86
85
  readonly AGENT_TOOLS: "ThirdPartyAgentTools";
87
86
  };
@@ -145,3 +144,4 @@ export declare const ACCOUNT_LEVELS: {
145
144
  readonly ENTERPRISE: "ENTERPRISE";
146
145
  };
147
146
  export declare const ACCOUNT_LEVEL_CHOICES: readonly ["FREE", "STARTER", "PROFESSIONAL", "ENTERPRISE"];
147
+ export declare const FEEDBACK_URL = "https://developers.hubspot.com/feedback";
package/lib/constants.js CHANGED
@@ -73,7 +73,6 @@ export const FEATURES = {
73
73
  SANDBOXES_V2_CLI: 'sandboxes:v2:cliEnabled',
74
74
  APP_EVENTS: 'Developers:UnifiedApps:AppEventsAccess',
75
75
  APPS_HOME: 'UIE:AppHome',
76
- MCP_ACCESS: 'Developers:CLIMCPAccess',
77
76
  THEME_MIGRATION_2025_2: 'Developers:ProjectThemeMigrations:2025.2',
78
77
  AGENT_TOOLS: 'ThirdPartyAgentTools',
79
78
  };
@@ -146,3 +145,4 @@ export const ACCOUNT_LEVEL_CHOICES = [
146
145
  ACCOUNT_LEVELS.PROFESSIONAL,
147
146
  ACCOUNT_LEVELS.ENTERPRISE,
148
147
  ];
148
+ export const FEEDBACK_URL = 'https://developers.hubspot.com/feedback';
@@ -1,5 +1,4 @@
1
1
  import fs from 'fs';
2
- import { EXIT_CODES } from './enums/exitCodes.js';
3
2
  import { commands } from '../lang/en.js';
4
3
  import { uiLogger } from './ui/logger.js';
5
4
  const CSS_COMMENTS_REGEX = new RegExp(/\/\*.*\*\//, 'g');
@@ -12,7 +11,7 @@ export function findFieldsJsonPath(basePath) {
12
11
  const _path = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
13
12
  if (!fs.existsSync(_path)) {
14
13
  uiLogger.error(commands.cms.subcommands.theme.subcommands.generateSelectors.errors.invalidPath(basePath));
15
- process.exit(EXIT_CODES.ERROR);
14
+ return null;
16
15
  }
17
16
  const files = fs.readdirSync(_path);
18
17
  if (files.includes('fields.json')) {
@@ -35,3 +35,16 @@ export declare function uploadAndDeployAction({ accountId, projectDest, }: {
35
35
  projectDest: string;
36
36
  }): Promise<UploadAndDeployResult>;
37
37
  export declare function trackGetStartedUsage(params: Record<string, unknown>, accountId: number): Promise<void>;
38
+ export type PollAppInstallationOptions = {
39
+ accountId: number;
40
+ projectId: number;
41
+ appUid: string;
42
+ requiredScopes?: string[];
43
+ optionalScopes?: string[];
44
+ timeoutMs?: number;
45
+ intervalMs?: number;
46
+ onTimeout?: () => void;
47
+ };
48
+ export declare function pollAppInstallation({ accountId, projectId, appUid, requiredScopes, optionalScopes, timeoutMs, // 2 minutes
49
+ intervalMs, // 2 seconds
50
+ onTimeout, }: PollAppInstallationOptions): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { fetchPublicAppsForPortal } from '@hubspot/local-dev-lib/api/appsDev';
4
+ import { fetchAppInstallationData } from '@hubspot/local-dev-lib/api/localDevAuth';
4
5
  import { fetchProject } from '@hubspot/local-dev-lib/api/projects';
5
6
  import { getConfigAccountEnvironment } from '@hubspot/local-dev-lib/config';
6
7
  import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
@@ -144,3 +145,55 @@ export async function uploadAndDeployAction({ accountId, projectDest, }) {
144
145
  export function trackGetStartedUsage(params, accountId) {
145
146
  return trackCommandMetadataUsage('get-started', params, accountId);
146
147
  }
148
+ export async function pollAppInstallation({ accountId, projectId, appUid, requiredScopes = [], optionalScopes = [], timeoutMs = 2 * 60 * 1000, // 2 minutes
149
+ intervalMs = 2000, // 2 seconds
150
+ onTimeout, }) {
151
+ return new Promise((resolve, reject) => {
152
+ let consecutiveErrors = 0;
153
+ const MAX_CONSECUTIVE_ERRORS = 5;
154
+ let pollInterval = null;
155
+ let pollTimeout = null;
156
+ const cleanup = () => {
157
+ if (pollInterval) {
158
+ clearTimeout(pollInterval);
159
+ pollInterval = null;
160
+ }
161
+ if (pollTimeout) {
162
+ clearTimeout(pollTimeout);
163
+ pollTimeout = null;
164
+ }
165
+ };
166
+ pollTimeout = setTimeout(() => {
167
+ cleanup();
168
+ if (onTimeout) {
169
+ onTimeout();
170
+ }
171
+ resolve(); // Resolve instead of reject to allow continuing with timeout state
172
+ }, timeoutMs);
173
+ const poll = async () => {
174
+ try {
175
+ const { data } = await fetchAppInstallationData(accountId, projectId, appUid, requiredScopes, optionalScopes);
176
+ // Reset error counter on successful fetch
177
+ consecutiveErrors = 0;
178
+ if (data.isInstalledWithScopeGroups) {
179
+ cleanup();
180
+ resolve();
181
+ }
182
+ else if (pollInterval) {
183
+ pollInterval = setTimeout(poll, intervalMs);
184
+ }
185
+ }
186
+ catch (error) {
187
+ consecutiveErrors++;
188
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
189
+ cleanup();
190
+ reject(new Error(`Failed to check app installation status after ${MAX_CONSECUTIVE_ERRORS} consecutive errors`, { cause: error }));
191
+ }
192
+ else if (pollInterval !== null) {
193
+ pollInterval = setTimeout(poll, intervalMs);
194
+ }
195
+ }
196
+ };
197
+ pollInterval = setTimeout(poll, 0);
198
+ });
199
+ }
@@ -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>;