@hubspot/cli 7.7.23-experimental.0 → 7.7.24-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 (69) hide show
  1. package/commands/account/auth.js +15 -4
  2. package/commands/auth.js +1 -1
  3. package/commands/mcp/start.d.ts +1 -0
  4. package/commands/mcp/start.js +12 -4
  5. package/commands/project/create.js +2 -2
  6. package/commands/project/validate.js +1 -0
  7. package/commands/sandbox/__tests__/create.test.js +207 -0
  8. package/commands/sandbox/create.d.ts +1 -1
  9. package/commands/sandbox/create.js +31 -16
  10. package/lang/en.d.ts +7 -3
  11. package/lang/en.js +12 -5
  12. package/lang/en.lyaml +4 -2
  13. package/lib/__tests__/buildAccount.test.js +62 -4
  14. package/lib/buildAccount.d.ts +4 -1
  15. package/lib/buildAccount.js +57 -2
  16. package/lib/commonOpts.js +25 -0
  17. package/lib/constants.d.ts +4 -0
  18. package/lib/constants.js +4 -0
  19. package/lib/errorHandlers/index.js +1 -3
  20. package/lib/errors/ProjectValidationError.d.ts +4 -0
  21. package/lib/errors/ProjectValidationError.js +9 -0
  22. package/lib/mcp/setup.d.ts +4 -0
  23. package/lib/mcp/setup.js +36 -0
  24. package/lib/projects/__tests__/LocalDevProcess.test.js +35 -0
  25. package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +170 -1
  26. package/lib/projects/add/v3AddComponent.js +2 -1
  27. package/lib/projects/create/index.js +2 -2
  28. package/lib/projects/create/v3.d.ts +0 -2
  29. package/lib/projects/create/v3.js +1 -3
  30. package/lib/projects/localDev/LocalDevProcess.d.ts +1 -0
  31. package/lib/projects/localDev/LocalDevProcess.js +3 -0
  32. package/lib/projects/localDev/LocalDevState.d.ts +1 -0
  33. package/lib/projects/localDev/LocalDevState.js +5 -0
  34. package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -2
  35. package/lib/projects/localDev/LocalDevWebsocketServer.js +35 -29
  36. package/lib/projects/upload.js +5 -12
  37. package/lib/sandboxes.d.ts +4 -0
  38. package/lib/sandboxes.js +4 -0
  39. package/lib/ui/index.d.ts +6 -0
  40. package/lib/ui/index.js +3 -5
  41. package/mcp-server/tools/index.js +6 -4
  42. package/mcp-server/tools/project/{AddFeatureToProject.d.ts → AddFeatureToProjectTool.d.ts} +4 -4
  43. package/mcp-server/tools/project/{AddFeatureToProject.js → AddFeatureToProjectTool.js} +6 -14
  44. package/mcp-server/tools/project/CreateProjectTool.d.ts +3 -3
  45. package/mcp-server/tools/project/CreateProjectTool.js +4 -14
  46. package/mcp-server/tools/project/{DeployProject.d.ts → DeployProjectTool.d.ts} +1 -1
  47. package/mcp-server/tools/project/{DeployProject.js → DeployProjectTool.js} +2 -2
  48. package/mcp-server/tools/project/GetConfigValuesTool.d.ts +20 -0
  49. package/mcp-server/tools/project/GetConfigValuesTool.js +51 -0
  50. package/mcp-server/tools/project/UploadProjectTools.js +1 -1
  51. package/mcp-server/tools/project/ValidateProjectTool.js +1 -1
  52. package/mcp-server/tools/project/__tests__/{AddFeatureToProject.test.js → AddFeatureToProjectTool.test.js} +7 -7
  53. package/mcp-server/tools/project/__tests__/CreateProjectTool.test.js +3 -4
  54. package/mcp-server/tools/project/__tests__/{DeployProject.test.js → DeployProjectTool.test.js} +4 -4
  55. package/mcp-server/tools/project/__tests__/GetConfigValuesTool.test.js +198 -0
  56. package/mcp-server/tools/project/__tests__/UploadProjectTools.test.js +2 -2
  57. package/mcp-server/tools/project/__tests__/ValidateProjectTool.test.js +2 -2
  58. package/mcp-server/tools/project/constants.d.ts +1 -0
  59. package/mcp-server/tools/project/constants.js +11 -0
  60. package/mcp-server/utils/__tests__/command.test.js +76 -3
  61. package/mcp-server/utils/command.d.ts +6 -0
  62. package/mcp-server/utils/command.js +19 -0
  63. package/package.json +2 -2
  64. package/mcp-server/utils/__tests__/project.test.js +0 -79
  65. package/mcp-server/utils/project.d.ts +0 -5
  66. package/mcp-server/utils/project.js +0 -14
  67. /package/mcp-server/tools/project/__tests__/{AddFeatureToProject.test.d.ts → AddFeatureToProjectTool.test.d.ts} +0 -0
  68. /package/mcp-server/tools/project/__tests__/{DeployProject.test.d.ts → DeployProjectTool.test.d.ts} +0 -0
  69. /package/mcp-server/{utils/__tests__/project.test.d.ts → tools/project/__tests__/GetConfigValuesTool.test.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { getAccessToken, updateConfigWithAccessToken, } from '@hubspot/local-dev-lib/personalAccessKey';
2
2
  import { accountNameExistsInConfig, updateAccountConfig, writeConfig, getAccountId, } from '@hubspot/local-dev-lib/config';
3
3
  import { createDeveloperTestAccount, fetchDeveloperTestAccountGateSyncStatus, generateDeveloperTestAccountPersonalAccessKey, } from '@hubspot/local-dev-lib/api/developerTestAccounts';
4
- import { createSandbox } from '@hubspot/local-dev-lib/api/sandboxHubs';
4
+ import { createSandbox, createV2Sandbox, getSandboxPersonalAccessKey, } from '@hubspot/local-dev-lib/api/sandboxHubs';
5
5
  import { HUBSPOT_ACCOUNT_TYPES } from '@hubspot/local-dev-lib/constants/config';
6
6
  import { personalAccessKeyPrompt } from '../prompts/personalAccessKeyPrompt.js';
7
7
  import { cliAccountNamePrompt } from '../prompts/accountNamePrompt.js';
@@ -33,6 +33,8 @@ const mockedCreateDeveloperTestAccount = createDeveloperTestAccount;
33
33
  const mockedFetchDeveloperTestAccountGateSyncStatus = fetchDeveloperTestAccountGateSyncStatus;
34
34
  const mockedGenerateDeveloperTestAccountPersonalAccessKey = generateDeveloperTestAccountPersonalAccessKey;
35
35
  const mockedCreateSandbox = createSandbox;
36
+ const mockedCreateV2Sandbox = createV2Sandbox;
37
+ const mockedGetPersonalAccessKey = getSandboxPersonalAccessKey;
36
38
  describe('lib/buildAccount', () => {
37
39
  describe('saveAccountToConfig()', () => {
38
40
  const mockAccountConfig = {
@@ -166,16 +168,17 @@ describe('lib/buildAccount', () => {
166
168
  });
167
169
  describe('buildSandbox()', () => {
168
170
  const mockParentAccountConfig = {
169
- name: 'Developer Test Account',
171
+ name: 'Prod account',
170
172
  accountId: 123456,
171
- accountType: HUBSPOT_ACCOUNT_TYPES.DEVELOPER_TEST,
173
+ accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD,
172
174
  env: 'prod',
173
175
  };
174
176
  const mockSandbox = {
175
177
  sandboxHubId: 56789,
176
178
  parentHubId: 123456,
177
179
  createdAt: '2025-01-01',
178
- type: 'sandbox',
180
+ type: 'STANDARD',
181
+ version: 'V1',
179
182
  archived: false,
180
183
  name: 'Test Sandbox',
181
184
  domain: 'test-sandbox.hubspot.com',
@@ -221,4 +224,59 @@ describe('lib/buildAccount', () => {
221
224
  await expect(buildAccount.buildSandbox(mockSandbox.name, mockParentAccountConfig, HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX, mockParentAccountConfig.env, false)).rejects.toThrow();
222
225
  });
223
226
  });
227
+ describe('buildV2Sandbox()', () => {
228
+ const mockParentAccountConfig = {
229
+ name: 'Prod account',
230
+ accountId: 123456,
231
+ accountType: HUBSPOT_ACCOUNT_TYPES.STANDARD,
232
+ env: 'prod',
233
+ };
234
+ const mockSandbox = {
235
+ sandboxHubId: 56789,
236
+ parentHubId: 123456,
237
+ createdAt: '2025-01-01',
238
+ type: 'STANDARD',
239
+ archived: false,
240
+ version: 'V2',
241
+ name: 'Test v2 Sandbox',
242
+ domain: 'test-v2-sandbox.hubspot.com',
243
+ createdByUser: {
244
+ id: 123456,
245
+ email: 'test@test.com',
246
+ firstName: 'Test',
247
+ lastName: 'User',
248
+ },
249
+ };
250
+ beforeEach(() => {
251
+ vi.spyOn(buildAccount, 'saveAccountToConfig').mockResolvedValue(mockParentAccountConfig.name);
252
+ mockedGetAccountId.mockReturnValue(mockParentAccountConfig.accountId);
253
+ mockedCreateV2Sandbox.mockResolvedValue({
254
+ data: mockSandbox,
255
+ });
256
+ mockedGetPersonalAccessKey.mockResolvedValue({
257
+ data: { personalAccessKey: { encodedOAuthRefreshToken: 'test-key' } },
258
+ });
259
+ });
260
+ afterEach(() => {
261
+ vi.clearAllMocks();
262
+ });
263
+ it('should create a v2 standard sandbox successfully and fetch a personal access key', async () => {
264
+ const result = await buildAccount.buildV2Sandbox(mockSandbox.name, mockParentAccountConfig, HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX, false, mockParentAccountConfig.env, false);
265
+ expect(result).toEqual({ sandbox: mockSandbox });
266
+ expect(mockedGetPersonalAccessKey).toHaveBeenCalledWith(mockParentAccountConfig.accountId, mockSandbox.sandboxHubId);
267
+ });
268
+ it('should create a development sandbox successfully and fetch a personal access key', async () => {
269
+ const result = await buildAccount.buildV2Sandbox(mockSandbox.name, mockParentAccountConfig, HUBSPOT_ACCOUNT_TYPES.DEVELOPMENT_SANDBOX, false, mockParentAccountConfig.env, false);
270
+ expect(result).toEqual({ sandbox: mockSandbox });
271
+ expect(mockedGetPersonalAccessKey).toHaveBeenCalledWith(mockParentAccountConfig.accountId, mockSandbox.sandboxHubId);
272
+ });
273
+ it('should throw error if account ID is not found', async () => {
274
+ mockedGetAccountId.mockReturnValue(null);
275
+ await expect(buildAccount.buildV2Sandbox(mockSandbox.name, mockParentAccountConfig, HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX, false, mockParentAccountConfig.env, false)).rejects.toThrow();
276
+ });
277
+ it('should handle API errors when creating sandbox', async () => {
278
+ mockedCreateV2Sandbox.mockRejectedValue(new Error('test-error'));
279
+ await expect(buildAccount.buildV2Sandbox(mockSandbox.name, mockParentAccountConfig, HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX, false, mockParentAccountConfig.env, false)).rejects.toThrow();
280
+ });
281
+ });
224
282
  });
@@ -1,7 +1,7 @@
1
1
  import { DeveloperTestAccountConfig } from '@hubspot/local-dev-lib/types/developerTestAccounts';
2
2
  import { Environment } from '@hubspot/local-dev-lib/types/Config';
3
3
  import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts';
4
- import { SandboxResponse } from '@hubspot/local-dev-lib/types/Sandbox';
4
+ import { SandboxResponse, V2Sandbox } from '@hubspot/local-dev-lib/types/Sandbox';
5
5
  import { SandboxAccountType } from '../types/Sandboxes.js';
6
6
  export declare function saveAccountToConfig(accountId: number | undefined, accountName: string, env: Environment, personalAccessKey?: string, force?: boolean): Promise<string>;
7
7
  export declare function createDeveloperTestAccountV3(parentAccountId: number, testAccountConfig: DeveloperTestAccountConfig): Promise<{
@@ -14,4 +14,7 @@ type SandboxAccount = SandboxResponse & {
14
14
  name: string;
15
15
  };
16
16
  export declare function buildSandbox(sandboxName: string, parentAccountConfig: CLIAccount, sandboxType: SandboxAccountType, env: Environment, force?: boolean): Promise<SandboxAccount>;
17
+ export declare function buildV2Sandbox(sandboxName: string, parentAccountConfig: CLIAccount, sandboxType: SandboxAccountType, syncObjectRecords: boolean, env: Environment, force?: boolean): Promise<{
18
+ sandbox: V2Sandbox;
19
+ }>;
17
20
  export {};
@@ -4,14 +4,14 @@ import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountId
4
4
  import { logger } from '@hubspot/local-dev-lib/logger';
5
5
  import { createDeveloperTestAccount, fetchDeveloperTestAccountGateSyncStatus, generateDeveloperTestAccountPersonalAccessKey, } from '@hubspot/local-dev-lib/api/developerTestAccounts';
6
6
  import { HUBSPOT_ACCOUNT_TYPES } from '@hubspot/local-dev-lib/constants/config';
7
- import { createSandbox } from '@hubspot/local-dev-lib/api/sandboxHubs';
7
+ import { createSandbox, createV2Sandbox, getSandboxPersonalAccessKey, } from '@hubspot/local-dev-lib/api/sandboxHubs';
8
8
  import { personalAccessKeyPrompt } from './prompts/personalAccessKeyPrompt.js';
9
9
  import { createDeveloperTestAccountConfigPrompt } from './prompts/createDeveloperTestAccountConfigPrompt.js';
10
10
  import { i18n } from './lang.js';
11
11
  import { cliAccountNamePrompt } from './prompts/accountNamePrompt.js';
12
12
  import SpinniesManager from './ui/SpinniesManager.js';
13
13
  import { debugError, logError } from './errorHandlers/index.js';
14
- import { SANDBOX_API_TYPE_MAP, handleSandboxCreateError } from './sandboxes.js';
14
+ import { SANDBOX_API_TYPE_MAP, SANDBOX_TYPE_MAP_V2, handleSandboxCreateError, } from './sandboxes.js';
15
15
  import { handleDeveloperTestAccountCreateError } from './developerTestAccounts.js';
16
16
  import { lib } from '../lang/en.js';
17
17
  import { poll } from './polling.js';
@@ -199,3 +199,58 @@ export async function buildSandbox(sandboxName, parentAccountConfig, sandboxType
199
199
  }
200
200
  return sandbox;
201
201
  }
202
+ export async function buildV2Sandbox(sandboxName, parentAccountConfig, sandboxType, syncObjectRecords, env, force = false) {
203
+ let i18nKey;
204
+ if (sandboxType === HUBSPOT_ACCOUNT_TYPES.STANDARD_SANDBOX) {
205
+ i18nKey = 'lib.sandbox.create.loading.standard';
206
+ }
207
+ else {
208
+ i18nKey = 'lib.sandbox.create.loading.developer';
209
+ }
210
+ const id = getAccountIdentifier(parentAccountConfig);
211
+ const parentAccountId = getAccountId(id);
212
+ if (!parentAccountId) {
213
+ throw new Error(i18n(`${i18nKey}.fail`));
214
+ }
215
+ SpinniesManager.init({
216
+ succeedColor: 'white',
217
+ });
218
+ logger.log('');
219
+ SpinniesManager.add('buildV2Sandbox', {
220
+ text: i18n(`${i18nKey}.add`, {
221
+ accountName: sandboxName,
222
+ }),
223
+ });
224
+ let sandbox;
225
+ let pak;
226
+ try {
227
+ const sandboxTypeV2 = SANDBOX_TYPE_MAP_V2[sandboxType];
228
+ const { data } = await createV2Sandbox(parentAccountId, sandboxName, sandboxTypeV2, syncObjectRecords);
229
+ sandbox = { ...data };
230
+ const { data: { personalAccessKey }, } = await getSandboxPersonalAccessKey(parentAccountId, sandbox.sandboxHubId);
231
+ pak = personalAccessKey.encodedOAuthRefreshToken;
232
+ SpinniesManager.succeed('buildV2Sandbox', {
233
+ text: i18n(`${i18nKey}.succeed`, {
234
+ accountName: sandboxName,
235
+ accountId: sandbox.sandboxHubId,
236
+ }),
237
+ });
238
+ }
239
+ catch (e) {
240
+ debugError(e);
241
+ SpinniesManager.fail('buildV2Sandbox', {
242
+ text: i18n(`${i18nKey}.fail`, {
243
+ accountName: sandboxName,
244
+ }),
245
+ });
246
+ handleSandboxCreateError(e, env, sandboxName, parentAccountId);
247
+ }
248
+ try {
249
+ await saveAccountToConfig(sandbox.sandboxHubId, sandboxName, env, pak, force);
250
+ }
251
+ catch (err) {
252
+ logError(err);
253
+ throw err;
254
+ }
255
+ return { sandbox };
256
+ }
package/lib/commonOpts.js CHANGED
@@ -7,6 +7,7 @@ import { debugError } from './errorHandlers/index.js';
7
7
  import { EXIT_CODES } from './enums/exitCodes.js';
8
8
  import { uiCommandReference } from './ui/index.js';
9
9
  import { i18n } from './lang.js';
10
+ import { getTerminalUISupport, UI_COLORS } from './ui/index.js';
10
11
  export function addGlobalOptions(yargs) {
11
12
  yargs.version(false);
12
13
  yargs.option('debug', {
@@ -76,8 +77,32 @@ export function addJSONOutputOptions(yargs) {
76
77
  hidden: true,
77
78
  });
78
79
  }
80
+ // Remove this once we've upgraded to yargs 18.0.0
81
+ function uiBetaTagWithColor(message) {
82
+ const terminalUISupport = getTerminalUISupport();
83
+ const tag = i18n(`lib.ui.betaTagWithStyle`);
84
+ const result = `${terminalUISupport.color ? chalk.hex(UI_COLORS.SORBET)(tag) : tag} ${message}`;
85
+ return result;
86
+ }
87
+ // Remove this once we've upgraded to yargs 18.0.0
88
+ function uiDeprecatedTagWithColor(message) {
89
+ const terminalUISupport = getTerminalUISupport();
90
+ const tag = i18n(`lib.ui.deprecatedTagWithStyle`);
91
+ const result = `${terminalUISupport.color ? chalk.yellow(tag) : tag} ${message}`;
92
+ return result;
93
+ }
79
94
  export async function addCustomHelpOutput(yargs, command, describe) {
80
95
  try {
96
+ // Remove this once we've upgraded to yargs 18.0.0
97
+ if (describe && describe.includes(i18n(`lib.ui.betaTag`))) {
98
+ describe = describe.replace(i18n(`lib.ui.betaTag`) + ' ', '');
99
+ describe = uiBetaTagWithColor(describe);
100
+ }
101
+ // Remove this once we've upgraded to yargs 18.0.0
102
+ if (describe && describe.includes(i18n(`lib.ui.deprecatedTag`))) {
103
+ describe = describe.replace(i18n(`lib.ui.deprecatedTag`) + ' ', '');
104
+ describe = uiDeprecatedTagWithColor(describe);
105
+ }
81
106
  const parsedArgv = yargsParser(process.argv.slice(2));
82
107
  if (parsedArgv && parsedArgv.help) {
83
108
  const commandBase = `hs ${parsedArgv._.slice(0, -1).join(' ')}`;
@@ -78,6 +78,8 @@ export declare const APP_AUTH_TYPES: {
78
78
  export declare const FEATURES: {
79
79
  readonly UNIFIED_THEME_PREVIEW: "cms:react:unifiedThemePreview";
80
80
  readonly UNIFIED_APPS: "Developers:UnifiedApps:PrivateBeta";
81
+ readonly SANDBOXES_V2: "sandboxes:v2:enabled";
82
+ readonly SANDBOXES_V2_CLI: "sandboxes:v2:cliEnabled";
81
83
  };
82
84
  export declare const LOCAL_DEV_UI_MESSAGE_SEND_TYPES: {
83
85
  UPLOAD_SUCCESS: string;
@@ -111,3 +113,5 @@ export declare const LOCAL_DEV_SERVER_MESSAGE_TYPES: {
111
113
  export declare const CONFIG_LOCAL_STATE_FLAGS: {
112
114
  readonly LOCAL_DEV_UI_WELCOME: "LOCAL_DEV_UI_WELCOME";
113
115
  };
116
+ export declare const EMPTY_PROJECT = "empty";
117
+ export declare const PROJECT_WITH_APP = "app";
package/lib/constants.js CHANGED
@@ -70,6 +70,8 @@ export const APP_AUTH_TYPES = {
70
70
  export const FEATURES = {
71
71
  UNIFIED_THEME_PREVIEW: 'cms:react:unifiedThemePreview',
72
72
  UNIFIED_APPS: 'Developers:UnifiedApps:PrivateBeta',
73
+ SANDBOXES_V2: 'sandboxes:v2:enabled',
74
+ SANDBOXES_V2_CLI: 'sandboxes:v2:cliEnabled',
73
75
  };
74
76
  export const LOCAL_DEV_UI_MESSAGE_SEND_TYPES = {
75
77
  UPLOAD_SUCCESS: 'server:uploadSuccess',
@@ -103,3 +105,5 @@ export const LOCAL_DEV_SERVER_MESSAGE_TYPES = {
103
105
  export const CONFIG_LOCAL_STATE_FLAGS = {
104
106
  LOCAL_DEV_UI_WELCOME: 'LOCAL_DEV_UI_WELCOME',
105
107
  };
108
+ export const EMPTY_PROJECT = 'empty';
109
+ export const PROJECT_WITH_APP = 'app';
@@ -5,6 +5,7 @@ import { shouldSuppressError } from './suppressError.js';
5
5
  import { i18n } from '../lang.js';
6
6
  import util from 'util';
7
7
  import { uiCommandReference } from '../ui/index.js';
8
+ import { isProjectValidationError } from '../errors/ProjectValidationError.js';
8
9
  export function logError(error, context) {
9
10
  debugError(error, context);
10
11
  if (isProjectValidationError(error)) {
@@ -84,9 +85,6 @@ export class ApiErrorContext {
84
85
  this.projectName = props.projectName || '';
85
86
  }
86
87
  }
87
- function isProjectValidationError(error) {
88
- return error instanceof Error && error.name === 'ProjectValidationError';
89
- }
90
88
  function isErrorWithMessageOrReason(error) {
91
89
  return (typeof error === 'object' &&
92
90
  error !== null &&
@@ -0,0 +1,4 @@
1
+ export default class ProjectValidationError extends Error {
2
+ constructor(message: string, options?: ErrorOptions);
3
+ }
4
+ export declare function isProjectValidationError(err: unknown): err is ProjectValidationError;
@@ -0,0 +1,9 @@
1
+ export default class ProjectValidationError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options);
4
+ this.name = 'ProjectValidationError';
5
+ }
6
+ }
7
+ export function isProjectValidationError(err) {
8
+ return err instanceof ProjectValidationError;
9
+ }
@@ -7,6 +7,9 @@ export declare const supportedTools: ({
7
7
  } | {
8
8
  name: "Windsurf";
9
9
  value: string;
10
+ } | {
11
+ name: "VSCode";
12
+ value: string;
10
13
  })[];
11
14
  interface McpCommand {
12
15
  command: string;
@@ -15,6 +18,7 @@ interface McpCommand {
15
18
  export declare function addMintlifyMcpServer(installTargets: string[]): Promise<void>;
16
19
  export declare function setupMintlify(derivedTargets?: string[]): Promise<boolean>;
17
20
  export declare function addMcpServerToConfig(targets: string[] | undefined): Promise<string[]>;
21
+ export declare function setupVsCode(mcpCommand?: McpCommand): Promise<boolean>;
18
22
  export declare function setupClaudeCode(mcpCommand?: McpCommand): Promise<boolean>;
19
23
  export declare function setupCursor(mcpCommand?: McpCommand): boolean;
20
24
  export declare function setupWindsurf(mcpCommand?: McpCommand): boolean;
package/lib/mcp/setup.js CHANGED
@@ -13,11 +13,13 @@ const mcpServerName = 'hubspot-cli-mcp';
13
13
  const claudeCode = 'claude';
14
14
  const windsurf = 'windsurf';
15
15
  const cursor = 'cursor';
16
+ const vscode = 'vscode';
16
17
  const supportedMintlifyClients = [windsurf, cursor];
17
18
  export const supportedTools = [
18
19
  { name: commands.mcp.setup.claudeCode, value: claudeCode },
19
20
  { name: commands.mcp.setup.cursor, value: cursor },
20
21
  { name: commands.mcp.setup.windsurf, value: windsurf },
22
+ { name: commands.mcp.setup.vsCode, value: vscode },
21
23
  ];
22
24
  const defaultMcpCommand = {
23
25
  command: 'hs',
@@ -75,6 +77,9 @@ export async function addMcpServerToConfig(targets) {
75
77
  if (derivedTargets.includes(windsurf)) {
76
78
  await runSetupFunction(setupWindsurf);
77
79
  }
80
+ if (derivedTargets.includes(vscode)) {
81
+ await runSetupFunction(setupVsCode);
82
+ }
78
83
  uiLogger.info(commands.mcp.setup.success(derivedTargets));
79
84
  return derivedTargets;
80
85
  }
@@ -150,6 +155,37 @@ function setupMcpConfigFile(config) {
150
155
  return false;
151
156
  }
152
157
  }
158
+ export async function setupVsCode(mcpCommand = defaultMcpCommand) {
159
+ try {
160
+ SpinniesManager.add('vsCode', {
161
+ text: commands.mcp.setup.spinners.configuringVsCode,
162
+ });
163
+ const mcpConfig = JSON.stringify({
164
+ name: mcpServerName,
165
+ ...buildCommandWithAgentString(mcpCommand, vscode),
166
+ });
167
+ await execAsync(`code --add-mcp '${mcpConfig}'`);
168
+ SpinniesManager.succeed('vsCode', {
169
+ text: commands.mcp.setup.spinners.configuredVsCode,
170
+ });
171
+ return true;
172
+ }
173
+ catch (error) {
174
+ if (error instanceof Error &&
175
+ error.message.includes('code: command not found')) {
176
+ SpinniesManager.fail('vsCode', {
177
+ text: commands.mcp.setup.spinners.vsCodeNotFound,
178
+ });
179
+ }
180
+ else {
181
+ SpinniesManager.fail('vsCode', {
182
+ text: commands.mcp.setup.spinners.failedToConfigureVsCode,
183
+ });
184
+ logError(error);
185
+ }
186
+ return false;
187
+ }
188
+ }
153
189
  export async function setupClaudeCode(mcpCommand = defaultMcpCommand) {
154
190
  try {
155
191
  SpinniesManager.add('claudeCode', {
@@ -16,6 +16,7 @@ vi.mock('@hubspot/ui-extensions-dev-server', () => ({
16
16
  cleanup: vi.fn().mockResolvedValue(undefined),
17
17
  },
18
18
  }));
19
+ vi.mock('open');
19
20
  vi.mock('@hubspot/project-parsing-lib');
20
21
  vi.mock('../upload');
21
22
  vi.mock('../config');
@@ -306,4 +307,38 @@ describe('LocalDevProcess', () => {
306
307
  expect(listener).toHaveBeenCalledWith(process.projectNodes);
307
308
  });
308
309
  });
310
+ describe('removeStateListener()', () => {
311
+ it('should remove state listener', () => {
312
+ const listener = vi.fn();
313
+ const key = 'projectNodes';
314
+ // Add the listener first
315
+ process.addStateListener(key, listener);
316
+ // Trigger state change to verify listener is called
317
+ // @ts-expect-error
318
+ process.state.projectNodes = {};
319
+ expect(listener).toHaveBeenCalledTimes(1);
320
+ // Remove the listener
321
+ process.removeStateListener(key, listener);
322
+ // Trigger state change again to verify listener is no longer called
323
+ // @ts-expect-error
324
+ process.state.projectNodes = { newNode: { uid: 'newNode' } };
325
+ expect(listener).toHaveBeenCalledTimes(1); // Should still be 1, not 2
326
+ });
327
+ it('should not affect other listeners when removing one', () => {
328
+ const listener1 = vi.fn();
329
+ const listener2 = vi.fn();
330
+ const key = 'projectNodes';
331
+ // Add two listeners
332
+ process.addStateListener(key, listener1);
333
+ process.addStateListener(key, listener2);
334
+ // Remove only the first listener
335
+ process.removeStateListener(key, listener1);
336
+ // Trigger state change
337
+ // @ts-expect-error
338
+ process.state.projectNodes = {};
339
+ // Only listener2 should be called
340
+ expect(listener1).not.toHaveBeenCalled();
341
+ expect(listener2).toHaveBeenCalled();
342
+ });
343
+ });
309
344
  });
@@ -1,7 +1,7 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import { isPortManagerServerRunning, requestPorts, } from '@hubspot/local-dev-lib/portManager';
3
3
  import { logger } from '@hubspot/local-dev-lib/logger';
4
- import { LOCAL_DEV_UI_MESSAGE_RECEIVE_TYPES, LOCAL_DEV_SERVER_MESSAGE_TYPES, } from '../../constants.js';
4
+ import { LOCAL_DEV_UI_MESSAGE_RECEIVE_TYPES, LOCAL_DEV_UI_MESSAGE_SEND_TYPES, LOCAL_DEV_SERVER_MESSAGE_TYPES, } from '../../constants.js';
5
5
  import LocalDevWebsocketServer from '../localDev/LocalDevWebsocketServer.js';
6
6
  import { lib } from '../../../lang/en.js';
7
7
  vi.mock('ws');
@@ -29,6 +29,7 @@ describe('LocalDevWebsocketServer', () => {
29
29
  // Setup mock LocalDevProcess
30
30
  mockLocalDevProcess = {
31
31
  addStateListener: vi.fn(),
32
+ removeStateListener: vi.fn(),
32
33
  uploadProject: vi.fn(),
33
34
  sendDevServerMessage: vi.fn(),
34
35
  };
@@ -167,4 +168,172 @@ describe('LocalDevWebsocketServer', () => {
167
168
  expect(mockWebSocketServer.close).toHaveBeenCalled();
168
169
  });
169
170
  });
171
+ describe('multiple connections', () => {
172
+ let mockWebSocket1;
173
+ let mockWebSocket2;
174
+ let mockWebSocket3;
175
+ let connectionCallback;
176
+ beforeEach(async () => {
177
+ // Setup multiple mock WebSockets
178
+ mockWebSocket1 = {
179
+ on: vi.fn(),
180
+ send: vi.fn(),
181
+ close: vi.fn(),
182
+ };
183
+ mockWebSocket2 = {
184
+ on: vi.fn(),
185
+ send: vi.fn(),
186
+ close: vi.fn(),
187
+ };
188
+ mockWebSocket3 = {
189
+ on: vi.fn(),
190
+ send: vi.fn(),
191
+ close: vi.fn(),
192
+ };
193
+ // Start the server
194
+ isPortManagerServerRunning.mockResolvedValue(true);
195
+ requestPorts.mockResolvedValue({
196
+ 'local-dev-ui-websocket-server': 1234,
197
+ });
198
+ await server.start();
199
+ // Get the connection callback
200
+ connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
201
+ });
202
+ it('should handle multiple valid connections simultaneously', () => {
203
+ // Establish three connections from valid origins
204
+ connectionCallback(mockWebSocket1, {
205
+ headers: { origin: 'https://app.hubspot.com' },
206
+ });
207
+ connectionCallback(mockWebSocket2, {
208
+ headers: { origin: 'https://app.hubspotqa.com' },
209
+ });
210
+ connectionCallback(mockWebSocket3, {
211
+ headers: { origin: 'https://local.hubspot.com' },
212
+ });
213
+ // All connections should be established with proper setup
214
+ expect(mockWebSocket1.on).toHaveBeenCalledWith('message', expect.any(Function));
215
+ expect(mockWebSocket2.on).toHaveBeenCalledWith('message', expect.any(Function));
216
+ expect(mockWebSocket3.on).toHaveBeenCalledWith('message', expect.any(Function));
217
+ // Each connection should trigger state listener setup
218
+ expect(mockLocalDevProcess.addStateListener).toHaveBeenCalledTimes(6); // 2 listeners per connection * 3 connections
219
+ // Each connection should trigger dev server message
220
+ expect(mockLocalDevProcess.sendDevServerMessage).toHaveBeenCalledTimes(3);
221
+ expect(mockLocalDevProcess.sendDevServerMessage).toHaveBeenCalledWith(LOCAL_DEV_SERVER_MESSAGE_TYPES.WEBSOCKET_SERVER_CONNECTED);
222
+ // No connections should be closed
223
+ expect(mockWebSocket1.close).not.toHaveBeenCalled();
224
+ expect(mockWebSocket2.close).not.toHaveBeenCalled();
225
+ expect(mockWebSocket3.close).not.toHaveBeenCalled();
226
+ });
227
+ it('should send project data to each connection independently', () => {
228
+ // Setup mock project data properties as getters
229
+ Object.defineProperty(mockLocalDevProcess, 'projectName', {
230
+ get: () => 'test-project',
231
+ configurable: true,
232
+ });
233
+ Object.defineProperty(mockLocalDevProcess, 'projectId', {
234
+ get: () => 123,
235
+ configurable: true,
236
+ });
237
+ Object.defineProperty(mockLocalDevProcess, 'targetProjectAccountId', {
238
+ get: () => 456,
239
+ configurable: true,
240
+ });
241
+ Object.defineProperty(mockLocalDevProcess, 'targetTestingAccountId', {
242
+ get: () => 789,
243
+ configurable: true,
244
+ });
245
+ // Establish multiple connections
246
+ connectionCallback(mockWebSocket1, {
247
+ headers: { origin: 'https://app.hubspot.com' },
248
+ });
249
+ connectionCallback(mockWebSocket2, {
250
+ headers: { origin: 'https://app.hubspotqa.com' },
251
+ });
252
+ // Each websocket should receive project data
253
+ expect(mockWebSocket1.send).toHaveBeenCalledWith(JSON.stringify({
254
+ type: LOCAL_DEV_UI_MESSAGE_SEND_TYPES.UPDATE_PROJECT_DATA,
255
+ data: {
256
+ projectName: 'test-project',
257
+ projectId: 123,
258
+ targetProjectAccountId: 456,
259
+ targetTestingAccountId: 789,
260
+ },
261
+ }));
262
+ expect(mockWebSocket2.send).toHaveBeenCalledWith(JSON.stringify({
263
+ type: LOCAL_DEV_UI_MESSAGE_SEND_TYPES.UPDATE_PROJECT_DATA,
264
+ data: {
265
+ projectName: 'test-project',
266
+ projectId: 123,
267
+ targetProjectAccountId: 456,
268
+ targetTestingAccountId: 789,
269
+ },
270
+ }));
271
+ });
272
+ it('should properly cleanup listeners when connections close', () => {
273
+ // Establish connections
274
+ connectionCallback(mockWebSocket1, {
275
+ headers: { origin: 'https://app.hubspot.com' },
276
+ });
277
+ connectionCallback(mockWebSocket2, {
278
+ headers: { origin: 'https://app.hubspotqa.com' },
279
+ });
280
+ // Get all the close callbacks for both connections (there should be 2 per connection)
281
+ const closeCallbacks1 = mockWebSocket1.on.mock.calls
282
+ .filter(call => call[0] === 'close')
283
+ .map(call => call[1]);
284
+ const closeCallbacks2 = mockWebSocket2.on.mock.calls
285
+ .filter(call => call[0] === 'close')
286
+ .map(call => call[1]);
287
+ expect(closeCallbacks1).toHaveLength(2); // projectNodes and appData listeners
288
+ expect(closeCallbacks2).toHaveLength(2); // projectNodes and appData listeners
289
+ // Simulate first connection closing (call all close callbacks)
290
+ closeCallbacks1.forEach(callback => callback());
291
+ // Should have removed listeners for first connection (2 listeners: projectNodes and appData)
292
+ expect(mockLocalDevProcess.removeStateListener).toHaveBeenCalledTimes(2);
293
+ // Simulate second connection closing
294
+ closeCallbacks2.forEach(callback => callback());
295
+ // Should have removed listeners for second connection as well
296
+ expect(mockLocalDevProcess.removeStateListener).toHaveBeenCalledTimes(4);
297
+ });
298
+ it('should broadcast state changes to all connected clients', () => {
299
+ // Establish connections
300
+ connectionCallback(mockWebSocket1, {
301
+ headers: { origin: 'https://app.hubspot.com' },
302
+ });
303
+ connectionCallback(mockWebSocket2, {
304
+ headers: { origin: 'https://app.hubspotqa.com' },
305
+ });
306
+ // Get the projectNodes listeners that were registered
307
+ const projectNodesListeners = mockLocalDevProcess.addStateListener.mock.calls
308
+ .filter(call => call[0] === 'projectNodes')
309
+ .map(call => call[1]);
310
+ expect(projectNodesListeners).toHaveLength(2);
311
+ // Simulate a project nodes update by calling the listeners
312
+ const mockProjectNodes = {
313
+ component1: {
314
+ uid: 'component1',
315
+ componentType: 'APP',
316
+ localDev: {
317
+ componentRoot: '/test/path',
318
+ componentConfigPath: '/test/path/config.json',
319
+ configUpdatedSinceLastUpload: false,
320
+ },
321
+ componentDeps: {},
322
+ metaFilePath: '/test/path',
323
+ config: {},
324
+ files: [],
325
+ },
326
+ };
327
+ projectNodesListeners.forEach(listener => listener(mockProjectNodes));
328
+ // Both websockets should receive the update
329
+ expect(mockWebSocket1.send).toHaveBeenCalledWith(JSON.stringify({
330
+ type: LOCAL_DEV_UI_MESSAGE_SEND_TYPES.UPDATE_PROJECT_NODES,
331
+ data: mockProjectNodes,
332
+ }));
333
+ expect(mockWebSocket2.send).toHaveBeenCalledWith(JSON.stringify({
334
+ type: LOCAL_DEV_UI_MESSAGE_SEND_TYPES.UPDATE_PROJECT_NODES,
335
+ data: mockProjectNodes,
336
+ }));
337
+ });
338
+ });
170
339
  });
@@ -1,6 +1,7 @@
1
1
  import { commands, lib } from '../../../lang/en.js';
2
2
  import { getConfigForPlatformVersion } from '../create/legacy.js';
3
- import { calculateComponentTemplateChoices, createV3App, PROJECT_WITH_APP, } from '../create/v3.js';
3
+ import { calculateComponentTemplateChoices, createV3App, } from '../create/v3.js';
4
+ import { PROJECT_WITH_APP } from '../../constants.js';
4
5
  import path from 'path';
5
6
  import fs from 'fs';
6
7
  import { projectAddPromptV3 } from '../../prompts/projectAddPrompt.js';
@@ -1,8 +1,8 @@
1
1
  import { selectProjectTemplatePrompt, } from '../../prompts/selectProjectTemplatePrompt.js';
2
2
  import { projectNameAndDestPrompt } from '../../prompts/projectNameAndDestPrompt.js';
3
- import { DEFAULT_PROJECT_TEMPLATE_BRANCH, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, } from '../../constants.js';
3
+ import { DEFAULT_PROJECT_TEMPLATE_BRANCH, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, EMPTY_PROJECT, } from '../../constants.js';
4
4
  import { useV3Api } from '../buildAndDeploy.js';
5
- import { EMPTY_PROJECT, v3ComponentFlow } from './v3.js';
5
+ import { v3ComponentFlow } from './v3.js';
6
6
  import { getProjectTemplateListFromRepo } from './legacy.js';
7
7
  import { uiLogger } from '../../ui/logger.js';
8
8
  import { commands } from '../../../lang/en.js';
@@ -2,8 +2,6 @@ import { Separator } from '@inquirer/prompts';
2
2
  import { ComponentTemplate, ComponentTemplateChoice, ProjectTemplateRepoConfig } from '../../../types/Projects.js';
3
3
  import { ProjectMetadata } from '@hubspot/project-parsing-lib/src/lib/project.js';
4
4
  import { SelectProjectTemplatePromptResponse } from '../../prompts/selectProjectTemplatePrompt.js';
5
- export declare const EMPTY_PROJECT = "empty";
6
- export declare const PROJECT_WITH_APP = "app";
7
5
  export declare function createV3App(providedAuth: string | undefined, providedDistribution: string | undefined): Promise<{
8
6
  authType: string;
9
7
  distribution: string;