@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.
@@ -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
- import startCommand from '../start.js';
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
  });
@@ -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 = {
@@ -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
- mcpConfig.mcpServers[mcpServerName] = {
126
- ...config.mcpCommand,
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 mcpConfig = JSON.stringify({
174
+ const commandWithAgent = buildCommandWithAgentString(mcpCommand, vscode);
175
+ const configObject = {
149
176
  name: mcpServerName,
150
- ...buildCommandWithAgentString(mcpCommand, vscode),
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 mcpConfig = JSON.stringify({
215
+ const commandWithAgent = buildCommandWithAgentString(mcpCommand, claudeCode);
216
+ const configObject = {
184
217
  type: 'stdio',
185
- ...buildCommandWithAgentString(mcpCommand, claudeCode),
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,
@@ -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.0-experimental.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.1.0-experimental.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
- }