@hubspot/cli 8.0.0 → 8.0.2-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/getStarted.d.ts +2 -1
- package/commands/getStarted.js +38 -15
- package/lang/en.d.ts +31 -5
- package/lang/en.js +33 -7
- 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__/commandSuggestion.test.d.ts +1 -0
- package/lib/__tests__/commandSuggestion.test.js +119 -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/getStarted/getStartedV2.d.ts +7 -0
- package/lib/getStarted/getStartedV2.js +56 -0
- package/lib/getStartedV2Actions.d.ts +8 -0
- package/lib/getStartedV2Actions.js +51 -0
- package/lib/mcp/setup.js +48 -54
- package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +43 -175
- package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -7
- package/lib/projects/localDev/LocalDevWebsocketServer.js +51 -98
- package/lib/projects/localDev/localDevWebsocketServerUtils.d.ts +8 -7
- package/lib/projects/platformVersion.js +1 -1
- package/package.json +9 -4
- 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/GetStartedFlow.d.ts +24 -0
- package/ui/components/GetStartedFlow.js +128 -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/constants.d.ts +6 -0
- package/ui/constants.js +6 -0
- package/ui/playground/fixtures.js +47 -0
- package/ui/render.d.ts +4 -0
- package/ui/render.js +25 -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,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,45 @@
|
|
|
1
|
+
import { uiLogger } from './ui/logger.js';
|
|
2
|
+
import { uiCommandReference } from './ui/index.js';
|
|
3
|
+
import { EXIT_CODES } from './enums/exitCodes.js';
|
|
4
|
+
export const commandSuggestionMappings = {
|
|
5
|
+
create: 'hs cms app|theme|module|webpack|function|template create',
|
|
6
|
+
fetch: 'hs cms fetch',
|
|
7
|
+
lint: 'hs cms lint',
|
|
8
|
+
list: 'hs cms list',
|
|
9
|
+
mv: 'hs cms mv',
|
|
10
|
+
remove: 'hs cms delete',
|
|
11
|
+
upload: 'hs cms upload',
|
|
12
|
+
watch: 'hs cms watch',
|
|
13
|
+
'function deploy': 'hs cms function deploy',
|
|
14
|
+
'function list': 'hs cms function list',
|
|
15
|
+
'function server': 'hs cms function server',
|
|
16
|
+
logs: 'hs cms function logs',
|
|
17
|
+
'module marketplace-validate': 'hs cms module marketplace-validate',
|
|
18
|
+
'theme generate-selectors': 'hs cms theme generate-selectors',
|
|
19
|
+
'theme marketplace-validate': 'hs cms theme marketplace-validate',
|
|
20
|
+
'theme preview': 'hs cms theme preview',
|
|
21
|
+
'custom-object schema create': 'hs custom-object create-schema',
|
|
22
|
+
'custom-object schema delete': 'hs custom-object delete-schema',
|
|
23
|
+
'custom-object schema fetch-all': 'hs custom-object fetch-all-schemas',
|
|
24
|
+
'custom-object schema fetch': 'hs custom-object fetch-schema',
|
|
25
|
+
'custom-object schema list': 'hs custom-object list-schemas',
|
|
26
|
+
'custom-object schema update': 'hs custom-object update-schema',
|
|
27
|
+
};
|
|
28
|
+
function createCommandSuggestionHandler(newCommand) {
|
|
29
|
+
return () => {
|
|
30
|
+
uiLogger.error(`Did you mean ${uiCommandReference(newCommand)}?`);
|
|
31
|
+
process.exit(EXIT_CODES.ERROR);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function createCommandSuggestion(oldCommand, newCommand) {
|
|
35
|
+
return {
|
|
36
|
+
command: oldCommand,
|
|
37
|
+
builder: async (yargs) => {
|
|
38
|
+
return yargs.strict(false);
|
|
39
|
+
},
|
|
40
|
+
handler: createCommandSuggestionHandler(newCommand),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function addCommandSuggestions(yargsInstance) {
|
|
44
|
+
return Object.entries(commandSuggestionMappings).reduce((yargs, [oldCommand, newCommand]) => yargs.command(createCommandSuggestion(oldCommand, newCommand)), yargsInstance);
|
|
45
|
+
}
|
package/lib/constants.d.ts
CHANGED
|
@@ -94,7 +94,6 @@ export declare const LOCAL_DEV_UI_MESSAGE_SEND_TYPES: {
|
|
|
94
94
|
UPDATE_APP_DATA: string;
|
|
95
95
|
UPDATE_PROJECT_DATA: string;
|
|
96
96
|
UPDATE_UPLOAD_WARNINGS: string;
|
|
97
|
-
CLI_METADATA: string;
|
|
98
97
|
DEV_SERVERS_STARTED: string;
|
|
99
98
|
};
|
|
100
99
|
export declare const LOCAL_DEV_UI_MESSAGE_RECEIVE_TYPES: {
|
package/lib/constants.js
CHANGED
|
@@ -86,7 +86,6 @@ export const LOCAL_DEV_UI_MESSAGE_SEND_TYPES = {
|
|
|
86
86
|
UPDATE_APP_DATA: 'server:updateAppData',
|
|
87
87
|
UPDATE_PROJECT_DATA: 'server:updateProjectData',
|
|
88
88
|
UPDATE_UPLOAD_WARNINGS: 'server:updateUploadWarnings',
|
|
89
|
-
CLI_METADATA: 'server:cliMetadata',
|
|
90
89
|
DEV_SERVERS_STARTED: 'server:devServersStarted',
|
|
91
90
|
};
|
|
92
91
|
export const LOCAL_DEV_UI_MESSAGE_RECEIVE_TYPES = {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LOG_LEVEL, setLogLevel } from '@hubspot/local-dev-lib/logger';
|
|
2
|
+
import { createProjectAction } from '../getStartedV2Actions.js';
|
|
3
|
+
import { trackCommandMetadataUsage } from '../usageTracking.js';
|
|
4
|
+
import { renderInteractive } from '../../ui/render.js';
|
|
5
|
+
import { getGetStartedFlow } from '../../ui/components/GetStartedFlow.js';
|
|
6
|
+
import { getErrorMessage } from '../errorHandlers/index.js';
|
|
7
|
+
export async function runGetStartedV2({ derivedAccountId, name, dest, }) {
|
|
8
|
+
setLogLevel(LOG_LEVEL.NONE);
|
|
9
|
+
const onRunCreateProject = async (args) => {
|
|
10
|
+
try {
|
|
11
|
+
const result = await createProjectAction(args);
|
|
12
|
+
await trackCommandMetadataUsage('get-started', {
|
|
13
|
+
successful: true,
|
|
14
|
+
step: 'github-clone',
|
|
15
|
+
}, derivedAccountId);
|
|
16
|
+
await trackCommandMetadataUsage('get-started', {
|
|
17
|
+
successful: true,
|
|
18
|
+
step: 'project-creation',
|
|
19
|
+
}, derivedAccountId);
|
|
20
|
+
return {
|
|
21
|
+
projectName: result.projectName,
|
|
22
|
+
projectDest: result.projectDest,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const errorMessage = getErrorMessage(error);
|
|
27
|
+
// Check if this is a nested project error (happens before cloning)
|
|
28
|
+
const isNestedProjectError = errorMessage.includes('Projects cannot be nested within other projects');
|
|
29
|
+
if (isNestedProjectError) {
|
|
30
|
+
await trackCommandMetadataUsage('get-started', {
|
|
31
|
+
successful: false,
|
|
32
|
+
step: 'project-creation',
|
|
33
|
+
}, derivedAccountId);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Clone or other error (failedToDownloadProject message)
|
|
37
|
+
await trackCommandMetadataUsage('get-started', {
|
|
38
|
+
successful: false,
|
|
39
|
+
step: 'github-clone',
|
|
40
|
+
}, derivedAccountId);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
await renderInteractive(getGetStartedFlow({
|
|
47
|
+
derivedAccountId,
|
|
48
|
+
onRunCreateProject,
|
|
49
|
+
initialName: name,
|
|
50
|
+
initialDest: dest,
|
|
51
|
+
}), { fullScreen: true });
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
setLogLevel(LOG_LEVEL.LOG);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getCwd } from '@hubspot/local-dev-lib/path';
|
|
4
|
+
import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
|
|
5
|
+
import { commands, lib } from '../lang/en.js';
|
|
6
|
+
import { HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, PROJECT_CONFIG_FILE, } from '../lib/constants.js';
|
|
7
|
+
import { getProjectConfig, writeProjectConfig, } from '../lib/projects/config.js';
|
|
8
|
+
import { validateProjectDirectory } from '../lib/prompts/projectNameAndDestPrompt.js';
|
|
9
|
+
import SpinniesManager from '../lib/ui/SpinniesManager.js';
|
|
10
|
+
export async function createProjectAction({ projectName, projectDest, }) {
|
|
11
|
+
// Validate project name
|
|
12
|
+
if (!projectName || projectName.trim() === '') {
|
|
13
|
+
throw new Error(lib.prompts.projectNameAndDestPrompt.errors.nameRequired);
|
|
14
|
+
}
|
|
15
|
+
// Validate destination path
|
|
16
|
+
const validationResult = validateProjectDirectory(projectDest);
|
|
17
|
+
if (validationResult !== true) {
|
|
18
|
+
throw new Error(typeof validationResult === 'string'
|
|
19
|
+
? validationResult
|
|
20
|
+
: 'Invalid project directory');
|
|
21
|
+
}
|
|
22
|
+
const projectDestAbsolute = path.resolve(getCwd(), projectDest);
|
|
23
|
+
const { projectConfig: existingProjectConfig, projectDir: existingProjectDir, } = await getProjectConfig(projectDestAbsolute);
|
|
24
|
+
if (existingProjectConfig &&
|
|
25
|
+
existingProjectDir &&
|
|
26
|
+
projectDestAbsolute.startsWith(existingProjectDir)) {
|
|
27
|
+
throw new Error(commands.project.create.errors.cannotNestProjects(existingProjectDir));
|
|
28
|
+
}
|
|
29
|
+
SpinniesManager.setDisableOutput(true);
|
|
30
|
+
try {
|
|
31
|
+
await cloneGithubRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, projectDestAbsolute, {
|
|
32
|
+
sourceDir: '2025.2/private-app-get-started-template',
|
|
33
|
+
hideLogs: true,
|
|
34
|
+
});
|
|
35
|
+
const projectConfigPath = path.join(projectDestAbsolute, PROJECT_CONFIG_FILE);
|
|
36
|
+
const parsedConfigFile = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
|
|
37
|
+
writeProjectConfig(projectConfigPath, {
|
|
38
|
+
...parsedConfigFile,
|
|
39
|
+
name: projectName,
|
|
40
|
+
});
|
|
41
|
+
return { projectName, projectDest: projectDestAbsolute };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new Error(commands.project.create.errors.failedToDownloadProject, {
|
|
45
|
+
cause: error,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
SpinniesManager.setDisableOutput(false);
|
|
50
|
+
}
|
|
51
|
+
}
|