@hubspot/cli 8.0.3-experimental.1 → 8.0.4-experimental.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/commands/project/migrate.js +2 -2
  2. package/lang/en.d.ts +5 -8
  3. package/lang/en.js +6 -9
  4. package/lib/getStartedV2Actions.d.ts +13 -0
  5. package/lib/getStartedV2Actions.js +53 -0
  6. package/lib/projects/__tests__/upload.test.js +0 -10
  7. package/lib/projects/platformVersion.d.ts +1 -1
  8. package/lib/projects/platformVersion.js +2 -1
  9. package/lib/projects/upload.js +0 -9
  10. package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +20 -3
  11. package/mcp-server/tools/project/AddFeatureToProjectTool.js +6 -10
  12. package/mcp-server/tools/project/CreateProjectTool.d.ts +24 -4
  13. package/mcp-server/tools/project/CreateProjectTool.js +5 -10
  14. package/mcp-server/tools/project/GetApiUsagePatternsByAppIdTool.js +5 -8
  15. package/mcp-server/tools/project/GetBuildLogsTool.d.ts +2 -2
  16. package/mcp-server/tools/project/GetBuildLogsTool.js +6 -7
  17. package/mcp-server/tools/project/GetBuildStatusTool.d.ts +1 -1
  18. package/mcp-server/tools/project/GetBuildStatusTool.js +3 -4
  19. package/mcp-server/tools/project/GuidedWalkthroughTool.d.ts +6 -1
  20. package/mcp-server/tools/project/GuidedWalkthroughTool.js +1 -6
  21. package/mcp-server/tools/project/__tests__/GetApiUsagePatternsByAppIdTool.test.js +0 -32
  22. package/mcp-server/tools/project/constants.d.ts +12 -1
  23. package/mcp-server/tools/project/constants.js +12 -16
  24. package/package.json +3 -3
  25. package/ui/components/getStarted/GetStartedFlow.js +79 -2
  26. package/ui/components/getStarted/reducer.d.ts +20 -0
  27. package/ui/components/getStarted/reducer.js +36 -0
  28. package/ui/components/getStarted/screens/InstallationScreen.d.ts +7 -0
  29. package/ui/components/getStarted/screens/InstallationScreen.js +16 -0
  30. package/ui/components/getStarted/screens/ProjectSetupScreen.js +2 -1
  31. package/ui/lib/constants.d.ts +1 -0
  32. package/ui/lib/constants.js +1 -0
  33. package/lib/projects/__tests__/workspaceArchive.test.d.ts +0 -1
  34. package/lib/projects/__tests__/workspaceArchive.test.js +0 -207
  35. package/lib/projects/workspaces.d.ts +0 -36
  36. package/lib/projects/workspaces.js +0 -224
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { sanitizeFileName, untildify } from '@hubspot/local-dev-lib/path';
3
3
  import { useApp, useFocus, useInput } from 'ink';
4
+ import open from 'open';
4
5
  import { useCallback, useEffect, useReducer } from 'react';
5
6
  import { commands } from '../../../lang/en.js';
6
- import { createProjectAction, trackGetStartedUsage, uploadAndDeployAction, } from '../../../lib/getStartedV2Actions.js';
7
+ import { createProjectAction, pollAppInstallation, trackGetStartedUsage, uploadAndDeployAction, } from '../../../lib/getStartedV2Actions.js';
8
+ import { validateProjectDirectory } from '../../../lib/prompts/projectNameAndDestPrompt.js';
7
9
  import { uiAccountDescription } from '../../../lib/ui/index.js';
8
10
  import { ACTION_STATUSES, GET_STARTED_FLOW_STEPS, } from '../../lib/constants.js';
9
11
  import { flowReducer } from './reducer.js';
12
+ import { InstallationScreen } from './screens/InstallationScreen.js';
10
13
  import { ProjectSetupScreen } from './screens/ProjectSetupScreen.js';
11
14
  import { UploadScreen } from './screens/UploadScreen.js';
12
15
  import { getProject } from './selectors.js';
@@ -44,6 +47,7 @@ export function GetStartedFlow({ derivedAccountId, initialName, initialDest, })
44
47
  statuses: {
45
48
  create: initialName ? ACTION_STATUSES.RUNNING : ACTION_STATUSES.IDLE,
46
49
  upload: ACTION_STATUSES.IDLE,
50
+ installApp: ACTION_STATUSES.IDLE,
47
51
  },
48
52
  });
49
53
  const [state, dispatch] = useReducer(flowReducer, getInitialState());
@@ -68,6 +72,16 @@ export function GetStartedFlow({ derivedAccountId, initialName, initialDest, })
68
72
  dispatch({ type: 'SET_STEP', payload: GET_STARTED_FLOW_STEPS.DEST_INPUT });
69
73
  }, []);
70
74
  const handleDestSubmit = useCallback(async () => {
75
+ const validationResult = validateProjectDirectory(project.destination);
76
+ if (validationResult !== true) {
77
+ dispatch({
78
+ type: 'SET_DEST_ERROR',
79
+ payload: typeof validationResult === 'string'
80
+ ? validationResult
81
+ : commands.getStarted.v2.unknownError,
82
+ });
83
+ return;
84
+ }
71
85
  dispatch({ type: 'SET_STEP', payload: GET_STARTED_FLOW_STEPS.CREATING });
72
86
  try {
73
87
  await createProjectAction({
@@ -108,6 +122,57 @@ export function GetStartedFlow({ derivedAccountId, initialName, initialDest, })
108
122
  dispatch({ type: 'UPLOAD_ERROR', payload: errorMessage });
109
123
  }
110
124
  }, [derivedAccountId, project.destination]);
125
+ const handlePollInstallation = useCallback(async () => {
126
+ const uploadApp = project.uploadResult?.app;
127
+ const projectId = project.uploadResult?.projectId;
128
+ if (!projectId || !uploadApp?.uid) {
129
+ dispatch({
130
+ type: 'INSTALL_APP_ERROR',
131
+ payload: commands.getStarted.v2.unknownError,
132
+ });
133
+ return;
134
+ }
135
+ try {
136
+ await pollAppInstallation({
137
+ accountId: derivedAccountId,
138
+ projectId,
139
+ appUid: uploadApp.uid,
140
+ requiredScopes: uploadApp.config?.auth?.requiredScopes,
141
+ optionalScopes: uploadApp.config?.auth?.optionalScopes,
142
+ onTimeout: () => {
143
+ dispatch({ type: 'SET_POLLING_TIMED_OUT', payload: true });
144
+ },
145
+ });
146
+ dispatch({ type: 'INSTALL_APP_DONE' });
147
+ }
148
+ catch (error) {
149
+ dispatch({
150
+ type: 'INSTALL_APP_ERROR',
151
+ payload: error instanceof Error
152
+ ? error.message
153
+ : commands.getStarted.v2.unknownError,
154
+ });
155
+ }
156
+ }, [project.uploadResult, derivedAccountId]);
157
+ const handleBrowserOpen = useCallback(async (shouldOpen) => {
158
+ await trackGetStartedUsage({
159
+ step: 'open-install-page',
160
+ type: shouldOpen ? 'opened' : 'declined',
161
+ }, derivedAccountId);
162
+ if (shouldOpen && project.uploadResult?.installUrl) {
163
+ try {
164
+ await open(project.uploadResult.installUrl, { url: true });
165
+ }
166
+ catch (error) {
167
+ dispatch({
168
+ type: 'SET_BROWSER_FAILED_URL',
169
+ payload: project.uploadResult.installUrl,
170
+ });
171
+ }
172
+ }
173
+ dispatch({ type: 'START_INSTALL_APP' });
174
+ await handlePollInstallation();
175
+ }, [project.uploadResult, derivedAccountId, handlePollInstallation]);
111
176
  const handleNameChange = useCallback((value) => {
112
177
  dispatch({ type: 'SET_PROJECT_NAME', payload: value });
113
178
  }, []);
@@ -116,7 +181,8 @@ export function GetStartedFlow({ derivedAccountId, initialName, initialDest, })
116
181
  }, []);
117
182
  useInput((_, key) => {
118
183
  const hasError = state.statuses.create === ACTION_STATUSES.ERROR ||
119
- state.statuses.upload === ACTION_STATUSES.ERROR;
184
+ state.statuses.upload === ACTION_STATUSES.ERROR ||
185
+ state.statuses.installApp === ACTION_STATUSES.ERROR;
120
186
  if (hasError) {
121
187
  exit();
122
188
  return;
@@ -126,11 +192,22 @@ export function GetStartedFlow({ derivedAccountId, initialName, initialDest, })
126
192
  if (state.step === GET_STARTED_FLOW_STEPS.COMPLETE) {
127
193
  handleUploadStart();
128
194
  }
195
+ else if (state.step === GET_STARTED_FLOW_STEPS.OPEN_APP_PROMPT) {
196
+ handleBrowserOpen(true);
197
+ }
198
+ else if (state.step === GET_STARTED_FLOW_STEPS.INSTALLING_APP &&
199
+ state.statuses.installApp === ACTION_STATUSES.DONE) {
200
+ // Ready for card setup - will be handled in PR3
201
+ exit();
202
+ }
129
203
  });
130
204
  if (state.step === GET_STARTED_FLOW_STEPS.UPLOADING ||
131
205
  state.step === GET_STARTED_FLOW_STEPS.OPEN_APP_PROMPT) {
132
206
  return _jsx(UploadScreen, { state: state, accountName: accountName });
133
207
  }
208
+ if (state.step === GET_STARTED_FLOW_STEPS.INSTALLING_APP) {
209
+ return _jsx(InstallationScreen, { state: state, accountName: accountName });
210
+ }
134
211
  // Show project setup screen for initial flow
135
212
  return (_jsx(ProjectSetupScreen, { state: state, onSelectOption: handleSelect, onNameChange: handleNameChange, onNameSubmit: handleNameSubmit, onDestChange: handleDestChange, onDestSubmit: handleDestSubmit }));
136
213
  }
@@ -14,6 +14,7 @@ export type AppState = {
14
14
  export type ActionStatuses = {
15
15
  create: ActionStatus;
16
16
  upload: ActionStatus;
17
+ installApp: ActionStatus;
17
18
  };
18
19
  export type FlowState = {
19
20
  step: FlowStep;
@@ -21,6 +22,9 @@ export type FlowState = {
21
22
  app: AppState;
22
23
  statuses: ActionStatuses;
23
24
  error?: string;
25
+ destError?: string;
26
+ browserFailedUrl?: string;
27
+ pollingTimedOut?: boolean;
24
28
  };
25
29
  type FlowAction = {
26
30
  type: 'SET_STEP';
@@ -37,6 +41,9 @@ type FlowAction = {
37
41
  } | {
38
42
  type: 'SET_ERROR';
39
43
  payload: string;
44
+ } | {
45
+ type: 'SET_DEST_ERROR';
46
+ payload: string;
40
47
  } | {
41
48
  type: 'CLEAR_ERROR';
42
49
  } | {
@@ -54,6 +61,19 @@ type FlowAction = {
54
61
  } | {
55
62
  type: 'UPLOAD_ERROR';
56
63
  payload: string;
64
+ } | {
65
+ type: 'START_INSTALL_APP';
66
+ } | {
67
+ type: 'INSTALL_APP_DONE';
68
+ } | {
69
+ type: 'INSTALL_APP_ERROR';
70
+ payload: string;
71
+ } | {
72
+ type: 'SET_BROWSER_FAILED_URL';
73
+ payload: string;
74
+ } | {
75
+ type: 'SET_POLLING_TIMED_OUT';
76
+ payload: boolean;
57
77
  };
58
78
  export declare function flowReducer(state: FlowState, action: FlowAction): FlowState;
59
79
  export {};
@@ -17,6 +17,13 @@ export function flowReducer(state, action) {
17
17
  return {
18
18
  ...state,
19
19
  project: { ...state.project, destination: action.payload },
20
+ destError: undefined,
21
+ };
22
+ case 'SET_DEST_ERROR':
23
+ return {
24
+ ...state,
25
+ step: GET_STARTED_FLOW_STEPS.DEST_INPUT,
26
+ destError: action.payload,
20
27
  };
21
28
  case 'SET_ERROR':
22
29
  return { ...state, error: action.payload };
@@ -66,6 +73,35 @@ export function flowReducer(state, action) {
66
73
  statuses: { ...state.statuses, upload: ACTION_STATUSES.ERROR },
67
74
  error: action.payload,
68
75
  };
76
+ case 'START_INSTALL_APP':
77
+ return {
78
+ ...state,
79
+ step: GET_STARTED_FLOW_STEPS.INSTALLING_APP,
80
+ statuses: { ...state.statuses, installApp: ACTION_STATUSES.RUNNING },
81
+ error: undefined,
82
+ pollingTimedOut: false,
83
+ };
84
+ case 'INSTALL_APP_DONE':
85
+ return {
86
+ ...state,
87
+ statuses: { ...state.statuses, installApp: ACTION_STATUSES.DONE },
88
+ };
89
+ case 'INSTALL_APP_ERROR':
90
+ return {
91
+ ...state,
92
+ statuses: { ...state.statuses, installApp: ACTION_STATUSES.ERROR },
93
+ error: action.payload,
94
+ };
95
+ case 'SET_BROWSER_FAILED_URL':
96
+ return {
97
+ ...state,
98
+ browserFailedUrl: action.payload,
99
+ };
100
+ case 'SET_POLLING_TIMED_OUT':
101
+ return {
102
+ ...state,
103
+ pollingTimedOut: action.payload,
104
+ };
69
105
  default:
70
106
  return state;
71
107
  }
@@ -0,0 +1,7 @@
1
+ import { FlowState } from '../reducer.js';
2
+ type InstallationScreenProps = {
3
+ state: FlowState;
4
+ accountName: string;
5
+ };
6
+ export declare function InstallationScreen({ state, accountName, }: InstallationScreenProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { commands } from '../../../../lang/en.js';
4
+ import { ActionSection } from '../../ActionSection.js';
5
+ import { BoxWithTitle } from '../../BoxWithTitle.js';
6
+ import { INK_COLORS } from '../../../styles.js';
7
+ import { getProject } from '../selectors.js';
8
+ import { ACTION_STATUSES, GET_STARTED_FLOW_STEPS, } from '../../../lib/constants.js';
9
+ export function InstallationScreen({ state, accountName, }) {
10
+ const project = getProject(state);
11
+ const titleText = commands.getStarted.v2.startTitle;
12
+ // If we get to the installation screen, the app is uploaded and we have the name
13
+ const appName = project.uploadResult?.app?.config.name;
14
+ return (_jsx(BoxWithTitle, { flexGrow: 1, title: "hs get-started", borderColor: INK_COLORS.HUBSPOT_ORANGE, titleBackgroundColor: INK_COLORS.HUBSPOT_ORANGE, children: _jsxs(Box, { flexDirection: "column", rowGap: 1, children: [_jsx(Text, { bold: true, children: titleText }), _jsx(Text, { children: commands.getStarted.v2.installInstructions }), _jsx(ActionSection, { status: state.statuses.installApp, statusText: commands.getStarted.v2.installingApp(appName, accountName) }), state.browserFailedUrl && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: INK_COLORS.WARNING_YELLOW, children: commands.getStarted.v2.browserFailedToOpen(state.browserFailedUrl) }) })), state.pollingTimedOut && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: INK_COLORS.WARNING_YELLOW, children: commands.getStarted.v2.pollingTimeout(2) }) })), state.step === GET_STARTED_FLOW_STEPS.INSTALLING_APP &&
15
+ state.statuses.installApp === ACTION_STATUSES.DONE && (_jsx(Text, { children: commands.getStarted.v2.pressEnterToContinueSetup }))] }) }));
16
+ }
@@ -35,5 +35,6 @@ export function ProjectSetupScreen({ state, onSelectOption, onNameChange, onName
35
35
  return (_jsx(BoxWithTitle, { flexGrow: 1, title: "hs get-started", borderColor: INK_COLORS.HUBSPOT_ORANGE, titleBackgroundColor: INK_COLORS.HUBSPOT_ORANGE, children: _jsxs(Box, { flexDirection: "column", rowGap: 1, children: [_jsx(Text, { bold: true, children: titleText }), state.step === GET_STARTED_FLOW_STEPS.SELECT ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: overviewText }), _jsx(Text, { children: projectsText }), _jsxs(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 1, children: [_jsx(Text, { color: INK_COLORS.HUBSPOT_TEAL, children: "?" }), _jsx(Text, { children: selectPrompt })] }), _jsx(SelectInput, { items: GET_STARTED_FLOW_OPTIONS, onSelect: onSelectOption })] })) : (_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 1, children: [_jsx(Text, { color: INK_COLORS.HUBSPOT_TEAL, children: "?" }), _jsx(Text, { children: `${selectPrompt}` }), _jsx(Text, { color: INK_COLORS.INFO_BLUE, children: state.app.selectedLabel })] })), _jsxs(ActionSection, { status: state.statuses.create, statusText: runningProjectCreateText, errorMessage: state.statuses.create === ACTION_STATUSES.ERROR
36
36
  ? `${state.error}\n\n${commands.getStarted.v2.pressKeyToExit}`
37
37
  : undefined, children: [state.step !== GET_STARTED_FLOW_STEPS.SELECT && (_jsx(InputField, { flag: "name", prompt: "Enter your project name", value: project.name, isEditing: state.step === GET_STARTED_FLOW_STEPS.NAME_INPUT, onChange: onNameChange, onSubmit: onNameSubmit })), state.step !== GET_STARTED_FLOW_STEPS.SELECT &&
38
- state.step !== GET_STARTED_FLOW_STEPS.NAME_INPUT && (_jsx(InputField, { flag: "dest", prompt: "Choose where to create the project", value: project.destination, isEditing: state.step === GET_STARTED_FLOW_STEPS.DEST_INPUT, onChange: onDestChange, onSubmit: onDestSubmit }))] }), state.step === GET_STARTED_FLOW_STEPS.COMPLETE && (_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 1, children: [_jsx(Text, { color: INK_COLORS.HUBSPOT_TEAL, children: "?" }), _jsx(Text, { children: commands.getStarted.v2.pressEnterToContinueDeploy(state.app.selectedLabel) })] }))] }) }));
38
+ state.step !== GET_STARTED_FLOW_STEPS.NAME_INPUT && (_jsxs(_Fragment, { children: [_jsx(InputField, { flag: "dest", prompt: "Choose where to create the project", value: project.destination, isEditing: state.step === GET_STARTED_FLOW_STEPS.DEST_INPUT, onChange: onDestChange, onSubmit: onDestSubmit }), state.destError &&
39
+ state.step === GET_STARTED_FLOW_STEPS.DEST_INPUT && (_jsx(Text, { color: INK_COLORS.ALERT_RED, children: state.destError }))] }))] }), state.step === GET_STARTED_FLOW_STEPS.COMPLETE && (_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 1, children: [_jsx(Text, { color: INK_COLORS.HUBSPOT_TEAL, children: "?" }), _jsx(Text, { children: commands.getStarted.v2.pressEnterToContinueDeploy(state.app.selectedLabel) })] }))] }) }));
39
40
  }
@@ -12,5 +12,6 @@ export declare const GET_STARTED_FLOW_STEPS: {
12
12
  readonly INSTALLING: "installing";
13
13
  readonly UPLOADING: "uploading";
14
14
  readonly OPEN_APP_PROMPT: "open-app-prompt";
15
+ readonly INSTALLING_APP: "installing-app";
15
16
  readonly COMPLETE: "complete";
16
17
  };
@@ -12,5 +12,6 @@ export const GET_STARTED_FLOW_STEPS = {
12
12
  INSTALLING: 'installing',
13
13
  UPLOADING: 'uploading',
14
14
  OPEN_APP_PROMPT: 'open-app-prompt',
15
+ INSTALLING_APP: 'installing-app',
15
16
  COMPLETE: 'complete',
16
17
  };
@@ -1 +0,0 @@
1
- export {};
@@ -1,207 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import path from 'path';
3
- import fs from 'fs-extra';
4
- import { computeExternalArchivePath, shortHash, updatePackageJsonInArchive, } from '../workspaces.js';
5
- describe('computeExternalArchivePath', () => {
6
- it('places external workspace in _workspaces/ with basename-hash', () => {
7
- const localPath = '/Users/test/company-libs/utils';
8
- const result = computeExternalArchivePath(localPath);
9
- const expectedHash = shortHash(path.resolve(localPath));
10
- expect(result).toBe(path.join('_workspaces', `utils-${expectedHash}`));
11
- });
12
- it('does not include an external/ subdirectory', () => {
13
- const result = computeExternalArchivePath('/Users/test/libs/utils');
14
- expect(result).not.toContain('external');
15
- expect(result.startsWith('_workspaces')).toBe(true);
16
- });
17
- it('produces different paths for different directories with same basename', () => {
18
- const path1 = computeExternalArchivePath('/Users/test/project-a/utils');
19
- const path2 = computeExternalArchivePath('/Users/test/project-b/utils');
20
- expect(path1).not.toBe(path2);
21
- expect(path1).toContain('utils-');
22
- expect(path2).toContain('utils-');
23
- });
24
- it('is deterministic', () => {
25
- const localPath = '/Users/test/libs/utils';
26
- expect(computeExternalArchivePath(localPath)).toBe(computeExternalArchivePath(localPath));
27
- });
28
- it('produces paths matching _workspaces/<name>-[8 hex chars]', () => {
29
- const result = computeExternalArchivePath('/Users/test/libs/utils');
30
- // Normalize path separators for cross-platform compatibility
31
- const normalized = result.replace(/\\/g, '/');
32
- expect(normalized).toMatch(/_workspaces\/utils-[a-f0-9]{8}$/);
33
- });
34
- it('uses the last path segment as basename', () => {
35
- const result = computeExternalArchivePath('/Users/test/libs/@company/shared-utils');
36
- expect(result).toContain('shared-utils-');
37
- const normalized = result.replace(/\\/g, '/');
38
- expect(normalized).toMatch(/_workspaces\/shared-utils-[a-f0-9]{8}$/);
39
- });
40
- it('never produces paths with .. segments', () => {
41
- const testCases = [
42
- '/Users/other/libs/utils',
43
- '/completely/different/path',
44
- '/Users/test/other-project/shared',
45
- ];
46
- testCases.forEach(localPath => {
47
- expect(computeExternalArchivePath(localPath)).not.toContain('..');
48
- });
49
- });
50
- });
51
- describe('shortHash', () => {
52
- it('produces 8-character hex string', () => {
53
- const hash = shortHash('/some/path');
54
- expect(hash).toMatch(/^[a-f0-9]{8}$/);
55
- });
56
- it('is deterministic', () => {
57
- const input = '/Users/test/workspace';
58
- expect(shortHash(input)).toBe(shortHash(input));
59
- });
60
- it('produces different hashes for different inputs', () => {
61
- const hash1 = shortHash('/path/a');
62
- const hash2 = shortHash('/path/b');
63
- expect(hash1).not.toBe(hash2);
64
- });
65
- });
66
- describe('updatePackageJsonInArchive', () => {
67
- const srcDir = '/project/src';
68
- function createMockArchive() {
69
- const appended = [];
70
- const mock = {
71
- append: (content, opts) => {
72
- appended.push({ content, name: opts.name });
73
- return mock;
74
- },
75
- };
76
- return {
77
- archive: mock,
78
- getAppended: () => appended,
79
- };
80
- }
81
- it('writes external workspace entries as absolute archive paths', async () => {
82
- const packageJsonPath = '/project/src/app/functions/package.json';
83
- const originalPackageJson = {
84
- name: 'my-app',
85
- workspaces: ['../../packages/utils'],
86
- dependencies: {},
87
- };
88
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
89
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(originalPackageJson));
90
- const { archive, getAppended } = createMockArchive();
91
- const packageWorkspaces = new Map();
92
- packageWorkspaces.set(packageJsonPath, [
93
- '/_workspaces/packages-utils-a1b2c3d4',
94
- '/_workspaces/packages-core-e5f6a7b8',
95
- ]);
96
- await updatePackageJsonInArchive(archive, srcDir, packageWorkspaces, new Map());
97
- const appended = getAppended();
98
- expect(appended).toHaveLength(1);
99
- const written = JSON.parse(appended[0].content);
100
- expect(written.workspaces).toEqual([
101
- '/_workspaces/packages-utils-a1b2c3d4',
102
- '/_workspaces/packages-core-e5f6a7b8',
103
- ]);
104
- vi.restoreAllMocks();
105
- });
106
- it('preserves internal workspace entries as relative paths', async () => {
107
- const packageJsonPath = '/project/src/app/functions/package.json';
108
- const originalPackageJson = {
109
- name: 'my-app',
110
- workspaces: ['../packages/utils'],
111
- };
112
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
113
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(originalPackageJson));
114
- const { archive, getAppended } = createMockArchive();
115
- const packageWorkspaces = new Map();
116
- packageWorkspaces.set(packageJsonPath, ['../packages/utils']);
117
- await updatePackageJsonInArchive(archive, srcDir, packageWorkspaces, new Map());
118
- const written = JSON.parse(getAppended()[0].content);
119
- expect(written.workspaces).toEqual(['../packages/utils']);
120
- vi.restoreAllMocks();
121
- });
122
- it('writes mixed internal and external workspace entries', async () => {
123
- const packageJsonPath = '/project/src/app/functions/package.json';
124
- const originalPackageJson = {
125
- name: 'my-app',
126
- workspaces: ['../packages/utils', '/_workspaces/logger-a1b2c3d4'],
127
- };
128
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
129
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(originalPackageJson));
130
- const { archive, getAppended } = createMockArchive();
131
- const packageWorkspaces = new Map();
132
- packageWorkspaces.set(packageJsonPath, [
133
- '../packages/utils',
134
- '/_workspaces/logger-a1b2c3d4',
135
- ]);
136
- await updatePackageJsonInArchive(archive, srcDir, packageWorkspaces, new Map());
137
- const written = JSON.parse(getAppended()[0].content);
138
- expect(written.workspaces).toEqual([
139
- '../packages/utils',
140
- '/_workspaces/logger-a1b2c3d4',
141
- ]);
142
- vi.restoreAllMocks();
143
- });
144
- it('rewrites external file: dependencies as absolute archive paths', async () => {
145
- const packageJsonPath = '/project/src/app/functions/package.json';
146
- const originalPackageJson = {
147
- name: 'my-app',
148
- dependencies: {
149
- '@company/logger': 'file:../../external/logger',
150
- react: '^18.0.0',
151
- },
152
- };
153
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
154
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(originalPackageJson));
155
- const { archive, getAppended } = createMockArchive();
156
- const packageFileDeps = new Map();
157
- packageFileDeps.set(packageJsonPath, new Map([['@company/logger', '_workspaces/logger-a1b2c3d4']]));
158
- await updatePackageJsonInArchive(archive, srcDir, new Map(), packageFileDeps);
159
- const appended = getAppended();
160
- expect(appended).toHaveLength(1);
161
- const written = JSON.parse(appended[0].content);
162
- expect(written.dependencies['@company/logger']).toBe('file:/_workspaces/logger-a1b2c3d4');
163
- expect(written.dependencies['react']).toBe('^18.0.0');
164
- vi.restoreAllMocks();
165
- });
166
- it('leaves internal file: dependencies untouched when not in packageFileDeps', async () => {
167
- const originalPackageJson = {
168
- name: 'my-app',
169
- dependencies: {
170
- '@internal/utils': 'file:../packages/utils',
171
- react: '^18.0.0',
172
- },
173
- };
174
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
175
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(originalPackageJson));
176
- const { archive, getAppended } = createMockArchive();
177
- await updatePackageJsonInArchive(archive, srcDir, new Map(), new Map());
178
- // Nothing to update, so no package.json should be appended
179
- expect(getAppended()).toHaveLength(0);
180
- vi.restoreAllMocks();
181
- });
182
- it('uses same absolute path regardless of package.json depth', async () => {
183
- const shallowPath = '/project/src/package.json';
184
- const deepPath = '/project/src/app/functions/nested/package.json';
185
- const archivePath = '/_workspaces/utils-a1b2c3d4';
186
- const makePackageJson = () => ({
187
- name: 'test',
188
- workspaces: ['placeholder'],
189
- });
190
- vi.spyOn(fs, 'existsSync').mockReturnValue(true);
191
- const { archive: archive1, getAppended: getAppended1 } = createMockArchive();
192
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(makePackageJson()));
193
- const workspaces1 = new Map();
194
- workspaces1.set(shallowPath, [archivePath]);
195
- await updatePackageJsonInArchive(archive1, srcDir, workspaces1, new Map());
196
- const { archive: archive2, getAppended: getAppended2 } = createMockArchive();
197
- vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(makePackageJson()));
198
- const workspaces2 = new Map();
199
- workspaces2.set(deepPath, [archivePath]);
200
- await updatePackageJsonInArchive(archive2, srcDir, workspaces2, new Map());
201
- const written1 = JSON.parse(getAppended1()[0].content);
202
- const written2 = JSON.parse(getAppended2()[0].content);
203
- expect(written1.workspaces).toEqual(['/_workspaces/utils-a1b2c3d4']);
204
- expect(written2.workspaces).toEqual(['/_workspaces/utils-a1b2c3d4']);
205
- vi.restoreAllMocks();
206
- });
207
- });
@@ -1,36 +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 an external workspace or file: dependency.
17
- * Produces `_workspaces/<basename>-<hash>` with no subdirectory.
18
- * The hash prevents collisions between different directories with the same basename.
19
- */
20
- export declare function computeExternalArchivePath(absolutePath: string): string;
21
- /**
22
- * Updates package.json files in the archive to reflect new workspace and file: dependency paths.
23
- *
24
- * Workspace entries in packageWorkspaces are already in final form:
25
- * - Internal workspaces: relative paths (e.g. "../packages/utils")
26
- * - External workspaces: absolute paths (e.g. "/_workspaces/logger-abc")
27
- *
28
- * Only external file: dependencies appear in packageFileDeps; internal ones
29
- * keep their original file: references and are left untouched.
30
- */
31
- export declare function updatePackageJsonInArchive(archive: archiver.Archiver, srcDir: string, packageWorkspaces: Map<string, string[]>, packageFileDeps: Map<string, Map<string, string>>): Promise<void>;
32
- /**
33
- * Main orchestration function that handles archiving of workspaces and file dependencies.
34
- * This is the clean integration point for upload.ts.
35
- */
36
- export declare function archiveWorkspacesAndDependencies(archive: archiver.Archiver, srcDir: string, projectDir: string, workspaceMappings: WorkspaceMapping[], fileDependencyMappings: FileDependencyMapping[]): Promise<WorkspaceArchiveResult>;