@hubspot/cli 8.0.0-experimental.0 → 8.0.1-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/commands/mcp/__tests__/start.test.js +67 -1
- package/commands/mcp/start.js +19 -1
- package/lang/en.d.ts +1 -8
- package/lang/en.js +1 -8
- package/lib/mcp/__tests__/setup.test.js +17 -0
- package/lib/mcp/setup.d.ts +1 -0
- package/lib/mcp/setup.js +52 -14
- package/lib/projects/__tests__/upload.test.js +0 -10
- package/lib/projects/upload.js +0 -9
- package/mcp-server/utils/__tests__/project.test.js +125 -0
- package/mcp-server/utils/project.js +8 -0
- package/package.json +2 -2
- package/lib/projects/__tests__/workspaceArchive.test.d.ts +0 -1
- package/lib/projects/__tests__/workspaceArchive.test.js +0 -237
- package/lib/projects/workspaces.d.ts +0 -35
- package/lib/projects/workspaces.js +0 -216
|
@@ -7,14 +7,20 @@ import * as errorHandlers from '../../../lib/errorHandlers/index.js';
|
|
|
7
7
|
import * as usageTrackingLib from '../../../lib/usageTracking.js';
|
|
8
8
|
import * as processLib from '../../../lib/process.js';
|
|
9
9
|
import { EXIT_CODES } from '../../../lib/enums/exitCodes.js';
|
|
10
|
-
|
|
10
|
+
// Create a mock execAsync function before importing the module
|
|
11
|
+
const execAsyncMock = vi.fn();
|
|
11
12
|
vi.mock('yargs');
|
|
12
13
|
vi.mock('../../../lib/commonOpts');
|
|
13
14
|
vi.mock('node:child_process');
|
|
15
|
+
vi.mock('node:util', () => ({
|
|
16
|
+
promisify: vi.fn(() => execAsyncMock),
|
|
17
|
+
}));
|
|
14
18
|
vi.mock('fs');
|
|
15
19
|
vi.mock('@hubspot/local-dev-lib/config');
|
|
16
20
|
vi.mock('../../../lib/errorHandlers/index.js');
|
|
17
21
|
vi.mock('../../../lib/process.js');
|
|
22
|
+
// Import after mocks are set up
|
|
23
|
+
const startCommand = await import('../start.js').then(m => m.default);
|
|
18
24
|
const spawnSpy = vi.mocked(spawn);
|
|
19
25
|
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
|
20
26
|
const trackCommandUsageSpy = vi.spyOn(usageTrackingLib, 'trackCommandUsage');
|
|
@@ -36,6 +42,7 @@ describe('commands/mcp/start', () => {
|
|
|
36
42
|
processExitSpy.mockImplementation(() => { });
|
|
37
43
|
// Mock config to prevent reading actual config file in CI
|
|
38
44
|
getConfigAccountIfExistsSpy.mockReturnValue(undefined);
|
|
45
|
+
execAsyncMock.mockClear();
|
|
39
46
|
});
|
|
40
47
|
describe('command', () => {
|
|
41
48
|
it('should have the correct command structure', () => {
|
|
@@ -133,5 +140,64 @@ describe('commands/mcp/start', () => {
|
|
|
133
140
|
expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start'));
|
|
134
141
|
expect(logErrorSpy).toHaveBeenCalledWith(error);
|
|
135
142
|
});
|
|
143
|
+
it('should fetch CLI version in standalone mode', async () => {
|
|
144
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
145
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
146
|
+
execAsyncMock.mockResolvedValue({
|
|
147
|
+
stdout: '8.0.0',
|
|
148
|
+
stderr: '',
|
|
149
|
+
});
|
|
150
|
+
await startCommand.handler(args);
|
|
151
|
+
expect(execAsyncMock).toHaveBeenCalledWith('npm view @hubspot/cli version');
|
|
152
|
+
expect(spawnSpy).toHaveBeenCalledWith('node', expect.any(Array), expect.objectContaining({
|
|
153
|
+
env: expect.objectContaining({
|
|
154
|
+
HUBSPOT_CLI_VERSION: '8.0.0',
|
|
155
|
+
}),
|
|
156
|
+
}));
|
|
157
|
+
// Restore original env
|
|
158
|
+
if (originalEnv === undefined) {
|
|
159
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
it('should not fetch CLI version when not in standalone mode', async () => {
|
|
166
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
167
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
168
|
+
await startCommand.handler(args);
|
|
169
|
+
expect(execAsyncMock).not.toHaveBeenCalled();
|
|
170
|
+
expect(spawnSpy).toHaveBeenCalledWith('node', expect.any(Array), expect.objectContaining({
|
|
171
|
+
env: expect.not.objectContaining({
|
|
172
|
+
HUBSPOT_CLI_VERSION: expect.anything(),
|
|
173
|
+
}),
|
|
174
|
+
}));
|
|
175
|
+
// Restore original env
|
|
176
|
+
if (originalEnv !== undefined) {
|
|
177
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it('should handle version fetch errors gracefully', async () => {
|
|
181
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
182
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
183
|
+
const error = new Error('Version fetch failed');
|
|
184
|
+
execAsyncMock.mockRejectedValue(error);
|
|
185
|
+
await startCommand.handler(args);
|
|
186
|
+
expect(execAsyncMock).toHaveBeenCalledWith('npm view @hubspot/cli version');
|
|
187
|
+
expect(uiLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to get CLI version'));
|
|
188
|
+
expect(logErrorSpy).toHaveBeenCalledWith(error);
|
|
189
|
+
expect(spawnSpy).toHaveBeenCalledWith('node', expect.any(Array), expect.objectContaining({
|
|
190
|
+
env: expect.not.objectContaining({
|
|
191
|
+
HUBSPOT_CLI_VERSION: expect.anything(),
|
|
192
|
+
}),
|
|
193
|
+
}));
|
|
194
|
+
// Restore original env
|
|
195
|
+
if (originalEnv === undefined) {
|
|
196
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
136
202
|
});
|
|
137
203
|
});
|
package/commands/mcp/start.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import { EXIT_CODES } from '../../lib/enums/exitCodes.js';
|
|
@@ -9,6 +10,7 @@ import { commands } from '../../lang/en.js';
|
|
|
9
10
|
import { handleExit } from '../../lib/process.js';
|
|
10
11
|
import { trackCommandUsage } from '../../lib/usageTracking.js';
|
|
11
12
|
import { fileURLToPath } from 'url';
|
|
13
|
+
const execAsync = promisify(exec);
|
|
12
14
|
const command = 'start';
|
|
13
15
|
const describe = undefined; // Leave hidden for now
|
|
14
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -28,12 +30,28 @@ async function startMcpServer(aiAgent) {
|
|
|
28
30
|
uiLogger.debug(commands.mcp.start.startingServer);
|
|
29
31
|
uiLogger.debug(commands.mcp.start.stopInstructions);
|
|
30
32
|
const args = [serverPath];
|
|
33
|
+
// Get CLI version if running in standalone mode
|
|
34
|
+
let cliVersion;
|
|
35
|
+
if (process.env.HUBSPOT_MCP_STANDALONE === 'true') {
|
|
36
|
+
try {
|
|
37
|
+
// const { stdout } = await execAsync('npm view @hubspot/cli version');
|
|
38
|
+
// // Extract version number from output (e.g., "8.0.0")
|
|
39
|
+
// cliVersion = stdout.trim();
|
|
40
|
+
cliVersion = '8.0.1-experimental.0';
|
|
41
|
+
uiLogger.debug(`Using @hubspot/cli version: ${cliVersion}`);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
uiLogger.warn('Failed to get CLI version, will use latest');
|
|
45
|
+
logError(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
// Start the server using ts-node
|
|
32
49
|
const child = spawn(`node`, args, {
|
|
33
50
|
stdio: 'inherit',
|
|
34
51
|
env: {
|
|
35
52
|
...process.env,
|
|
36
53
|
HUBSPOT_MCP_AI_AGENT: aiAgent || 'unknown',
|
|
54
|
+
...(cliVersion && { HUBSPOT_CLI_VERSION: cliVersion }),
|
|
37
55
|
},
|
|
38
56
|
});
|
|
39
57
|
// Handle server process events
|
package/lang/en.d.ts
CHANGED
|
@@ -1246,6 +1246,7 @@ export declare const commands: {
|
|
|
1246
1246
|
prompts: {
|
|
1247
1247
|
targets: string;
|
|
1248
1248
|
targetsRequired: string;
|
|
1249
|
+
standaloneMode: string;
|
|
1249
1250
|
};
|
|
1250
1251
|
};
|
|
1251
1252
|
start: {
|
|
@@ -3132,14 +3133,6 @@ export declare const lib: {
|
|
|
3132
3133
|
fileFiltered: (filename: string) => string;
|
|
3133
3134
|
legacyFileDetected: (filename: string, platformVersion: string) => string;
|
|
3134
3135
|
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;
|
|
3143
3136
|
};
|
|
3144
3137
|
};
|
|
3145
3138
|
importData: {
|
package/lang/en.js
CHANGED
|
@@ -1262,6 +1262,7 @@ export const commands = {
|
|
|
1262
1262
|
prompts: {
|
|
1263
1263
|
targets: '[--client] Which tools would you like to add the HubSpot CLI MCP server to?',
|
|
1264
1264
|
targetsRequired: 'Must choose at least one app to configure.',
|
|
1265
|
+
standaloneMode: 'Do you want to run in standalone mode? (This will use npx @hubspot/cli instead of local hs command)',
|
|
1265
1266
|
},
|
|
1266
1267
|
},
|
|
1267
1268
|
start: {
|
|
@@ -3155,14 +3156,6 @@ export const lib = {
|
|
|
3155
3156
|
fileFiltered: (filename) => `Ignore rule triggered for "${filename}"`,
|
|
3156
3157
|
legacyFileDetected: (filename, platformVersion) => `The ${chalk.bold(filename)} file is not supported on platform version ${chalk.bold(platformVersion)} and will be ignored.`,
|
|
3157
3158
|
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}`,
|
|
3166
3159
|
},
|
|
3167
3160
|
},
|
|
3168
3161
|
importData: {
|
|
@@ -132,6 +132,23 @@ describe('lib/mcp/setup', () => {
|
|
|
132
132
|
});
|
|
133
133
|
expect(mockedLogError).toHaveBeenCalledWith(error);
|
|
134
134
|
});
|
|
135
|
+
it('should pass through environment variables in command', async () => {
|
|
136
|
+
const mockMcpCommandWithEnv = {
|
|
137
|
+
command: 'test-command',
|
|
138
|
+
args: ['--arg1'],
|
|
139
|
+
env: { HUBSPOT_MCP_STANDALONE: 'true' },
|
|
140
|
+
};
|
|
141
|
+
mockedExecAsync.mockResolvedValueOnce({
|
|
142
|
+
stdout: 'codex version 1.0.0',
|
|
143
|
+
stderr: '',
|
|
144
|
+
});
|
|
145
|
+
mockedExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
|
146
|
+
const result = await setupCodex(mockMcpCommandWithEnv);
|
|
147
|
+
expect(result).toBe(true);
|
|
148
|
+
// The env is passed through buildCommandWithAgentString, but not used in the command string
|
|
149
|
+
// This is expected as env vars are set by the client when starting the MCP server
|
|
150
|
+
expect(mockedExecAsync).toHaveBeenCalledWith('codex mcp add "HubSpotDev" -- test-command --arg1 --ai-agent codex');
|
|
151
|
+
});
|
|
135
152
|
});
|
|
136
153
|
describe('setupGemini', () => {
|
|
137
154
|
const mockMcpCommand = {
|
package/lib/mcp/setup.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const supportedTools: {
|
|
|
5
5
|
interface McpCommand {
|
|
6
6
|
command: string;
|
|
7
7
|
args: string[];
|
|
8
|
+
env?: Record<string, string>;
|
|
8
9
|
}
|
|
9
10
|
export declare function addMcpServerToConfig(targets: string[] | undefined): Promise<string[]>;
|
|
10
11
|
export declare function setupVsCode(mcpCommand?: McpCommand): Promise<boolean>;
|
package/lib/mcp/setup.js
CHANGED
|
@@ -47,23 +47,44 @@ export async function addMcpServerToConfig(targets) {
|
|
|
47
47
|
else {
|
|
48
48
|
derivedTargets = targets;
|
|
49
49
|
}
|
|
50
|
+
// Prompt for standalone mode
|
|
51
|
+
const { useStandaloneMode } = await promptUser({
|
|
52
|
+
name: 'useStandaloneMode',
|
|
53
|
+
type: 'confirm',
|
|
54
|
+
message: commands.mcp.setup.prompts.standaloneMode,
|
|
55
|
+
default: false,
|
|
56
|
+
});
|
|
57
|
+
const mcpCommand = useStandaloneMode
|
|
58
|
+
? {
|
|
59
|
+
command: 'npx',
|
|
60
|
+
args: [
|
|
61
|
+
'-y',
|
|
62
|
+
'-p',
|
|
63
|
+
'@hubspot/cli@8.0.1-experimental.0',
|
|
64
|
+
'hs',
|
|
65
|
+
'mcp',
|
|
66
|
+
'start',
|
|
67
|
+
],
|
|
68
|
+
env: { HUBSPOT_MCP_STANDALONE: 'true' },
|
|
69
|
+
}
|
|
70
|
+
: defaultMcpCommand;
|
|
50
71
|
if (derivedTargets.includes(claudeCode)) {
|
|
51
|
-
await runSetupFunction(setupClaudeCode);
|
|
72
|
+
await runSetupFunction(() => setupClaudeCode(mcpCommand));
|
|
52
73
|
}
|
|
53
74
|
if (derivedTargets.includes(cursor)) {
|
|
54
|
-
await runSetupFunction(setupCursor);
|
|
75
|
+
await runSetupFunction(() => setupCursor(mcpCommand));
|
|
55
76
|
}
|
|
56
77
|
if (derivedTargets.includes(windsurf)) {
|
|
57
|
-
await runSetupFunction(setupWindsurf);
|
|
78
|
+
await runSetupFunction(() => setupWindsurf(mcpCommand));
|
|
58
79
|
}
|
|
59
80
|
if (derivedTargets.includes(vscode)) {
|
|
60
|
-
await runSetupFunction(setupVsCode);
|
|
81
|
+
await runSetupFunction(() => setupVsCode(mcpCommand));
|
|
61
82
|
}
|
|
62
83
|
if (derivedTargets.includes(codex)) {
|
|
63
|
-
await runSetupFunction(setupCodex);
|
|
84
|
+
await runSetupFunction(() => setupCodex(mcpCommand));
|
|
64
85
|
}
|
|
65
86
|
if (derivedTargets.includes(gemini)) {
|
|
66
|
-
await runSetupFunction(setupGemini);
|
|
87
|
+
await runSetupFunction(() => setupGemini(mcpCommand));
|
|
67
88
|
}
|
|
68
89
|
uiLogger.info(commands.mcp.setup.success(derivedTargets));
|
|
69
90
|
return derivedTargets;
|
|
@@ -122,9 +143,14 @@ function setupMcpConfigFile(config) {
|
|
|
122
143
|
mcpConfig.mcpServers = {};
|
|
123
144
|
}
|
|
124
145
|
// Add or update HubSpot CLI MCP server
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
const serverConfig = {
|
|
147
|
+
command: config.mcpCommand.command,
|
|
148
|
+
args: config.mcpCommand.args,
|
|
127
149
|
};
|
|
150
|
+
if (config.mcpCommand.env) {
|
|
151
|
+
serverConfig.env = config.mcpCommand.env;
|
|
152
|
+
}
|
|
153
|
+
mcpConfig.mcpServers[mcpServerName] = serverConfig;
|
|
128
154
|
// Write the updated config
|
|
129
155
|
fs.writeFileSync(config.configPath, JSON.stringify(mcpConfig, null, 2));
|
|
130
156
|
SpinniesManager.succeed('spinner', {
|
|
@@ -145,10 +171,16 @@ export async function setupVsCode(mcpCommand = defaultMcpCommand) {
|
|
|
145
171
|
SpinniesManager.add('vsCode', {
|
|
146
172
|
text: commands.mcp.setup.spinners.configuringVsCode,
|
|
147
173
|
});
|
|
148
|
-
const
|
|
174
|
+
const commandWithAgent = buildCommandWithAgentString(mcpCommand, vscode);
|
|
175
|
+
const configObject = {
|
|
149
176
|
name: mcpServerName,
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
command: commandWithAgent.command,
|
|
178
|
+
args: commandWithAgent.args,
|
|
179
|
+
};
|
|
180
|
+
if (commandWithAgent.env) {
|
|
181
|
+
configObject.env = commandWithAgent.env;
|
|
182
|
+
}
|
|
183
|
+
const mcpConfig = JSON.stringify(configObject);
|
|
152
184
|
await execAsync(`code --add-mcp ${JSON.stringify(mcpConfig)}`);
|
|
153
185
|
SpinniesManager.succeed('vsCode', {
|
|
154
186
|
text: commands.mcp.setup.spinners.configuredVsCode,
|
|
@@ -180,10 +212,16 @@ export async function setupClaudeCode(mcpCommand = defaultMcpCommand) {
|
|
|
180
212
|
// Check if claude command is available
|
|
181
213
|
await execAsync('claude --version');
|
|
182
214
|
// Run claude mcp add command
|
|
183
|
-
const
|
|
215
|
+
const commandWithAgent = buildCommandWithAgentString(mcpCommand, claudeCode);
|
|
216
|
+
const configObject = {
|
|
184
217
|
type: 'stdio',
|
|
185
|
-
|
|
186
|
-
|
|
218
|
+
command: commandWithAgent.command,
|
|
219
|
+
args: commandWithAgent.args,
|
|
220
|
+
};
|
|
221
|
+
if (commandWithAgent.env) {
|
|
222
|
+
configObject.env = commandWithAgent.env;
|
|
223
|
+
}
|
|
224
|
+
const mcpConfig = JSON.stringify(configObject);
|
|
187
225
|
const { stdout } = await execAsync('claude mcp list');
|
|
188
226
|
if (stdout.includes(mcpServerName)) {
|
|
189
227
|
SpinniesManager.update('claudeCode', {
|
|
@@ -13,7 +13,6 @@ 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';
|
|
17
16
|
import { shouldIgnoreFile } from '@hubspot/local-dev-lib/ignoreRules';
|
|
18
17
|
import { getConfigAccountIfExists } from '@hubspot/local-dev-lib/config';
|
|
19
18
|
// Mock dependencies
|
|
@@ -23,11 +22,6 @@ vi.mock('@hubspot/local-dev-lib/fs');
|
|
|
23
22
|
vi.mock('@hubspot/local-dev-lib/api/projects');
|
|
24
23
|
vi.mock('../ensureProjectExists.js');
|
|
25
24
|
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
|
-
}));
|
|
31
25
|
vi.mock('@hubspot/local-dev-lib/ignoreRules');
|
|
32
26
|
vi.mock('@hubspot/local-dev-lib/config');
|
|
33
27
|
vi.mock('archiver');
|
|
@@ -128,10 +122,6 @@ describe('lib/projects/upload', () => {
|
|
|
128
122
|
vi.mocked(shouldIgnoreFile).mockReturnValue(false);
|
|
129
123
|
vi.mocked(projectContainsHsMetaFiles).mockResolvedValue(false);
|
|
130
124
|
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([]);
|
|
135
125
|
vi.mocked(tmp.fileSync).mockReturnValue({
|
|
136
126
|
name: path.join(tempDir, 'test.zip'),
|
|
137
127
|
fd: 1,
|
package/lib/projects/upload.js
CHANGED
|
@@ -6,7 +6,6 @@ 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';
|
|
10
9
|
import SpinniesManager from '../ui/SpinniesManager.js';
|
|
11
10
|
import { uiAccountDescription } from '../ui/index.js';
|
|
12
11
|
import { logError } from '../errorHandlers/index.js';
|
|
@@ -19,7 +18,6 @@ import { EXIT_CODES } from '../enums/exitCodes.js';
|
|
|
19
18
|
import ProjectValidationError from '../errors/ProjectValidationError.js';
|
|
20
19
|
import { walk } from '@hubspot/local-dev-lib/fs';
|
|
21
20
|
import { LEGACY_CONFIG_FILES } from '../constants.js';
|
|
22
|
-
import { archiveWorkspacesAndDependencies } from './workspaces.js';
|
|
23
21
|
async function uploadProjectFiles(accountId, projectName, filePath, uploadMessage, platformVersion, intermediateRepresentation) {
|
|
24
22
|
const accountIdentifier = uiAccountDescription(accountId) || `${accountId}`;
|
|
25
23
|
SpinniesManager.add('upload', {
|
|
@@ -64,11 +62,6 @@ export async function handleProjectUpload({ accountId, projectConfig, projectDir
|
|
|
64
62
|
}
|
|
65
63
|
const tempFile = tmp.fileSync({ postfix: '.zip' });
|
|
66
64
|
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);
|
|
72
65
|
const output = fs.createWriteStream(tempFile.name);
|
|
73
66
|
const archive = archiver('zip');
|
|
74
67
|
const result = new Promise(resolve => output.on('close', async function () {
|
|
@@ -121,8 +114,6 @@ export async function handleProjectUpload({ accountId, projectConfig, projectDir
|
|
|
121
114
|
}
|
|
122
115
|
return ignored ? false : file;
|
|
123
116
|
});
|
|
124
|
-
// Archive workspaces and file: dependencies
|
|
125
|
-
await archiveWorkspacesAndDependencies(archive, srcDir, projectDir, workspaceMappings, fileDependencyMappings);
|
|
126
117
|
archive.finalize();
|
|
127
118
|
return result;
|
|
128
119
|
}
|
|
@@ -136,5 +136,130 @@ describe('mcp-server/utils/project', () => {
|
|
|
136
136
|
env: expect.any(Object),
|
|
137
137
|
}));
|
|
138
138
|
});
|
|
139
|
+
it('should use npx -p @hubspot/cli when HUBSPOT_MCP_STANDALONE is true', async () => {
|
|
140
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
141
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
142
|
+
const hsCommand = 'hs project upload';
|
|
143
|
+
const expectedResult = {
|
|
144
|
+
stdout: 'success',
|
|
145
|
+
stderr: '',
|
|
146
|
+
};
|
|
147
|
+
mockExistsSync.mockReturnValue(true);
|
|
148
|
+
mockExecAsync.mockResolvedValue(expectedResult);
|
|
149
|
+
await runCommandInDir(mockDirectory, hsCommand);
|
|
150
|
+
expect(mockExecAsync).toHaveBeenCalledWith('npx -p @hubspot/cli hs project upload --disable-usage-tracking "true"', expect.objectContaining({
|
|
151
|
+
cwd: mockResolvedPath,
|
|
152
|
+
env: expect.any(Object),
|
|
153
|
+
}));
|
|
154
|
+
// Restore original env
|
|
155
|
+
if (originalEnv === undefined) {
|
|
156
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
it('should use regular hs command when HUBSPOT_MCP_STANDALONE is not set', async () => {
|
|
163
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
164
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
165
|
+
const hsCommand = 'hs project upload';
|
|
166
|
+
const expectedResult = {
|
|
167
|
+
stdout: 'success',
|
|
168
|
+
stderr: '',
|
|
169
|
+
};
|
|
170
|
+
mockExistsSync.mockReturnValue(true);
|
|
171
|
+
mockExecAsync.mockResolvedValue(expectedResult);
|
|
172
|
+
await runCommandInDir(mockDirectory, hsCommand);
|
|
173
|
+
expect(mockExecAsync).toHaveBeenCalledWith('hs project upload --disable-usage-tracking "true"', expect.objectContaining({
|
|
174
|
+
cwd: mockResolvedPath,
|
|
175
|
+
env: expect.any(Object),
|
|
176
|
+
}));
|
|
177
|
+
// Restore original env
|
|
178
|
+
if (originalEnv !== undefined) {
|
|
179
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
it('should use npx -p @hubspot/cli for hs commands with flags in standalone mode', async () => {
|
|
183
|
+
const originalEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
184
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
185
|
+
const hsCommand = 'hs project upload --profile prod';
|
|
186
|
+
const expectedResult = {
|
|
187
|
+
stdout: 'success',
|
|
188
|
+
stderr: '',
|
|
189
|
+
};
|
|
190
|
+
mockExistsSync.mockReturnValue(true);
|
|
191
|
+
mockExecAsync.mockResolvedValue(expectedResult);
|
|
192
|
+
await runCommandInDir(mockDirectory, hsCommand);
|
|
193
|
+
expect(mockExecAsync).toHaveBeenCalledWith('npx -p @hubspot/cli hs project upload --profile prod --disable-usage-tracking "true"', expect.objectContaining({
|
|
194
|
+
cwd: mockResolvedPath,
|
|
195
|
+
env: expect.any(Object),
|
|
196
|
+
}));
|
|
197
|
+
// Restore original env
|
|
198
|
+
if (originalEnv === undefined) {
|
|
199
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalEnv;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
it('should use pinned CLI version when HUBSPOT_CLI_VERSION is set in standalone mode', async () => {
|
|
206
|
+
const originalStandaloneEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
207
|
+
const originalVersionEnv = process.env.HUBSPOT_CLI_VERSION;
|
|
208
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
209
|
+
process.env.HUBSPOT_CLI_VERSION = '8.0.0';
|
|
210
|
+
const hsCommand = 'hs project upload';
|
|
211
|
+
const expectedResult = {
|
|
212
|
+
stdout: 'success',
|
|
213
|
+
stderr: '',
|
|
214
|
+
};
|
|
215
|
+
mockExistsSync.mockReturnValue(true);
|
|
216
|
+
mockExecAsync.mockResolvedValue(expectedResult);
|
|
217
|
+
await runCommandInDir(mockDirectory, hsCommand);
|
|
218
|
+
expect(mockExecAsync).toHaveBeenCalledWith('npx -p @hubspot/cli@8.0.0 hs project upload --disable-usage-tracking "true"', expect.objectContaining({
|
|
219
|
+
cwd: mockResolvedPath,
|
|
220
|
+
env: expect.any(Object),
|
|
221
|
+
}));
|
|
222
|
+
// Restore original env
|
|
223
|
+
if (originalStandaloneEnv === undefined) {
|
|
224
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalStandaloneEnv;
|
|
228
|
+
}
|
|
229
|
+
if (originalVersionEnv === undefined) {
|
|
230
|
+
delete process.env.HUBSPOT_CLI_VERSION;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
process.env.HUBSPOT_CLI_VERSION = originalVersionEnv;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
it('should use latest CLI version when HUBSPOT_CLI_VERSION is not set in standalone mode', async () => {
|
|
237
|
+
const originalStandaloneEnv = process.env.HUBSPOT_MCP_STANDALONE;
|
|
238
|
+
const originalVersionEnv = process.env.HUBSPOT_CLI_VERSION;
|
|
239
|
+
process.env.HUBSPOT_MCP_STANDALONE = 'true';
|
|
240
|
+
delete process.env.HUBSPOT_CLI_VERSION;
|
|
241
|
+
const hsCommand = 'hs project upload';
|
|
242
|
+
const expectedResult = {
|
|
243
|
+
stdout: 'success',
|
|
244
|
+
stderr: '',
|
|
245
|
+
};
|
|
246
|
+
mockExistsSync.mockReturnValue(true);
|
|
247
|
+
mockExecAsync.mockResolvedValue(expectedResult);
|
|
248
|
+
await runCommandInDir(mockDirectory, hsCommand);
|
|
249
|
+
expect(mockExecAsync).toHaveBeenCalledWith('npx -p @hubspot/cli hs project upload --disable-usage-tracking "true"', expect.objectContaining({
|
|
250
|
+
cwd: mockResolvedPath,
|
|
251
|
+
env: expect.any(Object),
|
|
252
|
+
}));
|
|
253
|
+
// Restore original env
|
|
254
|
+
if (originalStandaloneEnv === undefined) {
|
|
255
|
+
delete process.env.HUBSPOT_MCP_STANDALONE;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
process.env.HUBSPOT_MCP_STANDALONE = originalStandaloneEnv;
|
|
259
|
+
}
|
|
260
|
+
if (originalVersionEnv !== undefined) {
|
|
261
|
+
process.env.HUBSPOT_CLI_VERSION = originalVersionEnv;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
139
264
|
});
|
|
140
265
|
});
|
|
@@ -7,6 +7,14 @@ export async function runCommandInDir(directory, command) {
|
|
|
7
7
|
}
|
|
8
8
|
let finalCommand = command;
|
|
9
9
|
if (command.startsWith('hs ')) {
|
|
10
|
+
// Check if running in standalone mode
|
|
11
|
+
if (process.env.HUBSPOT_MCP_STANDALONE === 'true') {
|
|
12
|
+
// Use pinned version if available, otherwise use latest
|
|
13
|
+
const cliPackage = process.env.HUBSPOT_CLI_VERSION
|
|
14
|
+
? `@hubspot/cli@8.0.1-experimental.0`
|
|
15
|
+
: '@hubspot/cli';
|
|
16
|
+
finalCommand = command.replace(/^hs /, `npx -y -p ${cliPackage} hs `);
|
|
17
|
+
}
|
|
10
18
|
finalCommand = addFlag(finalCommand, 'disable-usage-tracking', true);
|
|
11
19
|
}
|
|
12
20
|
return execAsync(finalCommand, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubspot/cli",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.1-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.11.2",
|
|
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",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,237 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
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>;
|
|
@@ -1,216 +0,0 @@
|
|
|
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
|
-
}
|