@hubspot/cli 7.10.1-experimental.0 → 7.11.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/commands/project/__tests__/deploy.test.js +6 -6
  2. package/commands/project/__tests__/validate.test.js +27 -285
  3. package/commands/project/create.js +20 -14
  4. package/commands/project/deploy.js +6 -14
  5. package/commands/project/dev/index.js +4 -13
  6. package/commands/project/dev/unifiedFlow.js +7 -1
  7. package/commands/project/upload.js +2 -8
  8. package/commands/project/validate.js +12 -72
  9. package/lang/en.d.ts +19 -14
  10. package/lang/en.js +21 -16
  11. package/lib/__tests__/projectProfiles.test.js +32 -273
  12. package/lib/errorHandlers/index.js +10 -7
  13. package/lib/projectProfiles.d.ts +3 -4
  14. package/lib/projectProfiles.js +32 -78
  15. package/lib/projects/__tests__/components.test.js +2 -22
  16. package/lib/projects/__tests__/deploy.test.js +15 -13
  17. package/lib/projects/add/__tests__/legacyAddComponent.test.js +1 -1
  18. package/lib/projects/add/__tests__/v2AddComponent.test.js +30 -4
  19. package/lib/projects/add/legacyAddComponent.js +1 -1
  20. package/lib/projects/add/v2AddComponent.js +16 -5
  21. package/lib/projects/components.d.ts +8 -1
  22. package/lib/projects/components.js +91 -8
  23. package/lib/projects/deploy.js +21 -8
  24. package/lib/projects/localDev/DevServerManager_DEPRECATED.js +9 -1
  25. package/lib/projects/localDev/helpers/process.js +5 -3
  26. package/lib/ui/SpinniesManager.d.ts +5 -7
  27. package/lib/ui/SpinniesManager.js +9 -12
  28. package/lib/ui/__tests__/SpinniesManager.test.d.ts +1 -0
  29. package/lib/ui/__tests__/SpinniesManager.test.js +489 -0
  30. package/mcp-server/utils/config.js +1 -1
  31. package/package.json +4 -4
  32. package/ui/components/BoxWithTitle.js +1 -1
@@ -14,13 +14,11 @@ export function logError(error, context) {
14
14
  if (shouldSuppressError(error, context)) {
15
15
  return;
16
16
  }
17
- if (isHubSpotHttpError(error) && 'context' in error) {
17
+ if (isHubSpotHttpError(error)) {
18
18
  if (shouldSuppressError(error, error.context)) {
19
19
  return;
20
20
  }
21
- }
22
- if (isHubSpotHttpError(error) && context) {
23
- error.updateContext(context);
21
+ error.updateContext(context || {}, lib.errorHandlers.index.additionalDebugContext);
24
22
  }
25
23
  if (isHubSpotHttpError(error) && isValidationError(error)) {
26
24
  uiLogger.error(error.formattedValidationErrors());
@@ -57,9 +55,14 @@ export function debugError(error, context) {
57
55
  }
58
56
  else {
59
57
  uiLogger.debug(lib.errorHandlers.index.errorOccurred(String(error)));
60
- }
61
- if (error instanceof Error && error.cause && !isHubSpotHttpError(error)) {
62
- uiLogger.debug(lib.errorHandlers.index.errorCause(util.inspect(error.cause, false, null, true)));
58
+ if (error instanceof Error && error.cause) {
59
+ if (isHubSpotHttpError(error.cause)) {
60
+ uiLogger.debug(error.cause.toString());
61
+ }
62
+ else {
63
+ uiLogger.debug(lib.errorHandlers.index.errorCause(util.inspect(error.cause, false, null, true)));
64
+ }
65
+ }
63
66
  }
64
67
  if (context) {
65
68
  uiLogger.debug(lib.errorHandlers.index.errorContext(util.inspect(context, false, null, true)));
@@ -2,7 +2,6 @@ import { HsProfileFile } from '@hubspot/project-parsing-lib/src/lib/types.js';
2
2
  import { ProjectConfig } from '../types/Projects.js';
3
3
  export declare function logProfileHeader(profileName: string): void;
4
4
  export declare function logProfileFooter(profile: HsProfileFile, includeVariables?: boolean): void;
5
- export declare function loadProfile(projectConfig: ProjectConfig | null, projectDir: string | null, profileName: string): HsProfileFile | never;
6
- export declare function enforceProfileUsage(projectConfig: ProjectConfig | null, projectDir: string | null): Promise<void>;
7
- export declare function loadAndValidateProfile(projectConfig: ProjectConfig | null, projectDir: string | null, profileName: string | undefined, silent?: boolean): Promise<number | undefined>;
8
- export declare function validateProjectForProfile(projectConfig: ProjectConfig, projectDir: string, profileName: string, derivedAccountId: number, indentSpinners?: boolean): Promise<(string | Error)[]>;
5
+ export declare function loadProfile(projectConfig: ProjectConfig | null, projectDir: string | null, profileName: string): HsProfileFile | undefined;
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): Promise<number | undefined>;
@@ -1,12 +1,9 @@
1
1
  import path from 'path';
2
2
  import { loadHsProfileFile, getHsProfileFilename, getAllHsProfiles, } from '@hubspot/project-parsing-lib';
3
- import { commands, lib } from '../lang/en.js';
3
+ import { lib } from '../lang/en.js';
4
4
  import { uiBetaTag, uiLine } from './ui/index.js';
5
5
  import { uiLogger } from './ui/logger.js';
6
- import { validateProfileVariables } from '@hubspot/project-parsing-lib/src/index.js';
7
- import { getConfigAccountById } from '@hubspot/local-dev-lib/config';
8
- import SpinniesManager from './ui/SpinniesManager.js';
9
- import { handleTranslate } from './projects/upload.js';
6
+ import { EXIT_CODES } from './enums/exitCodes.js';
10
7
  export function logProfileHeader(profileName) {
11
8
  uiLine();
12
9
  uiBetaTag(lib.projectProfiles.logs.usingProfile(getHsProfileFilename(profileName)));
@@ -26,94 +23,51 @@ export function logProfileFooter(profile, includeVariables = false) {
26
23
  }
27
24
  export function loadProfile(projectConfig, projectDir, profileName) {
28
25
  if (!projectConfig || !projectDir) {
29
- throw new Error(lib.projectProfiles.loadProfile.errors.noProjectConfig);
26
+ uiLogger.error(lib.projectProfiles.loadProfile.errors.noProjectConfig);
27
+ return;
30
28
  }
31
29
  const projectSourceDir = path.join(projectDir, projectConfig.srcDir);
32
30
  const profileFilename = getHsProfileFilename(profileName);
33
- let profile;
34
31
  try {
35
- profile = loadHsProfileFile(projectSourceDir, profileName);
32
+ const profile = loadHsProfileFile(projectSourceDir, profileName);
33
+ if (!profile) {
34
+ uiLogger.error(lib.projectProfiles.loadProfile.errors.profileNotFound(profileFilename));
35
+ return;
36
+ }
37
+ if (!profile.accountId) {
38
+ uiLogger.error(lib.projectProfiles.loadProfile.errors.missingAccountId(profileFilename));
39
+ return;
40
+ }
41
+ return profile;
36
42
  }
37
43
  catch (e) {
38
- throw new Error(lib.projectProfiles.loadProfile.errors.failedToLoadProfile(profileFilename));
39
- }
40
- if (!profile) {
41
- throw new Error(lib.projectProfiles.loadProfile.errors.profileNotFound(profileFilename));
42
- }
43
- if (!profile.accountId) {
44
- throw new Error(lib.projectProfiles.loadProfile.errors.missingAccountId(profileFilename));
45
- }
46
- try {
47
- getConfigAccountById(profile.accountId);
48
- }
49
- catch (error) {
50
- throw new Error(lib.projectProfiles.loadProfile.errors.listedAccountNotFound(profile.accountId, profileFilename));
44
+ uiLogger.error(lib.projectProfiles.loadProfile.errors.failedToLoadProfile(profileFilename));
45
+ return;
51
46
  }
52
- return profile;
53
47
  }
54
- export async function enforceProfileUsage(projectConfig, projectDir) {
48
+ export async function exitIfUsingProfiles(projectConfig, projectDir) {
55
49
  if (projectConfig && projectDir) {
56
50
  const existingProfiles = await getAllHsProfiles(path.join(projectDir, projectConfig.srcDir));
57
51
  if (existingProfiles.length > 0) {
58
- throw new Error(lib.projectProfiles.exitIfUsingProfiles.errors.noProfileSpecified);
52
+ uiLogger.error(lib.projectProfiles.exitIfUsingProfiles.errors.noProfileSpecified);
53
+ process.exit(EXIT_CODES.ERROR);
59
54
  }
60
55
  }
61
56
  }
62
- export async function loadAndValidateProfile(projectConfig, projectDir, profileName, silent = false) {
63
- if (!profileName) {
64
- await enforceProfileUsage(projectConfig, projectDir);
65
- return;
66
- }
67
- if (!silent) {
68
- logProfileHeader(profileName);
69
- }
70
- const profile = loadProfile(projectConfig, projectDir, profileName);
71
- if (!silent) {
72
- logProfileFooter(profile, true);
73
- }
74
- if (profile.variables) {
75
- const validationResult = validateProfileVariables(profile.variables, profileName);
76
- if (!validationResult.success) {
77
- throw new Error(lib.projectProfiles.loadProfile.errors.profileNotValid(getHsProfileFilename(profileName), validationResult.errors));
57
+ export async function loadAndValidateProfile(projectConfig, projectDir, argsProfile) {
58
+ if (argsProfile) {
59
+ logProfileHeader(argsProfile);
60
+ const profile = loadProfile(projectConfig, projectDir, argsProfile);
61
+ if (!profile) {
62
+ uiLine();
63
+ process.exit(EXIT_CODES.ERROR);
78
64
  }
65
+ logProfileFooter(profile, true);
66
+ return profile.accountId;
79
67
  }
80
- return profile.accountId;
81
- // A profile must be specified if this project has profiles configured
82
- }
83
- export async function validateProjectForProfile(projectConfig, projectDir, profileName, derivedAccountId, indentSpinners = false) {
84
- let targetAccountId = derivedAccountId;
85
- const spinnerName = `validatingProfile-${profileName}`;
86
- const profileFilename = getHsProfileFilename(profileName);
87
- SpinniesManager.init();
88
- SpinniesManager.add(spinnerName, {
89
- text: commands.project.validate.spinners.validatingProfile(profileFilename),
90
- indent: indentSpinners ? 4 : 0,
91
- });
92
- try {
93
- const accountId = await loadAndValidateProfile(projectConfig, projectDir, profileName, true);
94
- targetAccountId = accountId || derivedAccountId;
95
- }
96
- catch (error) {
97
- SpinniesManager.fail(spinnerName, {
98
- text: commands.project.validate.spinners.profileValidationFailed(profileFilename),
99
- });
100
- return [error instanceof Error ? error.message : `${error}`];
101
- }
102
- try {
103
- await handleTranslate(projectDir, projectConfig, targetAccountId, false, profileName);
104
- }
105
- catch (error) {
106
- SpinniesManager.fail(spinnerName, {
107
- text: commands.project.validate.spinners.invalidWithProfile(profileFilename, projectConfig.name),
108
- });
109
- const errors = [
110
- commands.project.validate.failure(projectConfig.name),
111
- ];
112
- errors.push(error instanceof Error ? error : `${error}`);
113
- return errors;
68
+ else {
69
+ // A profile must be specified if this project has profiles configured
70
+ await exitIfUsingProfiles(projectConfig, projectDir);
114
71
  }
115
- SpinniesManager.succeed(spinnerName, {
116
- text: commands.project.validate.spinners.profileValidationSucceeded(profileFilename),
117
- });
118
- return [];
72
+ return undefined;
119
73
  }
@@ -13,20 +13,6 @@ vi.mock('@hubspot/project-parsing-lib', () => ({
13
13
  vi.mock('@hubspot/project-parsing-lib/src/lib/constants.js', () => ({
14
14
  AppKey: 'app',
15
15
  }));
16
- vi.mock('../../../lang/en.js', () => ({
17
- lib: {
18
- projects: {
19
- updateHsMetaFilesWithAutoGeneratedFields: {
20
- header: 'Updating component metadata files...',
21
- applicationLog: (type, uid, name) => `Updated ${type} component with uid: ${uid} and name: ${name}`,
22
- componentLog: (type, uid) => `Updated ${type} component with uid: ${uid}`,
23
- },
24
- generateSafeFilenameDifferentiator: {
25
- failedToCheckFiles: 'Failed to check files for filename differentiator. Falling back to timestamp.',
26
- },
27
- },
28
- },
29
- }));
30
16
  const mockedFs = vi.mocked(fs);
31
17
  const mockCoerceToValidUid = vi.mocked(coerceToValidUid);
32
18
  const mockedFileExists = vi.mocked(fileExists);
@@ -247,7 +233,6 @@ describe('lib/projects/components', () => {
247
233
  });
248
234
  });
249
235
  describe('updateHsMetaFilesWithAutoGeneratedFields()', () => {
250
- const mockUiLogger = vi.mocked(uiLogger);
251
236
  beforeEach(() => {
252
237
  vi.resetAllMocks();
253
238
  mockCoerceToValidUid.mockImplementation((input) => input);
@@ -282,6 +267,8 @@ describe('lib/projects/components', () => {
282
267
  updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFilePaths);
283
268
  expect(mockCoerceToValidUid).toHaveBeenCalledWith('my-project_card');
284
269
  expect(mockCoerceToValidUid).toHaveBeenCalledWith('my-project_function');
270
+ expect(mockCoerceToValidUid).toHaveBeenCalledWith('my-project_card');
271
+ expect(mockCoerceToValidUid).toHaveBeenCalledWith('my-project_function');
285
272
  expect(mockedFs.writeFileSync).toHaveBeenCalledWith('/path/to/component1.meta.json', JSON.stringify({
286
273
  type: 'card',
287
274
  uid: 'card_my_project',
@@ -293,9 +280,6 @@ describe('lib/projects/components', () => {
293
280
  type: 'function',
294
281
  uid: 'function_my_project',
295
282
  }, null, 2));
296
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updating component metadata files...');
297
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updated card component with uid: card_my_project');
298
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updated function component with uid: function_my_project');
299
283
  });
300
284
  it('handles app components by updating both uid and config.name', () => {
301
285
  const projectName = 'test-app';
@@ -321,7 +305,6 @@ describe('lib/projects/components', () => {
321
305
  other: 'property',
322
306
  },
323
307
  }, null, 2));
324
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updated app component with uid: app_test_app and name: test-app-Application');
325
308
  });
326
309
  it('handles UID collisions by using differentiators', () => {
327
310
  const projectName = 'collision-project';
@@ -366,8 +349,6 @@ describe('lib/projects/components', () => {
366
349
  updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFilePaths);
367
350
  expect(mockedFs.readFileSync).not.toHaveBeenCalled();
368
351
  expect(mockedFs.writeFileSync).not.toHaveBeenCalled();
369
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updating component metadata files...');
370
- expect(mockUiLogger.log).toHaveBeenCalledWith('');
371
352
  });
372
353
  it('handles components without config property for app type', () => {
373
354
  const projectName = 'no-config-project';
@@ -386,7 +367,6 @@ describe('lib/projects/components', () => {
386
367
  type: 'app',
387
368
  uid: 'app_no_config_project',
388
369
  }, null, 2));
389
- expect(mockUiLogger.log).toHaveBeenCalledWith('Updated app component with uid: app_no_config_project');
390
370
  });
391
371
  it('replaces hyphens with underscores in coerced UIDs', () => {
392
372
  const projectName = 'my-project';
@@ -3,7 +3,7 @@ import { validateBuildIdForDeploy, logDeployErrors, handleProjectDeploy, } from
3
3
  import { uiLogger } from '../../ui/logger.js';
4
4
  import { commands } from '../../../lang/en.js';
5
5
  import { PROJECT_ERROR_TYPES } from '../../constants.js';
6
- import { deployProject } from '@hubspot/local-dev-lib/api/projects';
6
+ import { deployProjectV1, deployProjectV2, } from '@hubspot/local-dev-lib/api/projects';
7
7
  import { pollDeployStatus } from '../pollProjectBuildAndDeploy.js';
8
8
  // Mock external dependencies
9
9
  vi.mock('../../ui/logger.js');
@@ -11,7 +11,8 @@ vi.mock('@hubspot/local-dev-lib/api/projects');
11
11
  vi.mock('@hubspot/local-dev-lib/config');
12
12
  vi.mock('../pollProjectBuildAndDeploy.js');
13
13
  const mockUiLogger = vi.mocked(uiLogger);
14
- const mockDeployProject = vi.mocked(deployProject);
14
+ const mockDeployProjectV1 = vi.mocked(deployProjectV1);
15
+ const mockDeployProjectV2 = vi.mocked(deployProjectV2);
15
16
  const mockPollDeployStatus = vi.mocked(pollDeployStatus);
16
17
  describe('lib/projects/deploy', () => {
17
18
  beforeEach(() => {
@@ -123,12 +124,12 @@ describe('lib/projects/deploy', () => {
123
124
  source: 'HUBSPOT_USER',
124
125
  subdeployStatuses: [],
125
126
  };
126
- mockDeployProject.mockResolvedValue({
127
+ mockDeployProjectV2.mockResolvedValue({
127
128
  data: mockDeployResponseData,
128
129
  });
129
130
  mockPollDeployStatus.mockResolvedValue(mockDeployResult);
130
131
  const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
131
- expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, useV2Api, force);
132
+ expect(mockDeployProjectV2).toHaveBeenCalledWith(targetAccountId, projectName, buildId, force);
132
133
  expect(deploy).toEqual(mockDeployResult);
133
134
  });
134
135
  it('handles blocked deploy with warnings', async () => {
@@ -148,7 +149,7 @@ describe('lib/projects/deploy', () => {
148
149
  },
149
150
  ],
150
151
  };
151
- mockDeployProject.mockResolvedValue({
152
+ mockDeployProjectV2.mockResolvedValue({
152
153
  data: mockBlockedResponse,
153
154
  });
154
155
  await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
@@ -171,7 +172,7 @@ describe('lib/projects/deploy', () => {
171
172
  },
172
173
  ],
173
174
  };
174
- mockDeployProject.mockResolvedValue({
175
+ mockDeployProjectV2.mockResolvedValue({
175
176
  data: mockBlockedResponse,
176
177
  });
177
178
  await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
@@ -190,7 +191,7 @@ describe('lib/projects/deploy', () => {
190
191
  },
191
192
  ],
192
193
  };
193
- mockDeployProject.mockResolvedValue({
194
+ mockDeployProjectV2.mockResolvedValue({
194
195
  data: mockBlockedResponse,
195
196
  });
196
197
  await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
@@ -198,18 +199,18 @@ describe('lib/projects/deploy', () => {
198
199
  expect(mockUiLogger.log).toHaveBeenCalledWith(commands.project.deploy.errors.deployIssueComponentGeneric('component-1', 'module'));
199
200
  });
200
201
  it('handles general deploy failure', async () => {
201
- mockDeployProject.mockResolvedValue({ data: null });
202
+ mockDeployProjectV2.mockResolvedValue({ data: null });
202
203
  const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
203
204
  expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
204
205
  expect(deploy).toBeUndefined();
205
206
  });
206
207
  it('handles undefined deploy response', async () => {
207
- mockDeployProject.mockResolvedValue({ data: undefined });
208
+ mockDeployProjectV2.mockResolvedValue({ data: undefined });
208
209
  const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV2Api, force);
209
210
  expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
210
211
  expect(deploy).toBeUndefined();
211
212
  });
212
- it('passes correct parameters to deployProject', async () => {
213
+ it('passes correct parameters to deployProjectV1', async () => {
213
214
  const mockDeployResponseData = {
214
215
  id: 'deploy-123',
215
216
  buildResultType: 'DEPLOY_QUEUED',
@@ -217,14 +218,15 @@ describe('lib/projects/deploy', () => {
217
218
  status: 'http://status-url',
218
219
  },
219
220
  };
220
- mockDeployProject.mockResolvedValue({
221
+ mockDeployProjectV1.mockResolvedValue({
221
222
  data: mockDeployResponseData,
222
223
  });
223
224
  mockPollDeployStatus.mockResolvedValue({});
224
- await handleProjectDeploy(targetAccountId, projectName, buildId, false, true);
225
- expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, false, // useV2Api
225
+ await handleProjectDeploy(targetAccountId, projectName, buildId, false, // isV2Project
226
226
  true // force
227
227
  );
228
+ expect(mockDeployProjectV1).toHaveBeenCalledWith(targetAccountId, projectName, buildId, true // force
229
+ );
228
230
  });
229
231
  });
230
232
  });
@@ -76,7 +76,7 @@ describe('lib/projects/add/legacyAddComponent', () => {
76
76
  type: 'module',
77
77
  }, accountId);
78
78
  expect(mockedUiLogger.log).toHaveBeenCalledWith(commands.project.add.creatingComponent('test-project'));
79
- expect(mockedUiLogger.success).toHaveBeenCalledWith(commands.project.add.success('new-component'));
79
+ expect(mockedUiLogger.success).toHaveBeenCalledWith(commands.project.add.success('test-project'));
80
80
  });
81
81
  it('throws an error when project contains a public app', async () => {
82
82
  const mockComponents = [
@@ -5,9 +5,9 @@ import { createV2App } from '../../create/v2.js';
5
5
  import { confirmPrompt } from '../../../prompts/promptUtils.js';
6
6
  import { projectAddPromptV2 } from '../../../prompts/projectAddPrompt.js';
7
7
  import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
8
- import { uiLogger } from '../../../ui/logger.js';
9
8
  import { getProjectMetadata } from '@hubspot/project-parsing-lib/src/lib/project.js';
10
9
  import { trackCommandUsage } from '../../../usageTracking.js';
10
+ import { updateHsMetaFilesWithAutoGeneratedFields } from '../../components.js';
11
11
  import { commands } from '../../../../lang/en.js';
12
12
  vi.mock('fs');
13
13
  vi.mock('../../../prompts/promptUtils');
@@ -16,17 +16,19 @@ vi.mock('../../create/v2');
16
16
  vi.mock('../../../prompts/projectAddPrompt');
17
17
  vi.mock('@hubspot/local-dev-lib/github');
18
18
  vi.mock('../../../ui/logger.js');
19
+ vi.mock('../../../ui/SpinniesManager.js');
19
20
  vi.mock('@hubspot/project-parsing-lib/src/lib/project');
20
21
  vi.mock('../../../usageTracking');
22
+ vi.mock('../../components.js');
21
23
  const mockedFs = vi.mocked(fs);
22
24
  const mockedGetConfigForPlatformVersion = vi.mocked(getConfigForPlatformVersion);
23
25
  const mockedConfirmPrompt = vi.mocked(confirmPrompt);
24
26
  const mockedCreateV2App = vi.mocked(createV2App);
25
27
  const mockedProjectAddPromptV2 = vi.mocked(projectAddPromptV2);
26
28
  const mockedCloneGithubRepo = vi.mocked(cloneGithubRepo);
27
- const mockedUiLogger = vi.mocked(uiLogger);
28
29
  const mockedGetProjectMetadata = vi.mocked(getProjectMetadata);
29
30
  const mockedTrackCommandUsage = vi.mocked(trackCommandUsage);
31
+ const mockedUpdateHsMetaFilesWithAutoGeneratedFields = vi.mocked(updateHsMetaFilesWithAutoGeneratedFields);
30
32
  describe('lib/projects/add/v2AddComponent', () => {
31
33
  const mockProjectConfig = {
32
34
  name: 'test-project',
@@ -75,6 +77,7 @@ describe('lib/projects/add/v2AddComponent', () => {
75
77
  describe('v2AddComponent()', () => {
76
78
  it('successfully adds a component when app already exists', async () => {
77
79
  const mockAppMeta = {
80
+ name: 'Test App',
78
81
  config: {
79
82
  distribution: 'private',
80
83
  auth: { type: 'oauth' },
@@ -83,14 +86,33 @@ describe('lib/projects/add/v2AddComponent', () => {
83
86
  const mockPromptResponse = {
84
87
  componentTemplate: [mockComponentTemplate],
85
88
  };
89
+ const mockUpdatedProjectMetadata = {
90
+ hsMetaFiles: ['/path/to/new-module.meta.json'],
91
+ components: {
92
+ app: {
93
+ count: 1,
94
+ maxCount: 1,
95
+ hsMetaFiles: ['/path/to/app.meta.json'],
96
+ },
97
+ module: {
98
+ count: 1,
99
+ maxCount: 5,
100
+ hsMetaFiles: ['/path/to/new-module.meta.json'],
101
+ },
102
+ },
103
+ };
86
104
  mockedGetConfigForPlatformVersion.mockResolvedValue(mockConfig);
87
- mockedGetProjectMetadata.mockResolvedValue(mockProjectMetadata);
105
+ mockedGetProjectMetadata
106
+ .mockResolvedValueOnce(mockProjectMetadata)
107
+ .mockResolvedValueOnce(mockUpdatedProjectMetadata);
88
108
  mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockAppMeta));
89
109
  mockedProjectAddPromptV2.mockResolvedValue(mockPromptResponse);
90
110
  mockedCloneGithubRepo.mockResolvedValue(true);
111
+ mockedUpdateHsMetaFilesWithAutoGeneratedFields.mockResolvedValue(undefined);
91
112
  await v2AddComponent(mockArgs, projectDir, mockProjectConfig, mockAccountId);
92
113
  expect(mockedGetConfigForPlatformVersion).toHaveBeenCalledWith('2025.2');
93
114
  expect(mockedGetProjectMetadata).toHaveBeenCalledWith('/path/to/project/src');
115
+ expect(mockedGetProjectMetadata).toHaveBeenCalledTimes(2);
94
116
  expect(mockedProjectAddPromptV2).toHaveBeenCalled();
95
117
  expect(mockedTrackCommandUsage).toHaveBeenCalledWith('project-add', {
96
118
  type: 'module',
@@ -100,7 +122,11 @@ describe('lib/projects/add/v2AddComponent', () => {
100
122
  hideLogs: true,
101
123
  branch: 'main',
102
124
  }));
103
- expect(mockedUiLogger.success).toHaveBeenCalled();
125
+ expect(mockedUpdateHsMetaFilesWithAutoGeneratedFields).toHaveBeenCalledWith('test-project', ['/path/to/new-module.meta.json'], [], expect.objectContaining({
126
+ currentProjectMetadata: mockProjectMetadata,
127
+ updatedProjectMetadata: mockUpdatedProjectMetadata,
128
+ showSuccessMessage: true,
129
+ }));
104
130
  });
105
131
  it('creates an app when no app exists and user confirms', async () => {
106
132
  const mockProjectMetadataNoApps = {
@@ -38,7 +38,7 @@ export async function legacyAddComponent(args, projectDir, projectConfig, derive
38
38
  branch: DEFAULT_PROJECT_TEMPLATE_BRANCH,
39
39
  hideLogs: true,
40
40
  });
41
- uiLogger.success(commands.project.add.success(projectAddPromptResponse.name));
41
+ uiLogger.success(commands.project.add.success(projectConfig.name));
42
42
  }
43
43
  catch (error) {
44
44
  throw new Error(commands.project.add.error.failedToDownloadComponent);
@@ -13,8 +13,8 @@ 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
15
  import { trackCommandUsage } from '../../usageTracking.js';
16
+ import SpinniesManager from '../../ui/SpinniesManager.js';
16
17
  export async function v2AddComponent(args, projectDir, projectConfig, accountId) {
17
- uiLogger.log(commands.project.add.creatingComponent(projectConfig.name));
18
18
  const config = await getConfigForPlatformVersion(projectConfig.platformVersion);
19
19
  const { components, parentComponents } = config;
20
20
  if (!components || !components.length) {
@@ -52,6 +52,10 @@ export async function v2AddComponent(args, projectDir, projectConfig, accountId)
52
52
  await trackCommandUsage('project-add', {
53
53
  type: componentTypes?.join(','),
54
54
  }, accountId);
55
+ SpinniesManager.init();
56
+ SpinniesManager.add('project-add', {
57
+ text: commands.project.add.creatingComponent(projectConfig.name),
58
+ });
55
59
  try {
56
60
  const components = projectAddPromptResponse.componentTemplate?.map((componentTemplate) => {
57
61
  return path.join(projectConfig.platformVersion, componentTemplate.path);
@@ -87,12 +91,19 @@ export async function v2AddComponent(args, projectDir, projectConfig, accountId)
87
91
  return '';
88
92
  }
89
93
  });
90
- updateHsMetaFilesWithAutoGeneratedFields(projectConfig.name, newHsMetaFiles, existingUids);
91
- uiLogger.success(commands.project.add.success(projectAddPromptResponse.componentTemplate
92
- .map(template => `'${template.label}'`)
93
- .join(', '), projectAddPromptResponse.componentTemplate.length > 1));
94
+ SpinniesManager.succeed('project-add', {
95
+ text: commands.project.add.success(projectConfig.name),
96
+ });
97
+ await updateHsMetaFilesWithAutoGeneratedFields(projectConfig.name, newHsMetaFiles, existingUids, {
98
+ currentProjectMetadata,
99
+ updatedProjectMetadata,
100
+ showSuccessMessage: true,
101
+ });
94
102
  }
95
103
  catch (error) {
104
+ SpinniesManager.fail('project-add', {
105
+ text: commands.project.add.failure(projectConfig.name),
106
+ });
96
107
  debugError(error);
97
108
  throw new Error(commands.project.add.error.failedToDownloadComponent, {
98
109
  cause: error,
@@ -1,3 +1,10 @@
1
1
  import { Collision } from '@hubspot/local-dev-lib/types/Archive';
2
+ import { ProjectMetadata } from '@hubspot/project-parsing-lib/src/lib/project.js';
2
3
  export declare function handleComponentCollision({ dest, src, collisions }: Collision): void;
3
- export declare function updateHsMetaFilesWithAutoGeneratedFields(projectName: string, hsMetaFilePaths: string[], existingUids?: string[]): void;
4
+ export declare function updateHsMetaFilesWithAutoGeneratedFields(projectName: string, hsMetaFilePaths: string[], existingUids?: string[], options?: {
5
+ currentProjectMetadata?: ProjectMetadata;
6
+ updatedProjectMetadata?: ProjectMetadata;
7
+ showSuccessMessage?: boolean;
8
+ isProjectEmpty?: boolean;
9
+ projectDest?: string;
10
+ }): Promise<void>;
@@ -6,8 +6,59 @@ import { uiLogger } from '../ui/logger.js';
6
6
  import { AppKey } from '@hubspot/project-parsing-lib/src/lib/constants.js';
7
7
  import { lib } from '../../lang/en.js';
8
8
  import { debugError } from '../errorHandlers/index.js';
9
+ import { uiLink } from '../ui/index.js';
10
+ import chalk from 'chalk';
11
+ import { renderInline } from '../../ui/index.js';
12
+ import { getSuccessBox } from '../../ui/components/StatusMessageBoxes.js';
9
13
  // Prefix for the metafile extension
10
14
  const metafileExtensionPrefix = path.parse(metafileExtension).name;
15
+ function buildProjectTree(projectName, uids, componentsByType, showOnlyNew) {
16
+ const lines = [];
17
+ lines.push(chalk.bold(projectName));
18
+ const types = Array.from(componentsByType.keys());
19
+ for (const uid of uids) {
20
+ lines.push(`├─ [app] ${uid}`);
21
+ for (let i = 0; i < types.length; i++) {
22
+ const type = types[i];
23
+ const allComponents = componentsByType.get(type) || [];
24
+ const components = showOnlyNew
25
+ ? allComponents.filter(c => c.isNew)
26
+ : allComponents;
27
+ if (components.length === 0)
28
+ continue;
29
+ const isLastType = i === types.length - 1;
30
+ const typePrefix = isLastType ? '│ └─' : '│ ├─';
31
+ const typeConnector = isLastType ? ' ' : '│ ';
32
+ lines.push(`${typePrefix} ${type}`);
33
+ for (let j = 0; j < components.length; j++) {
34
+ const component = components[j];
35
+ const isLastComponent = j === components.length - 1;
36
+ const componentPrefix = isLastComponent ? '└─' : '├─';
37
+ const addedLabel = component.isNew ? chalk.green(' (added)') : '';
38
+ lines.push(`│ ${typeConnector}${componentPrefix} ${component.filename}${addedLabel}`);
39
+ }
40
+ }
41
+ }
42
+ return lines.join('\n');
43
+ }
44
+ function buildSuccessMessage(projectName, uids, componentsByType, newComponentsCount, showOnlyNew, isProjectEmpty, projectDest) {
45
+ const messages = lib.projects.components.buildSuccessMessage;
46
+ const tree = buildProjectTree(projectName, uids, componentsByType, showOnlyNew);
47
+ const featureText = `${newComponentsCount} feature${newComponentsCount > 1 ? 's' : ''}`;
48
+ const uid = uids.length === 1 ? uids[0] : projectName;
49
+ const docsLink = uiLink(messages.seeOurDocs, messages.docsUrl);
50
+ const header = projectDest
51
+ ? messages.headerCreated(projectName, projectDest)
52
+ : messages.headerAdded(featureText, uid, newComponentsCount > 1);
53
+ // Use \n\n between sections to create gaps, \n within sections for no gaps
54
+ const sections = [
55
+ header,
56
+ isProjectEmpty ? null : tree,
57
+ messages.docsDetails(docsLink),
58
+ `${messages.uploadPrompt}\n${messages.devPrompt}`,
59
+ ].filter(Boolean);
60
+ return sections.join('\n\n');
61
+ }
11
62
  function applyDifferentiatorToFilename(filename, differentiator, isHsMetaFile) {
12
63
  const { name, ext, dir } = path.parse(filename);
13
64
  if (isHsMetaFile) {
@@ -120,9 +171,7 @@ function handlePackageJsonCollisions(dest, src, packageJsonFiles) {
120
171
  fs.writeFileSync(path.join(dest, file), JSON.stringify(existingPackageJsonContents, null, 2));
121
172
  });
122
173
  }
123
- export function updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFilePaths, existingUids = []) {
124
- uiLogger.log('');
125
- uiLogger.log(lib.projects.updateHsMetaFilesWithAutoGeneratedFields.header);
174
+ export async function updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFilePaths, existingUids = [], options) {
126
175
  for (const hsMetaFile of hsMetaFilePaths) {
127
176
  try {
128
177
  const component = JSON.parse(fs.readFileSync(hsMetaFile).toString());
@@ -142,10 +191,6 @@ export function updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFile
142
191
  component.uid = uid;
143
192
  if (component.type === AppKey && component.config) {
144
193
  component.config.name = `${projectName}-Application`;
145
- uiLogger.log(lib.projects.updateHsMetaFilesWithAutoGeneratedFields.applicationLog(component.type, component.uid, component.config.name));
146
- }
147
- else {
148
- uiLogger.log(lib.projects.updateHsMetaFilesWithAutoGeneratedFields.componentLog(component.type, component.uid));
149
194
  }
150
195
  fs.writeFileSync(hsMetaFile, JSON.stringify(component, null, 2));
151
196
  }
@@ -154,5 +199,43 @@ export function updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMetaFile
154
199
  uiLogger.error(lib.projects.updateHsMetaFilesWithAutoGeneratedFields.failedToUpdate(hsMetaFile));
155
200
  }
156
201
  }
157
- uiLogger.log('');
202
+ if (options?.showSuccessMessage && options?.updatedProjectMetadata) {
203
+ const { currentProjectMetadata, updatedProjectMetadata, isProjectEmpty, projectDest, } = options;
204
+ const uids = [];
205
+ const updatedAppsMetadata = updatedProjectMetadata.components[AppKey];
206
+ // Get UID(s) from -hsmeta.json files
207
+ if (updatedAppsMetadata?.hsMetaFiles) {
208
+ updatedAppsMetadata.hsMetaFiles.forEach(appLocation => {
209
+ try {
210
+ const appConfig = JSON.parse(fs.readFileSync(appLocation, 'utf-8'));
211
+ uids.push(appConfig.uid || 'unknown app'); // fallback to unknown app incase we can't get uid
212
+ }
213
+ catch (err) {
214
+ uiLogger.debug(lib.projects.components.unableToGetUidFromHsmeta);
215
+ }
216
+ });
217
+ }
218
+ // Fallback if no app hsmeta files found or all failed to parse
219
+ if (uids.length === 0) {
220
+ uids.push('unknown app');
221
+ }
222
+ const componentsByType = new Map();
223
+ const addComponent = (hsMetaPath, isNew) => {
224
+ const type = path.basename(path.dirname(hsMetaPath));
225
+ const existing = componentsByType.get(type) || [];
226
+ existing.push({ filename: path.basename(hsMetaPath), isNew });
227
+ componentsByType.set(type, existing);
228
+ };
229
+ // Add new files
230
+ hsMetaFilePaths.forEach(hsMetaFile => addComponent(hsMetaFile, true));
231
+ if (currentProjectMetadata) {
232
+ Object.entries(currentProjectMetadata.components)
233
+ .filter(([type, metadata]) => type !== AppKey && metadata.count > 0)
234
+ .flatMap(([, metadata]) => metadata.hsMetaFiles)
235
+ .forEach(hsMetaFile => addComponent(hsMetaFile, false));
236
+ }
237
+ const newComponentsCount = hsMetaFilePaths.length;
238
+ const successMessage = buildSuccessMessage(projectName, uids, componentsByType, newComponentsCount, false, isProjectEmpty, projectDest);
239
+ await renderInline(getSuccessBox({ title: 'SUCCESS', message: successMessage }));
240
+ }
158
241
  }