@hubspot/cli 8.0.0-beta.1 → 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.
package/lang/en.d.ts CHANGED
@@ -3132,6 +3132,14 @@ export declare const lib: {
3132
3132
  fileFiltered: (filename: string) => string;
3133
3133
  legacyFileDetected: (filename: string, platformVersion: string) => string;
3134
3134
  projectDoesNotExist: (accountId: number) => string;
3135
+ workspaceIncluded: (workspaceDir: string, archivePath: string) => string;
3136
+ fileDependencyIncluded: (packageName: string, localPath: string, archivePath: string) => string;
3137
+ malformedPackageJson: (packageJsonPath: string, error: string) => string;
3138
+ workspaceCollision: (archivePath: string, workspaceDir: string, existingWorkspace: string) => string;
3139
+ fileDependencyAlreadyIncluded: (packageName: string, archivePath: string) => string;
3140
+ updatingPackageJsonWorkspaces: (packageJsonPath: string) => string;
3141
+ updatedWorkspaces: (workspaces: string) => string;
3142
+ updatedFileDependency: (packageName: string, relativePath: string) => string;
3135
3143
  };
3136
3144
  };
3137
3145
  importData: {
package/lang/en.js CHANGED
@@ -3155,6 +3155,14 @@ export const lib = {
3155
3155
  fileFiltered: (filename) => `Ignore rule triggered for "${filename}"`,
3156
3156
  legacyFileDetected: (filename, platformVersion) => `The ${chalk.bold(filename)} file is not supported on platform version ${chalk.bold(platformVersion)} and will be ignored.`,
3157
3157
  projectDoesNotExist: (accountId) => `Upload cancelled. Run ${uiCommandReference('hs project upload')} again to create the project in ${uiAccountDescription(accountId)}.`,
3158
+ workspaceIncluded: (workspaceDir, archivePath) => `Including workspace: ${workspaceDir} → ${archivePath}`,
3159
+ fileDependencyIncluded: (packageName, localPath, archivePath) => `Including file: dependency ${packageName}: ${localPath} → ${archivePath}`,
3160
+ malformedPackageJson: (packageJsonPath, error) => `Skipping malformed package.json at ${packageJsonPath}: ${error}`,
3161
+ workspaceCollision: (archivePath, workspaceDir, existingWorkspace) => `Workspace collision: ${archivePath} from ${workspaceDir} and ${existingWorkspace}`,
3162
+ fileDependencyAlreadyIncluded: (packageName, archivePath) => `file: dependency ${packageName} already included as workspace: ${archivePath}`,
3163
+ updatingPackageJsonWorkspaces: (packageJsonPath) => `Updating package.json workspaces in archive: ${packageJsonPath}`,
3164
+ updatedWorkspaces: (workspaces) => ` Updated workspaces: ${workspaces}`,
3165
+ updatedFileDependency: (packageName, relativePath) => ` Updated dependencies.${packageName}: file:${relativePath}`,
3158
3166
  },
3159
3167
  },
3160
3168
  importData: {
@@ -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
+ });
@@ -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
  }
@@ -0,0 +1,35 @@
1
+ import archiver from 'archiver';
2
+ import { WorkspaceMapping, FileDependencyMapping } from '@hubspot/project-parsing-lib/workspaces';
3
+ /**
4
+ * Result of archiving workspaces and file dependencies
5
+ */
6
+ export type WorkspaceArchiveResult = {
7
+ packageWorkspaces: Map<string, string[]>;
8
+ packageFileDeps: Map<string, Map<string, string>>;
9
+ };
10
+ /**
11
+ * Generates a short hash of the input string for use in workspace paths.
12
+ * Uses SHA256 truncated to 8 hex characters (4 billion possibilities).
13
+ */
14
+ export declare function shortHash(input: string): string;
15
+ /**
16
+ * Determines the archive path for a workspace directory.
17
+ * Internal workspaces use their relative path from projectDir.
18
+ * External workspaces use 'external' subdirectory + basename + hash.
19
+ */
20
+ export declare function determineWorkspaceArchivePath(workspaceDir: string, projectDir: string): string;
21
+ /**
22
+ * Determines the archive path for a file: dependency.
23
+ * All file: dependencies are treated as external and placed in _workspaces/external/
24
+ * with a hash suffix to prevent collisions.
25
+ */
26
+ export declare function determineFileDependencyArchivePath(localPath: string): string;
27
+ /**
28
+ * Updates package.json files in the archive to reflect new workspace and file: dependency paths.
29
+ */
30
+ export declare function updatePackageJsonInArchive(archive: archiver.Archiver, srcDir: string, packageWorkspaces: Map<string, string[]>, packageFileDeps: Map<string, Map<string, string>>): Promise<void>;
31
+ /**
32
+ * Main orchestration function that handles archiving of workspaces and file dependencies.
33
+ * This is the clean integration point for upload.ts.
34
+ */
35
+ export declare function archiveWorkspacesAndDependencies(archive: archiver.Archiver, srcDir: string, projectDir: string, workspaceMappings: WorkspaceMapping[], fileDependencyMappings: FileDependencyMapping[]): Promise<WorkspaceArchiveResult>;
@@ -0,0 +1,216 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { shouldIgnoreFile } from '@hubspot/local-dev-lib/ignoreRules';
5
+ import { getPackableFiles, } from '@hubspot/project-parsing-lib/workspaces';
6
+ import { uiLogger } from '../ui/logger.js';
7
+ import { lib } from '../../lang/en.js';
8
+ /**
9
+ * Generates a short hash of the input string for use in workspace paths.
10
+ * Uses SHA256 truncated to 8 hex characters (4 billion possibilities).
11
+ */
12
+ export function shortHash(input) {
13
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
14
+ }
15
+ /**
16
+ * Determines the archive path for a workspace directory.
17
+ * Internal workspaces use their relative path from projectDir.
18
+ * External workspaces use 'external' subdirectory + basename + hash.
19
+ */
20
+ export function determineWorkspaceArchivePath(workspaceDir, projectDir) {
21
+ const normalizedWorkspace = path.resolve(workspaceDir);
22
+ const normalizedProject = path.resolve(projectDir);
23
+ const relPath = path.relative(normalizedProject, normalizedWorkspace);
24
+ if (!relPath.startsWith('..') && !path.isAbsolute(relPath)) {
25
+ // Internal workspace - use relative path from projectDir
26
+ return path.join('_workspaces', relPath);
27
+ }
28
+ else {
29
+ // External workspace - use 'external' subdirectory + basename + hash
30
+ const workspaceName = path.basename(normalizedWorkspace);
31
+ const hash = shortHash(normalizedWorkspace);
32
+ return path.join('_workspaces', 'external', `${workspaceName}-${hash}`);
33
+ }
34
+ }
35
+ /**
36
+ * Determines the archive path for a file: dependency.
37
+ * All file: dependencies are treated as external and placed in _workspaces/external/
38
+ * with a hash suffix to prevent collisions.
39
+ */
40
+ export function determineFileDependencyArchivePath(localPath) {
41
+ const normalizedPath = path.resolve(localPath);
42
+ const depName = path.basename(normalizedPath);
43
+ const hash = shortHash(normalizedPath);
44
+ return path.join('_workspaces', 'external', `${depName}-${hash}`);
45
+ }
46
+ /**
47
+ * Creates a file filter function for workspace archiving.
48
+ * Filters files based on packable files list and ignore rules.
49
+ */
50
+ function createWorkspaceFileFilter(packableFiles) {
51
+ return (file) => {
52
+ if (packableFiles.size > 0 && !packableFiles.has(file.name)) {
53
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.fileFiltered(file.name));
54
+ return false;
55
+ }
56
+ const ignored = shouldIgnoreFile(file.name, true);
57
+ if (ignored) {
58
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.fileFiltered(file.name));
59
+ return false;
60
+ }
61
+ return file;
62
+ };
63
+ }
64
+ /**
65
+ * Archives workspace directories and returns mapping information.
66
+ */
67
+ async function archiveWorkspaceDirectories(archive, projectDir, workspaceMappings) {
68
+ const workspacePaths = new Map();
69
+ const archivePathToWorkspace = new Map();
70
+ const packageWorkspaces = new Map();
71
+ // First pass: determine archive paths and check for collisions (O(n) with reverse map)
72
+ for (const mapping of workspaceMappings) {
73
+ const { workspaceDir, sourcePackageJsonPath } = mapping;
74
+ const archivePath = determineWorkspaceArchivePath(workspaceDir, projectDir);
75
+ // Check for name collisions using reverse map (O(1) lookup)
76
+ const existingWorkspace = archivePathToWorkspace.get(archivePath);
77
+ if (existingWorkspace) {
78
+ throw new Error(lib.projectUpload.handleProjectUpload.workspaceCollision(archivePath, workspaceDir, existingWorkspace));
79
+ }
80
+ archivePathToWorkspace.set(archivePath, workspaceDir);
81
+ workspacePaths.set(workspaceDir, archivePath);
82
+ // Track which archive paths belong to which package.json
83
+ if (!packageWorkspaces.has(sourcePackageJsonPath)) {
84
+ packageWorkspaces.set(sourcePackageJsonPath, []);
85
+ }
86
+ packageWorkspaces.get(sourcePackageJsonPath).push(archivePath);
87
+ }
88
+ // Fetch all packable files in parallel (I/O optimization)
89
+ const workspacePackableFiles = await Promise.all(workspaceMappings.map(async (mapping) => ({
90
+ mapping,
91
+ packableFiles: await getPackableFiles(mapping.workspaceDir),
92
+ })));
93
+ // Archive directories sequentially (archiver requires sequential operations)
94
+ for (const { mapping, packableFiles } of workspacePackableFiles) {
95
+ const { workspaceDir } = mapping;
96
+ const archivePath = workspacePaths.get(workspaceDir);
97
+ uiLogger.log(lib.projectUpload.handleProjectUpload.workspaceIncluded(workspaceDir, archivePath));
98
+ archive.directory(workspaceDir, archivePath, createWorkspaceFileFilter(packableFiles));
99
+ }
100
+ return { workspacePaths, archivePathToWorkspace, packageWorkspaces };
101
+ }
102
+ /**
103
+ * Archives file: dependencies and returns mapping information.
104
+ */
105
+ async function archiveFileDependencies(archive, fileDependencyMappings, workspacePaths, archivePathToWorkspace) {
106
+ const packageFileDeps = new Map();
107
+ const fileDepPathsToArchive = [];
108
+ // First pass: determine which file deps need archiving and track mappings
109
+ for (const mapping of fileDependencyMappings) {
110
+ const { packageName, localPath, sourcePackageJsonPath } = mapping;
111
+ const archivePath = determineFileDependencyArchivePath(localPath);
112
+ // Check for path collisions using reverse map (O(1) lookup)
113
+ if (archivePathToWorkspace.has(archivePath)) {
114
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.fileDependencyAlreadyIncluded(packageName, archivePath));
115
+ // Still track the dependency mapping even if already archived
116
+ if (!packageFileDeps.has(sourcePackageJsonPath)) {
117
+ packageFileDeps.set(sourcePackageJsonPath, new Map());
118
+ }
119
+ packageFileDeps.get(sourcePackageJsonPath).set(packageName, archivePath);
120
+ continue;
121
+ }
122
+ // Only archive each unique path once
123
+ if (!workspacePaths.has(localPath)) {
124
+ workspacePaths.set(localPath, archivePath);
125
+ archivePathToWorkspace.set(archivePath, localPath);
126
+ fileDepPathsToArchive.push({ localPath, archivePath, packageName });
127
+ }
128
+ // Track which package.json has which file: dependencies
129
+ if (!packageFileDeps.has(sourcePackageJsonPath)) {
130
+ packageFileDeps.set(sourcePackageJsonPath, new Map());
131
+ }
132
+ packageFileDeps.get(sourcePackageJsonPath).set(packageName, archivePath);
133
+ }
134
+ // Fetch all packable files in parallel (I/O optimization)
135
+ const fileDepPackableFiles = await Promise.all(fileDepPathsToArchive.map(async ({ localPath, archivePath, packageName }) => ({
136
+ localPath,
137
+ archivePath,
138
+ packageName,
139
+ packableFiles: await getPackableFiles(localPath),
140
+ })));
141
+ // Archive directories sequentially (archiver requires sequential operations)
142
+ for (const { localPath, archivePath, packageName, packableFiles, } of fileDepPackableFiles) {
143
+ uiLogger.log(lib.projectUpload.handleProjectUpload.fileDependencyIncluded(packageName, localPath, archivePath));
144
+ archive.directory(localPath, archivePath, createWorkspaceFileFilter(packableFiles));
145
+ }
146
+ return packageFileDeps;
147
+ }
148
+ /**
149
+ * Updates package.json files in the archive to reflect new workspace and file: dependency paths.
150
+ */
151
+ export async function updatePackageJsonInArchive(archive, srcDir, packageWorkspaces, packageFileDeps) {
152
+ // Collect all package.json paths that need updating
153
+ const allPackageJsonPaths = new Set([
154
+ ...packageWorkspaces.keys(),
155
+ ...packageFileDeps.keys(),
156
+ ]);
157
+ for (const packageJsonPath of allPackageJsonPaths) {
158
+ if (!fs.existsSync(packageJsonPath)) {
159
+ continue;
160
+ }
161
+ let packageJson;
162
+ try {
163
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
164
+ }
165
+ catch (e) {
166
+ uiLogger.warn(lib.projectUpload.handleProjectUpload.malformedPackageJson(packageJsonPath, e instanceof Error ? e.message : String(e)));
167
+ continue;
168
+ }
169
+ const relativePackageJsonPath = path.relative(srcDir, packageJsonPath);
170
+ const packageJsonDir = path.dirname(relativePackageJsonPath);
171
+ let modified = false;
172
+ // Update workspaces field if this package.json has workspaces
173
+ const workspaceArchivePaths = packageWorkspaces.get(packageJsonPath);
174
+ if (workspaceArchivePaths && packageJson.workspaces) {
175
+ packageJson.workspaces = workspaceArchivePaths;
176
+ modified = true;
177
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.updatingPackageJsonWorkspaces(relativePackageJsonPath));
178
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.updatedWorkspaces(workspaceArchivePaths.join(', ')));
179
+ }
180
+ // Update file: dependencies if this package.json has any
181
+ const fileDeps = packageFileDeps.get(packageJsonPath);
182
+ if (fileDeps && fileDeps.size > 0 && packageJson.dependencies) {
183
+ for (const [packageName, archivePath] of fileDeps.entries()) {
184
+ if (packageJson.dependencies[packageName]?.startsWith('file:')) {
185
+ // Calculate relative path from package.json location to archive path
186
+ const relativePath = path.relative(packageJsonDir, archivePath);
187
+ packageJson.dependencies[packageName] = `file:${relativePath}`;
188
+ modified = true;
189
+ uiLogger.debug(lib.projectUpload.handleProjectUpload.updatedFileDependency(packageName, relativePath));
190
+ }
191
+ }
192
+ }
193
+ if (modified) {
194
+ // Add modified package.json to archive (will replace the original)
195
+ archive.append(JSON.stringify(packageJson, null, 2), {
196
+ name: relativePackageJsonPath,
197
+ });
198
+ }
199
+ }
200
+ // Ensure all append operations are queued before finalize is called
201
+ // Use setImmediate to yield control and let archiver process the queue
202
+ await new Promise(resolve => setImmediate(resolve));
203
+ }
204
+ /**
205
+ * Main orchestration function that handles archiving of workspaces and file dependencies.
206
+ * This is the clean integration point for upload.ts.
207
+ */
208
+ export async function archiveWorkspacesAndDependencies(archive, srcDir, projectDir, workspaceMappings, fileDependencyMappings) {
209
+ // Archive workspace directories
210
+ const { workspacePaths, archivePathToWorkspace, packageWorkspaces } = await archiveWorkspaceDirectories(archive, projectDir, workspaceMappings);
211
+ // Archive file: dependencies
212
+ const packageFileDeps = await archiveFileDependencies(archive, fileDependencyMappings, workspacePaths, archivePathToWorkspace);
213
+ // Update package.json files with new paths
214
+ await updatePackageJsonInArchive(archive, srcDir, packageWorkspaces, packageFileDeps);
215
+ return { packageWorkspaces, packageFileDeps };
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/cli",
3
- "version": "8.0.0-beta.1",
3
+ "version": "8.0.0-experimental.0",
4
4
  "description": "The official CLI for developing on HubSpot",
5
5
  "license": "Apache-2.0",
6
6
  "repository": "https://github.com/HubSpot/hubspot-cli",
@@ -8,7 +8,7 @@
8
8
  "dependencies": {
9
9
  "@hubspot/cms-dev-server": "1.2.1",
10
10
  "@hubspot/local-dev-lib": "5.1.1",
11
- "@hubspot/project-parsing-lib": "0.11.2",
11
+ "@hubspot/project-parsing-lib": "0.1.0-experimental.0",
12
12
  "@hubspot/serverless-dev-runtime": "7.0.7",
13
13
  "@hubspot/ui-extensions-dev-server": "1.1.3",
14
14
  "@inquirer/prompts": "7.1.0",