@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 +8 -0
- package/lang/en.js +8 -0
- package/lib/projects/__tests__/upload.test.js +10 -0
- package/lib/projects/__tests__/workspaceArchive.test.d.ts +1 -0
- package/lib/projects/__tests__/workspaceArchive.test.js +237 -0
- package/lib/projects/upload.js +9 -0
- package/lib/projects/workspaces.d.ts +35 -0
- package/lib/projects/workspaces.js +216 -0
- package/package.json +2 -2
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
|
+
});
|
package/lib/projects/upload.js
CHANGED
|
@@ -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-
|
|
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
|
+
"@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",
|