@hubspot/cli 4.1.8-beta.1 → 4.1.8-beta.2

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.
@@ -0,0 +1,526 @@
1
+ const chokidar = require('chokidar');
2
+ const path = require('path');
3
+ const { default: PQueue } = require('p-queue');
4
+ const { i18n } = require('@hubspot/cli-lib/lib/lang');
5
+ const { logger } = require('@hubspot/cli-lib/logger');
6
+ const {
7
+ isSpecifiedError,
8
+ } = require('@hubspot/cli-lib/errorHandlers/apiErrors');
9
+ const { handleKeypress } = require('@hubspot/cli-lib/lib/process');
10
+ const {
11
+ logApiErrorInstance,
12
+ ApiErrorContext,
13
+ } = require('@hubspot/cli-lib/errorHandlers');
14
+ const { ERROR_TYPES } = require('@hubspot/cli-lib/lib/constants');
15
+ const { isAllowedExtension } = require('@hubspot/cli-lib/path');
16
+ const { shouldIgnoreFile } = require('@hubspot/cli-lib/ignoreRules');
17
+ const {
18
+ cancelStagedBuild,
19
+ uploadFileToBuild,
20
+ deleteFileFromBuild,
21
+ provisionBuild,
22
+ queueBuild,
23
+ } = require('@hubspot/cli-lib/api/dfs');
24
+ const SpinniesManager = require('./SpinniesManager');
25
+ const { EXIT_CODES } = require('./enums/exitCodes');
26
+ const {
27
+ getProjectDetailUrl,
28
+ pollProjectBuildAndDeploy,
29
+ } = require('./projects');
30
+ const { uiAccountDescription, uiLink } = require('./ui');
31
+
32
+ const i18nKey = 'cli.lib.LocalDevManager';
33
+
34
+ const BUILD_DEBOUNCE_TIME = 2000;
35
+
36
+ const WATCH_EVENTS = {
37
+ add: 'add',
38
+ change: 'change',
39
+ unlink: 'unlink',
40
+ unlinkDir: 'unlinkDir',
41
+ };
42
+
43
+ const UPLOAD_PERMISSIONS = {
44
+ always: 'always',
45
+ manual: 'manual',
46
+ never: 'never',
47
+ };
48
+ class LocalDevManager {
49
+ constructor(options) {
50
+ this.targetAccountId = options.targetAccountId;
51
+ this.projectConfig = options.projectConfig;
52
+ this.projectDir = options.projectDir;
53
+ this.uploadPermission =
54
+ options.uploadPermission || UPLOAD_PERMISSIONS.always;
55
+ this.debug = options.debug || false;
56
+ this.mockServers = options.mockServers || false;
57
+ this.projectSourceDir = path.join(
58
+ this.projectDir,
59
+ this.projectConfig.srcDir
60
+ );
61
+ this.spinnies = null;
62
+ this.watcher = null;
63
+ this.uploadQueue = null;
64
+ this.standbyChanges = [];
65
+ this.debouncedBuild = null;
66
+ this.currentStagedBuildId = null;
67
+
68
+ if (!this.targetAccountId || !this.projectConfig || !this.projectDir) {
69
+ process.exit(EXIT_CODES.ERROR);
70
+ }
71
+ }
72
+
73
+ async start() {
74
+ this.spinnies = SpinniesManager.init();
75
+
76
+ this.watcher = chokidar.watch(this.projectSourceDir, {
77
+ ignoreInitial: true,
78
+ ignored: file => shouldIgnoreFile(file),
79
+ });
80
+
81
+ this.uploadQueue = new PQueue({ concurrency: 10 });
82
+
83
+ if (this.debug) {
84
+ this.uploadQueue.on('error', error => {
85
+ logger.debug(error);
86
+ });
87
+ }
88
+
89
+ console.clear();
90
+
91
+ this.uploadQueue.start();
92
+
93
+ this.logConsoleHeader();
94
+ await this.startServers();
95
+ await this.startWatching();
96
+ this.updateKeypressListeners();
97
+ }
98
+
99
+ async stop() {
100
+ this.clearConsoleContent();
101
+
102
+ this.spinnies.add('cleanupMessage', {
103
+ text: i18n(`${i18nKey}.exitingStart`),
104
+ });
105
+
106
+ await this.stopWatching();
107
+
108
+ await this.cleanupServers();
109
+
110
+ let exitCode = EXIT_CODES.SUCCESS;
111
+
112
+ if (this.currentStagedBuildId) {
113
+ try {
114
+ await cancelStagedBuild(this.targetAccountId, this.projectConfig.name);
115
+ } catch (err) {
116
+ if (
117
+ !isSpecifiedError(err, {
118
+ subCategory: ERROR_TYPES.BUILD_NOT_IN_PROGRESS,
119
+ })
120
+ ) {
121
+ logApiErrorInstance(
122
+ err,
123
+ new ApiErrorContext({
124
+ accountId: this.targetAccountId,
125
+ projectName: this.projectConfig.name,
126
+ })
127
+ );
128
+ exitCode = EXIT_CODES.ERROR;
129
+ }
130
+ }
131
+ }
132
+
133
+ if (exitCode === EXIT_CODES.SUCCESS) {
134
+ this.spinnies.succeed('cleanupMessage', {
135
+ text: i18n(`${i18nKey}.exitingSucceed`),
136
+ });
137
+ } else {
138
+ this.spinnies.fail('cleanupMessage', {
139
+ text: i18n(`${i18nKey}.exitingFail`),
140
+ });
141
+ }
142
+
143
+ process.exit(exitCode);
144
+ }
145
+
146
+ logConsoleHeader() {
147
+ this.spinnies.removeAll();
148
+ this.spinnies.add('devModeRunning', {
149
+ text: i18n(`${i18nKey}.running`),
150
+ isParent: true,
151
+ category: 'header',
152
+ });
153
+ this.spinnies.add('devModeStatus', {
154
+ text: i18n(`${i18nKey}.status.clean`),
155
+ status: 'non-spinnable',
156
+ indent: 1,
157
+ category: 'header',
158
+ });
159
+ const projectDetailUrl = getProjectDetailUrl(
160
+ this.projectConfig.name,
161
+ this.targetAccountId
162
+ );
163
+ this.spinnies.add('viewInHubSpotLink', {
164
+ text: uiLink(i18n(`${i18nKey}.viewInHubSpot`), projectDetailUrl, {
165
+ inSpinnies: true,
166
+ }),
167
+ status: 'non-spinnable',
168
+ indent: 1,
169
+ category: 'header',
170
+ });
171
+ this.spinnies.add('spacer-1', {
172
+ text: ' ',
173
+ status: 'non-spinnable',
174
+ category: 'header',
175
+ });
176
+ this.spinnies.add('keypressMessage', {
177
+ text: i18n(`${i18nKey}.quitHelper`),
178
+ status: 'non-spinnable',
179
+ indent: 1,
180
+ category: 'header',
181
+ });
182
+ this.spinnies.add('lineSeparator', {
183
+ text: '-'.repeat(50),
184
+ status: 'non-spinnable',
185
+ noIndent: true,
186
+ category: 'header',
187
+ });
188
+ }
189
+
190
+ clearConsoleContent() {
191
+ this.spinnies.removeAll({ preserveCategory: 'header' });
192
+ }
193
+
194
+ updateKeypressListeners() {
195
+ handleKeypress(async key => {
196
+ if ((key.ctrl && key.name === 'c') || key.name === 'q') {
197
+ this.stop();
198
+ } else if (key.name === 'y') {
199
+ if (
200
+ this.uploadPermission === UPLOAD_PERMISSIONS.manual &&
201
+ this.hasAnyUnsupportedStandbyChanges()
202
+ ) {
203
+ this.clearConsoleContent();
204
+ this.updateDevModeStatus('manualUpload');
205
+ await this.createNewStagingBuild();
206
+ await this.flushStandbyChanges();
207
+ await this.queueBuild();
208
+ }
209
+ } else if (key.name === 'n') {
210
+ if (
211
+ this.uploadPermission === UPLOAD_PERMISSIONS.manual &&
212
+ this.hasAnyUnsupportedStandbyChanges()
213
+ ) {
214
+ this.clearConsoleContent();
215
+ this.spinnies.add('manualUploadSkipped', {
216
+ text: i18n(`${i18nKey}.upload.manualUploadSkipped`),
217
+ status: 'fail',
218
+ failColor: 'white',
219
+ noIndent: true,
220
+ });
221
+ }
222
+ }
223
+ });
224
+ }
225
+
226
+ updateDevModeStatus(langKey) {
227
+ this.spinnies.update('devModeStatus', {
228
+ text: i18n(`${i18nKey}.status.${langKey}`),
229
+ status: 'non-spinnable',
230
+ noIndent: true,
231
+ });
232
+ }
233
+
234
+ async pauseUploadQueue() {
235
+ this.spinnies.add('uploading', {
236
+ text: i18n(`${i18nKey}.upload.uploadingChanges`, {
237
+ accountIdentifier: uiAccountDescription(this.targetAccountId),
238
+ }),
239
+ noIndent: true,
240
+ });
241
+
242
+ this.uploadQueue.pause();
243
+ await this.uploadQueue.onIdle();
244
+ }
245
+
246
+ hasAnyUnsupportedStandbyChanges() {
247
+ return this.standbyChanges.some(({ supported }) => !supported);
248
+ }
249
+
250
+ async createNewStagingBuild() {
251
+ try {
252
+ const { buildId } = await provisionBuild(
253
+ this.targetAccountId,
254
+ this.projectConfig.name
255
+ );
256
+ this.currentStagedBuildId = buildId;
257
+ } catch (err) {
258
+ logger.debug(err);
259
+ if (isSpecifiedError(err, { subCategory: ERROR_TYPES.PROJECT_LOCKED })) {
260
+ await cancelStagedBuild(this.targetAccountId, this.projectConfig.name);
261
+ logger.log(i18n(`${i18nKey}.previousStagingBuildCancelled`));
262
+ }
263
+ process.exit(EXIT_CODES.ERROR);
264
+ }
265
+ }
266
+
267
+ async startWatching() {
268
+ if (this.uploadPermission === UPLOAD_PERMISSIONS.always) {
269
+ await this.createNewStagingBuild();
270
+ }
271
+
272
+ this.watcher.on('add', async filePath => {
273
+ this.handleWatchEvent(filePath, WATCH_EVENTS.add);
274
+ });
275
+ this.watcher.on('change', async filePath => {
276
+ this.handleWatchEvent(filePath, WATCH_EVENTS.change);
277
+ });
278
+ this.watcher.on('unlink', async filePath => {
279
+ this.handleWatchEvent(filePath, WATCH_EVENTS.unlink);
280
+ });
281
+ this.watcher.on('unlinkDir', async filePath => {
282
+ this.handleWatchEvent(filePath, WATCH_EVENTS.unlinkDir);
283
+ });
284
+ }
285
+
286
+ async handleWatchEvent(filePath, event) {
287
+ const changeInfo = {
288
+ event,
289
+ filePath,
290
+ remotePath: path.relative(this.projectSourceDir, filePath),
291
+ };
292
+
293
+ const isSupportedChange = await this.notifyServers(changeInfo);
294
+
295
+ if (isSupportedChange) {
296
+ this.updateDevModeStatus('supportedChange');
297
+ this.addChangeToStandbyQueue({ ...changeInfo, supported: true });
298
+ return;
299
+ }
300
+
301
+ if (this.uploadPermission !== UPLOAD_PERMISSIONS.always) {
302
+ this.handlePreventedUpload(changeInfo);
303
+ return;
304
+ }
305
+
306
+ if (this.uploadQueue.isPaused) {
307
+ if (
308
+ !this.standbyChanges.find(
309
+ changeInfo => changeInfo.filePath === filePath
310
+ )
311
+ ) {
312
+ this.addChangeToStandbyQueue({ ...changeInfo, supported: false });
313
+ }
314
+ } else {
315
+ await this.flushStandbyChanges();
316
+
317
+ if (!this.uploadQueue.isPaused) {
318
+ this.debounceQueueBuild();
319
+ }
320
+
321
+ return this.uploadQueue.add(async () => {
322
+ await this.sendChanges(changeInfo);
323
+ });
324
+ }
325
+ }
326
+
327
+ handlePreventedUpload(changeInfo) {
328
+ const { remotePath } = changeInfo;
329
+
330
+ this.clearConsoleContent();
331
+ if (this.uploadPermission === UPLOAD_PERMISSIONS.never) {
332
+ this.updateDevModeStatus('noUploadsAllowed');
333
+
334
+ this.spinnies.add('noUploadsAllowed', {
335
+ text: i18n(`${i18nKey}.upload.noUploadsAllowed`, {
336
+ filePath: remotePath,
337
+ }),
338
+ status: 'fail',
339
+ failColor: 'white',
340
+ noIndent: true,
341
+ });
342
+ } else {
343
+ this.updateDevModeStatus('manualUploadRequired');
344
+
345
+ this.addChangeToStandbyQueue({ ...changeInfo, supported: false });
346
+
347
+ this.spinnies.add('manualUploadRequired', {
348
+ text: i18n(`${i18nKey}.upload.manualUploadRequired`),
349
+ status: 'fail',
350
+ failColor: 'white',
351
+ noIndent: true,
352
+ });
353
+ this.spinnies.add('manualUploadExplanation1', {
354
+ text: i18n(`${i18nKey}.upload.manualUploadExplanation1`),
355
+ status: 'non-spinnable',
356
+ indent: 1,
357
+ });
358
+ this.spinnies.add('manualUploadExplanation2', {
359
+ text: i18n(`${i18nKey}.upload.manualUploadExplanation2`),
360
+ status: 'non-spinnable',
361
+ indent: 1,
362
+ });
363
+ this.spinnies.add('manualUploadPrompt', {
364
+ text: i18n(`${i18nKey}.upload.manualUploadPrompt`),
365
+ status: 'non-spinnable',
366
+ indent: 1,
367
+ });
368
+ }
369
+ }
370
+
371
+ addChangeToStandbyQueue(changeInfo) {
372
+ const { event, filePath } = changeInfo;
373
+
374
+ if (event === WATCH_EVENTS.add || event === WATCH_EVENTS.change) {
375
+ if (!isAllowedExtension(filePath)) {
376
+ logger.debug(`Extension not allowed: ${filePath}`);
377
+ return;
378
+ }
379
+ }
380
+ if (shouldIgnoreFile(filePath, true)) {
381
+ logger.debug(`File ignored: ${filePath}`);
382
+ return;
383
+ }
384
+ this.standbyChanges.push(changeInfo);
385
+ }
386
+
387
+ async sendChanges(changeInfo) {
388
+ const { event, filePath, remotePath } = changeInfo;
389
+
390
+ this.spinnies.add(filePath, {
391
+ text: i18n(`${i18nKey}.upload.uploadingChange`, {
392
+ filePath: remotePath,
393
+ }),
394
+ status: 'non-spinnable',
395
+ });
396
+ try {
397
+ if (event === WATCH_EVENTS.add || event === WATCH_EVENTS.change) {
398
+ await uploadFileToBuild(
399
+ this.targetAccountId,
400
+ this.projectConfig.name,
401
+ filePath,
402
+ remotePath
403
+ );
404
+ } else if (
405
+ event === WATCH_EVENTS.unlink ||
406
+ event === WATCH_EVENTS.unlinkDir
407
+ ) {
408
+ await deleteFileFromBuild(
409
+ this.targetAccountId,
410
+ this.projectConfig.name,
411
+ remotePath
412
+ );
413
+ }
414
+ } catch (err) {
415
+ logger.debug(err);
416
+ }
417
+ }
418
+
419
+ debounceQueueBuild() {
420
+ if (this.uploadPermission === UPLOAD_PERMISSIONS.always) {
421
+ this.updateDevModeStatus('uploadPending');
422
+ }
423
+
424
+ if (this.debouncedBuild) {
425
+ clearTimeout(this.debouncedBuild);
426
+ }
427
+
428
+ this.debouncedBuild = setTimeout(
429
+ this.queueBuild.bind(this),
430
+ BUILD_DEBOUNCE_TIME
431
+ );
432
+ }
433
+
434
+ async queueBuild() {
435
+ await this.pauseUploadQueue();
436
+
437
+ try {
438
+ await queueBuild(this.targetAccountId, this.projectConfig.name);
439
+ } catch (err) {
440
+ logger.debug(err);
441
+ if (
442
+ isSpecifiedError(err, {
443
+ subCategory: ERROR_TYPES.MISSING_PROJECT_PROVISION,
444
+ })
445
+ ) {
446
+ logger.log(i18n(`${i18nKey}.cancelledFromUI`));
447
+ this.stop();
448
+ } else {
449
+ logApiErrorInstance(
450
+ err,
451
+ new ApiErrorContext({
452
+ accountId: this.targetAccountId,
453
+ projectName: this.projectConfig.name,
454
+ })
455
+ );
456
+ }
457
+ return;
458
+ }
459
+
460
+ await pollProjectBuildAndDeploy(
461
+ this.targetAccountId,
462
+ this.projectConfig,
463
+ null,
464
+ this.currentStagedBuildId,
465
+ true
466
+ );
467
+
468
+ if (this.uploadPermission === UPLOAD_PERMISSIONS.always) {
469
+ await this.createNewStagingBuild();
470
+ }
471
+
472
+ this.uploadQueue.start();
473
+ this.clearConsoleContent();
474
+
475
+ if (this.hasAnyUnsupportedStandbyChanges()) {
476
+ this.flushStandbyChanges();
477
+ } else {
478
+ this.updateDevModeStatus('clean');
479
+ }
480
+ }
481
+
482
+ async flushStandbyChanges() {
483
+ if (this.standbyChanges.length) {
484
+ await this.uploadQueue.addAll(
485
+ this.standbyChanges.map(changeInfo => {
486
+ return async () => {
487
+ if (
488
+ this.uploadPermission === UPLOAD_PERMISSIONS.always &&
489
+ !this.uploadQueue.isPaused
490
+ ) {
491
+ this.debounceQueueBuild();
492
+ }
493
+ await this.sendChanges(changeInfo);
494
+ };
495
+ })
496
+ );
497
+ this.standbyChanges = [];
498
+ }
499
+ }
500
+
501
+ async stopWatching() {
502
+ await this.watcher.close();
503
+ }
504
+
505
+ async startServers() {
506
+ // TODO spin up local dev servers
507
+ return true;
508
+ }
509
+
510
+ async notifyServers(changeInfo) {
511
+ const { remotePath } = changeInfo;
512
+
513
+ // TODO notify servers of the change
514
+ if (this.mockServers) {
515
+ return !remotePath.endsWith('app.json');
516
+ }
517
+ return false;
518
+ }
519
+
520
+ async cleanupServers() {
521
+ // TODO tell servers to cleanup
522
+ return;
523
+ }
524
+ }
525
+
526
+ module.exports = { LocalDevManager, UPLOAD_PERMISSIONS };
@@ -0,0 +1,96 @@
1
+ const Spinnies = require('spinnies');
2
+
3
+ // Allows us to maintain a single instance of spinnies across multiple files
4
+ class SpinniesManager {
5
+ constructor() {
6
+ this.spinnies = null;
7
+ this.parentKey = null;
8
+ this.categories = {};
9
+ }
10
+
11
+ init(options) {
12
+ if (!this.spinnies) {
13
+ this.spinnies = new Spinnies(options);
14
+ }
15
+
16
+ return {
17
+ add: this.add.bind(this),
18
+ pick: this.spinnies.pick.bind(this.spinnies),
19
+ remove: this.remove.bind(this),
20
+ removeAll: this.removeAll.bind(this),
21
+ update: this.spinnies.update.bind(this.spinnies),
22
+ succeed: this.spinnies.succeed.bind(this.spinnies),
23
+ fail: this.spinnies.fail.bind(this.spinnies),
24
+ stopAll: this.spinnies.stopAll.bind(this.spinnies),
25
+ hasActiveSpinners: this.spinnies.hasActiveSpinners.bind(this.spinnies),
26
+ };
27
+ }
28
+
29
+ addKeyToCategory(key, category) {
30
+ if (!this.categories[category]) {
31
+ this.categories[category] = [];
32
+ }
33
+ this.categories[category].push(key);
34
+ }
35
+
36
+ getCategoryForKey(key) {
37
+ return Object.keys(this.categories).find(category =>
38
+ this.categories[category].find(k => k === key)
39
+ );
40
+ }
41
+
42
+ removeKeyFromCategory(key) {
43
+ const category = this.getCategoryForKey(key);
44
+ if (category) {
45
+ const index = this.categories[category].indexOf(key);
46
+ this.categories[category].splice(index, 1);
47
+ }
48
+ }
49
+
50
+ add(key, options = {}) {
51
+ const { category, isParent, noIndent, ...rest } = options;
52
+ const originalIndent = rest.indent || 0;
53
+
54
+ if (category) {
55
+ this.addKeyToCategory(key, category);
56
+ }
57
+
58
+ this.spinnies.add(key, {
59
+ ...rest,
60
+ indent: this.parentKey && !noIndent ? originalIndent + 1 : originalIndent,
61
+ });
62
+
63
+ if (isParent) {
64
+ this.parentKey = key;
65
+ }
66
+ }
67
+
68
+ remove(key) {
69
+ if (this.spinnies) {
70
+ if (key === this.parentKey) {
71
+ this.parentKey = null;
72
+ }
73
+ this.removeKeyFromCategory(key);
74
+ this.spinnies.remove(key);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Removes all spinnies instances
80
+ * @param {string} preserveCategory - do not remove spinnies with a matching category
81
+ */
82
+ removeAll({ preserveCategory = null } = {}) {
83
+ if (this.spinnies) {
84
+ Object.keys(this.spinnies.spinners).forEach(key => {
85
+ if (
86
+ !preserveCategory ||
87
+ this.getCategoryForKey(key) !== preserveCategory
88
+ ) {
89
+ this.remove(key);
90
+ }
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = new SpinniesManager();