@hubspot/cli 7.8.1-experimental.0 → 7.8.3-experimental.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/commands/__tests__/getStarted.test.js +2 -2
  2. package/commands/__tests__/mcp.test.js +1 -1
  3. package/commands/__tests__/project.test.js +0 -3
  4. package/commands/app/__tests__/migrate.test.js +0 -1
  5. package/commands/app/migrate.js +4 -5
  6. package/commands/app/secret/add.js +2 -1
  7. package/commands/app/secret/delete.js +2 -1
  8. package/commands/app/secret/list.js +2 -1
  9. package/commands/app/secret/update.js +2 -1
  10. package/commands/app/secret.js +2 -1
  11. package/commands/app.js +2 -2
  12. package/commands/config/set.js +0 -1
  13. package/commands/feedback.js +1 -1
  14. package/commands/getStarted.d.ts +0 -2
  15. package/commands/getStarted.js +2 -2
  16. package/commands/mcp/__tests__/setup.test.js +2 -2
  17. package/commands/mcp/setup.js +3 -2
  18. package/commands/mcp.js +3 -3
  19. package/commands/project/__tests__/create.test.js +6 -6
  20. package/commands/project/__tests__/deploy.test.js +0 -3
  21. package/commands/project/__tests__/devUnifiedFlow.test.js +2 -4
  22. package/commands/project/__tests__/logs.test.js +0 -3
  23. package/commands/project/__tests__/migrate.test.js +1 -2
  24. package/commands/project/__tests__/migrateApp.test.js +1 -2
  25. package/commands/project/__tests__/profile.test.js +1 -1
  26. package/commands/project/add.js +1 -5
  27. package/commands/project/create.js +3 -9
  28. package/commands/project/deploy.js +2 -2
  29. package/commands/project/dev/index.js +4 -3
  30. package/commands/project/dev/unifiedFlow.js +5 -3
  31. package/commands/project/download.js +1 -2
  32. package/commands/project/installDeps.js +1 -2
  33. package/commands/project/listBuilds.js +2 -2
  34. package/commands/project/logs.js +2 -2
  35. package/commands/project/migrate.js +15 -6
  36. package/commands/project/migrateApp.js +1 -2
  37. package/commands/project/open.js +1 -2
  38. package/commands/project/profile/add.js +3 -3
  39. package/commands/project/profile/delete.js +1 -2
  40. package/commands/project/profile.js +2 -3
  41. package/commands/project/upload.js +2 -2
  42. package/commands/project/validate.js +1 -1
  43. package/commands/project/watch.js +2 -2
  44. package/commands/project.js +1 -2
  45. package/commands/sandbox/delete.js +1 -1
  46. package/commands/testAccount/importData.d.ts +1 -1
  47. package/commands/testAccount/importData.js +1 -1
  48. package/commands/testAccount.js +1 -1
  49. package/lang/en.d.ts +11 -3
  50. package/lang/en.js +13 -5
  51. package/lib/constants.d.ts +5 -0
  52. package/lib/constants.js +5 -0
  53. package/lib/mcp/setup.js +1 -1
  54. package/lib/middleware/fireAlarmMiddleware.js +15 -5
  55. package/lib/projects/__tests__/LocalDevProcess.test.js +227 -16
  56. package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +16 -21
  57. package/lib/projects/__tests__/deploy.test.js +71 -6
  58. package/lib/projects/__tests__/localDevProjectHelpers.test.js +4 -2
  59. package/lib/projects/create/__tests__/v3.test.js +79 -4
  60. package/lib/projects/create/v3.js +8 -6
  61. package/lib/projects/localDev/AppDevModeInterface.js +5 -5
  62. package/lib/projects/localDev/LocalDevLogger.d.ts +4 -0
  63. package/lib/projects/localDev/LocalDevLogger.js +22 -0
  64. package/lib/projects/localDev/LocalDevProcess.d.ts +7 -5
  65. package/lib/projects/localDev/LocalDevProcess.js +90 -19
  66. package/lib/projects/localDev/LocalDevState.d.ts +9 -8
  67. package/lib/projects/localDev/LocalDevState.js +18 -17
  68. package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -0
  69. package/lib/projects/localDev/LocalDevWebsocketServer.js +55 -23
  70. package/lib/projects/localDev/helpers/project.js +5 -1
  71. package/lib/projects/localDev/localDevWebsocketServerUtils.d.ts +4 -0
  72. package/lib/projects/localDev/localDevWebsocketServerUtils.js +10 -0
  73. package/lib/projects/pollProjectBuildAndDeploy.js +4 -4
  74. package/lib/prompts/projectAddPrompt.js +2 -1
  75. package/lib/prompts/promptUtils.js +3 -0
  76. package/lib/prompts/selectProjectTemplatePrompt.js +2 -0
  77. package/package.json +4 -4
  78. package/types/LocalDev.d.ts +19 -3
  79. package/ui/index.js +1 -1
@@ -1,7 +1,10 @@
1
1
  import path from 'path';
2
2
  import { translateForLocalDev } from '@hubspot/project-parsing-lib';
3
3
  import { handleProjectUpload } from '../upload.js';
4
+ import { handleProjectDeploy } from '../deploy.js';
4
5
  import { getProjectConfig } from '../config.js';
6
+ import { fetchProject } from '@hubspot/local-dev-lib/api/projects';
7
+ import { isHubSpotHttpError } from '@hubspot/local-dev-lib/errors/index';
5
8
  import LocalDevProcess from '../localDev/LocalDevProcess.js';
6
9
  import LocalDevLogger from '../localDev/LocalDevLogger.js';
7
10
  import DevServerManagerV2 from '../localDev/DevServerManagerV2.js';
@@ -19,7 +22,10 @@ vi.mock('@hubspot/ui-extensions-dev-server', () => ({
19
22
  vi.mock('open');
20
23
  vi.mock('@hubspot/project-parsing-lib');
21
24
  vi.mock('../upload');
25
+ vi.mock('../deploy');
22
26
  vi.mock('../config');
27
+ vi.mock('@hubspot/local-dev-lib/api/projects');
28
+ vi.mock('@hubspot/local-dev-lib/errors/index');
23
29
  vi.mock('../localDev/LocalDevLogger');
24
30
  vi.mock('../localDev/DevServerManagerV2');
25
31
  // Tests for LocalDevProcess and LocalDevState
@@ -37,11 +43,35 @@ describe('LocalDevProcess', () => {
37
43
  projectConfig: mockProjectConfig,
38
44
  targetProjectAccountId: 123,
39
45
  targetTestingAccountId: 456,
40
- projectId: 789,
46
+ projectData: {
47
+ id: 789,
48
+ name: 'test-project',
49
+ portalId: 123,
50
+ createdAt: 0,
51
+ deletedAt: 0,
52
+ isLocked: false,
53
+ updatedAt: 0,
54
+ latestBuild: {
55
+ activitySource: { type: 'HUBSPOT_USER', userId: 456 },
56
+ buildId: 123,
57
+ createdAt: '2023-01-01T00:00:00Z',
58
+ deployableState: 'DEPLOYABLE',
59
+ deployStatusTaskLocator: { id: 'task-123', links: [] },
60
+ enqueuedAt: '2023-01-01T00:00:00Z',
61
+ finishedAt: '2023-01-01T00:05:00Z',
62
+ isAutoDeployEnabled: false,
63
+ portalId: 123,
64
+ projectName: 'test-project',
65
+ startedAt: '2023-01-01T00:01:00Z',
66
+ status: 'SUCCESS',
67
+ subbuildStatuses: [],
68
+ uploadMessage: 'Build completed',
69
+ autoDeployId: 0,
70
+ },
71
+ },
41
72
  initialProjectNodes: {},
42
73
  initialProjectProfileData: {},
43
74
  env: ENVIRONMENTS.PROD,
44
- projectName: 'test-project',
45
75
  };
46
76
  beforeEach(() => {
47
77
  vi.clearAllMocks();
@@ -62,6 +92,9 @@ describe('LocalDevProcess', () => {
62
92
  uploadSuccess: vi.fn(),
63
93
  fileChangeError: vi.fn(),
64
94
  uploadWarning: vi.fn(),
95
+ deployInitiated: vi.fn(),
96
+ deployError: vi.fn(),
97
+ deploySuccess: vi.fn(),
65
98
  };
66
99
  mockDevServerManager = {
67
100
  setup: vi.fn().mockResolvedValue(undefined),
@@ -72,6 +105,8 @@ describe('LocalDevProcess', () => {
72
105
  // Mock constructors
73
106
  LocalDevLogger.mockImplementation(() => mockLocalDevLogger);
74
107
  DevServerManagerV2.mockImplementation(() => mockDevServerManager);
108
+ // Mock external functions
109
+ isHubSpotHttpError.mockReturnValue(false);
75
110
  // Create process instance
76
111
  process = new LocalDevProcess(mockOptions);
77
112
  // Mock process.exit
@@ -141,9 +176,14 @@ describe('LocalDevProcess', () => {
141
176
  handleProjectUpload.mockResolvedValue({
142
177
  uploadError: new Error('Upload failed'),
143
178
  });
144
- const success = await process.uploadProject();
179
+ const result = await process.uploadProject();
145
180
  expect(mockLocalDevLogger.uploadError).toHaveBeenCalledWith(new Error('Upload failed'));
146
- expect(success).toBe(false);
181
+ expect(result).toEqual({
182
+ uploadSuccess: false,
183
+ buildSuccess: false,
184
+ deploySuccess: false,
185
+ deployId: undefined,
186
+ });
147
187
  });
148
188
  it('should handle successful upload', async () => {
149
189
  await process.handleConfigFileChange();
@@ -152,38 +192,120 @@ describe('LocalDevProcess', () => {
152
192
  });
153
193
  handleProjectUpload.mockResolvedValue({
154
194
  uploadError: null,
195
+ result: {
196
+ deployResult: {
197
+ id: 'deploy-123',
198
+ deployId: 123,
199
+ status: 'SUCCESS',
200
+ },
201
+ },
202
+ });
203
+ fetchProject.mockResolvedValue({
204
+ data: {
205
+ id: 789,
206
+ name: 'test-project',
207
+ portalId: 123,
208
+ createdAt: 0,
209
+ deletedAt: 0,
210
+ isLocked: false,
211
+ updatedAt: 0,
212
+ latestBuild: { id: 'build-1', status: 'SUCCESS' },
213
+ deployedBuild: { id: 'build-1', status: 'SUCCESS' },
214
+ },
155
215
  });
156
- const success = await process.uploadProject();
216
+ const result = await process.uploadProject();
217
+ expect(fetchProject).toHaveBeenCalledWith(mockOptions.targetProjectAccountId, mockOptions.projectConfig.name);
157
218
  expect(mockLocalDevLogger.uploadSuccess).toHaveBeenCalled();
158
219
  // @ts-expect-error accessing private property for testing
159
220
  expect(process.state.uploadWarnings.size).toBe(0);
160
- expect(success).toBe(true);
221
+ expect(result).toEqual({
222
+ uploadSuccess: true,
223
+ buildSuccess: true,
224
+ deploySuccess: true,
225
+ deployId: 123,
226
+ });
161
227
  });
162
- it('should reset projectNodesAtLastUpload', async () => {
163
- const mockNodes = { node1: { uid: 'node1' } };
164
- const initialProjectNodes = { existingNode: { uid: 'existingNode' } };
228
+ it('should reset projectNodesAtLastUpload if deploy is successful', async () => {
229
+ const mockInitialNodes = {
230
+ node1: {
231
+ uid: 'node1',
232
+ componentType: 'APP',
233
+ localDev: {
234
+ componentRoot: '/test/path',
235
+ componentConfigPath: '/test/path/config.json',
236
+ configUpdatedSinceLastUpload: false,
237
+ },
238
+ componentDeps: {},
239
+ metaFilePath: '/test/path',
240
+ config: { name: 'Node 1' },
241
+ files: [],
242
+ },
243
+ };
244
+ const mockNewNodes = {
245
+ node1: {
246
+ uid: 'node2',
247
+ componentType: 'APP',
248
+ localDev: {
249
+ componentRoot: '/test/path',
250
+ componentConfigPath: '/test/path/config.json',
251
+ configUpdatedSinceLastUpload: false,
252
+ },
253
+ componentDeps: {},
254
+ metaFilePath: '/test/path',
255
+ config: { name: 'Node 2' },
256
+ files: [],
257
+ },
258
+ };
165
259
  // @ts-expect-error accessing private property for testing
166
- process.state._projectNodesAtLastUpload = initialProjectNodes;
260
+ process.state.projectNodesAtLastDeploy = mockInitialNodes;
167
261
  getProjectConfig.mockResolvedValue({
168
262
  projectConfig: mockOptions.projectConfig,
169
263
  });
170
264
  handleProjectUpload.mockResolvedValue({
171
265
  uploadError: null,
266
+ result: {
267
+ deployResult: {
268
+ id: 'deploy-123',
269
+ deployId: 456,
270
+ status: 'SUCCESS',
271
+ },
272
+ },
172
273
  });
173
274
  translateForLocalDev.mockResolvedValue({
174
- intermediateNodesIndexedByUid: mockNodes,
275
+ intermediateNodesIndexedByUid: mockNewNodes,
175
276
  });
176
- const success = await process.uploadProject();
177
- // Verify translateForLocalDev was called without projectNodesAtLastUpload option
277
+ fetchProject.mockResolvedValue({
278
+ data: {
279
+ id: 789,
280
+ name: 'test-project',
281
+ portalId: 123,
282
+ createdAt: 0,
283
+ deletedAt: 0,
284
+ isLocked: false,
285
+ updatedAt: 0,
286
+ latestBuild: { id: 'build-1', status: 'SUCCESS' },
287
+ deployedBuild: { id: 'build-1', status: 'SUCCESS' },
288
+ },
289
+ });
290
+ const result = await process.uploadProject();
291
+ // Verify translateForLocalDev was called during updateProjectNodesAfterDeploy
178
292
  expect(translateForLocalDev).toHaveBeenCalledWith({
179
293
  projectSourceDir: path.join(mockOptions.projectDir, mockOptions.projectConfig.srcDir),
180
294
  platformVersion: mockOptions.projectConfig.platformVersion,
181
295
  accountId: mockOptions.targetProjectAccountId,
182
- }, { projectNodesAtLastUpload: undefined });
296
+ }, {
297
+ profile: undefined,
298
+ projectNodesAtLastUpload: undefined,
299
+ });
183
300
  // Verify projectNodesAtLastUpload was reset to the new nodes
184
301
  // @ts-expect-error accessing private property for testing
185
- expect(process.state.projectNodesAtLastUpload).toEqual(mockNodes);
186
- expect(success).toBe(true);
302
+ expect(process.state.projectNodesAtLastDeploy).toEqual(mockNewNodes);
303
+ expect(result).toEqual({
304
+ uploadSuccess: true,
305
+ buildSuccess: true,
306
+ deploySuccess: true,
307
+ deployId: 456,
308
+ });
187
309
  });
188
310
  });
189
311
  describe('handleFileChange()', () => {
@@ -252,4 +374,93 @@ describe('LocalDevProcess', () => {
252
374
  expect(listener).toHaveBeenCalledTimes(1);
253
375
  });
254
376
  });
377
+ describe('deployLatestBuild()', () => {
378
+ beforeEach(() => {
379
+ vi.clearAllMocks();
380
+ });
381
+ it('should successfully deploy latest build', async () => {
382
+ const mockDeploy = {
383
+ deployId: 456,
384
+ buildId: 123,
385
+ status: 'SUCCESS',
386
+ enqueuedAt: '2023-01-01T00:00:00Z',
387
+ startedAt: '2023-01-01T00:01:00Z',
388
+ finishedAt: '2023-01-01T00:05:00Z',
389
+ portalId: 123,
390
+ projectName: 'test-project',
391
+ userId: 789,
392
+ source: 'HUBSPOT_USER',
393
+ subdeployStatuses: [],
394
+ };
395
+ handleProjectDeploy.mockResolvedValue(mockDeploy);
396
+ const result = await process.deployLatestBuild();
397
+ expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
398
+ expect(handleProjectDeploy).toHaveBeenCalledWith(123, // targetProjectAccountId
399
+ 'test-project', // projectName
400
+ 123, // buildId
401
+ true, // useV3Api
402
+ false // force
403
+ );
404
+ expect(mockLocalDevLogger.deploySuccess).toHaveBeenCalled();
405
+ expect(result).toEqual({
406
+ success: true,
407
+ deployId: 456,
408
+ });
409
+ });
410
+ it('should deploy with force parameter', async () => {
411
+ const mockDeploy = {
412
+ deployId: 456,
413
+ buildId: 123,
414
+ status: 'SUCCESS',
415
+ enqueuedAt: '2023-01-01T00:00:00Z',
416
+ startedAt: '2023-01-01T00:01:00Z',
417
+ finishedAt: '2023-01-01T00:05:00Z',
418
+ portalId: 123,
419
+ projectName: 'test-project',
420
+ userId: 789,
421
+ source: 'HUBSPOT_USER',
422
+ subdeployStatuses: [],
423
+ };
424
+ handleProjectDeploy.mockResolvedValue(mockDeploy);
425
+ const result = await process.deployLatestBuild(true);
426
+ expect(handleProjectDeploy).toHaveBeenCalledWith(123, // targetProjectAccountId
427
+ 'test-project', // projectName
428
+ 123, // buildId
429
+ true, // useV3Api
430
+ true // force
431
+ );
432
+ expect(result).toEqual({
433
+ success: true,
434
+ deployId: 456,
435
+ });
436
+ });
437
+ it('should return error when no build exists', async () => {
438
+ // Create a process without latestBuild
439
+ const optionsWithoutBuild = {
440
+ ...mockOptions,
441
+ projectData: {
442
+ ...mockOptions.projectData,
443
+ latestBuild: undefined,
444
+ },
445
+ };
446
+ const processWithoutBuild = new LocalDevProcess(optionsWithoutBuild);
447
+ const result = await processWithoutBuild.deployLatestBuild();
448
+ expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
449
+ expect(mockLocalDevLogger.deployError).toHaveBeenCalledWith('Error deploying project. No build was found to deploy.');
450
+ expect(result).toEqual({
451
+ success: false,
452
+ });
453
+ expect(handleProjectDeploy).not.toHaveBeenCalled();
454
+ });
455
+ it('should handle deploy failure when no deploy object returned', async () => {
456
+ handleProjectDeploy.mockResolvedValue(undefined);
457
+ const result = await process.deployLatestBuild();
458
+ expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
459
+ expect(handleProjectDeploy).toHaveBeenCalled();
460
+ expect(result).toEqual({
461
+ success: false,
462
+ });
463
+ expect(mockLocalDevLogger.deploySuccess).not.toHaveBeenCalled();
464
+ });
465
+ });
255
466
  });
@@ -30,8 +30,16 @@ describe('LocalDevWebsocketServer', () => {
30
30
  mockLocalDevProcess = {
31
31
  addStateListener: vi.fn(),
32
32
  removeStateListener: vi.fn(),
33
- uploadProject: vi.fn(),
33
+ uploadProject: vi.fn().mockResolvedValue({}),
34
34
  sendDevServerMessage: vi.fn(),
35
+ projectData: {
36
+ name: 'test-project',
37
+ id: 123,
38
+ latestBuild: { id: 'build-1', status: 'SUCCESS' },
39
+ deployedBuild: { id: 'build-1', status: 'SUCCESS' },
40
+ },
41
+ targetProjectAccountId: 456,
42
+ targetTestingAccountId: 789,
35
43
  };
36
44
  // Mock WebSocketServer constructor
37
45
  WebSocketServer.mockImplementation(() => mockWebSocketServer);
@@ -225,23 +233,6 @@ describe('LocalDevWebsocketServer', () => {
225
233
  expect(mockWebSocket3.close).not.toHaveBeenCalled();
226
234
  });
227
235
  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
236
  // Establish multiple connections
246
237
  connectionCallback(mockWebSocket1, {
247
238
  headers: { origin: 'https://app.hubspot.com' },
@@ -255,6 +246,8 @@ describe('LocalDevWebsocketServer', () => {
255
246
  data: {
256
247
  projectName: 'test-project',
257
248
  projectId: 123,
249
+ latestBuild: { id: 'build-1', status: 'SUCCESS' },
250
+ deployedBuild: { id: 'build-1', status: 'SUCCESS' },
258
251
  targetProjectAccountId: 456,
259
252
  targetTestingAccountId: 789,
260
253
  },
@@ -264,6 +257,8 @@ describe('LocalDevWebsocketServer', () => {
264
257
  data: {
265
258
  projectName: 'test-project',
266
259
  projectId: 123,
260
+ latestBuild: { id: 'build-1', status: 'SUCCESS' },
261
+ deployedBuild: { id: 'build-1', status: 'SUCCESS' },
267
262
  targetProjectAccountId: 456,
268
263
  targetTestingAccountId: 789,
269
264
  },
@@ -284,11 +279,11 @@ describe('LocalDevWebsocketServer', () => {
284
279
  const closeCallbacks2 = mockWebSocket2.on.mock.calls
285
280
  .filter(call => call[0] === 'close')
286
281
  .map(call => call[1]);
287
- expect(closeCallbacks1).toHaveLength(3); // projectNodes and appData listeners
288
- expect(closeCallbacks2).toHaveLength(3); // projectNodes and appData listeners
282
+ expect(closeCallbacks1).toHaveLength(3); // projectNodes, appData, and uploadWarnings listeners
283
+ expect(closeCallbacks2).toHaveLength(3); // projectNodes, appData, and uploadWarnings listeners
289
284
  // Simulate first connection closing (call all close callbacks)
290
285
  closeCallbacks1.forEach(callback => callback());
291
- // Should have removed listeners for first connection (2 listeners: projectNodes and appData)
286
+ // Should have removed listeners for first connection (3 listeners: projectNodes, appData, and uploadWarnings)
292
287
  expect(mockLocalDevProcess.removeStateListener).toHaveBeenCalledTimes(3);
293
288
  // Simulate second connection closing
294
289
  closeCallbacks2.forEach(callback => callback());
@@ -126,9 +126,9 @@ describe('lib/projects/deploy', () => {
126
126
  data: mockDeployResponseData,
127
127
  });
128
128
  mockPollDeployStatus.mockResolvedValue(mockDeployResult);
129
- const result = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
129
+ const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
130
130
  expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, useV3Api, force);
131
- expect(result).toEqual(mockDeployResult);
131
+ expect(deploy).toEqual(mockDeployResult);
132
132
  });
133
133
  it('handles blocked deploy with warnings', async () => {
134
134
  const mockBlockedResponse = {
@@ -150,15 +150,80 @@ describe('lib/projects/deploy', () => {
150
150
  mockDeployProject.mockResolvedValue({
151
151
  data: mockBlockedResponse,
152
152
  });
153
- const result = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
153
+ await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
154
154
  expect(mockUiLogger.warn).toHaveBeenCalledWith(commands.project.deploy.errors.deployWarningsHeader);
155
- expect(result).toBeUndefined();
155
+ });
156
+ it('handles blocked deploy with errors (cannot be forced)', async () => {
157
+ const mockBlockedResponse = {
158
+ buildResultType: 'DEPLOY_BLOCKED',
159
+ issues: [
160
+ {
161
+ uid: 'component-1',
162
+ componentTypeName: 'module',
163
+ errorMessages: [],
164
+ blockingMessages: [
165
+ {
166
+ message: 'This is an error',
167
+ isWarning: false,
168
+ },
169
+ ],
170
+ },
171
+ ],
172
+ };
173
+ mockDeployProject.mockResolvedValue({
174
+ data: mockBlockedResponse,
175
+ });
176
+ await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
177
+ expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deployBlockedHeader);
178
+ expect(mockUiLogger.log).toHaveBeenCalledWith(commands.project.deploy.errors.deployIssueComponentWarning('component-1', 'module', 'This is an error'));
179
+ });
180
+ it('handles blocked deploy with no blocking messages', async () => {
181
+ const mockBlockedResponse = {
182
+ buildResultType: 'DEPLOY_BLOCKED',
183
+ issues: [
184
+ {
185
+ uid: 'component-1',
186
+ componentTypeName: 'module',
187
+ errorMessages: [],
188
+ blockingMessages: [],
189
+ },
190
+ ],
191
+ };
192
+ mockDeployProject.mockResolvedValue({
193
+ data: mockBlockedResponse,
194
+ });
195
+ await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
196
+ expect(mockUiLogger.warn).toHaveBeenCalledWith(commands.project.deploy.errors.deployWarningsHeader);
197
+ expect(mockUiLogger.log).toHaveBeenCalledWith(commands.project.deploy.errors.deployIssueComponentGeneric('component-1', 'module'));
156
198
  });
157
199
  it('handles general deploy failure', async () => {
158
200
  mockDeployProject.mockResolvedValue({ data: null });
159
- const result = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
201
+ const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
202
+ expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
203
+ expect(deploy).toBeUndefined();
204
+ });
205
+ it('handles undefined deploy response', async () => {
206
+ mockDeployProject.mockResolvedValue({ data: undefined });
207
+ const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
160
208
  expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
161
- expect(result).toBeUndefined();
209
+ expect(deploy).toBeUndefined();
210
+ });
211
+ it('passes correct parameters to deployProject', async () => {
212
+ const mockDeployResponseData = {
213
+ id: 'deploy-123',
214
+ buildResultType: 'DEPLOY_QUEUED',
215
+ links: {
216
+ status: 'http://status-url',
217
+ },
218
+ };
219
+ mockDeployProject.mockResolvedValue({
220
+ data: mockDeployResponseData,
221
+ });
222
+ mockPollDeployStatus.mockResolvedValue({});
223
+ await handleProjectDeploy(targetAccountId, projectName, buildId, false, true);
224
+ expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, false, // useV3Api
225
+ true // force
226
+ );
162
227
  });
163
228
  });
164
229
  });
@@ -99,7 +99,8 @@ describe('isDeployedProjectUpToDateWithLocal', () => {
99
99
  it('should clean up temp directory even when errors occur', async () => {
100
100
  // Mock downloadProject to throw an error after temp dir is created
101
101
  downloadProject.mockRejectedValue(new Error('Download Error'));
102
- await expect(isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes)).rejects.toThrow('Download Error');
102
+ const result = await isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes);
103
+ expect(result).toBe(false);
103
104
  expect(fs.remove).toHaveBeenCalledWith(mockTempDir);
104
105
  });
105
106
  it('should handle translateForLocalDev errors', async () => {
@@ -111,7 +112,8 @@ describe('isDeployedProjectUpToDateWithLocal', () => {
111
112
  extractZipArchive.mockResolvedValue(undefined);
112
113
  // Mock translate to throw an error
113
114
  translate.mockRejectedValue(new Error('Translation Error'));
114
- await expect(isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes)).rejects.toThrow('Translation Error');
115
+ const result = await isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes);
116
+ expect(result).toBe(false);
115
117
  expect(fs.remove).toHaveBeenCalledWith(mockTempDir);
116
118
  });
117
119
  });
@@ -1,8 +1,17 @@
1
1
  import { calculateComponentTemplateChoices } from '../v3.js';
2
+ import { hasFeature } from '../../../hasFeature.js';
2
3
  vi.mock('@hubspot/local-dev-lib/logger');
3
4
  vi.mock('@hubspot/local-dev-lib/api/github');
5
+ vi.mock('../../../hasFeature.js');
6
+ const mockHasFeature = vi.mocked(hasFeature);
4
7
  describe('lib/projects/create/v3', () => {
8
+ beforeEach(() => {
9
+ mockHasFeature.mockResolvedValue(true);
10
+ });
5
11
  describe('calculateComponentTemplateChoices()', () => {
12
+ beforeEach(() => {
13
+ mockHasFeature.mockClear();
14
+ });
6
15
  const mockComponents = [
7
16
  {
8
17
  label: 'Module Component',
@@ -30,7 +39,7 @@ describe('lib/projects/create/v3', () => {
30
39
  const choices = await calculateComponentTemplateChoices(mockComponents, 'oauth', 'private', 123, mockProjectMetadataForChoices);
31
40
  expect(choices).toHaveLength(4); // includes separator
32
41
  expect(choices[0]).toEqual({
33
- name: 'Module Component',
42
+ name: 'Module Component [module]',
34
43
  value: mockComponents[0],
35
44
  });
36
45
  expect(choices[2]).toEqual({
@@ -70,7 +79,7 @@ describe('lib/projects/create/v3', () => {
70
79
  components: { module: { count: 0, maxCount: 5, hsMetaFiles: [] } },
71
80
  });
72
81
  expect(choices[0]).toEqual({
73
- name: 'Unrestricted Component',
82
+ name: 'Unrestricted Component [module]',
74
83
  value: componentsWithoutRestrictions[0],
75
84
  });
76
85
  });
@@ -94,7 +103,7 @@ describe('lib/projects/create/v3', () => {
94
103
  const choices = await calculateComponentTemplateChoices(componentWithCliSelector, 'oauth', 'private', 213, projectMetadataWithWorkflowAction);
95
104
  expect(choices).toHaveLength(1); // no disabled components
96
105
  expect(choices[0]).toEqual({
97
- name: 'Workflow Action Tool',
106
+ name: 'Workflow Action Tool [workflow-action-tool]',
98
107
  value: componentWithCliSelector[0],
99
108
  });
100
109
  });
@@ -137,7 +146,7 @@ describe('lib/projects/create/v3', () => {
137
146
  const choices = await calculateComponentTemplateChoices(componentWithCliSelector, 'oauth', 'private', 123, undefined);
138
147
  expect(choices).toHaveLength(1); // no disabled components
139
148
  expect(choices[0]).toEqual({
140
- name: 'Workflow Action Tool',
149
+ name: 'Workflow Action Tool [workflow-action-tool]',
141
150
  value: componentWithCliSelector[0],
142
151
  });
143
152
  });
@@ -162,5 +171,71 @@ describe('lib/projects/create/v3', () => {
162
171
  // @ts-expect-error breaking stuff on purpose
163
172
  projectMetadataWithoutComponents)).rejects.toThrow();
164
173
  });
174
+ it('disables gated components when hasFeature returns false', async () => {
175
+ mockHasFeature.mockResolvedValue(false);
176
+ const gatedComponent = [
177
+ {
178
+ label: 'Workflow Action Tool',
179
+ path: 'workflow-action-tool',
180
+ type: 'workflow-action',
181
+ cliSelector: 'workflow-action-tool',
182
+ supportedAuthTypes: ['oauth'],
183
+ supportedDistributions: ['private'],
184
+ },
185
+ ];
186
+ const choices = await calculateComponentTemplateChoices(gatedComponent, 'oauth', 'private', 123, mockProjectMetadataForChoices);
187
+ expect(choices).toHaveLength(3); // includes separators
188
+ expect(choices[1]).toEqual({
189
+ name: expect.stringContaining('Workflow Action Tool'),
190
+ value: gatedComponent[0],
191
+ disabled: expect.stringContaining('does not have access to this feature'),
192
+ });
193
+ expect(mockHasFeature).toHaveBeenCalledWith(123, expect.any(String));
194
+ });
195
+ it('enables gated components when hasFeature returns true', async () => {
196
+ mockHasFeature.mockResolvedValue(true);
197
+ const gatedComponent = [
198
+ {
199
+ label: 'Workflow Action Tool',
200
+ path: 'workflow-action-tool',
201
+ type: 'workflow-action',
202
+ cliSelector: 'workflow-action-tool',
203
+ supportedAuthTypes: ['oauth'],
204
+ supportedDistributions: ['private'],
205
+ },
206
+ ];
207
+ const projectMetadataWithWorkflowAction = {
208
+ hsMetaFiles: [],
209
+ components: {
210
+ 'workflow-action': { count: 0, maxCount: 3, hsMetaFiles: [] },
211
+ },
212
+ };
213
+ const choices = await calculateComponentTemplateChoices(gatedComponent, 'oauth', 'private', 123, projectMetadataWithWorkflowAction);
214
+ expect(choices).toHaveLength(1); // no disabled components
215
+ expect(choices[0]).toEqual({
216
+ name: 'Workflow Action Tool [workflow-action-tool]',
217
+ value: gatedComponent[0],
218
+ });
219
+ expect(mockHasFeature).toHaveBeenCalledWith(123, expect.any(String));
220
+ });
221
+ it('handles non-gated components without calling hasFeature', async () => {
222
+ const nonGatedComponent = [
223
+ {
224
+ label: 'Regular Component',
225
+ path: 'regular',
226
+ type: 'module',
227
+ supportedAuthTypes: ['oauth'],
228
+ supportedDistributions: ['private'],
229
+ },
230
+ ];
231
+ const choices = await calculateComponentTemplateChoices(nonGatedComponent, 'oauth', 'private', 123, mockProjectMetadataForChoices);
232
+ expect(choices).toHaveLength(1);
233
+ expect(choices[0]).toEqual({
234
+ name: 'Regular Component [module]',
235
+ value: nonGatedComponent[0],
236
+ });
237
+ // hasFeature should not be called for non-gated components
238
+ expect(mockHasFeature).not.toHaveBeenCalled();
239
+ });
165
240
  });
166
241
  });