@hubspot/cli 7.7.28-experimental.0 → 7.7.30-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.
@@ -1,7 +1,22 @@
1
1
  import yargs from 'yargs';
2
2
  import projectAddCommand from '../add.js';
3
3
  import { marketplaceDistribution, oAuth, privateDistribution, staticAuth, } from '../../../lib/constants.js';
4
+ import { v3AddComponent } from '../../../lib/projects/add/v3AddComponent.js';
5
+ import { legacyAddComponent } from '../../../lib/projects/add/legacyAddComponent.js';
6
+ import { getProjectConfig } from '../../../lib/projects/config.js';
7
+ import { useV3Api } from '../../../lib/projects/buildAndDeploy.js';
8
+ import { trackCommandUsage } from '../../../lib/usageTracking.js';
4
9
  vi.mock('../../../lib/commonOpts');
10
+ vi.mock('../../../lib/projects/add/v3AddComponent');
11
+ vi.mock('../../../lib/projects/add/legacyAddComponent');
12
+ vi.mock('../../../lib/projects/config');
13
+ vi.mock('../../../lib/projects/buildAndDeploy');
14
+ vi.mock('../../../lib/usageTracking');
15
+ const mockedV3AddComponent = vi.mocked(v3AddComponent);
16
+ const mockedLegacyAddComponent = vi.mocked(legacyAddComponent);
17
+ const mockedGetProjectConfig = vi.mocked(getProjectConfig);
18
+ const mockedUseV3Api = vi.mocked(useV3Api);
19
+ const mockedTrackCommandUsage = vi.mocked(trackCommandUsage);
5
20
  describe('commands/project/add', () => {
6
21
  const yargsMock = yargs;
7
22
  describe('command', () => {
@@ -40,4 +55,55 @@ describe('commands/project/add', () => {
40
55
  });
41
56
  });
42
57
  });
58
+ describe('handler', () => {
59
+ const mockProjectConfig = {
60
+ name: 'test-project',
61
+ srcDir: 'src',
62
+ platformVersion: 'v3',
63
+ };
64
+ const mockProjectDir = '/path/to/project';
65
+ const mockArgs = {
66
+ derivedAccountId: 123,
67
+ name: 'test-component',
68
+ type: 'module',
69
+ };
70
+ beforeEach(() => {
71
+ mockedGetProjectConfig.mockResolvedValue({
72
+ projectConfig: mockProjectConfig,
73
+ projectDir: mockProjectDir,
74
+ });
75
+ mockedTrackCommandUsage.mockResolvedValue();
76
+ mockedV3AddComponent.mockResolvedValue();
77
+ mockedLegacyAddComponent.mockResolvedValue();
78
+ vi.spyOn(process, 'exit').mockImplementation(() => {
79
+ throw new Error('process.exit called');
80
+ });
81
+ });
82
+ it('should call v3AddComponent with accountId for v3 projects', async () => {
83
+ mockedUseV3Api.mockReturnValue(true);
84
+ await expect(projectAddCommand.handler(mockArgs)).rejects.toThrow('process.exit called');
85
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', undefined, 123);
86
+ expect(mockedV3AddComponent).toHaveBeenCalledWith(mockArgs, mockProjectDir, mockProjectConfig, 123);
87
+ expect(mockedLegacyAddComponent).not.toHaveBeenCalled();
88
+ });
89
+ it('should call legacyAddComponent for non-v3 projects', async () => {
90
+ mockedUseV3Api.mockReturnValue(false);
91
+ await expect(projectAddCommand.handler(mockArgs)).rejects.toThrow('process.exit called');
92
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', undefined, 123);
93
+ expect(mockedLegacyAddComponent).toHaveBeenCalledWith(mockArgs, mockProjectDir, mockProjectConfig);
94
+ expect(mockedV3AddComponent).not.toHaveBeenCalled();
95
+ });
96
+ it('should exit with error when project config is not found', async () => {
97
+ mockedGetProjectConfig.mockResolvedValue({
98
+ projectConfig: null,
99
+ projectDir: null,
100
+ });
101
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
102
+ throw new Error('process.exit called');
103
+ });
104
+ await expect(projectAddCommand.handler(mockArgs)).rejects.toThrow('process.exit called');
105
+ expect(mockExit).toHaveBeenCalledWith(1);
106
+ mockExit.mockRestore();
107
+ });
108
+ });
43
109
  });
@@ -1,5 +1,5 @@
1
1
  import { YargsCommandModule, CommonArgs } from '../../types/Yargs.js';
2
- type ProjectAddArgs = CommonArgs & {
2
+ export type ProjectAddArgs = CommonArgs & {
3
3
  type?: string;
4
4
  name?: string;
5
5
  features?: string[];
@@ -23,7 +23,7 @@ async function handler(args) {
23
23
  }
24
24
  const isV3ProjectCreate = useV3Api(projectConfig.platformVersion);
25
25
  if (isV3ProjectCreate) {
26
- await v3AddComponent(args, projectDir, projectConfig);
26
+ await v3AddComponent(args, projectDir, projectConfig, derivedAccountId);
27
27
  }
28
28
  else {
29
29
  await legacyAddComponent(args, projectDir, projectConfig);
@@ -41,7 +41,7 @@ async function handler(args) {
41
41
  type: selectProjectTemplatePromptResponse.projectTemplate?.name ||
42
42
  (selectProjectTemplatePromptResponse.componentTemplates || [])
43
43
  // @ts-expect-error
44
- .map((item) => item.label)
44
+ .map((item) => item.type)
45
45
  .join(','),
46
46
  }, derivedAccountId);
47
47
  const projectDest = path.resolve(getCwd(), projectNameAndDestPromptResponse.dest);
@@ -1,5 +1,5 @@
1
- import { CommonArgs, EnvironmentArgs, JSONOutputArgs, YargsCommandModule } from '../../types/Yargs.js';
2
- type ProjectUploadArgs = CommonArgs & JSONOutputArgs & EnvironmentArgs & {
1
+ import { CommonArgs, JSONOutputArgs, YargsCommandModule } from '../../types/Yargs.js';
2
+ type ProjectUploadArgs = CommonArgs & JSONOutputArgs & {
3
3
  forceCreate: boolean;
4
4
  message: string;
5
5
  m: string;
@@ -25,7 +25,7 @@ async function handler(args) {
25
25
  validateProjectConfig(projectConfig, projectDir);
26
26
  let targetAccountId;
27
27
  if (useV3Api(projectConfig.platformVersion)) {
28
- targetAccountId = await loadAndValidateProfile(projectConfig, projectDir, profile, args.useEnv);
28
+ targetAccountId = await loadAndValidateProfile(projectConfig, projectDir, profile);
29
29
  }
30
30
  targetAccountId = targetAccountId || derivedAccountId;
31
31
  const accountConfig = getAccountConfig(targetAccountId);
package/lib/mcp/setup.js CHANGED
@@ -140,7 +140,7 @@ export async function setupVsCode(mcpCommand = defaultMcpCommand) {
140
140
  name: mcpServerName,
141
141
  ...buildCommandWithAgentString(mcpCommand, vscode),
142
142
  });
143
- await execAsync(`code --add-mcp '${mcpConfig}'`);
143
+ await execAsync(`code --add-mcp ${JSON.stringify(mcpConfig)}`);
144
144
  SpinniesManager.succeed('vsCode', {
145
145
  text: commands.mcp.setup.spinners.configuredVsCode,
146
146
  });
@@ -182,15 +182,14 @@ export async function setupClaudeCode(mcpCommand = defaultMcpCommand) {
182
182
  });
183
183
  await execAsync(`claude mcp remove "${mcpServerName}" --scope user`);
184
184
  }
185
- await execAsync(`claude mcp add-json "${mcpServerName}" '${mcpConfig}' --scope user`);
185
+ await execAsync(`claude mcp add-json "${mcpServerName}" ${JSON.stringify(mcpConfig)} --scope user`);
186
186
  SpinniesManager.succeed('claudeCode', {
187
187
  text: commands.mcp.setup.spinners.configuredClaudeCode,
188
188
  });
189
189
  return true;
190
190
  }
191
191
  catch (error) {
192
- if (error instanceof Error &&
193
- error.message.includes('claude: command not found')) {
192
+ if (error instanceof Error && error.message.includes('claude')) {
194
193
  SpinniesManager.fail('claudeCode', {
195
194
  text: commands.mcp.setup.spinners.claudeCodeNotFound,
196
195
  });
@@ -4,4 +4,4 @@ export declare function logProfileHeader(profileName: string): void;
4
4
  export declare function logProfileFooter(profile: HsProfileFile, includeVariables?: boolean): void;
5
5
  export declare function loadProfile(projectConfig: ProjectConfig | null, projectDir: string | null, profileName: string): HsProfileFile | undefined;
6
6
  export declare function exitIfUsingProfiles(projectConfig: ProjectConfig | null, projectDir: string | null): Promise<void>;
7
- export declare function loadAndValidateProfile(projectConfig: ProjectConfig | null, projectDir: string | null, argsProfile: string | undefined, useEnv?: boolean): Promise<number | undefined>;
7
+ export declare function loadAndValidateProfile(projectConfig: ProjectConfig | null, projectDir: string | null, argsProfile: string | undefined): Promise<number | undefined>;
@@ -38,12 +38,7 @@ export function loadProfile(projectConfig, projectDir, profileName) {
38
38
  uiLogger.error(lib.projectProfiles.loadProfile.errors.missingAccountId(profileFilename));
39
39
  return;
40
40
  }
41
- return {
42
- ...profile,
43
- accountId: process.env.HUBSPOT_ACCOUNT_ID
44
- ? Number(process.env.HUBSPOT_ACCOUNT_ID)
45
- : profile.accountId,
46
- };
41
+ return profile;
47
42
  }
48
43
  catch (e) {
49
44
  uiLogger.error(lib.projectProfiles.loadProfile.errors.failedToLoadProfile(profileFilename));
@@ -59,7 +54,7 @@ export async function exitIfUsingProfiles(projectConfig, projectDir) {
59
54
  }
60
55
  }
61
56
  }
62
- export async function loadAndValidateProfile(projectConfig, projectDir, argsProfile, useEnv = false) {
57
+ export async function loadAndValidateProfile(projectConfig, projectDir, argsProfile) {
63
58
  if (argsProfile) {
64
59
  logProfileHeader(argsProfile);
65
60
  const profile = loadProfile(projectConfig, projectDir, argsProfile);
@@ -68,9 +63,6 @@ export async function loadAndValidateProfile(projectConfig, projectDir, argsProf
68
63
  process.exit(EXIT_CODES.ERROR);
69
64
  }
70
65
  logProfileFooter(profile, true);
71
- if (useEnv) {
72
- return Number(process.env.HUBSPOT_ACCOUNT_ID);
73
- }
74
66
  return profile.accountId;
75
67
  }
76
68
  else {
@@ -7,6 +7,7 @@ import { projectAddPromptV3 } from '../../../prompts/projectAddPrompt.js';
7
7
  import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
8
8
  import { logger } from '@hubspot/local-dev-lib/logger';
9
9
  import { getProjectMetadata } from '@hubspot/project-parsing-lib/src/lib/project.js';
10
+ import { trackCommandUsage } from '../../../usageTracking.js';
10
11
  import { commands } from '../../../../lang/en.js';
11
12
  vi.mock('fs');
12
13
  vi.mock('../../../prompts/promptUtils');
@@ -16,6 +17,7 @@ vi.mock('../../../prompts/projectAddPrompt');
16
17
  vi.mock('@hubspot/local-dev-lib/github');
17
18
  vi.mock('@hubspot/local-dev-lib/logger');
18
19
  vi.mock('@hubspot/project-parsing-lib/src/lib/project');
20
+ vi.mock('../../../usageTracking');
19
21
  const mockedFs = vi.mocked(fs);
20
22
  const mockedGetConfigForPlatformVersion = vi.mocked(getConfigForPlatformVersion);
21
23
  const mockedConfirmPrompt = vi.mocked(confirmPrompt);
@@ -24,6 +26,7 @@ const mockedProjectAddPromptV3 = vi.mocked(projectAddPromptV3);
24
26
  const mockedCloneGithubRepo = vi.mocked(cloneGithubRepo);
25
27
  const mockedLogger = vi.mocked(logger);
26
28
  const mockedGetProjectMetadata = vi.mocked(getProjectMetadata);
29
+ const mockedTrackCommandUsage = vi.mocked(trackCommandUsage);
27
30
  describe('lib/projects/add/v3AddComponent', () => {
28
31
  const mockProjectConfig = {
29
32
  name: 'test-project',
@@ -32,6 +35,7 @@ describe('lib/projects/add/v3AddComponent', () => {
32
35
  };
33
36
  const mockArgs = { name: 'test-component', type: 'module' };
34
37
  const projectDir = '/path/to/project';
38
+ const mockAccountId = 123;
35
39
  const mockComponentTemplate = {
36
40
  label: 'Test Component',
37
41
  path: 'test-component',
@@ -62,6 +66,7 @@ describe('lib/projects/add/v3AddComponent', () => {
62
66
  authType: 'oauth',
63
67
  distribution: 'private',
64
68
  });
69
+ mockedTrackCommandUsage.mockResolvedValue();
65
70
  });
66
71
  describe('v3AddComponent()', () => {
67
72
  it('successfully adds a component when app already exists', async () => {
@@ -79,10 +84,13 @@ describe('lib/projects/add/v3AddComponent', () => {
79
84
  mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockAppMeta));
80
85
  mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
81
86
  mockedCloneGithubRepo.mockResolvedValue(true);
82
- await v3AddComponent(mockArgs, projectDir, mockProjectConfig);
87
+ await v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
83
88
  expect(mockedGetConfigForPlatformVersion).toHaveBeenCalledWith('v3');
84
89
  expect(mockedGetProjectMetadata).toHaveBeenCalledWith('/path/to/project/src');
85
90
  expect(mockedProjectAddPromptV3).toHaveBeenCalled();
91
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
92
+ type: 'module',
93
+ }, mockAccountId);
86
94
  expect(mockedCloneGithubRepo).toHaveBeenCalledWith(expect.any(String), projectDir, expect.objectContaining({
87
95
  sourceDir: ['v3/test-component'],
88
96
  hideLogs: true,
@@ -106,8 +114,11 @@ describe('lib/projects/add/v3AddComponent', () => {
106
114
  mockedConfirmPrompt.mockResolvedValue(true);
107
115
  mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
108
116
  mockedCloneGithubRepo.mockResolvedValue(true);
109
- await v3AddComponent(mockArgs, projectDir, mockProjectConfig);
117
+ await v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
110
118
  expect(mockedCreateV3App).toHaveBeenCalled();
119
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
120
+ type: 'module',
121
+ }, mockAccountId);
111
122
  expect(mockedCloneGithubRepo).toHaveBeenCalledWith(expect.any(String), projectDir, expect.objectContaining({
112
123
  sourceDir: ['v3/test-component', 'v3/app-template'],
113
124
  }));
@@ -132,8 +143,11 @@ describe('lib/projects/add/v3AddComponent', () => {
132
143
  mockedConfirmPrompt.mockResolvedValue(true);
133
144
  mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
134
145
  mockedCloneGithubRepo.mockResolvedValue(true);
135
- await v3AddComponent(mockArgs, projectDir, mockProjectConfig);
146
+ await v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
136
147
  expect(mockedCreateV3App).not.toHaveBeenCalled();
148
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
149
+ type: '',
150
+ }, mockAccountId);
137
151
  expect(mockedCloneGithubRepo).not.toHaveBeenCalled();
138
152
  });
139
153
  it('throws an error when app count exceeds maximum', async () => {
@@ -146,7 +160,7 @@ describe('lib/projects/add/v3AddComponent', () => {
146
160
  };
147
161
  mockedGetConfigForPlatformVersion.mockResolvedValue(mockConfig);
148
162
  mockedGetProjectMetadata.mockResolvedValue(mockProjectMetadataMaxApps);
149
- await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig)).rejects.toThrow('This project currently has the maximum number of apps: 1');
163
+ await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId)).rejects.toThrow('This project currently has the maximum number of apps: 1');
150
164
  });
151
165
  it('throws an error when components list is empty', async () => {
152
166
  const mockEmptyConfig = {
@@ -154,7 +168,7 @@ describe('lib/projects/add/v3AddComponent', () => {
154
168
  parentComponents: [],
155
169
  };
156
170
  mockedGetConfigForPlatformVersion.mockResolvedValue(mockEmptyConfig);
157
- await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig)).rejects.toThrow(commands.project.add.error.failedToFetchComponentList);
171
+ await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId)).rejects.toThrow(commands.project.add.error.failedToFetchComponentList);
158
172
  });
159
173
  it('throws an error when app meta file cannot be parsed', async () => {
160
174
  mockedGetConfigForPlatformVersion.mockResolvedValue(mockConfig);
@@ -162,7 +176,7 @@ describe('lib/projects/add/v3AddComponent', () => {
162
176
  mockedFs.readFileSync.mockImplementation(() => {
163
177
  throw new Error('File read error');
164
178
  });
165
- await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig)).rejects.toThrow('Unable to parse app file');
179
+ await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId)).rejects.toThrow('Unable to parse app file');
166
180
  });
167
181
  it('throws an error when cloning fails', async () => {
168
182
  const mockAppMeta = {
@@ -179,7 +193,57 @@ describe('lib/projects/add/v3AddComponent', () => {
179
193
  mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockAppMeta));
180
194
  mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
181
195
  mockedCloneGithubRepo.mockRejectedValue(new Error('Clone failed'));
182
- await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig)).rejects.toThrow(commands.project.add.error.failedToDownloadComponent);
196
+ await expect(v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId)).rejects.toThrow(commands.project.add.error.failedToDownloadComponent);
197
+ });
198
+ it('should track usage with multiple component types', async () => {
199
+ const mockAppMeta = {
200
+ config: {
201
+ distribution: 'private',
202
+ auth: { type: 'oauth' },
203
+ },
204
+ };
205
+ const mockSecondComponentTemplate = {
206
+ label: 'Test Card',
207
+ path: 'test-card',
208
+ type: 'card',
209
+ supportedAuthTypes: ['oauth'],
210
+ supportedDistributions: ['private'],
211
+ };
212
+ const mockPromptResponse = {
213
+ componentTemplate: [mockComponentTemplate, mockSecondComponentTemplate],
214
+ };
215
+ mockedGetConfigForPlatformVersion.mockResolvedValue(mockConfig);
216
+ mockedGetProjectMetadata.mockResolvedValue(mockProjectMetadata);
217
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockAppMeta));
218
+ mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
219
+ mockedCloneGithubRepo.mockResolvedValue(true);
220
+ await v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
221
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
222
+ type: 'module,card',
223
+ }, mockAccountId);
224
+ });
225
+ it('should track usage with empty type when no components are selected', async () => {
226
+ const mockProjectMetadataNoApps = {
227
+ hsMetaFiles: [],
228
+ components: {
229
+ app: {
230
+ count: 1,
231
+ maxCount: 1,
232
+ hsMetaFiles: ['/path/to/app.meta.json'],
233
+ },
234
+ module: { count: 0, maxCount: 5, hsMetaFiles: [] },
235
+ },
236
+ };
237
+ const mockPromptResponse = {
238
+ componentTemplate: [],
239
+ };
240
+ mockedGetConfigForPlatformVersion.mockResolvedValue(mockConfig);
241
+ mockedGetProjectMetadata.mockResolvedValue(mockProjectMetadataNoApps);
242
+ mockedProjectAddPromptV3.mockResolvedValue(mockPromptResponse);
243
+ await v3AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
244
+ expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
245
+ type: '',
246
+ }, mockAccountId);
183
247
  });
184
248
  });
185
249
  });
@@ -5,4 +5,4 @@ export declare function v3AddComponent(args: {
5
5
  features?: string[];
6
6
  auth?: string;
7
7
  distribution?: string;
8
- }, projectDir: string, projectConfig: ProjectConfig): Promise<void>;
8
+ }, projectDir: string, projectConfig: ProjectConfig, accountId: number): Promise<void>;
@@ -12,7 +12,8 @@ import { AppKey } from '@hubspot/project-parsing-lib/src/lib/constants.js';
12
12
  import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
13
13
  import { debugError } from '../../errorHandlers/index.js';
14
14
  import { uiLogger } from '../../ui/logger.js';
15
- export async function v3AddComponent(args, projectDir, projectConfig) {
15
+ import { trackCommandUsage } from '../../usageTracking.js';
16
+ export async function v3AddComponent(args, projectDir, projectConfig, accountId) {
16
17
  uiLogger.log(commands.project.add.creatingComponent(projectConfig.name));
17
18
  const config = await getConfigForPlatformVersion(projectConfig.platformVersion);
18
19
  const { components, parentComponents } = config;
@@ -47,6 +48,10 @@ export async function v3AddComponent(args, projectDir, projectConfig) {
47
48
  }
48
49
  const componentTemplateChoices = calculateComponentTemplateChoices(components, derivedAuthType, derivedDistribution, currentProjectMetadata);
49
50
  const projectAddPromptResponse = await projectAddPromptV3(componentTemplateChoices, args.features);
51
+ const componentTypes = projectAddPromptResponse.componentTemplate?.map(componentTemplate => componentTemplate.type);
52
+ await trackCommandUsage('project-add', {
53
+ type: componentTypes?.join(','),
54
+ }, accountId);
50
55
  try {
51
56
  const components = projectAddPromptResponse.componentTemplate?.map((componentTemplate) => {
52
57
  return path.join(projectConfig.platformVersion, componentTemplate.path);
@@ -0,0 +1,38 @@
1
+ import { TextContentResponse, Tool } from '../../types.js';
2
+ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { z } from 'zod';
4
+ declare const inputSchemaZodObject: z.ZodObject<{
5
+ absoluteCurrentWorkingDirectory: z.ZodString;
6
+ userSuppliedName: z.ZodOptional<z.ZodString>;
7
+ dest: z.ZodOptional<z.ZodString>;
8
+ moduleLabel: z.ZodOptional<z.ZodString>;
9
+ reactType: z.ZodOptional<z.ZodBoolean>;
10
+ contentTypes: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
11
+ global: z.ZodOptional<z.ZodBoolean>;
12
+ availableForNewContent: z.ZodOptional<z.ZodBoolean>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ absoluteCurrentWorkingDirectory: string;
15
+ dest?: string | undefined;
16
+ global?: boolean | undefined;
17
+ moduleLabel?: string | undefined;
18
+ reactType?: boolean | undefined;
19
+ contentTypes?: string | undefined;
20
+ availableForNewContent?: boolean | undefined;
21
+ userSuppliedName?: string | undefined;
22
+ }, {
23
+ absoluteCurrentWorkingDirectory: string;
24
+ dest?: string | undefined;
25
+ global?: boolean | undefined;
26
+ moduleLabel?: string | undefined;
27
+ reactType?: boolean | undefined;
28
+ contentTypes?: string | undefined;
29
+ availableForNewContent?: boolean | undefined;
30
+ userSuppliedName?: string | undefined;
31
+ }>;
32
+ export type HsCreateModuleInputSchema = z.infer<typeof inputSchemaZodObject>;
33
+ export declare class HsCreateModuleTool extends Tool<HsCreateModuleInputSchema> {
34
+ constructor(mcpServer: McpServer);
35
+ handler({ userSuppliedName, dest, moduleLabel, reactType, contentTypes, global, availableForNewContent, absoluteCurrentWorkingDirectory, }: HsCreateModuleInputSchema): Promise<TextContentResponse>;
36
+ register(): RegisteredTool;
37
+ }
38
+ export {};
@@ -0,0 +1,118 @@
1
+ import { Tool } from '../../types.js';
2
+ import { z } from 'zod';
3
+ import { absoluteCurrentWorkingDirectory } from '../project/constants.js';
4
+ import { runCommandInDir } from '../../utils/project.js';
5
+ import { formatTextContents, formatTextContent } from '../../utils/content.js';
6
+ import { trackToolUsage } from '../../utils/toolUsageTracking.js';
7
+ import { addFlag } from '../../utils/command.js';
8
+ import { CONTENT_TYPES } from '../../../types/Cms.js';
9
+ const inputSchema = {
10
+ absoluteCurrentWorkingDirectory,
11
+ userSuppliedName: z
12
+ .string()
13
+ .describe('REQUIRED - If not specified by the user, DO NOT choose. Ask the user to specify the name of the module they want to create.')
14
+ .optional(),
15
+ dest: z
16
+ .string()
17
+ .describe('The destination path where the module should be created on the current computer.')
18
+ .optional(),
19
+ moduleLabel: z
20
+ .string()
21
+ .describe('Label for module creation. Required for non-interactive module creation. If not provided, ask the user to provide it.')
22
+ .optional(),
23
+ reactType: z
24
+ .boolean()
25
+ .describe('Whether to create a React module. If the user has not specified that they want a React module, DO NOT choose for them, ask them what type of module they want to create HubL or React.')
26
+ .optional(),
27
+ contentTypes: z
28
+ .string()
29
+ .refine(val => {
30
+ if (!val)
31
+ return true; // optional
32
+ const types = val.split(',').map(t => t.trim().toUpperCase());
33
+ return types.every(type => CONTENT_TYPES.includes(type));
34
+ }, {
35
+ message: `Content types must be a comma-separated list of valid values: ${CONTENT_TYPES.join(', ')}`,
36
+ })
37
+ .describe(`Content types where the module can be used. Comma-separated list. Valid values: ${CONTENT_TYPES.join(', ')}. Defaults to "ANY".`)
38
+ .optional(),
39
+ global: z
40
+ .boolean()
41
+ .describe('Whether the module is global. Defaults to false.')
42
+ .optional(),
43
+ availableForNewContent: z
44
+ .boolean()
45
+ .describe('Whether the module is available for new content. Defaults to true.')
46
+ .optional(),
47
+ };
48
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
49
+ const inputSchemaZodObject = z.object({ ...inputSchema });
50
+ const toolName = 'create-hubspot-cms-module';
51
+ export class HsCreateModuleTool extends Tool {
52
+ constructor(mcpServer) {
53
+ super(mcpServer);
54
+ }
55
+ async handler({ userSuppliedName, dest, moduleLabel, reactType, contentTypes, global, availableForNewContent, absoluteCurrentWorkingDirectory, }) {
56
+ await trackToolUsage(toolName);
57
+ const content = [];
58
+ // Always require a name
59
+ if (!userSuppliedName) {
60
+ content.push(formatTextContent(`Ask the user to specify the name of the module they want to create.`));
61
+ }
62
+ // Require module label
63
+ if (!moduleLabel) {
64
+ content.push(formatTextContent(`Ask the user to provide a label for the module.`));
65
+ }
66
+ // Ask about React vs HubL if not specified
67
+ if (reactType === undefined) {
68
+ content.push(formatTextContent(`Ask the user what type of module they want to create: HubL or React?`));
69
+ }
70
+ // If we have missing required information, return the prompts
71
+ if (content.length > 0) {
72
+ return {
73
+ content,
74
+ };
75
+ }
76
+ // Build the command
77
+ let command = 'hs create module';
78
+ if (userSuppliedName) {
79
+ command += ` "${userSuppliedName}"`;
80
+ }
81
+ if (dest) {
82
+ command += ` "${dest}"`;
83
+ }
84
+ // Add module-specific flags
85
+ if (moduleLabel) {
86
+ command = addFlag(command, 'module-label', moduleLabel);
87
+ }
88
+ if (reactType !== undefined) {
89
+ command = addFlag(command, 'react-type', reactType);
90
+ }
91
+ if (contentTypes) {
92
+ command = addFlag(command, 'content-types', contentTypes);
93
+ }
94
+ else {
95
+ command = addFlag(command, 'content-types', 'ANY');
96
+ }
97
+ if (global !== undefined) {
98
+ command = addFlag(command, 'global', global);
99
+ }
100
+ if (availableForNewContent !== undefined) {
101
+ command = addFlag(command, 'available-for-new-content', availableForNewContent);
102
+ }
103
+ try {
104
+ const { stdout, stderr } = await runCommandInDir(absoluteCurrentWorkingDirectory, command);
105
+ return formatTextContents(stdout, stderr);
106
+ }
107
+ catch (error) {
108
+ return formatTextContents(error instanceof Error ? error.message : `${error}`);
109
+ }
110
+ }
111
+ register() {
112
+ return this.mcpServer.registerTool(toolName, {
113
+ title: 'Create HubSpot CMS Module',
114
+ description: 'Creates a new HubSpot CMS module using the hs create module command. Modules can be created non-interactively by specifying moduleLabel and other module options. You can create either HubL or React modules by setting the reactType parameter.',
115
+ inputSchema,
116
+ }, this.handler);
117
+ }
118
+ }
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { HsCreateModuleTool } from '../HsCreateModuleTool.js';
3
+ import { runCommandInDir } from '../../../utils/project.js';
4
+ import { addFlag } from '../../../utils/command.js';
5
+ vi.mock('@modelcontextprotocol/sdk/server/mcp.js');
6
+ vi.mock('../../../utils/project');
7
+ vi.mock('../../../utils/command');
8
+ vi.mock('../../../utils/toolUsageTracking', () => ({
9
+ trackToolUsage: vi.fn(),
10
+ }));
11
+ const mockRunCommandInDir = runCommandInDir;
12
+ const mockAddFlag = addFlag;
13
+ describe('HsCreateModuleTool', () => {
14
+ let mockMcpServer;
15
+ let tool;
16
+ let mockRegisteredTool;
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ // @ts-expect-error Not mocking the whole server
20
+ mockMcpServer = {
21
+ registerTool: vi.fn(),
22
+ };
23
+ mockRegisteredTool = {};
24
+ mockMcpServer.registerTool.mockReturnValue(mockRegisteredTool);
25
+ tool = new HsCreateModuleTool(mockMcpServer);
26
+ });
27
+ describe('register', () => {
28
+ it('should register the tool with the MCP server', () => {
29
+ const result = tool.register();
30
+ expect(mockMcpServer.registerTool).toHaveBeenCalledWith('create-hubspot-cms-module', {
31
+ title: 'Create HubSpot CMS Module',
32
+ description: 'Creates a new HubSpot CMS module using the hs create module command. Modules can be created non-interactively by specifying moduleLabel and other module options. You can create either HubL or React modules by setting the reactType parameter.',
33
+ inputSchema: expect.any(Object),
34
+ }, expect.any(Function));
35
+ expect(result).toBe(mockRegisteredTool);
36
+ });
37
+ });
38
+ describe('handler', () => {
39
+ it('should prompt for missing required parameters', async () => {
40
+ const result = await tool.handler({
41
+ absoluteCurrentWorkingDirectory: '/test/dir',
42
+ });
43
+ expect(result.content).toHaveLength(3);
44
+ expect(result.content[0].text).toContain('Ask the user to specify the name of the module');
45
+ expect(result.content[1].text).toContain('Ask the user to provide a label for the module');
46
+ expect(result.content[2].text).toContain('Ask the user what type of module they want to create: HubL or React?');
47
+ });
48
+ it('should prompt for missing name only when other params provided', async () => {
49
+ const result = await tool.handler({
50
+ absoluteCurrentWorkingDirectory: '/test/dir',
51
+ moduleLabel: 'Test Label',
52
+ reactType: false,
53
+ });
54
+ expect(result.content).toHaveLength(1);
55
+ expect(result.content[0].text).toContain('Ask the user to specify the name of the module');
56
+ });
57
+ it('should prompt for missing moduleLabel when name provided', async () => {
58
+ const result = await tool.handler({
59
+ absoluteCurrentWorkingDirectory: '/test/dir',
60
+ userSuppliedName: 'Test Module',
61
+ reactType: true,
62
+ });
63
+ expect(result.content).toHaveLength(1);
64
+ expect(result.content[0].text).toContain('Ask the user to provide a label for the module');
65
+ });
66
+ it('should prompt for missing reactType when other required params provided', async () => {
67
+ const result = await tool.handler({
68
+ absoluteCurrentWorkingDirectory: '/test/dir',
69
+ userSuppliedName: 'Test Module',
70
+ moduleLabel: 'Test Label',
71
+ });
72
+ expect(result.content).toHaveLength(1);
73
+ expect(result.content[0].text).toContain('Ask the user what type of module they want to create: HubL or React?');
74
+ });
75
+ it('should execute command with all required parameters (HubL module)', async () => {
76
+ mockAddFlag
77
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label')
78
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false')
79
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false --content-types ANY');
80
+ mockRunCommandInDir.mockResolvedValue({
81
+ stdout: 'Module created successfully',
82
+ stderr: '',
83
+ });
84
+ const result = await tool.handler({
85
+ absoluteCurrentWorkingDirectory: '/test/dir',
86
+ userSuppliedName: 'Test Module',
87
+ moduleLabel: 'Test Label',
88
+ reactType: false,
89
+ });
90
+ expect(mockAddFlag).toHaveBeenCalledWith('hs create module "Test Module"', 'module-label', 'Test Label');
91
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('module-label'), 'react-type', false);
92
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('react-type'), 'content-types', 'ANY');
93
+ expect(mockRunCommandInDir).toHaveBeenCalledWith('/test/dir', expect.stringContaining('hs create module'));
94
+ expect(result.content).toHaveLength(2);
95
+ expect(result.content[0].text).toContain('Module created successfully');
96
+ });
97
+ it('should execute command with React module', async () => {
98
+ mockAddFlag
99
+ .mockReturnValueOnce('hs create module "React Module" --module-label React Label')
100
+ .mockReturnValueOnce('hs create module "React Module" --module-label React Label --react-type true')
101
+ .mockReturnValueOnce('hs create module "React Module" --module-label React Label --react-type true --content-types ANY');
102
+ mockRunCommandInDir.mockResolvedValue({
103
+ stdout: 'React module created successfully',
104
+ stderr: '',
105
+ });
106
+ const result = await tool.handler({
107
+ absoluteCurrentWorkingDirectory: '/test/dir',
108
+ userSuppliedName: 'React Module',
109
+ moduleLabel: 'React Label',
110
+ reactType: true,
111
+ });
112
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('module-label'), 'react-type', true);
113
+ expect(result.content[0].text).toContain('React module created successfully');
114
+ });
115
+ it('should execute command with destination path', async () => {
116
+ mockAddFlag
117
+ .mockReturnValueOnce('hs create module "Test Module" "custom/path" --module-label Test Label')
118
+ .mockReturnValueOnce('hs create module "Test Module" "custom/path" --module-label Test Label --react-type false')
119
+ .mockReturnValueOnce('hs create module "Test Module" "custom/path" --module-label Test Label --react-type false --content-types ANY');
120
+ mockRunCommandInDir.mockResolvedValue({
121
+ stdout: 'Module created at custom path',
122
+ stderr: '',
123
+ });
124
+ const result = await tool.handler({
125
+ absoluteCurrentWorkingDirectory: '/test/dir',
126
+ userSuppliedName: 'Test Module',
127
+ dest: 'custom/path',
128
+ moduleLabel: 'Test Label',
129
+ reactType: false,
130
+ });
131
+ expect(mockRunCommandInDir).toHaveBeenCalledWith('/test/dir', expect.stringContaining('"custom/path"'));
132
+ expect(result.content[0].text).toContain('Module created at custom path');
133
+ });
134
+ it('should execute command with custom content types', async () => {
135
+ mockAddFlag
136
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label')
137
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false')
138
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false --content-types LANDING_PAGE,BLOG_POST');
139
+ mockRunCommandInDir.mockResolvedValue({
140
+ stdout: 'Module with custom content types created',
141
+ stderr: '',
142
+ });
143
+ const result = await tool.handler({
144
+ absoluteCurrentWorkingDirectory: '/test/dir',
145
+ userSuppliedName: 'Test Module',
146
+ moduleLabel: 'Test Label',
147
+ reactType: false,
148
+ contentTypes: 'LANDING_PAGE,BLOG_POST',
149
+ });
150
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('react-type'), 'content-types', 'LANDING_PAGE,BLOG_POST');
151
+ expect(result.content[0].text).toContain('Module with custom content types created');
152
+ });
153
+ it('should execute command with global flag', async () => {
154
+ mockAddFlag
155
+ .mockReturnValueOnce('hs create module "Global Module" --module-label Global Label')
156
+ .mockReturnValueOnce('hs create module "Global Module" --module-label Global Label --react-type false')
157
+ .mockReturnValueOnce('hs create module "Global Module" --module-label Global Label --react-type false --content-types ANY')
158
+ .mockReturnValueOnce('hs create module "Global Module" --module-label Global Label --react-type false --content-types ANY --global true');
159
+ mockRunCommandInDir.mockResolvedValue({
160
+ stdout: 'Global module created',
161
+ stderr: '',
162
+ });
163
+ const result = await tool.handler({
164
+ absoluteCurrentWorkingDirectory: '/test/dir',
165
+ userSuppliedName: 'Global Module',
166
+ moduleLabel: 'Global Label',
167
+ reactType: false,
168
+ global: true,
169
+ });
170
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('content-types'), 'global', true);
171
+ expect(result.content[0].text).toContain('Global module created');
172
+ });
173
+ it('should execute command with availableForNewContent flag', async () => {
174
+ mockAddFlag
175
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label')
176
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false')
177
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false --content-types ANY')
178
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false --content-types ANY --available-for-new-content false');
179
+ mockRunCommandInDir.mockResolvedValue({
180
+ stdout: 'Module created with availableForNewContent false',
181
+ stderr: '',
182
+ });
183
+ const result = await tool.handler({
184
+ absoluteCurrentWorkingDirectory: '/test/dir',
185
+ userSuppliedName: 'Test Module',
186
+ moduleLabel: 'Test Label',
187
+ reactType: false,
188
+ availableForNewContent: false,
189
+ });
190
+ expect(mockAddFlag).toHaveBeenCalledWith(expect.stringContaining('content-types'), 'available-for-new-content', false);
191
+ expect(result.content[0].text).toContain('Module created with availableForNewContent false');
192
+ });
193
+ it('should handle command execution errors', async () => {
194
+ mockRunCommandInDir.mockRejectedValue(new Error('Command failed'));
195
+ const result = await tool.handler({
196
+ absoluteCurrentWorkingDirectory: '/test/dir',
197
+ userSuppliedName: 'Test Module',
198
+ moduleLabel: 'Test Label',
199
+ reactType: false,
200
+ });
201
+ expect(result.content).toHaveLength(1);
202
+ expect(result.content[0].text).toContain('Command failed');
203
+ });
204
+ it('should handle stderr output', async () => {
205
+ mockAddFlag
206
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label')
207
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false')
208
+ .mockReturnValueOnce('hs create module "Test Module" --module-label Test Label --react-type false --content-types ANY');
209
+ mockRunCommandInDir.mockResolvedValue({
210
+ stdout: 'Module created successfully',
211
+ stderr: 'Warning: Deprecated feature used',
212
+ });
213
+ const result = await tool.handler({
214
+ absoluteCurrentWorkingDirectory: '/test/dir',
215
+ userSuppliedName: 'Test Module',
216
+ moduleLabel: 'Test Label',
217
+ reactType: false,
218
+ });
219
+ expect(result.content).toHaveLength(2);
220
+ expect(result.content[0].text).toContain('Module created successfully');
221
+ expect(result.content[1].text).toContain('Warning: Deprecated feature used');
222
+ });
223
+ });
224
+ });
@@ -8,6 +8,7 @@ import { GetConfigValuesTool } from './project/GetConfigValuesTool.js';
8
8
  import { DocsSearchTool } from './project/DocsSearchTool.js';
9
9
  import { DocFetchTool } from './project/DocFetchTool.js';
10
10
  import { HsListTool } from './cms/HsListTool.js';
11
+ import { HsCreateModuleTool } from './cms/HsCreateModuleTool.js';
11
12
  export function registerProjectTools(mcpServer) {
12
13
  return [
13
14
  new UploadProjectTools(mcpServer).register(),
@@ -22,5 +23,8 @@ export function registerProjectTools(mcpServer) {
22
23
  ];
23
24
  }
24
25
  export function registerCmsTools(mcpServer) {
25
- return [new HsListTool(mcpServer).register()];
26
+ return [
27
+ new HsListTool(mcpServer).register(),
28
+ new HsCreateModuleTool(mcpServer).register(),
29
+ ];
26
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/cli",
3
- "version": "7.7.28-experimental.0",
3
+ "version": "7.7.30-experimental.0",
4
4
  "description": "The official CLI for developing on HubSpot",
5
5
  "license": "Apache-2.0",
6
6
  "repository": "https://github.com/HubSpot/hubspot-cli",
package/types/Yargs.d.ts CHANGED
@@ -15,7 +15,7 @@ export type AccountArgs = {
15
15
  account?: string;
16
16
  };
17
17
  export type EnvironmentArgs = {
18
- 'use-env'?: boolean;
18
+ 'use-env'?: string;
19
19
  };
20
20
  export type OverwriteArgs = Options & {
21
21
  o?: boolean;