@hubspot/cli 8.0.1-experimental.0 → 8.0.3-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/bin/cli.js +8 -5
- package/commands/__tests__/getStarted.test.js +12 -0
- package/commands/__tests__/project.test.js +30 -0
- package/commands/account/auth.js +8 -97
- package/commands/account/use.js +19 -4
- package/commands/cms/module/marketplace-validate.js +23 -5
- package/commands/cms/theme/marketplace-validate.js +25 -6
- package/commands/getStarted.d.ts +2 -1
- package/commands/getStarted.js +38 -15
- package/commands/mcp/__tests__/start.test.js +1 -67
- package/commands/mcp/setup.js +1 -2
- package/commands/mcp/start.js +1 -19
- package/commands/mcp.js +1 -2
- package/commands/project.js +22 -1
- package/lang/en.d.ts +53 -7
- package/lang/en.js +59 -13
- package/lib/CLIWebSocketServer.d.ts +28 -0
- package/lib/CLIWebSocketServer.js +91 -0
- package/lib/__tests__/CLIWebSocketServer.test.d.ts +1 -0
- package/lib/__tests__/CLIWebSocketServer.test.js +252 -0
- package/lib/__tests__/accountAuth.test.d.ts +1 -0
- package/lib/__tests__/accountAuth.test.js +258 -0
- package/lib/__tests__/commandSuggestion.test.d.ts +1 -0
- package/lib/__tests__/commandSuggestion.test.js +119 -0
- package/lib/accountAuth.d.ts +10 -0
- package/lib/accountAuth.js +105 -0
- package/lib/app/urls.d.ts +1 -0
- package/lib/app/urls.js +4 -0
- package/lib/commandSuggestion.d.ts +3 -0
- package/lib/commandSuggestion.js +45 -0
- package/lib/constants.d.ts +0 -1
- package/lib/constants.js +0 -1
- package/lib/errors/ProjectErrors.d.ts +15 -0
- package/lib/errors/ProjectErrors.js +30 -0
- package/lib/getStarted/getStartedV2.d.ts +7 -0
- package/lib/getStarted/getStartedV2.js +18 -0
- package/lib/getStartedV2Actions.d.ts +37 -0
- package/lib/getStartedV2Actions.js +146 -0
- package/lib/marketplaceValidate.d.ts +1 -1
- package/lib/marketplaceValidate.js +23 -41
- package/lib/mcp/__tests__/setup.test.js +0 -17
- package/lib/mcp/setup.d.ts +0 -1
- package/lib/mcp/setup.js +59 -103
- package/lib/projects/ProjectLogsManager.d.ts +12 -3
- package/lib/projects/ProjectLogsManager.js +70 -12
- package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +43 -175
- package/lib/projects/__tests__/ProjectLogsManager.test.js +131 -18
- package/lib/projects/__tests__/platformVersion.test.js +37 -1
- package/lib/projects/__tests__/projects.test.js +6 -2
- package/lib/projects/components.d.ts +6 -0
- package/lib/projects/components.js +1 -1
- package/lib/projects/config.js +9 -2
- package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -7
- package/lib/projects/localDev/LocalDevWebsocketServer.js +51 -98
- package/lib/projects/localDev/helpers/project.d.ts +4 -1
- package/lib/projects/localDev/helpers/project.js +13 -8
- package/lib/projects/localDev/localDevWebsocketServerUtils.d.ts +8 -7
- package/lib/projects/platformVersion.d.ts +8 -0
- package/lib/projects/platformVersion.js +31 -2
- package/lib/prompts/accountsPrompt.d.ts +2 -1
- package/lib/prompts/accountsPrompt.js +10 -2
- package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +20 -3
- package/mcp-server/tools/project/AddFeatureToProjectTool.js +6 -10
- package/mcp-server/tools/project/CreateProjectTool.d.ts +24 -4
- package/mcp-server/tools/project/CreateProjectTool.js +5 -10
- package/mcp-server/tools/project/GetApiUsagePatternsByAppIdTool.js +5 -8
- package/mcp-server/tools/project/GetBuildLogsTool.d.ts +2 -2
- package/mcp-server/tools/project/GetBuildLogsTool.js +3 -4
- package/mcp-server/tools/project/GetBuildStatusTool.d.ts +1 -1
- package/mcp-server/tools/project/GetBuildStatusTool.js +3 -4
- package/mcp-server/tools/project/GuidedWalkthroughTool.d.ts +6 -1
- package/mcp-server/tools/project/GuidedWalkthroughTool.js +1 -6
- package/mcp-server/tools/project/constants.d.ts +12 -1
- package/mcp-server/tools/project/constants.js +12 -16
- package/mcp-server/utils/__tests__/project.test.js +0 -125
- package/mcp-server/utils/project.js +0 -8
- package/package.json +10 -5
- package/types/LocalDev.d.ts +0 -4
- package/ui/components/ActionSection.d.ts +12 -0
- package/ui/components/ActionSection.js +25 -0
- package/ui/components/BoxWithTitle.d.ts +4 -2
- package/ui/components/BoxWithTitle.js +2 -2
- package/ui/components/FullScreen.d.ts +6 -0
- package/ui/components/FullScreen.js +13 -0
- package/ui/components/InputField.d.ts +10 -0
- package/ui/components/InputField.js +10 -0
- package/ui/components/SelectInput.d.ts +11 -0
- package/ui/components/SelectInput.js +59 -0
- package/ui/components/StatusIcon.d.ts +9 -0
- package/ui/components/StatusIcon.js +17 -0
- package/ui/components/getStarted/GetStartedFlow.d.ts +8 -0
- package/ui/components/getStarted/GetStartedFlow.js +136 -0
- package/ui/components/getStarted/reducer.d.ts +59 -0
- package/ui/components/getStarted/reducer.js +72 -0
- package/ui/components/getStarted/screens/ProjectSetupScreen.d.ts +16 -0
- package/ui/components/getStarted/screens/ProjectSetupScreen.js +39 -0
- package/ui/components/getStarted/screens/UploadScreen.d.ts +7 -0
- package/ui/components/getStarted/screens/UploadScreen.js +43 -0
- package/ui/components/getStarted/selectors.d.ts +2 -0
- package/ui/components/getStarted/selectors.js +1 -0
- package/ui/constants.d.ts +6 -0
- package/ui/constants.js +6 -0
- package/ui/lib/constants.d.ts +16 -0
- package/ui/lib/constants.js +16 -0
- package/ui/playground/fixtures.js +47 -0
- package/ui/render.d.ts +4 -0
- package/ui/render.js +31 -0
- package/ui/styles.d.ts +3 -0
- package/ui/styles.js +3 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { isPortManagerServerRunning, requestPorts, } from '@hubspot/local-dev-lib/portManager';
|
|
3
|
+
import { uiLogger } from '../ui/logger.js';
|
|
4
|
+
import CLIWebSocketServer from '../CLIWebSocketServer.js';
|
|
5
|
+
import { lib } from '../../lang/en.js';
|
|
6
|
+
import { pkg } from '../jsonLoader.js';
|
|
7
|
+
vi.mock('ws');
|
|
8
|
+
vi.mock('@hubspot/local-dev-lib/portManager');
|
|
9
|
+
const INSTANCE_ID = 'test-websocket-server';
|
|
10
|
+
const LOG_PREFIX = '[TestWebSocketServer]';
|
|
11
|
+
const PORT = 1234;
|
|
12
|
+
describe('CLIWebSocketServer', () => {
|
|
13
|
+
let mockWebSocket;
|
|
14
|
+
let mockWebSocketServer;
|
|
15
|
+
let server;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockWebSocket = {
|
|
18
|
+
on: vi.fn(),
|
|
19
|
+
send: vi.fn(),
|
|
20
|
+
close: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
mockWebSocketServer = {
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
close: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
WebSocketServer.mockImplementation(() => mockWebSocketServer);
|
|
27
|
+
server = new CLIWebSocketServer({
|
|
28
|
+
instanceId: INSTANCE_ID,
|
|
29
|
+
logPrefix: LOG_PREFIX,
|
|
30
|
+
debug: true,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('start()', () => {
|
|
34
|
+
it('should throw error if port manager is not running', async () => {
|
|
35
|
+
isPortManagerServerRunning.mockResolvedValue(false);
|
|
36
|
+
await expect(server.start({})).rejects.toThrow(lib.CLIWebsocketServer.errors.portManagerNotRunning(LOG_PREFIX));
|
|
37
|
+
});
|
|
38
|
+
it('should start websocket server on the assigned port', async () => {
|
|
39
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
40
|
+
requestPorts.mockResolvedValue({
|
|
41
|
+
[INSTANCE_ID]: PORT,
|
|
42
|
+
});
|
|
43
|
+
await server.start({});
|
|
44
|
+
expect(WebSocketServer).toHaveBeenCalledWith({ port: PORT });
|
|
45
|
+
expect(mockWebSocketServer.on).toHaveBeenCalledWith('connection', expect.any(Function));
|
|
46
|
+
});
|
|
47
|
+
it('should log startup message when debug is enabled', async () => {
|
|
48
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
49
|
+
requestPorts.mockResolvedValue({
|
|
50
|
+
[INSTANCE_ID]: PORT,
|
|
51
|
+
});
|
|
52
|
+
await server.start({});
|
|
53
|
+
expect(uiLogger.log).toHaveBeenCalledWith(expect.stringContaining(String(PORT)));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('origin validation', () => {
|
|
57
|
+
const validOrigins = [
|
|
58
|
+
'https://app.hubspot.com',
|
|
59
|
+
'https://app.hubspotqa.com',
|
|
60
|
+
'https://local.hubspot.com',
|
|
61
|
+
'https://local.hubspotqa.com',
|
|
62
|
+
'https://app-na2.hubspot.com',
|
|
63
|
+
'https://app-na2.hubspotqa.com',
|
|
64
|
+
'https://app-na3.hubspot.com',
|
|
65
|
+
'https://app-na3.hubspotqa.com',
|
|
66
|
+
'https://app-ap1.hubspot.com',
|
|
67
|
+
'https://app-ap1.hubspotqa.com',
|
|
68
|
+
'https://app-eu1.hubspot.com',
|
|
69
|
+
'https://app-eu1.hubspotqa.com',
|
|
70
|
+
];
|
|
71
|
+
let connectionCallback;
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
74
|
+
requestPorts.mockResolvedValue({
|
|
75
|
+
[INSTANCE_ID]: PORT,
|
|
76
|
+
});
|
|
77
|
+
await server.start({});
|
|
78
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
79
|
+
.calls[0][1];
|
|
80
|
+
});
|
|
81
|
+
validOrigins.forEach(origin => {
|
|
82
|
+
it(`should accept connection from ${origin}`, () => {
|
|
83
|
+
connectionCallback(mockWebSocket, { headers: { origin } });
|
|
84
|
+
expect(mockWebSocket.close).not.toHaveBeenCalled();
|
|
85
|
+
expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
const invalidOrigins = [
|
|
89
|
+
'https://malicious-site.com',
|
|
90
|
+
'https://app.malicious-site.com',
|
|
91
|
+
'https://app.hubspot.com.evil.com',
|
|
92
|
+
];
|
|
93
|
+
invalidOrigins.forEach(origin => {
|
|
94
|
+
it(`should reject connection from "${origin}"`, () => {
|
|
95
|
+
connectionCallback(mockWebSocket, { headers: { origin } });
|
|
96
|
+
expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.CLIWebsocketServer.errors.originNotAllowed(origin));
|
|
97
|
+
expect(mockWebSocket.on).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
it('should reject connection with no origin header', () => {
|
|
101
|
+
connectionCallback(mockWebSocket, { headers: {} });
|
|
102
|
+
expect(mockWebSocket.close).toHaveBeenCalledWith(1008, lib.CLIWebsocketServer.errors.originNotAllowed());
|
|
103
|
+
expect(mockWebSocket.on).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('sendCliMetadata', () => {
|
|
107
|
+
let connectionCallback;
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
110
|
+
requestPorts.mockResolvedValue({
|
|
111
|
+
[INSTANCE_ID]: PORT,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
it('should send cliVersion on connection', async () => {
|
|
115
|
+
await server.start({});
|
|
116
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
117
|
+
.calls[0][1];
|
|
118
|
+
connectionCallback(mockWebSocket, {
|
|
119
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
120
|
+
});
|
|
121
|
+
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({
|
|
122
|
+
type: 'server:cliMetadata',
|
|
123
|
+
data: { cliVersion: pkg.version },
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
it('should merge additional metadata when provided', async () => {
|
|
127
|
+
await server.start({
|
|
128
|
+
metadata: { customField: 'customValue', version: 2 },
|
|
129
|
+
});
|
|
130
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
131
|
+
.calls[0][1];
|
|
132
|
+
connectionCallback(mockWebSocket, {
|
|
133
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
134
|
+
});
|
|
135
|
+
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({
|
|
136
|
+
type: 'server:cliMetadata',
|
|
137
|
+
data: {
|
|
138
|
+
cliVersion: pkg.version,
|
|
139
|
+
customField: 'customValue',
|
|
140
|
+
version: 2,
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('callbacks', () => {
|
|
146
|
+
let connectionCallback;
|
|
147
|
+
beforeEach(async () => {
|
|
148
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
149
|
+
requestPorts.mockResolvedValue({
|
|
150
|
+
[INSTANCE_ID]: PORT,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
it('should call onConnection for valid connections', async () => {
|
|
154
|
+
const onConnection = vi.fn();
|
|
155
|
+
await server.start({ onConnection });
|
|
156
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
157
|
+
.calls[0][1];
|
|
158
|
+
connectionCallback(mockWebSocket, {
|
|
159
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
160
|
+
});
|
|
161
|
+
expect(onConnection).toHaveBeenCalledWith(mockWebSocket);
|
|
162
|
+
});
|
|
163
|
+
it('should not call onConnection for rejected connections', async () => {
|
|
164
|
+
const onConnection = vi.fn();
|
|
165
|
+
await server.start({ onConnection });
|
|
166
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
167
|
+
.calls[0][1];
|
|
168
|
+
connectionCallback(mockWebSocket, {
|
|
169
|
+
headers: { origin: 'https://malicious.com' },
|
|
170
|
+
});
|
|
171
|
+
expect(onConnection).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
it('should call onMessage when a valid message is received', async () => {
|
|
174
|
+
const onMessage = vi.fn().mockReturnValue(true);
|
|
175
|
+
await server.start({ onMessage });
|
|
176
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
177
|
+
.calls[0][1];
|
|
178
|
+
connectionCallback(mockWebSocket, {
|
|
179
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
180
|
+
});
|
|
181
|
+
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
|
|
182
|
+
messageCallback(JSON.stringify({ type: 'test:action', data: { key: 'value' } }));
|
|
183
|
+
expect(onMessage).toHaveBeenCalledWith(mockWebSocket, {
|
|
184
|
+
type: 'test:action',
|
|
185
|
+
data: { key: 'value' },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
it('should log error when onMessage returns false', async () => {
|
|
189
|
+
const onMessage = vi.fn().mockReturnValue(false);
|
|
190
|
+
await server.start({ onMessage });
|
|
191
|
+
connectionCallback = mockWebSocketServer.on.mock
|
|
192
|
+
.calls[0][1];
|
|
193
|
+
connectionCallback(mockWebSocket, {
|
|
194
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
195
|
+
});
|
|
196
|
+
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
|
|
197
|
+
messageCallback(JSON.stringify({ type: 'UNKNOWN_TYPE' }));
|
|
198
|
+
expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('UNKNOWN_TYPE'));
|
|
199
|
+
});
|
|
200
|
+
it('should call onClose when server closes', async () => {
|
|
201
|
+
const onClose = vi.fn();
|
|
202
|
+
await server.start({ onClose });
|
|
203
|
+
const closeCallback = mockWebSocketServer.on.mock.calls.find(call => call[0] === 'close')[1];
|
|
204
|
+
closeCallback();
|
|
205
|
+
expect(onClose).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('message parsing', () => {
|
|
209
|
+
let messageCallback;
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
212
|
+
requestPorts.mockResolvedValue({
|
|
213
|
+
[INSTANCE_ID]: PORT,
|
|
214
|
+
});
|
|
215
|
+
await server.start({});
|
|
216
|
+
const connectionCallback = mockWebSocketServer.on.mock.calls[0][1];
|
|
217
|
+
connectionCallback(mockWebSocket, {
|
|
218
|
+
headers: { origin: 'https://app.hubspot.com' },
|
|
219
|
+
});
|
|
220
|
+
messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')[1];
|
|
221
|
+
});
|
|
222
|
+
it('should log error for messages missing a type field', () => {
|
|
223
|
+
messageCallback(JSON.stringify({}));
|
|
224
|
+
expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('Missing type field'));
|
|
225
|
+
});
|
|
226
|
+
it('should log error for invalid JSON', () => {
|
|
227
|
+
messageCallback('not valid json');
|
|
228
|
+
expect(uiLogger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON'));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('sendMessage()', () => {
|
|
232
|
+
it('should serialize and send a message to the websocket', () => {
|
|
233
|
+
const message = { type: 'test:message', data: { foo: 'bar' } };
|
|
234
|
+
server.sendMessage(mockWebSocket, message);
|
|
235
|
+
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
describe('shutdown()', () => {
|
|
239
|
+
it('should close the websocket server', async () => {
|
|
240
|
+
isPortManagerServerRunning.mockResolvedValue(true);
|
|
241
|
+
requestPorts.mockResolvedValue({
|
|
242
|
+
[INSTANCE_ID]: PORT,
|
|
243
|
+
});
|
|
244
|
+
await server.start({});
|
|
245
|
+
server.shutdown();
|
|
246
|
+
expect(mockWebSocketServer.close).toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
it('should handle shutdown when server was never started', () => {
|
|
249
|
+
expect(() => server.shutdown()).not.toThrow();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { updateConfigAccount, createEmptyConfigFile, getConfigFilePath, localConfigFileExists, globalConfigFileExists, setConfigAccountAsDefault, } from '@hubspot/local-dev-lib/config';
|
|
2
|
+
import { getAccessToken, updateConfigWithAccessToken, } from '@hubspot/local-dev-lib/personalAccessKey';
|
|
3
|
+
import { toKebabCase } from '@hubspot/local-dev-lib/text';
|
|
4
|
+
import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '@hubspot/local-dev-lib/constants/auth';
|
|
5
|
+
import { handleMerge, handleMigration } from '../configMigrate.js';
|
|
6
|
+
import { personalAccessKeyPrompt } from '../prompts/personalAccessKeyPrompt.js';
|
|
7
|
+
import { cliAccountNamePrompt } from '../prompts/accountNamePrompt.js';
|
|
8
|
+
import { setAsDefaultAccountPrompt } from '../prompts/setAsDefaultAccountPrompt.js';
|
|
9
|
+
import { authenticateNewAccount } from '../accountAuth.js';
|
|
10
|
+
vi.mock('@hubspot/local-dev-lib/config');
|
|
11
|
+
vi.mock('@hubspot/local-dev-lib/personalAccessKey');
|
|
12
|
+
vi.mock('@hubspot/local-dev-lib/text');
|
|
13
|
+
vi.mock('../configMigrate.js');
|
|
14
|
+
vi.mock('../errorHandlers/index.js');
|
|
15
|
+
vi.mock('../prompts/personalAccessKeyPrompt.js');
|
|
16
|
+
vi.mock('../prompts/accountNamePrompt.js');
|
|
17
|
+
vi.mock('../prompts/setAsDefaultAccountPrompt.js');
|
|
18
|
+
vi.mock('../ui/logger.js', () => ({
|
|
19
|
+
uiLogger: {
|
|
20
|
+
log: vi.fn(),
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
success: vi.fn(),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
const mockedUpdateConfigAccount = updateConfigAccount;
|
|
26
|
+
const mockedCreateEmptyConfigFile = createEmptyConfigFile;
|
|
27
|
+
const mockedGetConfigFilePath = getConfigFilePath;
|
|
28
|
+
const mockedLocalConfigFileExists = localConfigFileExists;
|
|
29
|
+
const mockedGlobalConfigFileExists = globalConfigFileExists;
|
|
30
|
+
const mockedSetConfigAccountAsDefault = setConfigAccountAsDefault;
|
|
31
|
+
const mockedGetAccessToken = getAccessToken;
|
|
32
|
+
const mockedUpdateConfigWithAccessToken = updateConfigWithAccessToken;
|
|
33
|
+
const mockedToKebabCase = toKebabCase;
|
|
34
|
+
const mockedHandleMerge = handleMerge;
|
|
35
|
+
const mockedHandleMigration = handleMigration;
|
|
36
|
+
const mockedPersonalAccessKeyPrompt = personalAccessKeyPrompt;
|
|
37
|
+
const mockedCliAccountNamePrompt = cliAccountNamePrompt;
|
|
38
|
+
const mockedSetAsDefaultAccountPrompt = setAsDefaultAccountPrompt;
|
|
39
|
+
describe('lib/accountAuth', () => {
|
|
40
|
+
describe('authenticateNewAccount()', () => {
|
|
41
|
+
const mockAccessToken = {
|
|
42
|
+
portalId: 123456,
|
|
43
|
+
accessToken: 'test-token',
|
|
44
|
+
expiresAt: '2025-01-01',
|
|
45
|
+
scopeGroups: ['test-scope'],
|
|
46
|
+
enabledFeatures: { 'test-feature': 1 },
|
|
47
|
+
encodedOAuthRefreshToken: 'test-refresh-token',
|
|
48
|
+
hubName: 'Test Hub',
|
|
49
|
+
accountType: 'STANDARD',
|
|
50
|
+
};
|
|
51
|
+
const mockAccountConfig = {
|
|
52
|
+
name: 'test-hub',
|
|
53
|
+
accountId: 123456,
|
|
54
|
+
env: 'prod',
|
|
55
|
+
accountType: 'STANDARD',
|
|
56
|
+
authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value,
|
|
57
|
+
personalAccessKey: 'test-key',
|
|
58
|
+
auth: {
|
|
59
|
+
tokenInfo: {
|
|
60
|
+
accessToken: 'test-token',
|
|
61
|
+
expiresAt: '2025-01-01',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
mockedLocalConfigFileExists.mockReturnValue(false);
|
|
68
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
69
|
+
mockedGetAccessToken.mockResolvedValue(mockAccessToken);
|
|
70
|
+
mockedUpdateConfigWithAccessToken.mockResolvedValue(mockAccountConfig);
|
|
71
|
+
mockedToKebabCase.mockReturnValue('test-hub');
|
|
72
|
+
mockedCliAccountNamePrompt.mockResolvedValue({ name: 'test-hub' });
|
|
73
|
+
mockedGetConfigFilePath.mockReturnValue('/path/to/config');
|
|
74
|
+
mockedPersonalAccessKeyPrompt.mockResolvedValue({
|
|
75
|
+
personalAccessKey: 'test-key',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it('should create config file if it does not exist', async () => {
|
|
79
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
80
|
+
await authenticateNewAccount({
|
|
81
|
+
env: 'prod',
|
|
82
|
+
providedPersonalAccessKey: 'test-key',
|
|
83
|
+
accountId: 123456,
|
|
84
|
+
});
|
|
85
|
+
expect(mockedCreateEmptyConfigFile).toHaveBeenCalledWith(true);
|
|
86
|
+
});
|
|
87
|
+
it('should not create config file if it already exists', async () => {
|
|
88
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
89
|
+
await authenticateNewAccount({
|
|
90
|
+
env: 'prod',
|
|
91
|
+
providedPersonalAccessKey: 'test-key',
|
|
92
|
+
accountId: 123456,
|
|
93
|
+
});
|
|
94
|
+
expect(mockedCreateEmptyConfigFile).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
it('should use provided personal access key without prompting', async () => {
|
|
97
|
+
await authenticateNewAccount({
|
|
98
|
+
env: 'prod',
|
|
99
|
+
providedPersonalAccessKey: 'test-key',
|
|
100
|
+
accountId: 123456,
|
|
101
|
+
});
|
|
102
|
+
expect(mockedPersonalAccessKeyPrompt).not.toHaveBeenCalled();
|
|
103
|
+
expect(mockedGetAccessToken).toHaveBeenCalledWith('test-key', 'prod');
|
|
104
|
+
});
|
|
105
|
+
it('should prompt for personal access key if not provided', async () => {
|
|
106
|
+
await authenticateNewAccount({
|
|
107
|
+
env: 'prod',
|
|
108
|
+
accountId: 123456,
|
|
109
|
+
});
|
|
110
|
+
expect(mockedPersonalAccessKeyPrompt).toHaveBeenCalledWith({
|
|
111
|
+
env: 'prod',
|
|
112
|
+
account: 123456,
|
|
113
|
+
});
|
|
114
|
+
expect(mockedGetAccessToken).toHaveBeenCalledWith('test-key', 'prod');
|
|
115
|
+
});
|
|
116
|
+
it('should prompt for account name if config does not exist', async () => {
|
|
117
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
118
|
+
await authenticateNewAccount({
|
|
119
|
+
env: 'prod',
|
|
120
|
+
providedPersonalAccessKey: 'test-key',
|
|
121
|
+
accountId: 123456,
|
|
122
|
+
});
|
|
123
|
+
expect(mockedCliAccountNamePrompt).toHaveBeenCalledWith('test-hub');
|
|
124
|
+
});
|
|
125
|
+
it('should not prompt for account name if config already exists', async () => {
|
|
126
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
127
|
+
await authenticateNewAccount({
|
|
128
|
+
env: 'prod',
|
|
129
|
+
providedPersonalAccessKey: 'test-key',
|
|
130
|
+
accountId: 123456,
|
|
131
|
+
});
|
|
132
|
+
expect(mockedCliAccountNamePrompt).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it('should set account as default when setAsDefaultAccount is true', async () => {
|
|
135
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
136
|
+
await authenticateNewAccount({
|
|
137
|
+
env: 'prod',
|
|
138
|
+
providedPersonalAccessKey: 'test-key',
|
|
139
|
+
accountId: 123456,
|
|
140
|
+
setAsDefaultAccount: true,
|
|
141
|
+
});
|
|
142
|
+
expect(mockedSetConfigAccountAsDefault).toHaveBeenCalledWith('test-hub');
|
|
143
|
+
});
|
|
144
|
+
it('should prompt to set as default when setAsDefaultAccount is not provided and config exists', async () => {
|
|
145
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
146
|
+
await authenticateNewAccount({
|
|
147
|
+
env: 'prod',
|
|
148
|
+
providedPersonalAccessKey: 'test-key',
|
|
149
|
+
accountId: 123456,
|
|
150
|
+
});
|
|
151
|
+
expect(mockedSetAsDefaultAccountPrompt).toHaveBeenCalledWith('test-hub');
|
|
152
|
+
});
|
|
153
|
+
it('should return the updated account config on success', async () => {
|
|
154
|
+
const result = await authenticateNewAccount({
|
|
155
|
+
env: 'prod',
|
|
156
|
+
providedPersonalAccessKey: 'test-key',
|
|
157
|
+
accountId: 123456,
|
|
158
|
+
});
|
|
159
|
+
expect(result).toEqual(mockAccountConfig);
|
|
160
|
+
});
|
|
161
|
+
it('should return null if access token fetch fails', async () => {
|
|
162
|
+
mockedGetAccessToken.mockRejectedValue(new Error('Invalid token'));
|
|
163
|
+
const result = await authenticateNewAccount({
|
|
164
|
+
env: 'prod',
|
|
165
|
+
providedPersonalAccessKey: 'test-key',
|
|
166
|
+
accountId: 123456,
|
|
167
|
+
});
|
|
168
|
+
expect(result).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
it('should return null if config update fails', async () => {
|
|
171
|
+
mockedUpdateConfigWithAccessToken.mockResolvedValue(null);
|
|
172
|
+
const result = await authenticateNewAccount({
|
|
173
|
+
env: 'prod',
|
|
174
|
+
providedPersonalAccessKey: 'test-key',
|
|
175
|
+
accountId: 123456,
|
|
176
|
+
});
|
|
177
|
+
expect(result).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
it('should handle missing account name and prompt for it', async () => {
|
|
180
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
181
|
+
mockedUpdateConfigWithAccessToken.mockResolvedValue({
|
|
182
|
+
...mockAccountConfig,
|
|
183
|
+
name: undefined,
|
|
184
|
+
});
|
|
185
|
+
mockedCliAccountNamePrompt.mockResolvedValue({
|
|
186
|
+
name: 'new-account-name',
|
|
187
|
+
});
|
|
188
|
+
await authenticateNewAccount({
|
|
189
|
+
env: 'prod',
|
|
190
|
+
providedPersonalAccessKey: 'test-key',
|
|
191
|
+
accountId: 123456,
|
|
192
|
+
});
|
|
193
|
+
expect(mockedCliAccountNamePrompt).toHaveBeenCalledWith('test-hub');
|
|
194
|
+
expect(mockedUpdateConfigAccount).toHaveBeenCalledWith(expect.objectContaining({
|
|
195
|
+
name: 'new-account-name',
|
|
196
|
+
}));
|
|
197
|
+
});
|
|
198
|
+
describe('config migration', () => {
|
|
199
|
+
it('should handle migration when local config exists and global does not', async () => {
|
|
200
|
+
mockedLocalConfigFileExists.mockReturnValue(true);
|
|
201
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
202
|
+
mockedHandleMigration.mockResolvedValue(true);
|
|
203
|
+
await authenticateNewAccount({
|
|
204
|
+
env: 'prod',
|
|
205
|
+
providedPersonalAccessKey: 'test-key',
|
|
206
|
+
accountId: 123456,
|
|
207
|
+
});
|
|
208
|
+
expect(mockedHandleMigration).toHaveBeenCalled();
|
|
209
|
+
expect(mockedHandleMerge).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
it('should handle merge when both local and global configs exist', async () => {
|
|
212
|
+
mockedLocalConfigFileExists.mockReturnValue(true);
|
|
213
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
214
|
+
mockedHandleMerge.mockResolvedValue(true);
|
|
215
|
+
await authenticateNewAccount({
|
|
216
|
+
env: 'prod',
|
|
217
|
+
providedPersonalAccessKey: 'test-key',
|
|
218
|
+
accountId: 123456,
|
|
219
|
+
});
|
|
220
|
+
expect(mockedHandleMerge).toHaveBeenCalled();
|
|
221
|
+
expect(mockedHandleMigration).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
it('should return null if migration is not confirmed', async () => {
|
|
224
|
+
mockedLocalConfigFileExists.mockReturnValue(true);
|
|
225
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
226
|
+
mockedHandleMigration.mockResolvedValue(false);
|
|
227
|
+
const result = await authenticateNewAccount({
|
|
228
|
+
env: 'prod',
|
|
229
|
+
providedPersonalAccessKey: 'test-key',
|
|
230
|
+
accountId: 123456,
|
|
231
|
+
});
|
|
232
|
+
expect(result).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
it('should return null if merge is not confirmed', async () => {
|
|
235
|
+
mockedLocalConfigFileExists.mockReturnValue(true);
|
|
236
|
+
mockedGlobalConfigFileExists.mockReturnValue(true);
|
|
237
|
+
mockedHandleMerge.mockResolvedValue(false);
|
|
238
|
+
const result = await authenticateNewAccount({
|
|
239
|
+
env: 'prod',
|
|
240
|
+
providedPersonalAccessKey: 'test-key',
|
|
241
|
+
accountId: 123456,
|
|
242
|
+
});
|
|
243
|
+
expect(result).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
it('should return null if migration throws error', async () => {
|
|
246
|
+
mockedLocalConfigFileExists.mockReturnValue(true);
|
|
247
|
+
mockedGlobalConfigFileExists.mockReturnValue(false);
|
|
248
|
+
mockedHandleMigration.mockRejectedValue(new Error('Migration failed'));
|
|
249
|
+
const result = await authenticateNewAccount({
|
|
250
|
+
env: 'prod',
|
|
251
|
+
providedPersonalAccessKey: 'test-key',
|
|
252
|
+
accountId: 123456,
|
|
253
|
+
});
|
|
254
|
+
expect(result).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import yargs from 'yargs';
|
|
2
|
+
import { addCommandSuggestions, commandSuggestionMappings, } from '../commandSuggestion.js';
|
|
3
|
+
import { uiLogger } from '../ui/logger.js';
|
|
4
|
+
import { uiCommandReference } from '../ui/index.js';
|
|
5
|
+
import { EXIT_CODES } from '../enums/exitCodes.js';
|
|
6
|
+
vi.mock('../ui/logger.js');
|
|
7
|
+
vi.mock('../ui/index.js');
|
|
8
|
+
const commandSpy = vi
|
|
9
|
+
.spyOn(yargs, 'command')
|
|
10
|
+
.mockReturnValue(yargs);
|
|
11
|
+
describe('lib/commandSuggestion', () => {
|
|
12
|
+
const uiLoggerErrorMock = uiLogger.error;
|
|
13
|
+
const uiCommandReferenceMock = uiCommandReference;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
commandSpy.mockClear();
|
|
17
|
+
uiCommandReferenceMock.mockImplementation((command) => `\`${command}\``);
|
|
18
|
+
});
|
|
19
|
+
describe('addCommandSuggestions', () => {
|
|
20
|
+
it('adds all command suggestions to yargs instance', () => {
|
|
21
|
+
addCommandSuggestions(yargs);
|
|
22
|
+
expect(commandSpy).toHaveBeenCalledTimes(Object.keys(commandSuggestionMappings).length);
|
|
23
|
+
});
|
|
24
|
+
it('registers each mapping from commandSuggestionMappings', () => {
|
|
25
|
+
addCommandSuggestions(yargs);
|
|
26
|
+
Object.entries(commandSuggestionMappings).forEach(([oldCommand]) => {
|
|
27
|
+
expect(commandSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
28
|
+
command: oldCommand,
|
|
29
|
+
}));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('returns the yargs instance', () => {
|
|
33
|
+
const yargsInstance = yargs;
|
|
34
|
+
const result = addCommandSuggestions(yargsInstance);
|
|
35
|
+
expect(result).toBe(yargsInstance);
|
|
36
|
+
});
|
|
37
|
+
it('creates command modules with handler that logs error and exits', () => {
|
|
38
|
+
vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
39
|
+
throw new Error(`process.exit called with ${EXIT_CODES.ERROR}`);
|
|
40
|
+
});
|
|
41
|
+
addCommandSuggestions(yargs);
|
|
42
|
+
// Get the first registered command module
|
|
43
|
+
const firstCall = commandSpy.mock.calls[0];
|
|
44
|
+
const commandModule = firstCall[0];
|
|
45
|
+
const firstMapping = Object.entries(commandSuggestionMappings)[0];
|
|
46
|
+
const [, newCommand] = firstMapping;
|
|
47
|
+
// Verify the command module structure
|
|
48
|
+
expect(commandModule).toHaveProperty('command');
|
|
49
|
+
expect(commandModule).toHaveProperty('handler');
|
|
50
|
+
expect(commandModule).toHaveProperty('builder');
|
|
51
|
+
// Invoke the handler to verify behavior
|
|
52
|
+
expect(() => {
|
|
53
|
+
commandModule.handler({});
|
|
54
|
+
}).toThrow('process.exit called');
|
|
55
|
+
expect(uiCommandReferenceMock).toHaveBeenCalledWith(newCommand);
|
|
56
|
+
expect(uiLoggerErrorMock).toHaveBeenCalledWith(`Did you mean \`${newCommand}\`?`);
|
|
57
|
+
expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR);
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
it('creates command modules with builder that sets strict mode to false', async () => {
|
|
61
|
+
const yargsInstance = yargs;
|
|
62
|
+
const commandSpyForTest = vi
|
|
63
|
+
.spyOn(yargsInstance, 'command')
|
|
64
|
+
.mockReturnValue(yargsInstance);
|
|
65
|
+
addCommandSuggestions(yargsInstance);
|
|
66
|
+
// Get a command module and verify it has a builder
|
|
67
|
+
const firstCall = commandSpyForTest.mock.calls[0];
|
|
68
|
+
const commandModule = firstCall[0];
|
|
69
|
+
expect(commandModule).toHaveProperty('builder');
|
|
70
|
+
expect(typeof commandModule.builder).toBe('function');
|
|
71
|
+
// Create a mock yargs builder with strict method
|
|
72
|
+
const mockYargsBuilder = {
|
|
73
|
+
strict: vi.fn().mockReturnThis(),
|
|
74
|
+
};
|
|
75
|
+
await commandModule.builder(mockYargsBuilder);
|
|
76
|
+
expect(mockYargsBuilder.strict).toHaveBeenCalledWith(false);
|
|
77
|
+
commandSpyForTest.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
it('handles commands with multiple words', () => {
|
|
80
|
+
vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
81
|
+
throw new Error(`process.exit called with ${EXIT_CODES.ERROR}`);
|
|
82
|
+
});
|
|
83
|
+
// Find a multi-word command
|
|
84
|
+
const multiWordCommand = Object.entries(commandSuggestionMappings).find(([oldCommand]) => oldCommand.includes(' '));
|
|
85
|
+
expect(multiWordCommand).toBeDefined();
|
|
86
|
+
if (multiWordCommand) {
|
|
87
|
+
const [oldCommand, newCommand] = multiWordCommand;
|
|
88
|
+
const yargsInstance = yargs;
|
|
89
|
+
const commandSpyForTest = vi
|
|
90
|
+
.spyOn(yargsInstance, 'command')
|
|
91
|
+
.mockReturnValue(yargsInstance);
|
|
92
|
+
addCommandSuggestions(yargsInstance);
|
|
93
|
+
// Find the call for this multi-word command
|
|
94
|
+
const call = commandSpyForTest.mock.calls.find(call => call[0]
|
|
95
|
+
.command === oldCommand);
|
|
96
|
+
expect(call).toBeDefined();
|
|
97
|
+
const commandModule = call[0];
|
|
98
|
+
expect(commandModule).toHaveProperty('handler');
|
|
99
|
+
// Invoke handler to verify it works with multi-word commands
|
|
100
|
+
expect(() => {
|
|
101
|
+
commandModule.handler({});
|
|
102
|
+
}).toThrow('process.exit called');
|
|
103
|
+
expect(uiCommandReferenceMock).toHaveBeenCalledWith(newCommand);
|
|
104
|
+
expect(uiLoggerErrorMock).toHaveBeenCalledWith(`Did you mean \`${newCommand}\`?`);
|
|
105
|
+
commandSpyForTest.mockRestore();
|
|
106
|
+
}
|
|
107
|
+
vi.restoreAllMocks();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('commandSuggestionMappings', () => {
|
|
111
|
+
it('all mappings point to valid new commands', () => {
|
|
112
|
+
Object.entries(commandSuggestionMappings).forEach(([, newCommand]) => {
|
|
113
|
+
expect(newCommand).toMatch(/^hs /);
|
|
114
|
+
expect(typeof newCommand).toBe('string');
|
|
115
|
+
expect(newCommand.length).toBeGreaterThan(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Environment } from '@hubspot/local-dev-lib/types/Accounts';
|
|
2
|
+
import { HubSpotConfigAccount } from '@hubspot/local-dev-lib/types/Accounts';
|
|
3
|
+
type AuthenticateNewAccountOptions = {
|
|
4
|
+
env: Environment;
|
|
5
|
+
providedPersonalAccessKey?: string;
|
|
6
|
+
accountId?: number;
|
|
7
|
+
setAsDefaultAccount?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function authenticateNewAccount({ env, providedPersonalAccessKey, accountId, setAsDefaultAccount, }: AuthenticateNewAccountOptions): Promise<HubSpotConfigAccount | null>;
|
|
10
|
+
export {};
|