@hubspot/cli 8.0.0-beta.0 → 8.0.0-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 (32) hide show
  1. package/commands/cms/__tests__/fetch.test.js +2 -1
  2. package/commands/cms/fetch.js +2 -1
  3. package/commands/project/__tests__/create.test.js +1 -1
  4. package/commands/project/__tests__/dev.test.js +39 -8
  5. package/commands/project/dev/index.js +18 -15
  6. package/commands/testAccount/create.js +1 -1
  7. package/lang/en.d.ts +12 -1
  8. package/lang/en.js +25 -14
  9. package/lib/errorHandlers/__tests__/index.test.d.ts +1 -0
  10. package/lib/errorHandlers/__tests__/index.test.js +278 -0
  11. package/lib/errorHandlers/index.js +11 -2
  12. package/lib/projects/__tests__/components.test.js +1 -1
  13. package/lib/projects/__tests__/upload.test.js +10 -0
  14. package/lib/projects/__tests__/workspaceArchive.test.d.ts +1 -0
  15. package/lib/projects/__tests__/workspaceArchive.test.js +237 -0
  16. package/lib/projects/components.js +1 -1
  17. package/lib/projects/upload.js +9 -0
  18. package/lib/projects/workspaces.d.ts +35 -0
  19. package/lib/projects/workspaces.js +216 -0
  20. package/mcp-server/tools/project/AddFeatureToProjectTool.js +2 -2
  21. package/mcp-server/tools/project/CreateProjectTool.js +2 -2
  22. package/mcp-server/tools/project/GetApiUsagePatternsByAppIdTool.js +2 -2
  23. package/mcp-server/tools/project/GetApplicationInfoTool.js +3 -3
  24. package/mcp-server/tools/project/UploadProjectTools.js +5 -2
  25. package/mcp-server/tools/project/__tests__/AddFeatureToProjectTool.test.js +1 -1
  26. package/mcp-server/tools/project/__tests__/CreateProjectTool.test.js +1 -1
  27. package/mcp-server/tools/project/__tests__/GetApiUsagePatternsByAppIdTool.test.js +1 -1
  28. package/mcp-server/tools/project/__tests__/GetApplicationInfoTool.test.js +3 -3
  29. package/mcp-server/tools/project/__tests__/UploadProjectTools.test.js +12 -0
  30. package/package.json +3 -3
  31. package/types/Cms.d.ts +1 -1
  32. package/types/Cms.js +2 -0
@@ -0,0 +1,278 @@
1
+ import { logError, debugError, ApiErrorContext, isErrorWithMessageOrReason, getErrorMessage, } from '../index.js';
2
+ import { uiLogger } from '../../ui/logger.js';
3
+ import { isHubSpotHttpError, isValidationError, } from '@hubspot/local-dev-lib/errors/index';
4
+ import { getConfig } from '@hubspot/local-dev-lib/config';
5
+ import { shouldSuppressError } from '../suppressError.js';
6
+ import { isProjectValidationError } from '../../errors/ProjectValidationError.js';
7
+ import { lib } from '../../../lang/en.js';
8
+ vi.mock('../../ui/logger.js');
9
+ vi.mock('@hubspot/local-dev-lib/errors/index');
10
+ vi.mock('@hubspot/local-dev-lib/config');
11
+ vi.mock('../suppressError.js');
12
+ vi.mock('../../errors/ProjectValidationError.js');
13
+ describe('lib/errorHandlers/index', () => {
14
+ const uiLoggerErrorMock = uiLogger.error;
15
+ const uiLoggerDebugMock = uiLogger.debug;
16
+ const isHubSpotHttpErrorMock = isHubSpotHttpError;
17
+ const isValidationErrorMock = isValidationError;
18
+ const getConfigMock = getConfig;
19
+ const shouldSuppressErrorMock = shouldSuppressError;
20
+ const isProjectValidationErrorMock = isProjectValidationError;
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ isHubSpotHttpErrorMock.mockReturnValue(false);
24
+ isValidationErrorMock.mockReturnValue(false);
25
+ shouldSuppressErrorMock.mockReturnValue(false);
26
+ isProjectValidationErrorMock.mockReturnValue(false);
27
+ getConfigMock.mockReturnValue({});
28
+ });
29
+ describe('logError', () => {
30
+ it('logs ProjectValidationError message and returns early', () => {
31
+ const error = {
32
+ message: 'Project validation failed',
33
+ name: 'ProjectValidationError',
34
+ };
35
+ isProjectValidationErrorMock.mockReturnValue(true);
36
+ logError(error);
37
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Project validation failed');
38
+ expect(uiLoggerErrorMock).toHaveBeenCalledTimes(1);
39
+ });
40
+ it('returns early when error should be suppressed', () => {
41
+ const error = new Error('Suppressed error');
42
+ shouldSuppressErrorMock.mockReturnValue(true);
43
+ logError(error);
44
+ expect(shouldSuppressErrorMock).toHaveBeenCalled();
45
+ expect(uiLoggerErrorMock).not.toHaveBeenCalled();
46
+ });
47
+ it('logs validation errors for HubSpotHttpError with validation errors', () => {
48
+ const mockError = {
49
+ formattedValidationErrors: vi
50
+ .fn()
51
+ .mockReturnValue('Formatted validation errors'),
52
+ updateContext: vi.fn(),
53
+ context: {},
54
+ };
55
+ isHubSpotHttpErrorMock.mockReturnValue(true);
56
+ isValidationErrorMock.mockReturnValue(true);
57
+ logError(mockError);
58
+ expect(mockError.formattedValidationErrors).toHaveBeenCalled();
59
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Formatted validation errors');
60
+ });
61
+ it('logs error message for errors with message property', () => {
62
+ const error = new Error('Something went wrong');
63
+ logError(error);
64
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Something went wrong');
65
+ });
66
+ it('logs error with both message and reason', () => {
67
+ const error = { message: 'Error message', reason: 'Error reason' };
68
+ logError(error);
69
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Error message Error reason');
70
+ });
71
+ it('logs unknown error message for errors without message or reason', () => {
72
+ const error = { foo: 'bar' };
73
+ logError(error);
74
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith(lib.errorHandlers.index.unknownErrorOccurred);
75
+ });
76
+ it('calls updateContext on HubSpotHttpError', () => {
77
+ const mockError = {
78
+ updateContext: vi.fn(),
79
+ context: {},
80
+ message: 'test',
81
+ };
82
+ isHubSpotHttpErrorMock.mockReturnValue(true);
83
+ logError(mockError);
84
+ expect(mockError.updateContext).toHaveBeenCalled();
85
+ });
86
+ describe('timeout error handling', () => {
87
+ it('shows config timeout message for direct ETIMEDOUT error matching default timeout', () => {
88
+ const mockError = {
89
+ code: 'ETIMEDOUT',
90
+ timeout: 15000,
91
+ updateContext: vi.fn(),
92
+ context: {},
93
+ message: 'Timeout',
94
+ };
95
+ isHubSpotHttpErrorMock.mockReturnValue(true);
96
+ getConfigMock.mockReturnValue({ httpTimeout: 15000 });
97
+ logError(mockError);
98
+ expect(uiLoggerErrorMock).toHaveBeenCalledTimes(2);
99
+ expect(uiLoggerErrorMock).toHaveBeenNthCalledWith(1, 'Timeout');
100
+ expect(uiLoggerErrorMock).toHaveBeenNthCalledWith(2, lib.errorHandlers.index.configTimeoutErrorOccurred(15000, 'hs config set'));
101
+ });
102
+ it('shows generic timeout message for direct ETIMEDOUT error with custom timeout', () => {
103
+ const mockError = {
104
+ code: 'ETIMEDOUT',
105
+ timeout: 30000,
106
+ updateContext: vi.fn(),
107
+ context: {},
108
+ message: 'Timeout',
109
+ };
110
+ isHubSpotHttpErrorMock.mockReturnValue(true);
111
+ getConfigMock.mockReturnValue({ httpTimeout: 15000 });
112
+ logError(mockError);
113
+ expect(uiLoggerErrorMock).toHaveBeenCalledTimes(2);
114
+ expect(uiLoggerErrorMock).toHaveBeenNthCalledWith(2, lib.errorHandlers.index.genericTimeoutErrorOccurred);
115
+ });
116
+ it('detects timeout error wrapped in error.cause', () => {
117
+ const causeError = {
118
+ code: 'ETIMEDOUT',
119
+ timeout: 15000,
120
+ name: 'HubSpotHttpError',
121
+ };
122
+ const wrapperError = new Error('Assets unavailable');
123
+ Object.defineProperty(wrapperError, 'cause', {
124
+ value: causeError,
125
+ writable: true,
126
+ });
127
+ isHubSpotHttpErrorMock.mockImplementation(err => {
128
+ return err === causeError;
129
+ });
130
+ getConfigMock.mockReturnValue({ httpTimeout: 15000 });
131
+ logError(wrapperError);
132
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Assets unavailable');
133
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith(lib.errorHandlers.index.configTimeoutErrorOccurred(15000, 'hs config set'));
134
+ });
135
+ it('shows generic timeout message for wrapped timeout with different timeout value', () => {
136
+ const causeError = {
137
+ code: 'ETIMEDOUT',
138
+ timeout: 60000,
139
+ name: 'HubSpotHttpError',
140
+ };
141
+ const wrapperError = new Error('Assets unavailable');
142
+ Object.defineProperty(wrapperError, 'cause', {
143
+ value: causeError,
144
+ writable: true,
145
+ });
146
+ isHubSpotHttpErrorMock.mockImplementation(err => {
147
+ return err === causeError;
148
+ });
149
+ getConfigMock.mockReturnValue({ httpTimeout: 15000 });
150
+ logError(wrapperError);
151
+ expect(uiLoggerErrorMock).toHaveBeenCalledTimes(2);
152
+ expect(uiLoggerErrorMock).toHaveBeenNthCalledWith(2, lib.errorHandlers.index.genericTimeoutErrorOccurred);
153
+ });
154
+ it('does not show timeout message for non-timeout errors', () => {
155
+ const error = new Error('Regular error');
156
+ logError(error);
157
+ expect(uiLoggerErrorMock).toHaveBeenCalledTimes(1);
158
+ expect(uiLoggerErrorMock).toHaveBeenCalledWith('Regular error');
159
+ });
160
+ });
161
+ });
162
+ describe('debugError', () => {
163
+ it('logs HubSpotHttpError using toString', () => {
164
+ const mockError = {
165
+ toString: vi.fn().mockReturnValue('HubSpotHttpError details'),
166
+ };
167
+ isHubSpotHttpErrorMock.mockReturnValue(true);
168
+ debugError(mockError);
169
+ expect(uiLoggerDebugMock).toHaveBeenCalledWith('HubSpotHttpError details');
170
+ });
171
+ it('logs regular error using lib.errorHandlers.index.errorOccurred', () => {
172
+ const error = new Error('Regular error');
173
+ debugError(error);
174
+ expect(uiLoggerDebugMock).toHaveBeenCalledWith(lib.errorHandlers.index.errorOccurred('Error: Regular error'));
175
+ });
176
+ it('logs error.cause when it is a HubSpotHttpError', () => {
177
+ const causeError = {
178
+ toString: vi.fn().mockReturnValue('Cause error details'),
179
+ };
180
+ const error = new Error('Wrapper error');
181
+ Object.defineProperty(error, 'cause', {
182
+ value: causeError,
183
+ writable: true,
184
+ });
185
+ isHubSpotHttpErrorMock.mockImplementation(err => {
186
+ return err === causeError;
187
+ });
188
+ debugError(error);
189
+ expect(causeError.toString).toHaveBeenCalled();
190
+ expect(uiLoggerDebugMock).toHaveBeenCalledWith('Cause error details');
191
+ });
192
+ it('logs error.cause using lib.errorHandlers.index.errorCause when not a HubSpotHttpError', () => {
193
+ const causeError = { customField: 'value' };
194
+ const error = new Error('Wrapper error');
195
+ Object.defineProperty(error, 'cause', {
196
+ value: causeError,
197
+ writable: true,
198
+ });
199
+ debugError(error);
200
+ expect(uiLoggerDebugMock).toHaveBeenCalledWith(expect.stringMatching(/^Cause:/));
201
+ });
202
+ it('logs context using lib.errorHandlers.index.errorContext when provided', () => {
203
+ const error = new Error('Error');
204
+ const context = new ApiErrorContext({
205
+ accountId: 123,
206
+ request: '/api/test',
207
+ });
208
+ debugError(error, context);
209
+ expect(uiLoggerDebugMock).toHaveBeenCalledWith(expect.stringMatching(/^Context:/));
210
+ });
211
+ });
212
+ describe('ApiErrorContext', () => {
213
+ it('creates context with all properties', () => {
214
+ const context = new ApiErrorContext({
215
+ accountId: 123,
216
+ request: '/api/test',
217
+ payload: '{"data": "value"}',
218
+ projectName: 'my-project',
219
+ });
220
+ expect(context.accountId).toBe(123);
221
+ expect(context.request).toBe('/api/test');
222
+ expect(context.payload).toBe('{"data": "value"}');
223
+ expect(context.projectName).toBe('my-project');
224
+ });
225
+ it('creates context with default values', () => {
226
+ const context = new ApiErrorContext();
227
+ expect(context.accountId).toBeUndefined();
228
+ expect(context.request).toBe('');
229
+ expect(context.payload).toBe('');
230
+ expect(context.projectName).toBe('');
231
+ });
232
+ it('creates context with partial properties', () => {
233
+ const context = new ApiErrorContext({
234
+ accountId: 456,
235
+ });
236
+ expect(context.accountId).toBe(456);
237
+ expect(context.request).toBe('');
238
+ expect(context.payload).toBe('');
239
+ expect(context.projectName).toBe('');
240
+ });
241
+ });
242
+ describe('isErrorWithMessageOrReason', () => {
243
+ it('returns true for object with message property', () => {
244
+ expect(isErrorWithMessageOrReason({ message: 'test' })).toBe(true);
245
+ });
246
+ it('returns true for object with reason property', () => {
247
+ expect(isErrorWithMessageOrReason({ reason: 'test' })).toBe(true);
248
+ });
249
+ it('returns true for object with both message and reason', () => {
250
+ expect(isErrorWithMessageOrReason({ message: 'msg', reason: 'rsn' })).toBe(true);
251
+ });
252
+ it('returns true for Error instances', () => {
253
+ expect(isErrorWithMessageOrReason(new Error('test'))).toBe(true);
254
+ });
255
+ it('returns false for null', () => {
256
+ expect(isErrorWithMessageOrReason(null)).toBe(false);
257
+ });
258
+ it('returns false for undefined', () => {
259
+ expect(isErrorWithMessageOrReason(undefined)).toBe(false);
260
+ });
261
+ it('returns false for primitive values', () => {
262
+ expect(isErrorWithMessageOrReason('string')).toBe(false);
263
+ expect(isErrorWithMessageOrReason(123)).toBe(false);
264
+ expect(isErrorWithMessageOrReason(true)).toBe(false);
265
+ });
266
+ it('returns false for object without message or reason', () => {
267
+ expect(isErrorWithMessageOrReason({ foo: 'bar' })).toBe(false);
268
+ });
269
+ it('returns false for empty object', () => {
270
+ expect(isErrorWithMessageOrReason({})).toBe(false);
271
+ });
272
+ });
273
+ describe('getErrorMessage', () => {
274
+ it('returns message from Error instance', () => {
275
+ expect(getErrorMessage(new Error('Error message'))).toBe('Error message');
276
+ });
277
+ });
278
+ });
@@ -36,12 +36,21 @@ export function logError(error, context) {
36
36
  // Unknown errors
37
37
  uiLogger.error(lib.errorHandlers.index.unknownErrorOccurred);
38
38
  }
39
+ let timeoutError = null;
39
40
  if (isHubSpotHttpError(error) && error.code === 'ETIMEDOUT') {
41
+ timeoutError = error;
42
+ }
43
+ else if (error instanceof Error &&
44
+ isHubSpotHttpError(error.cause) &&
45
+ error.cause.code === 'ETIMEDOUT') {
46
+ timeoutError = error.cause;
47
+ }
48
+ if (timeoutError) {
40
49
  const config = getConfig();
41
50
  const defaultTimeout = config?.httpTimeout;
42
51
  // Timeout was caused by the default timeout
43
- if (error.timeout && defaultTimeout === error.timeout) {
44
- uiLogger.error(lib.errorHandlers.index.configTimeoutErrorOccurred(error.timeout, 'hs config set'));
52
+ if (timeoutError.timeout && defaultTimeout === timeoutError.timeout) {
53
+ uiLogger.error(lib.errorHandlers.index.configTimeoutErrorOccurred(timeoutError.timeout, 'hs config set'));
45
54
  }
46
55
  // Timeout was caused by a custom timeout set by the CLI or LDL
47
56
  else {
@@ -285,7 +285,7 @@ describe('lib/projects/components', () => {
285
285
  type: 'app',
286
286
  uid: 'app_test_app',
287
287
  config: {
288
- name: 'test-app-Application',
288
+ name: 'test-app-App',
289
289
  other: 'property',
290
290
  },
291
291
  }, null, 2));
@@ -13,6 +13,7 @@ import { walk } from '@hubspot/local-dev-lib/fs';
13
13
  import { uploadProject } from '@hubspot/local-dev-lib/api/projects';
14
14
  import { ensureProjectExists } from '../ensureProjectExists.js';
15
15
  import { projectContainsHsMetaFiles } from '@hubspot/project-parsing-lib/projects';
16
+ import { findAndParsePackageJsonFiles, collectWorkspaceDirectories, collectFileDependencies, } from '@hubspot/project-parsing-lib/workspaces';
16
17
  import { shouldIgnoreFile } from '@hubspot/local-dev-lib/ignoreRules';
17
18
  import { getConfigAccountIfExists } from '@hubspot/local-dev-lib/config';
18
19
  // Mock dependencies
@@ -22,6 +23,11 @@ vi.mock('@hubspot/local-dev-lib/fs');
22
23
  vi.mock('@hubspot/local-dev-lib/api/projects');
23
24
  vi.mock('../ensureProjectExists.js');
24
25
  vi.mock('@hubspot/project-parsing-lib/projects');
26
+ vi.mock('@hubspot/project-parsing-lib/workspaces', () => ({
27
+ findAndParsePackageJsonFiles: vi.fn(),
28
+ collectWorkspaceDirectories: vi.fn(),
29
+ collectFileDependencies: vi.fn(),
30
+ }));
25
31
  vi.mock('@hubspot/local-dev-lib/ignoreRules');
26
32
  vi.mock('@hubspot/local-dev-lib/config');
27
33
  vi.mock('archiver');
@@ -122,6 +128,10 @@ describe('lib/projects/upload', () => {
122
128
  vi.mocked(shouldIgnoreFile).mockReturnValue(false);
123
129
  vi.mocked(projectContainsHsMetaFiles).mockResolvedValue(false);
124
130
  vi.mocked(isV2Project).mockReturnValue(false);
131
+ // Mock workspace functions to return empty arrays
132
+ vi.mocked(findAndParsePackageJsonFiles).mockResolvedValue([]);
133
+ vi.mocked(collectWorkspaceDirectories).mockResolvedValue([]);
134
+ vi.mocked(collectFileDependencies).mockResolvedValue([]);
125
135
  vi.mocked(tmp.fileSync).mockReturnValue({
126
136
  name: path.join(tempDir, 'test.zip'),
127
137
  fd: 1,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'path';
3
+ import { determineWorkspaceArchivePath, determineFileDependencyArchivePath, shortHash, } from '../workspaces.js';
4
+ describe('determineWorkspaceArchivePath', () => {
5
+ const projectDir = '/Users/test/my-project';
6
+ describe('internal workspaces', () => {
7
+ it('handles workspace in src directory', () => {
8
+ const workspaceDir = path.join(projectDir, 'src/app/workspaces/utils');
9
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
10
+ expect(result).toBe(path.join('_workspaces', 'src/app/workspaces/utils'));
11
+ expect(result).not.toContain('..');
12
+ });
13
+ it('handles workspace at project root', () => {
14
+ const workspaceDir = path.join(projectDir, 'packages/core');
15
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
16
+ expect(result).toBe(path.join('_workspaces', 'packages/core'));
17
+ expect(result).not.toContain('..');
18
+ });
19
+ it('handles deeply nested workspace', () => {
20
+ const workspaceDir = path.join(projectDir, 'src/app/functions/shared/utils/http');
21
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
22
+ expect(result).toBe(path.join('_workspaces', 'src/app/functions/shared/utils/http'));
23
+ expect(result).not.toContain('..');
24
+ });
25
+ });
26
+ describe('external workspaces', () => {
27
+ it('handles workspace outside project directory', () => {
28
+ const workspaceDir = '/Users/test/company-libs/utils';
29
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
30
+ const expectedHash = shortHash(path.resolve(workspaceDir));
31
+ expect(result).toBe(path.join('_workspaces', 'external', `utils-${expectedHash}`));
32
+ expect(result).not.toContain('..');
33
+ });
34
+ it('handles workspace in home directory', () => {
35
+ const workspaceDir = '/Users/test/shared-code/helpers';
36
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
37
+ const expectedHash = shortHash(path.resolve(workspaceDir));
38
+ expect(result).toBe(path.join('_workspaces', 'external', `helpers-${expectedHash}`));
39
+ expect(result).not.toContain('..');
40
+ });
41
+ it('handles workspace with parent directory navigation', () => {
42
+ const workspaceDir = '/Users/test/other-project/shared';
43
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
44
+ const expectedHash = shortHash(path.resolve(workspaceDir));
45
+ expect(result).toBe(path.join('_workspaces', 'external', `shared-${expectedHash}`));
46
+ expect(result).not.toContain('..');
47
+ });
48
+ it('detects path traversal vulnerability with similar project names', () => {
49
+ // This tests the vulnerability where /Users/test/my-project-malicious
50
+ // would incorrectly be treated as internal with startsWith() check
51
+ const maliciousWorkspace = '/Users/test/my-project-malicious/evil';
52
+ const result = determineWorkspaceArchivePath(maliciousWorkspace, projectDir);
53
+ const expectedHash = shortHash(path.resolve(maliciousWorkspace));
54
+ // Should be treated as external, not internal
55
+ expect(result).toBe(path.join('_workspaces', 'external', `evil-${expectedHash}`));
56
+ expect(result).not.toContain('my-project-malicious');
57
+ });
58
+ it('includes hash suffix for deterministic collision prevention', () => {
59
+ const workspaceDir = '/Users/test/libs/utils';
60
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
61
+ // Normalize path separators for cross-platform compatibility (Windows uses backslashes)
62
+ const normalized = result.replace(/\\/g, '/');
63
+ // Verify the hash suffix format: basename-8hexchars
64
+ expect(normalized).toMatch(/_workspaces\/external\/utils-[a-f0-9]{8}$/);
65
+ });
66
+ });
67
+ describe('path validity', () => {
68
+ it('never produces paths with .. segments', () => {
69
+ const testCases = [
70
+ path.join(projectDir, 'src/utils'),
71
+ '/Users/other/libs/utils',
72
+ '/completely/different/path',
73
+ path.join(projectDir, '../sibling-project/shared'),
74
+ ];
75
+ testCases.forEach(workspaceDir => {
76
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
77
+ expect(result).not.toContain('..');
78
+ });
79
+ });
80
+ it('always starts with _workspaces/', () => {
81
+ const testCases = [
82
+ path.join(projectDir, 'src/utils'),
83
+ '/Users/other/libs/utils',
84
+ path.join(projectDir, 'packages/core'),
85
+ ];
86
+ testCases.forEach(workspaceDir => {
87
+ const result = determineWorkspaceArchivePath(workspaceDir, projectDir);
88
+ expect(result.startsWith('_workspaces')).toBe(true);
89
+ });
90
+ });
91
+ });
92
+ });
93
+ describe('workspace collision prevention', () => {
94
+ const projectDir = '/Users/test/my-project';
95
+ it('prevents collision when two external workspaces have same basename', () => {
96
+ const workspace1 = '/Users/test/project-a/utils';
97
+ const workspace2 = '/Users/test/project-b/utils';
98
+ const path1 = determineWorkspaceArchivePath(workspace1, projectDir);
99
+ const path2 = determineWorkspaceArchivePath(workspace2, projectDir);
100
+ // Paths should be DIFFERENT due to hash suffix
101
+ expect(path1).not.toBe(path2);
102
+ // Both should contain the basename
103
+ expect(path1).toContain('utils-');
104
+ expect(path2).toContain('utils-');
105
+ // Both should be in external folder
106
+ expect(path1).toContain('external');
107
+ expect(path2).toContain('external');
108
+ });
109
+ it('produces deterministic paths for the same workspace', () => {
110
+ const workspaceDir = '/Users/test/libs/utils';
111
+ const path1 = determineWorkspaceArchivePath(workspaceDir, projectDir);
112
+ const path2 = determineWorkspaceArchivePath(workspaceDir, projectDir);
113
+ // Same input should always produce same output
114
+ expect(path1).toBe(path2);
115
+ });
116
+ it('no collision when workspaces have different names', () => {
117
+ const workspace1 = '/Users/test/libs/utils';
118
+ const workspace2 = '/Users/test/libs/helpers';
119
+ const path1 = determineWorkspaceArchivePath(workspace1, projectDir);
120
+ const path2 = determineWorkspaceArchivePath(workspace2, projectDir);
121
+ expect(path1).not.toBe(path2);
122
+ });
123
+ it('no collision between internal and external with same basename', () => {
124
+ const internalWorkspace = path.join(projectDir, 'src/utils');
125
+ const externalWorkspace = '/Users/test/other/utils';
126
+ const path1 = determineWorkspaceArchivePath(internalWorkspace, projectDir);
127
+ const path2 = determineWorkspaceArchivePath(externalWorkspace, projectDir);
128
+ const expectedHash = shortHash(path.resolve(externalWorkspace));
129
+ expect(path1).not.toBe(path2);
130
+ expect(path1).toBe(path.join('_workspaces', 'src/utils'));
131
+ expect(path2).toBe(path.join('_workspaces', 'external', `utils-${expectedHash}`));
132
+ });
133
+ });
134
+ describe('package.json workspace mapping', () => {
135
+ const projectDir = '/project';
136
+ it('maps workspaces to correct package.json files using determineWorkspaceArchivePath', () => {
137
+ const mappings = [
138
+ {
139
+ workspaceDir: '/project/src/app/workspaces/functions-utils',
140
+ sourcePackageJsonPath: '/project/src/app/functions/package.json',
141
+ },
142
+ {
143
+ workspaceDir: '/project/src/app/workspaces/cards-utils',
144
+ sourcePackageJsonPath: '/project/src/app/cards/package.json',
145
+ },
146
+ ];
147
+ const packageWorkspaces = new Map();
148
+ for (const mapping of mappings) {
149
+ // Use the actual function to determine archive path
150
+ const archivePath = determineWorkspaceArchivePath(mapping.workspaceDir, projectDir);
151
+ if (!packageWorkspaces.has(mapping.sourcePackageJsonPath)) {
152
+ packageWorkspaces.set(mapping.sourcePackageJsonPath, []);
153
+ }
154
+ packageWorkspaces.get(mapping.sourcePackageJsonPath).push(archivePath);
155
+ }
156
+ // Internal workspaces use relative paths (no hash)
157
+ expect(packageWorkspaces.get('/project/src/app/functions/package.json')).toEqual([path.join('_workspaces', 'src/app/workspaces/functions-utils')]);
158
+ expect(packageWorkspaces.get('/project/src/app/cards/package.json')).toEqual([path.join('_workspaces', 'src/app/workspaces/cards-utils')]);
159
+ });
160
+ it('handles multiple external workspaces per package.json with hash suffixes', () => {
161
+ const mappings = [
162
+ {
163
+ workspaceDir: '/external/utils-a',
164
+ sourcePackageJsonPath: '/project/package.json',
165
+ },
166
+ {
167
+ workspaceDir: '/external/utils-b',
168
+ sourcePackageJsonPath: '/project/package.json',
169
+ },
170
+ ];
171
+ const packageWorkspaces = new Map();
172
+ for (const mapping of mappings) {
173
+ const archivePath = determineWorkspaceArchivePath(mapping.workspaceDir, projectDir);
174
+ if (!packageWorkspaces.has(mapping.sourcePackageJsonPath)) {
175
+ packageWorkspaces.set(mapping.sourcePackageJsonPath, []);
176
+ }
177
+ packageWorkspaces.get(mapping.sourcePackageJsonPath).push(archivePath);
178
+ }
179
+ const workspaces = packageWorkspaces.get('/project/package.json');
180
+ expect(workspaces).toHaveLength(2);
181
+ // External workspaces should have hash suffixes
182
+ expect(workspaces[0]).toMatch(/_workspaces\/external\/utils-a-[a-f0-9]{8}/);
183
+ expect(workspaces[1]).toMatch(/_workspaces\/external\/utils-b-[a-f0-9]{8}/);
184
+ });
185
+ });
186
+ describe('shortHash', () => {
187
+ it('produces 8-character hex string', () => {
188
+ const hash = shortHash('/some/path');
189
+ expect(hash).toMatch(/^[a-f0-9]{8}$/);
190
+ });
191
+ it('is deterministic', () => {
192
+ const input = '/Users/test/workspace';
193
+ expect(shortHash(input)).toBe(shortHash(input));
194
+ });
195
+ it('produces different hashes for different inputs', () => {
196
+ const hash1 = shortHash('/path/a');
197
+ const hash2 = shortHash('/path/b');
198
+ expect(hash1).not.toBe(hash2);
199
+ });
200
+ });
201
+ describe('determineFileDependencyArchivePath', () => {
202
+ it('places file: dependencies in _workspaces/external/', () => {
203
+ const localPath = '/Users/test/my-local-lib';
204
+ const result = determineFileDependencyArchivePath(localPath);
205
+ expect(result).toContain('_workspaces');
206
+ expect(result).toContain('external');
207
+ expect(result).toContain('my-local-lib');
208
+ });
209
+ it('includes hash suffix for collision prevention', () => {
210
+ const localPath = '/Users/test/libs/utils';
211
+ const result = determineFileDependencyArchivePath(localPath);
212
+ // Should match pattern: _workspaces/external/utils-[8 hex chars]
213
+ expect(result).toMatch(/_workspaces\/external\/utils-[a-f0-9]{8}$/);
214
+ });
215
+ it('produces different paths for different local paths with same basename', () => {
216
+ const path1 = '/Users/test/project-a/utils';
217
+ const path2 = '/Users/test/project-b/utils';
218
+ const result1 = determineFileDependencyArchivePath(path1);
219
+ const result2 = determineFileDependencyArchivePath(path2);
220
+ expect(result1).not.toBe(result2);
221
+ expect(result1).toContain('utils-');
222
+ expect(result2).toContain('utils-');
223
+ });
224
+ it('is deterministic', () => {
225
+ const localPath = '/Users/test/my-lib';
226
+ const result1 = determineFileDependencyArchivePath(localPath);
227
+ const result2 = determineFileDependencyArchivePath(localPath);
228
+ expect(result1).toBe(result2);
229
+ });
230
+ it('handles scoped package directories', () => {
231
+ const localPath = '/Users/test/libs/@company/shared-utils';
232
+ const result = determineFileDependencyArchivePath(localPath);
233
+ // Should use the last path segment (shared-utils) as the basename
234
+ expect(result).toContain('shared-utils-');
235
+ expect(result).toMatch(/_workspaces\/external\/shared-utils-[a-f0-9]{8}$/);
236
+ });
237
+ });
@@ -190,7 +190,7 @@ export async function updateHsMetaFilesWithAutoGeneratedFields(projectName, hsMe
190
190
  }
191
191
  component.uid = uid;
192
192
  if (component.type === AppKey && component.config) {
193
- component.config.name = `${projectName}-Application`;
193
+ component.config.name = `${projectName}-App`;
194
194
  }
195
195
  fs.writeFileSync(hsMetaFile, JSON.stringify(component, null, 2));
196
196
  }
@@ -6,6 +6,7 @@ import { uploadProject } from '@hubspot/local-dev-lib/api/projects';
6
6
  import { shouldIgnoreFile } from '@hubspot/local-dev-lib/ignoreRules';
7
7
  import { isTranslationError, translate, } from '@hubspot/project-parsing-lib/translate';
8
8
  import { projectContainsHsMetaFiles } from '@hubspot/project-parsing-lib/projects';
9
+ import { findAndParsePackageJsonFiles, collectWorkspaceDirectories, collectFileDependencies, } from '@hubspot/project-parsing-lib/workspaces';
9
10
  import SpinniesManager from '../ui/SpinniesManager.js';
10
11
  import { uiAccountDescription } from '../ui/index.js';
11
12
  import { logError } from '../errorHandlers/index.js';
@@ -18,6 +19,7 @@ import { EXIT_CODES } from '../enums/exitCodes.js';
18
19
  import ProjectValidationError from '../errors/ProjectValidationError.js';
19
20
  import { walk } from '@hubspot/local-dev-lib/fs';
20
21
  import { LEGACY_CONFIG_FILES } from '../constants.js';
22
+ import { archiveWorkspacesAndDependencies } from './workspaces.js';
21
23
  async function uploadProjectFiles(accountId, projectName, filePath, uploadMessage, platformVersion, intermediateRepresentation) {
22
24
  const accountIdentifier = uiAccountDescription(accountId) || `${accountId}`;
23
25
  SpinniesManager.add('upload', {
@@ -62,6 +64,11 @@ export async function handleProjectUpload({ accountId, projectConfig, projectDir
62
64
  }
63
65
  const tempFile = tmp.fileSync({ postfix: '.zip' });
64
66
  uiLogger.debug(lib.projectUpload.handleProjectUpload.compressing(tempFile.name));
67
+ // Find and parse all package.json files once (avoids duplicate filesystem walks)
68
+ const parsedPackageJsons = await findAndParsePackageJsonFiles(srcDir);
69
+ // Collect workspace directories and file: dependencies from parsed data
70
+ const workspaceMappings = await collectWorkspaceDirectories(parsedPackageJsons);
71
+ const fileDependencyMappings = await collectFileDependencies(parsedPackageJsons);
65
72
  const output = fs.createWriteStream(tempFile.name);
66
73
  const archive = archiver('zip');
67
74
  const result = new Promise(resolve => output.on('close', async function () {
@@ -114,6 +121,8 @@ export async function handleProjectUpload({ accountId, projectConfig, projectDir
114
121
  }
115
122
  return ignored ? false : file;
116
123
  });
124
+ // Archive workspaces and file: dependencies
125
+ await archiveWorkspacesAndDependencies(archive, srcDir, projectDir, workspaceMappings, fileDependencyMappings);
117
126
  archive.finalize();
118
127
  return result;
119
128
  }