• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

box / boxcli / 23142057749

16 Mar 2026 11:46AM UTC coverage: 84.001% (-0.08%) from 84.083%
23142057749

push

github

web-flow
feat: Unify secure storage backend across platforms (#647)

Co-authored-by: Ɓukasz Socha <31014760+lukaszsocha2@users.noreply.github.com>

1464 of 2003 branches covered (73.09%)

Branch coverage included in aggregate %.

85 of 92 new or added lines in 3 files covered. (92.39%)

3 existing lines in 1 file now uncovered.

4978 of 5666 relevant lines covered (87.86%)

644.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.98
/src/box-command.js
1
'use strict';
2

3
const originalEmitWarning = process.emitWarning;
153✔
4
process.emitWarning = (warning, ...args) => {
153✔
5
        const message =
6
                typeof warning === 'string' ? warning : warning?.message || '';
7,965!
7

8
        if (message.includes('DEPRECATION WARNING')) {
7,965✔
9
                return;
7,875✔
10
        }
11
        // If not the BoxClient deprecation warning, call the original emitWarning function
12
        originalEmitWarning.call(process, warning, ...args);
90✔
13
};
14

15
const { Command, Flags } = require('@oclif/core');
153✔
16
const chalk = require('chalk');
153✔
17
const { promisify } = require('node:util');
153✔
18
const _ = require('lodash');
153✔
19
const fs = require('node:fs');
153✔
20
const { mkdirp } = require('mkdirp');
153✔
21
const os = require('node:os');
153✔
22
const path = require('node:path');
153✔
23
const yaml = require('js-yaml');
153✔
24
const csv = require('csv');
153✔
25
const csvParse = promisify(csv.parse);
153✔
26
const csvStringify = promisify(csv.stringify);
153✔
27
const dateTime = require('date-fns');
153✔
28
const BoxSDK = require('box-node-sdk').default;
153✔
29
const BoxTSSDK = require('box-node-sdk/sdk-gen');
153✔
30
const BoxTsErrors = require('box-node-sdk/sdk-gen/box/errors');
153✔
31
const BoxCLIError = require('./cli-error');
153✔
32
const CLITokenCache = require('./token-cache');
153✔
33
const utils = require('./util');
153✔
34
const pkg = require('../package.json');
153✔
35
const inquirer = require('./inquirer');
153✔
36
const { stringifyStream } = require('@discoveryjs/json-ext');
153✔
37
const progress = require('cli-progress');
153✔
38
const secureStorage = require('./secure-storage');
153✔
39

40
const DEBUG = require('./debug');
153✔
41
const stream = require('node:stream');
153✔
42
const pipeline = promisify(stream.pipeline);
153✔
43

44
const { Transform } = require('node:stream');
153✔
45

46
const KEY_MAPPINGS = {
153✔
47
        url: 'URL',
48
        id: 'ID',
49
        etag: 'ETag',
50
        sha1: 'SHA1',
51
        templateKey: 'Template Key',
52
        displayName: 'Display Name',
53
        tos: 'ToS',
54
        statusCode: 'Status Code',
55
        boxReportsFolderPath: 'Box Reports Folder Path',
56
        boxReportsFolderName: 'Box Reports Folder Name (Deprecated)',
57
        boxReportsFileFormat: 'Box Reports File Format',
58
        boxDownloadsFolderPath: 'Box Downloads Folder Path',
59
        boxDownloadsFolderName: 'Box Downloads Folder Name (Deprecated)',
60
        outputJson: 'Output JSON',
61
        clientId: 'Client ID',
62
        enterpriseId: 'Enterprise ID',
63
        boxConfigFilePath: 'Box Config File Path',
64
        hasInLinePrivateKey: 'Has Inline Private Key',
65
        privateKeyPath: 'Private Key Path',
66
        defaultAsUserId: 'Default As-User ID',
67
        useDefaultAsUser: 'Use Default As-User',
68
        cacheTokens: 'Cache Tokens',
69
        ip: 'IP',
70
        operationParams: 'Operation Params',
71
        copyInstanceOnItemCopy: 'Copy Instance On Item Copy',
72
};
73

74
const REQUIRED_FIELDS = ['type', 'id'];
153✔
75

76
const SDK_CONFIG = Object.freeze({
153✔
77
        iterators: true,
78
        analyticsClient: {
79
                version: pkg.version,
80
        },
81
        request: {
82
                headers: {
83
                        'User-Agent': `Box CLI v${pkg.version}`,
84
                },
85
        },
86
});
87

88
const CONFIG_FOLDER_PATH = path.join(os.homedir(), '.box');
153✔
89
const SETTINGS_FILE_PATH = path.join(CONFIG_FOLDER_PATH, 'settings.json');
153✔
90
const ENVIRONMENTS_FILE_PATH = path.join(
153✔
91
        CONFIG_FOLDER_PATH,
92
        'box_environments.json'
93
);
94
const ENVIRONMENTS_KEYCHAIN_SERVICE = 'boxcli';
153✔
95
const ENVIRONMENTS_KEYCHAIN_ACCOUNT = 'Box';
153✔
96

97
const DEFAULT_ANALYTICS_CLIENT_NAME = 'box-cli';
153✔
98

99
/**
100
 * Convert error objects to a stable debug-safe shape.
101
 *
102
 * @param {unknown} error A caught error object
103
 * @returns {Object} A reduced object for DEBUG logging
104
 */
105
function getDebugErrorDetails(error) {
NEW
106
        if (!error || typeof error !== 'object') {
×
NEW
107
                return { message: String(error) };
×
108
        }
NEW
109
        return {
×
110
                name: error.name || 'Error',
×
111
                code: error.code,
112
                message: error.message || String(error),
×
113
                stack: error.stack,
114
        };
115
}
116

117
/**
118
 * Parse a string value from CSV into the correct boolean value
119
 * @param {string|boolean} value The value to parse
120
 * @returns {boolean} The parsed value
121
 * @private
122
 */
123
function getBooleanFlagValue(value) {
124
        let trueValues = ['yes', 'y', 'true', '1', 't', 'on'];
1,593✔
125
        let falseValues = ['no', 'n', 'false', '0', 'f', 'off'];
1,593✔
126
        if (typeof value === 'boolean') {
1,593✔
127
                return value;
1,467✔
128
        } else if (trueValues.includes(value.toLowerCase())) {
126✔
129
                return true;
81✔
130
        } else if (falseValues.includes(value.toLowerCase())) {
45!
131
                return false;
45✔
132
        }
133
        let possibleValues = [...trueValues, ...falseValues].join(', ');
×
134
        throw new Error(
×
135
                `Incorrect boolean value "${value}" passed. Possible values are ${possibleValues}`
136
        );
137
}
138

139
/**
140
 * Removes all the undefined values from the object
141
 *
142
 * @param {Object} obj The object to format for display
143
 * @returns {Object} The formatted object output
144
 */
145
function removeUndefinedValues(obj) {
146
        if (typeof obj !== 'object' || obj === null) {
295,182✔
147
                return obj;
239,922✔
148
        }
149

150
        if (Array.isArray(obj)) {
55,260✔
151
                return obj.map((item) => removeUndefinedValues(item));
14,040✔
152
        }
153

154
        for (const key of Object.keys(obj)) {
48,402✔
155
                if (obj[key] === undefined) {
274,509✔
156
                        delete obj[key];
126✔
157
                } else {
158
                        obj[key] = removeUndefinedValues(obj[key]);
274,383✔
159
                }
160
        }
161

162
        return obj;
48,402✔
163
}
164

165
/**
166
 * Add or subtract a given offset from a date
167
 *
168
 * @param {Date} date The date to offset
169
 * @param {int} timeLength The number of time units to offset by
170
 * @param {string} timeUnit The unit of time to offset by, in single-character shorthand
171
 * @returns {Date} The date with offset applied
172
 */
173
function offsetDate(date, timeLength, timeUnit) {
174
        switch (timeUnit) {
234!
175
                case 's': {
176
                        return dateTime.addSeconds(date, timeLength);
36✔
177
                }
178
                case 'm': {
179
                        return dateTime.addMinutes(date, timeLength);
27✔
180
                }
181
                case 'h': {
182
                        return dateTime.addHours(date, timeLength);
36✔
183
                }
184
                case 'd': {
185
                        return dateTime.addDays(date, timeLength);
54✔
186
                }
187
                case 'w': {
188
                        return dateTime.addWeeks(date, timeLength);
27✔
189
                }
190
                case 'M': {
191
                        return dateTime.addMonths(date, timeLength);
27✔
192
                }
193
                case 'y': {
194
                        return dateTime.addYears(date, timeLength);
27✔
195
                }
196
                default: {
197
                        throw new Error(`Invalid time unit: ${timeUnit}`);
×
198
                }
199
        }
200
}
201

202
/**
203
 * Formats an API key (e.g. field name) for human-readable display
204
 *
205
 * @param {string} key The key to format
206
 * @returns {string} The formatted key
207
 * @private
208
 */
209
function formatKey(key) {
210
        // Converting camel case to snake case and then to title case
211
        return key
49,752✔
212
                .replaceAll(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`)
432✔
213
                .split('_')
214
                .map((s) => KEY_MAPPINGS[s] || _.capitalize(s))
67,266✔
215
                .join(' ');
216
}
217

218
/**
219
 * Formats an object's keys for human-readable output
220
 * @param {*} obj The thing to format
221
 * @returns {*} The formatted thing
222
 * @private
223
 */
224
function formatObjectKeys(obj) {
225
        // No need to process primitive values
226
        if (typeof obj !== 'object' || obj === null) {
53,217✔
227
                return obj;
42,912✔
228
        }
229

230
        // If type is Date, convert to ISO string
231
        if (obj instanceof Date) {
10,305✔
232
                return obj.toISOString();
36✔
233
        }
234

235
        // Don't format metadata objects to avoid mangling keys
236
        if (obj.$type) {
10,269✔
237
                return obj;
90✔
238
        }
239

240
        if (Array.isArray(obj)) {
10,179✔
241
                return obj.map((el) => formatObjectKeys(el));
1,323✔
242
        }
243

244
        let formattedObj = Object.create(null);
9,207✔
245
        for (const key of Object.keys(obj)) {
9,207✔
246
                let formattedKey = formatKey(key);
49,599✔
247
                formattedObj[formattedKey] = formatObjectKeys(obj[key]);
49,599✔
248
        }
249

250
        return formattedObj;
9,207✔
251
}
252

253
/**
254
 * Formats an object for output by prettifying its keys
255
 * and rendering it in a more human-readable form (i.e. YAML)
256
 *
257
 * @param {Object} obj The object to format for display
258
 * @returns {string} The formatted object output
259
 * @private
260
 */
261
function formatObject(obj) {
262
        let outputData = formatObjectKeys(obj);
2,295✔
263

264
        // Other objects are formatted as YAML for human-readable output
265
        let yamlString = yaml.dump(outputData, {
2,295✔
266
                indent: 4,
267
                noRefs: true,
268
        });
269

270
        // The YAML library puts a trailing newline at the end of the string, which is
271
        // redundant with the automatic newline added by oclif when writing to stdout
272
        return yamlString
2,295✔
273
                .replace(/\r?\n$/u, '')
274
                .replaceAll(/^([^:]+:)/gmu, (match, key) => chalk.cyan(key));
50,454✔
275
}
276

277
/**
278
 * Formats the object header, used to separate multiple objects in a collection
279
 *
280
 * @param {Object} obj The object to generate a header for
281
 * @returns {string} The header string
282
 * @private
283
 */
284
function formatObjectHeader(obj) {
285
        if (!obj.type || !obj.id) {
153!
286
                return chalk`{dim ----------}`;
×
287
        }
288
        return chalk`{dim ----- ${formatKey(obj.type)} ${obj.id} -----}`;
153✔
289
}
290

291
/**
292
 * Base class for all Box CLI commands
293
 */
294
class BoxCommand extends Command {
295
        // @TODO(2018-08-15): Move all fs methods used here to be async
296

297
        /**
298
         * Initialize before the command is run
299
         * @returns {void}
300
         */
301
        async init() {
302
                DEBUG.init('Initializing Box CLI');
7,875✔
303
                let originalArgs, originalFlags;
304
                if (
7,875✔
305
                        this.argv.some((arg) => arg.startsWith('--bulk-file-path')) &&
32,382✔
306
                        Object.keys(this.constructor.flags).includes('bulk-file-path')
307
                ) {
308
                        // Set up the command for bulk run
309
                        DEBUG.init('Preparing for bulk input');
324✔
310
                        this.isBulk = true;
324✔
311
                        // eslint-disable-next-line unicorn/prefer-structured-clone
312
                        originalArgs = _.cloneDeep(this.constructor.args);
324✔
313
                        // eslint-disable-next-line unicorn/prefer-structured-clone
314
                        originalFlags = _.cloneDeep(this.constructor.flags);
324✔
315
                        this.disableRequiredArgsAndFlags();
324✔
316
                }
317

318
                this.supportsSecureStorage = secureStorage.available;
7,875✔
319

320
                let { flags, args } = await this.parse(this.constructor);
7,875✔
321

322
                this.flags = flags;
7,875✔
323
                this.args = args;
7,875✔
324
                this.settings = await this._loadSettings();
7,875✔
325
                this.client = await this.getClient();
7,875✔
326
                this.tsClient = await this.getTsClient();
7,875✔
327

328
                if (this.isBulk) {
7,875✔
329
                        this.constructor.args = originalArgs;
324✔
330
                        this.constructor.flags = originalFlags;
324✔
331
                        this.bulkOutputList = [];
324✔
332
                        this.bulkErrors = [];
324✔
333
                        this._singleRun = this.run;
324✔
334
                        this.run = this.bulkOutputRun;
324✔
335
                }
336

337
                DEBUG.execute(
7,875✔
338
                        'Starting execution command: %s argv: %O',
339
                        this.id,
340
                        this.argv
341
                );
342
        }
343

344
        /**
345
         * Read in the input file and run the command once for each set of inputs
346
         * @returns {void}
347
         */
348
        async bulkOutputRun() {
349
                const allPossibleArgs = Object.keys(this.constructor.args || {});
324!
350
                const allPossibleFlags = Object.keys(this.constructor.flags || {});
324!
351
                // Map from matchKey (arg/flag name in all lower-case characters) => {type, fieldKey}
352
                let fieldMapping = Object.assign(
324✔
353
                        {},
354
                        ...allPossibleArgs.map((arg) => ({
486✔
355
                                [arg.toLowerCase()]: { type: 'arg', fieldKey: arg },
356
                        })),
357
                        ...allPossibleFlags.map((flag) => ({
8,082✔
358
                                [flag.replaceAll('-', '')]: { type: 'flag', fieldKey: flag },
359
                        }))
360
                );
361
                let bulkCalls = await this._parseBulkFile(
324✔
362
                        this.flags['bulk-file-path'],
363
                        fieldMapping
364
                );
365
                let bulkEntryIndex = 0;
279✔
366
                let progressBar = new progress.Bar({
279✔
367
                        format: '[{bar}] {percentage}% | {value}/{total}',
368
                        stopOnComplete: true,
369
                });
370
                progressBar.start(bulkCalls.length, 0);
279✔
371

372
                for (let bulkData of bulkCalls) {
279✔
373
                        this.argv = [];
603✔
374
                        bulkEntryIndex += 1;
603✔
375
                        this._getArgsForBulkInput(allPossibleArgs, bulkData);
603✔
376
                        this._setFlagsForBulkInput(bulkData);
603✔
377
                        await this._handleAsUserSettings(bulkData);
603✔
378
                        DEBUG.execute('Executing in bulk mode argv: %O', this.argv);
603✔
379
                        // @TODO(2018-08-29): Convert this to a promise queue to improve performance
380

381
                        try {
603✔
382
                                await this._singleRun();
603✔
383
                        } catch (error) {
384
                                // In bulk mode, we don't want to write directly to console and kill the command
385
                                // Instead, we should buffer the error output so subsequent commands might be able to succeed
386
                                DEBUG.execute(
27✔
387
                                        'Caught error from bulk input entry %d',
388
                                        bulkEntryIndex
389
                                );
390
                                this.bulkErrors.push({
27✔
391
                                        index: bulkEntryIndex,
392
                                        data: bulkData,
393
                                        error: this.wrapError(error),
394
                                });
395
                        }
396

397
                        progressBar.update(bulkEntryIndex);
603✔
398
                }
399
                this.isBulk = false;
279✔
400
                DEBUG.execute('Leaving bulk mode and writing final output');
279✔
401
                await this.output(this.bulkOutputList);
279✔
402
                this._handleBulkErrors();
279✔
403
        }
404

405
        /**
406
         * Logs bulk processing errors if any occured.
407
         * @returns {void}
408
         * @private
409
         */
410
        _handleBulkErrors() {
411
                const numErrors = this.bulkErrors.length;
279✔
412
                if (numErrors === 0) {
279✔
413
                        this.info(
261✔
414
                                chalk`{green All bulk input entries processed successfully.}`
415
                        );
416
                        return;
261✔
417
                }
418
                this.info(
18✔
419
                        chalk`{redBright ${numErrors} entr${numErrors > 1 ? 'ies' : 'y'} failed!}`
18✔
420
                );
421
                for (const errorInfo of this.bulkErrors) {
18✔
422
                        this.info(chalk`{dim ----------}`);
27✔
423
                        let entryData = errorInfo.data
27✔
424
                                .map((o) => `    ${o.fieldKey}=${o.value}`)
18✔
425
                                .join(os.EOL);
426
                        this.info(
27✔
427
                                chalk`{redBright Entry ${errorInfo.index} (${
428
                                        os.EOL + entryData + os.EOL
429
                                }) failed with error:}`
430
                        );
431
                        let err = errorInfo.error;
27✔
432
                        let contextInfo;
433
                        if (
27✔
434
                                err.response &&
45✔
435
                                err.response.body &&
436
                                err.response.body.context_info
437
                        ) {
438
                                contextInfo = formatObject(err.response.body.context_info);
9✔
439
                                // Remove color codes from context info
440
                                // eslint-disable-next-line no-control-regex
441
                                contextInfo = contextInfo.replaceAll(/\u001B\[\d+m/gu, '');
9✔
442
                                // Remove \n with os.EOL
443
                                contextInfo = contextInfo.replaceAll('\n', os.EOL);
9✔
444
                        }
445
                        let errMsg = chalk`{redBright ${
27✔
446
                                this.flags && this.flags.verbose ? err.stack : err.message
81!
447
                        }${os.EOL}${contextInfo ? contextInfo + os.EOL : ''}}`;
27✔
448
                        this.info(errMsg);
27✔
449
                }
450
        }
451

452
        /**
453
         * Set as-user header from the bulk file or use the default one.
454
         * @param {Array} bulkData Bulk data
455
         * @returns {Promise<void>} Returns nothing
456
         * @private
457
         */
458
        async _handleAsUserSettings(bulkData) {
459
                let asUser = bulkData.find((o) => o.fieldKey === 'as-user') || {};
1,647✔
460
                if (!_.isEmpty(asUser)) {
603✔
461
                        if (_.isNil(asUser.value)) {
27✔
462
                                let environmentsObj = await this.getEnvironments();
9✔
463
                                if (environmentsObj.default) {
9!
464
                                        let environment =
465
                                                environmentsObj.environments[environmentsObj.default];
×
466
                                        DEBUG.init(
×
467
                                                'Using environment %s %O',
468
                                                environmentsObj.default,
469
                                                environment
470
                                        );
471
                                        if (environment.useDefaultAsUser) {
×
472
                                                this.client.asUser(environment.defaultAsUserId);
×
473
                                                DEBUG.init(
×
474
                                                        'Impersonating default user ID %s',
475
                                                        environment.defaultAsUserId
476
                                                );
477
                                        } else {
478
                                                this.client.asSelf();
×
479
                                        }
480
                                } else {
481
                                        this.client.asSelf();
9✔
482
                                }
483
                        } else {
484
                                this.client.asUser(asUser.value);
18✔
485
                                DEBUG.init('Impersonating user ID %s', asUser.value);
18✔
486
                        }
487
                }
488
        }
489

490
        /**
491
         * Include flag values from command line first,
492
         * they'll automatically be overwritten/combined with later values by the oclif parser.
493
         * @param {Array} bulkData Bulk data
494
         * @returns {void}
495
         * @private
496
         */
497
        _setFlagsForBulkInput(bulkData) {
498
                const bulkDataFlags = new Set(
603✔
499
                        bulkData
500
                                .filter((o) => o.type === 'flag' && !_.isNil(o.value))
1,647✔
501
                                .map((o) => o.fieldKey)
738✔
502
                );
503
                for (const flag of Object.keys(this.flags)
603✔
504
                        .filter((flag) => flag !== 'bulk-file-path') // Remove the bulk file path flag so we don't recurse!
3,330✔
505
                        .filter((flag) => !bulkDataFlags.has(flag))) {
2,727✔
506
                        // Some flags can be specified multiple times in a single command. For these flags, their value is an array of user inputted values.
507
                        // For these flags, we iterate through their values and add each one as a separate flag to comply with oclif
508
                        if (Array.isArray(this.flags[flag])) {
2,322✔
509
                                for (const value of this.flags[flag]) {
9✔
510
                                        this._addFlagToArgv(flag, value);
18✔
511
                                }
512
                        } else {
513
                                this._addFlagToArgv(flag, this.flags[flag]);
2,313✔
514
                        }
515
                }
516
                // Include all flag values from bulk input, which will override earlier ones
517
                // from the command line
518
                for (const o of bulkData
603✔
519
                        // Remove the bulk file path flag so we don't recurse!
520
                        .filter(
521
                                (o) => o.type === 'flag' && o.fieldKey !== 'bulk-file-path'
1,647✔
522
                        ))
523
                        this._addFlagToArgv(o.fieldKey, o.value);
846✔
524
        }
525

526
        /**
527
         * For each possible arg, find the correct value between bulk input and values given on the command line.
528
         * @param {Array} allPossibleArgs All possible args
529
         * @param {Array} bulkData Bulk data
530
         * @returns {void}
531
         * @private
532
         */
533
        _getArgsForBulkInput(allPossibleArgs, bulkData) {
534
                for (let arg of allPossibleArgs) {
603✔
535
                        let bulkArg = bulkData.find((o) => o.fieldKey === arg) || {};
1,422✔
536
                        if (!_.isNil(bulkArg.value)) {
927✔
537
                                // Use value from bulk input file when available
538
                                this.argv.push(bulkArg.value);
756✔
539
                        } else if (this.args[arg]) {
171✔
540
                                // Fall back to value from command line
541
                                this.argv.push(this.args[arg]);
135✔
542
                        }
543
                }
544
        }
545

546
        /**
547
         * Parses file wilk bulk commands
548
         * @param {String} filePath Path to file with bulk commands
549
         * @param {Array} fieldMapping Data to parse
550
         * @returns {Promise<*>} Returns parsed data
551
         * @private
552
         */
553
        async _parseBulkFile(filePath, fieldMapping) {
554
                const fileExtension = path.extname(filePath);
324✔
555
                const fileContents = this._readBulkFile(filePath);
324✔
556
                let bulkCalls;
557
                if (fileExtension === '.json') {
324✔
558
                        bulkCalls = this._handleJsonFile(fileContents, fieldMapping);
144✔
559
                } else if (fileExtension === '.csv') {
180✔
560
                        bulkCalls = await this._handleCsvFile(fileContents, fieldMapping);
171✔
561
                } else {
562
                        throw new Error(
9✔
563
                                `Input file had extension "${fileExtension}", but only .json and .csv are supported`
564
                        );
565
                }
566
                // Filter out any undefined values, which can arise when the input file contains extraneous keys
567
                bulkCalls = bulkCalls.map((args) =>
279✔
568
                        args.filter((o) => o !== undefined)
1,737✔
569
                );
570
                DEBUG.execute(
279✔
571
                        'Read %d entries from bulk file %s',
572
                        bulkCalls.length,
573
                        this.flags['bulk-file-path']
574
                );
575
                return bulkCalls;
279✔
576
        }
577

578
        /**
579
         * Parses CSV file
580
         * @param {Object} fileContents File content to parse
581
         * @param {Array} fieldMapping Field mapings
582
         * @returns {Promise<string|null|*>} Returns parsed data
583
         * @private
584
         */
585
        async _handleCsvFile(fileContents, fieldMapping) {
586
                let parsedData = await csvParse(fileContents, {
171✔
587
                        bom: true,
588
                        delimiter: ',',
589
                        cast(value, context) {
590
                                if (value.length === 0) {
1,584✔
591
                                        // Regard unquoted empty values as null
592
                                        return context.quoting ? '' : null;
162✔
593
                                }
594
                                return value;
1,422✔
595
                        },
596
                });
597
                if (parsedData.length < 2) {
171✔
598
                        throw new Error(
9✔
599
                                'CSV input file should contain the headers row and at least on data row'
600
                        );
601
                }
602
                // @NOTE: We don't parse the CSV into an aray of Objects
603
                // and instead mainatain a separate array of headers, in
604
                // order to ensure that ordering is maintained in the keys
605
                let headers = parsedData.shift().map((key) => {
162✔
606
                        let keyParts = key.match(/(.*)_\d+$/u);
522✔
607
                        let someKey = keyParts ? keyParts[1] : key;
522✔
608
                        return someKey.toLowerCase().replaceAll(/[-_]/gu, '');
522✔
609
                });
610
                return parsedData.map((values) =>
162✔
611
                        values.map((value, index) => {
324✔
612
                                let key = headers[index];
1,044✔
613
                                let field = fieldMapping[key];
1,044✔
614
                                return field ? { ...field, value } : undefined;
1,044✔
615
                        })
616
                );
617
        }
618

619
        /**
620
         * Parses JSON file
621
         * @param {Object} fileContents File content to parse
622
         * @param {Array} fieldMapping Field mapings
623
         * @returns {*} Returns parsed data
624
         * @private
625
         */
626
        _handleJsonFile(fileContents, fieldMapping) {
627
                let parsedData;
628
                try {
144✔
629
                        let jsonFile = JSON.parse(fileContents);
144✔
630
                        parsedData = Object.hasOwn(jsonFile, 'entries')
126✔
631
                                ? jsonFile.entries
632
                                : jsonFile;
633
                } catch (error) {
634
                        throw new BoxCLIError(
18✔
635
                                `Could not parse JSON input file ${this.flags['bulk-file-path']}`,
636
                                error
637
                        );
638
                }
639
                if (!Array.isArray(parsedData)) {
126✔
640
                        throw new TypeError(
9✔
641
                                'Expected input file to contain an array of input objects, but none found'
642
                        );
643
                }
644
                // Translate each row object to an array of {type, fieldKey, value}, to be handled below
645
                return parsedData.map(function flattenObjectToArgs(obj) {
117✔
646
                        // One top-level object key can map to multiple args/flags, so we need to deeply flatten after mapping
647
                        return _.flatMapDeep(obj, (value, key) => {
315✔
648
                                let matchKey = key.toLowerCase().replaceAll(/[-_]/gu, '');
693✔
649
                                let field = fieldMapping[matchKey];
693✔
650
                                if (_.isPlainObject(value)) {
693✔
651
                                        // Map e.g. { item: { id: 12345, type: folder } } => { item: 12345, itemtype: folder }
652
                                        // @NOTE: For now, we only support nesting keys this way one level deep
653
                                        return Object.keys(value).map((nestedKey) => {
18✔
654
                                                let nestedMatchKey =
655
                                                        matchKey +
27✔
656
                                                        nestedKey.toLowerCase().replaceAll(/[-_]/gu, '');
657
                                                let nestedField = fieldMapping[nestedMatchKey];
27✔
658
                                                return nestedField
27✔
659
                                                        ? { ...nestedField, value: value[nestedKey] }
660
                                                        : undefined;
661
                                        });
662
                                } else if (Array.isArray(value)) {
675✔
663
                                        // Arrays can be one of two things: an array of values for a single key,
664
                                        // or an array of grouped flags/args as objects
665
                                        // First, check if everything in the array is either all object or all non-object
666
                                        let types = value.map((t) => typeof t);
63✔
667
                                        if (
27!
668
                                                types.some((t) => t !== 'object') &&
54✔
669
                                                types.includes('object')
670
                                        ) {
671
                                                throw new BoxCLIError(
×
672
                                                        'Mixed types in bulk input JSON array; use strings or Objects'
673
                                                );
674
                                        }
675
                                        // If everything in the array is objects, handle each one as a group of flags and args
676
                                        // by recursively parsing that object into args
677
                                        if (types[0] === 'object') {
27✔
678
                                                return value.map((o) => flattenObjectToArgs(o));
36✔
679
                                        }
680
                                        // If the array is of values for this field, just return those
681
                                        return field
18✔
682
                                                ? value.map((v) => ({ ...field, value: v }))
18✔
683
                                                : [];
684
                                }
685
                                return field ? { ...field, value } : undefined;
648✔
686
                        });
687
                });
688
        }
689

690
        /**
691
         * Returns bulk file contents
692
         * @param {String} filePath Path to bulk file
693
         * @returns {Buffer} Bulk file contents
694
         * @private
695
         */
696
        _readBulkFile(filePath) {
697
                try {
324✔
698
                        const fileContents = fs.readFileSync(filePath);
324✔
699
                        DEBUG.execute('Read bulk input file at %s', filePath);
324✔
700
                        return fileContents;
324✔
701
                } catch (error) {
702
                        throw new BoxCLIError(
×
703
                                `Could not open input file ${filePath}`,
704
                                error
705
                        );
706
                }
707
        }
708

709
        /**
710
         * Writes a given flag value to the command's argv array
711
         *
712
         * @param {string} flag The flag name
713
         * @param {*} flagValue The flag value
714
         * @returns {void}
715
         * @private
716
         */
717
        _addFlagToArgv(flag, flagValue) {
718
                if (_.isNil(flagValue)) {
3,177✔
719
                        return;
108✔
720
                }
721

722
                if (this.constructor.flags[flag].type === 'boolean') {
3,069✔
723
                        if (getBooleanFlagValue(flagValue)) {
1,593✔
724
                                this.argv.push(`--${flag}`);
1,494✔
725
                        } else {
726
                                this.argv.push(`--no-${flag}`);
99✔
727
                        }
728
                } else {
729
                        this.argv.push(`--${flag}=${flagValue}`);
1,476✔
730
                }
731
        }
732

733
        /**
734
         * Ensure that all args and flags for the command are not marked as required,
735
         * to avoid issues when filling in required values from the input file.
736
         * @returns {void}
737
         */
738
        disableRequiredArgsAndFlags() {
739
                if (this.constructor.args !== undefined) {
324!
740
                        for (const key of Object.keys(this.constructor.args)) {
324✔
741
                                this.constructor.args[key].required = false;
486✔
742
                        }
743
                }
744

745
                if (this.constructor.flags !== undefined) {
324!
746
                        for (const key of Object.keys(this.constructor.flags)) {
324✔
747
                                this.constructor.flags[key].required = false;
8,082✔
748
                        }
749
                }
750
        }
751

752
        /**
753
         * Instantiate the SDK client for making API calls
754
         *
755
         * @returns {BoxClient} The client for making API calls in the command
756
         */
757
        async getClient() {
758
                // Allow some commands (e.g. configure:environments:add, login) to skip client setup so they can run
759
                if (this.constructor.noClient) {
7,875!
760
                        return null;
×
761
                }
762
                let environmentsObj = await this.getEnvironments();
7,875✔
763
                const environment =
764
                        environmentsObj.environments[environmentsObj.default] || {};
7,875✔
765
                const { authMethod } = environment;
7,875✔
766

767
                let client;
768
                if (this.flags.token) {
7,875!
769
                        DEBUG.init('Using passed in token %s', this.flags.token);
7,875✔
770
                        let sdk = new BoxSDK({
7,875✔
771
                                clientID: '',
772
                                clientSecret: '',
773
                                ...SDK_CONFIG,
774
                        });
775
                        this._configureSdk(sdk, { ...SDK_CONFIG });
7,875✔
776
                        this.sdk = sdk;
7,875✔
777
                        client = sdk.getBasicClient(this.flags.token);
7,875✔
778
                } else if (authMethod === 'ccg') {
×
779
                        DEBUG.init('Using Client Credentials Grant Authentication');
×
780

781
                        const { clientId, clientSecret, ccgUser } = environment;
×
782

783
                        if (!clientId || !clientSecret) {
×
784
                                throw new BoxCLIError(
×
785
                                        'You need to have a default environment with clientId and clientSecret in order to use CCG'
786
                                );
787
                        }
788

789
                        let configObj;
790
                        try {
×
791
                                configObj = JSON.parse(
×
792
                                        fs.readFileSync(environment.boxConfigFilePath)
793
                                );
794
                        } catch (error) {
795
                                throw new BoxCLIError(
×
796
                                        'Could not read environments config file',
797
                                        error
798
                                );
799
                        }
800

801
                        const { enterpriseID } = configObj;
×
802
                        const sdk = new BoxSDK({
×
803
                                clientID: clientId,
804
                                clientSecret,
805
                                enterpriseID,
806
                                ...SDK_CONFIG,
807
                        });
808
                        this._configureSdk(sdk, { ...SDK_CONFIG });
×
809
                        this.sdk = sdk;
×
810
                        client = ccgUser
×
811
                                ? sdk.getCCGClientForUser(ccgUser)
812
                                : sdk.getAnonymousClient();
813
                } else if (
×
814
                        environmentsObj.default &&
×
815
                        environmentsObj.environments[environmentsObj.default].authMethod ===
816
                                'oauth20'
817
                ) {
818
                        try {
×
819
                                DEBUG.init(
×
820
                                        'Using environment %s %O',
821
                                        environmentsObj.default,
822
                                        environment
823
                                );
824
                                let tokenCache = new CLITokenCache(environmentsObj.default);
×
825

826
                                let sdk = new BoxSDK({
×
827
                                        clientID: environment.clientId,
828
                                        clientSecret: environment.clientSecret,
829
                                        ...SDK_CONFIG,
830
                                });
831
                                this._configureSdk(sdk, { ...SDK_CONFIG });
×
832
                                this.sdk = sdk;
×
833
                                let tokenInfo = await new Promise((resolve, reject) => {
×
834
                                        tokenCache.read((error, localTokenInfo) => {
×
835
                                                if (error) {
×
836
                                                        reject(error);
×
837
                                                } else {
838
                                                        resolve(localTokenInfo);
×
839
                                                }
840
                                        });
841
                                });
842
                                client = sdk.getPersistentClient(tokenInfo, tokenCache);
×
843
                        } catch {
844
                                throw new BoxCLIError(
×
845
                                        `Can't load the default OAuth environment "${environmentsObj.default}". Please reauthorize selected environment, login again or provide a token.`
846
                                );
847
                        }
848
                } else if (environmentsObj.default) {
×
849
                        DEBUG.init(
×
850
                                'Using environment %s %O',
851
                                environmentsObj.default,
852
                                environment
853
                        );
854
                        let tokenCache =
855
                                environment.cacheTokens === false
×
856
                                        ? null
857
                                        : new CLITokenCache(environmentsObj.default);
858
                        let configObj;
859
                        try {
×
860
                                configObj = JSON.parse(
×
861
                                        fs.readFileSync(environment.boxConfigFilePath)
862
                                );
863
                        } catch (error) {
864
                                throw new BoxCLIError(
×
865
                                        'Could not read environments config file',
866
                                        error
867
                                );
868
                        }
869

870
                        if (!environment.hasInLinePrivateKey) {
×
871
                                try {
×
872
                                        configObj.boxAppSettings.appAuth.privateKey =
×
873
                                                fs.readFileSync(environment.privateKeyPath, 'utf8');
874
                                        DEBUG.init(
×
875
                                                'Loaded JWT private key from %s',
876
                                                environment.privateKeyPath
877
                                        );
878
                                } catch (error) {
879
                                        throw new BoxCLIError(
×
880
                                                `Could not read private key file ${environment.privateKeyPath}`,
881
                                                error
882
                                        );
883
                                }
884
                        }
885

886
                        this.sdk = BoxSDK.getPreconfiguredInstance(configObj);
×
887
                        this._configureSdk(this.sdk, { ...SDK_CONFIG });
×
888

889
                        client = this.sdk.getAppAuthClient(
×
890
                                'enterprise',
891
                                environment.enterpriseId,
892
                                tokenCache
893
                        );
894
                        DEBUG.init('Initialized client from environment config');
×
895
                } else {
896
                        // No environments set up yet!
897
                        throw new BoxCLIError(
×
898
                                `No default environment found.
899
                                It looks like you haven't configured the Box CLI yet.
900
                                See this command for help adding an environment: box configure:environments:add --help
901
                                Or, supply a token with your command with --token.`.replaceAll(/^\s+/gmu, '')
902
                        );
903
                }
904

905
                // Using the as-user flag should have precedence over the environment setting
906
                if (this.flags['as-user']) {
7,875✔
907
                        client.asUser(this.flags['as-user']);
9✔
908
                        DEBUG.init(
9✔
909
                                'Impersonating user ID %s using the ID provided via the --as-user flag',
910
                                this.flags['as-user']
911
                        );
912
                } else if (!this.flags.token && environment.useDefaultAsUser) {
7,866!
913
                        // We don't want to use any environment settings if a token is passed in the command
914
                        client.asUser(environment.defaultAsUserId);
×
915
                        DEBUG.init(
×
916
                                'Impersonating default user ID %s using environment configuration',
917
                                environment.defaultAsUserId
918
                        );
919
                }
920
                return client;
7,875✔
921
        }
922

923
        /**
924
         * Instantiate the TypeScript SDK client for making API calls
925
         *
926
         * @returns {BoxTSSDK.BoxClient} The TypeScript SDK client for making API calls in the command
927
         */
928
        async getTsClient() {
929
                // Allow some commands (e.g. configure:environments:add, login) to skip client setup so they can run
930
                if (this.constructor.noClient) {
7,875!
931
                        return null;
×
932
                }
933
                let environmentsObj = await this.getEnvironments();
7,875✔
934
                const environment =
935
                        environmentsObj.environments[environmentsObj.default] || {};
7,875✔
936
                const { authMethod } = environment;
7,875✔
937

938
                let client;
939
                if (this.flags.token) {
7,875!
940
                        DEBUG.init('Using passed in token %s', this.flags.token);
7,875✔
941
                        let tsSdkAuth = new BoxTSSDK.BoxDeveloperTokenAuth({
7,875✔
942
                                token: this.flags.token,
943
                        });
944
                        client = new BoxTSSDK.BoxClient({
7,875✔
945
                                auth: tsSdkAuth,
946
                        });
947
                        client = this._configureTsSdk(client, SDK_CONFIG);
7,875✔
948
                } else if (authMethod === 'ccg') {
×
949
                        DEBUG.init('Using Client Credentials Grant Authentication');
×
950

951
                        const { clientId, clientSecret, ccgUser } = environment;
×
952

953
                        if (!clientId || !clientSecret) {
×
954
                                throw new BoxCLIError(
×
955
                                        'You need to have a default environment with clientId and clientSecret in order to use CCG'
956
                                );
957
                        }
958

959
                        let configObj;
960
                        try {
×
961
                                configObj = JSON.parse(
×
962
                                        fs.readFileSync(environment.boxConfigFilePath)
963
                                );
964
                        } catch (error) {
965
                                throw new BoxCLIError(
×
966
                                        'Could not read environments config file',
967
                                        error
968
                                );
969
                        }
970

971
                        const { enterpriseID } = configObj;
×
972
                        const tokenCache =
973
                                environment.cacheTokens === false
×
974
                                        ? null
975
                                        : new CLITokenCache(environmentsObj.default);
976
                        let ccgConfig = new BoxTSSDK.CcgConfig(
×
977
                                ccgUser
×
978
                                        ? {
979
                                                        clientId,
980
                                                        clientSecret,
981
                                                        userId: ccgUser,
982
                                                        tokenStorage: tokenCache,
983
                                                }
984
                                        : {
985
                                                        clientId,
986
                                                        clientSecret,
987
                                                        enterpriseId: enterpriseID,
988
                                                        tokenStorage: tokenCache,
989
                                                }
990
                        );
991
                        let ccgAuth = new BoxTSSDK.BoxCcgAuth({ config: ccgConfig });
×
992
                        client = new BoxTSSDK.BoxClient({
×
993
                                auth: ccgAuth,
994
                        });
995
                        client = this._configureTsSdk(client, SDK_CONFIG);
×
996
                } else if (
×
997
                        environmentsObj.default &&
×
998
                        environmentsObj.environments[environmentsObj.default].authMethod ===
999
                                'oauth20'
1000
                ) {
1001
                        try {
×
1002
                                DEBUG.init(
×
1003
                                        'Using environment %s %O',
1004
                                        environmentsObj.default,
1005
                                        environment
1006
                                );
1007
                                const tokenCache = new CLITokenCache(environmentsObj.default);
×
1008
                                const oauthConfig = new BoxTSSDK.OAuthConfig({
×
1009
                                        clientId: environment.clientId,
1010
                                        clientSecret: environment.clientSecret,
1011
                                        tokenStorage: tokenCache,
1012
                                });
1013
                                const oauthAuth = new BoxTSSDK.BoxOAuth({
×
1014
                                        config: oauthConfig,
1015
                                });
1016
                                client = new BoxTSSDK.BoxClient({ auth: oauthAuth });
×
1017
                                client = this._configureTsSdk(client, SDK_CONFIG);
×
1018
                        } catch {
1019
                                throw new BoxCLIError(
×
1020
                                        `Can't load the default OAuth environment "${environmentsObj.default}". Please reauthorize selected environment, login again or provide a token.`
1021
                                );
1022
                        }
1023
                } else if (environmentsObj.default) {
×
1024
                        DEBUG.init(
×
1025
                                'Using environment %s %O',
1026
                                environmentsObj.default,
1027
                                environment
1028
                        );
1029
                        let tokenCache =
1030
                                environment.cacheTokens === false
×
1031
                                        ? null
1032
                                        : new CLITokenCache(environmentsObj.default);
1033
                        let configObj;
1034
                        try {
×
1035
                                configObj = JSON.parse(
×
1036
                                        fs.readFileSync(environment.boxConfigFilePath)
1037
                                );
1038
                        } catch (error) {
1039
                                throw new BoxCLIError(
×
1040
                                        'Could not read environments config file',
1041
                                        error
1042
                                );
1043
                        }
1044

1045
                        if (!environment.hasInLinePrivateKey) {
×
1046
                                try {
×
1047
                                        configObj.boxAppSettings.appAuth.privateKey =
×
1048
                                                fs.readFileSync(environment.privateKeyPath, 'utf8');
1049
                                        DEBUG.init(
×
1050
                                                'Loaded JWT private key from %s',
1051
                                                environment.privateKeyPath
1052
                                        );
1053
                                } catch (error) {
1054
                                        throw new BoxCLIError(
×
1055
                                                `Could not read private key file ${environment.privateKeyPath}`,
1056
                                                error
1057
                                        );
1058
                                }
1059
                        }
1060

1061
                        const jwtConfig = new BoxTSSDK.JwtConfig({
×
1062
                                clientId: configObj.boxAppSettings.clientID,
1063
                                clientSecret: configObj.boxAppSettings.clientSecret,
1064
                                jwtKeyId: configObj.boxAppSettings.appAuth.publicKeyID,
1065
                                privateKey: configObj.boxAppSettings.appAuth.privateKey,
1066
                                privateKeyPassphrase:
1067
                                        configObj.boxAppSettings.appAuth.passphrase,
1068
                                enterpriseId: environment.enterpriseId,
1069
                                tokenStorage: tokenCache,
1070
                        });
1071
                        let jwtAuth = new BoxTSSDK.BoxJwtAuth({ config: jwtConfig });
×
1072
                        client = new BoxTSSDK.BoxClient({ auth: jwtAuth });
×
1073

1074
                        DEBUG.init('Initialized client from environment config');
×
1075
                        if (environment.useDefaultAsUser) {
×
1076
                                client = client.withAsUserHeader(environment.defaultAsUserId);
×
1077
                                DEBUG.init(
×
1078
                                        'Impersonating default user ID %s',
1079
                                        environment.defaultAsUserId
1080
                                );
1081
                        }
1082
                        client = this._configureTsSdk(client, SDK_CONFIG);
×
1083
                } else {
1084
                        // No environments set up yet!
1085
                        throw new BoxCLIError(
×
1086
                                `No default environment found.
1087
                                It looks like you haven't configured the Box CLI yet.
1088
                                See this command for help adding an environment: box configure:environments:add --help
1089
                                Or, supply a token with your command with --token.`.replaceAll(/^\s+/gmu, '')
1090
                        );
1091
                }
1092
                if (this.flags['as-user']) {
7,875✔
1093
                        client = client.withAsUserHeader(this.flags['as-user']);
9✔
1094
                        DEBUG.init('Impersonating user ID %s', this.flags['as-user']);
9✔
1095
                }
1096
                return client;
7,875✔
1097
        }
1098

1099
        /**
1100
         * Configures SDK by using values from settings.json file
1101
         * @param {*} sdk to configure
1102
         * @param {*} config Additional options to use while building configuration
1103
         * @returns {void}
1104
         */
1105
        _configureSdk(sdk, config = {}) {
×
1106
                const clientSettings = { ...config };
7,875✔
1107
                if (this.settings.enableProxy) {
7,875!
1108
                        clientSettings.proxy = this.settings.proxy;
×
1109
                }
1110
                if (this.settings.apiRootURL) {
7,875!
1111
                        clientSettings.apiRootURL = this.settings.apiRootURL;
×
1112
                }
1113
                if (this.settings.uploadAPIRootURL) {
7,875!
1114
                        clientSettings.uploadAPIRootURL = this.settings.uploadAPIRootURL;
×
1115
                }
1116
                if (this.settings.authorizeRootURL) {
7,875!
1117
                        clientSettings.authorizeRootURL = this.settings.authorizeRootURL;
×
1118
                }
1119
                if (this.settings.numMaxRetries) {
7,875!
1120
                        clientSettings.numMaxRetries = this.settings.numMaxRetries;
×
1121
                }
1122
                if (this.settings.retryIntervalMS) {
7,875!
1123
                        clientSettings.retryIntervalMS = this.settings.retryIntervalMS;
×
1124
                }
1125
                if (this.settings.uploadRequestTimeoutMS) {
7,875!
1126
                        clientSettings.uploadRequestTimeoutMS =
×
1127
                                this.settings.uploadRequestTimeoutMS;
1128
                }
1129
                clientSettings.analyticsClient.name =
7,875✔
1130
                        this.settings.enableAnalyticsClient &&
15,750!
1131
                        this.settings.analyticsClient.name
1132
                                ? `${DEFAULT_ANALYTICS_CLIENT_NAME} ${this.settings.analyticsClient.name}`
1133
                                : DEFAULT_ANALYTICS_CLIENT_NAME;
1134

1135
                if (Object.keys(clientSettings).length > 0) {
7,875!
1136
                        DEBUG.init('SDK client settings %s', clientSettings);
7,875✔
1137
                        sdk.configure(clientSettings);
7,875✔
1138
                }
1139
        }
1140

1141
        /**
1142
         * Configures TS SDK by using values from settings.json file
1143
         *
1144
         * @param {BoxTSSDK.BoxClient} client to configure
1145
         * @param {Object} config Additional options to use while building configuration
1146
         * @returns {BoxTSSDK.BoxClient} The configured client
1147
         */
1148
        _configureTsSdk(client, config) {
1149
                let additionalHeaders = config.request.headers;
7,875✔
1150
                let customBaseURL = {
7,875✔
1151
                        baseUrl: 'https://api.box.com',
1152
                        uploadUrl: 'https://upload.box.com/api',
1153
                        oauth2Url: 'https://account.box.com/api/oauth2',
1154
                };
1155
                if (this.settings.enableProxy) {
7,875!
1156
                        client = client.withProxy(this.settings.proxy);
×
1157
                }
1158
                if (this.settings.apiRootURL) {
7,875!
1159
                        customBaseURL.baseUrl = this.settings.apiRootURL;
×
1160
                }
1161
                if (this.settings.uploadAPIRootURL) {
7,875!
1162
                        customBaseURL.uploadUrl = this.settings.uploadAPIRootURL;
×
1163
                }
1164
                if (this.settings.authorizeRootURL) {
7,875!
1165
                        customBaseURL.oauth2Url = this.settings.authorizeRootURL;
×
1166
                }
1167
                client = client.withCustomBaseUrls(customBaseURL);
7,875✔
1168

1169
                if (this.settings.numMaxRetries) {
7,875!
1170
                        // Not supported in TS SDK
1171
                }
1172
                if (this.settings.retryIntervalMS) {
7,875!
1173
                        // Not supported in TS SDK
1174
                }
1175
                if (this.settings.uploadRequestTimeoutMS) {
7,875!
1176
                        // Not supported in TS SDK
1177
                }
1178
                additionalHeaders['X-Box-UA'] =
7,875✔
1179
                        this.settings.enableAnalyticsClient &&
15,750!
1180
                        this.settings.analyticsClient.name
1181
                                ? `${DEFAULT_ANALYTICS_CLIENT_NAME} ${this.settings.analyticsClient.name}`
1182
                                : DEFAULT_ANALYTICS_CLIENT_NAME;
1183
                client = client.withExtraHeaders(additionalHeaders);
7,875✔
1184
                DEBUG.init('TS SDK configured with settings from settings.json');
7,875✔
1185

1186
                return client;
7,875✔
1187
        }
1188

1189
        /**
1190
         * Format data for output to stdout
1191
         * @param {*} content The content to output
1192
         * @returns {Promise<void>} A promise resolving when output is handled
1193
         */
1194
        async output(content) {
1195
                if (this.isBulk) {
7,335✔
1196
                        this.bulkOutputList.push(content);
576✔
1197
                        DEBUG.output(
576✔
1198
                                'Added command output to bulk list total: %d',
1199
                                this.bulkOutputList.length
1200
                        );
1201
                        return;
576✔
1202
                }
1203

1204
                let formattedOutputData;
1205
                if (Array.isArray(content)) {
6,759✔
1206
                        // Format each object individually and then flatten in case this an array of arrays,
1207
                        // which happens when a command that outputs a collection gets run in bulk
1208
                        const formattedOutputResults = await Promise.all(
405✔
1209
                                content.map((o) => this._formatOutputObject(o))
1,080✔
1210
                        );
1211
                        formattedOutputData = formattedOutputResults.flat();
405✔
1212
                        DEBUG.output(
405✔
1213
                                'Formatted %d output entries for display',
1214
                                content.length
1215
                        );
1216
                } else {
1217
                        formattedOutputData = await this._formatOutputObject(content);
6,354✔
1218
                        DEBUG.output('Formatted output content for display');
6,354✔
1219
                }
1220
                let outputFormat = this._getOutputFormat();
6,759✔
1221
                DEBUG.output('Using %s output format', outputFormat);
6,759✔
1222
                DEBUG.output(formattedOutputData);
6,759✔
1223

1224
                let writeFunc;
1225
                let logFunc;
1226
                let stringifiedOutput;
1227

1228
                // remove all the undefined values from the object
1229
                formattedOutputData = removeUndefinedValues(formattedOutputData);
6,759✔
1230

1231
                if (outputFormat === 'json') {
6,759✔
1232
                        stringifiedOutput = stringifyStream(formattedOutputData, null, 4);
4,275✔
1233

1234
                        let appendNewLineTransform = new Transform({
4,275✔
1235
                                transform(chunk, encoding, callback) {
1236
                                        callback(null, chunk);
36✔
1237
                                },
1238
                                flush(callback) {
1239
                                        this.push(os.EOL);
36✔
1240
                                        callback();
36✔
1241
                                },
1242
                        });
1243

1244
                        writeFunc = async (savePath) => {
4,275✔
1245
                                await pipeline(
36✔
1246
                                        stringifiedOutput,
1247
                                        appendNewLineTransform,
1248
                                        fs.createWriteStream(savePath, { encoding: 'utf8' })
1249
                                );
1250
                        };
1251

1252
                        logFunc = async () => {
4,275✔
1253
                                await this.logStream(stringifiedOutput);
4,239✔
1254
                        };
1255
                } else {
1256
                        stringifiedOutput =
2,484✔
1257
                                await this._stringifyOutput(formattedOutputData);
1258

1259
                        writeFunc = async (savePath) => {
2,484✔
1260
                                await utils.writeFileAsync(
9✔
1261
                                        savePath,
1262
                                        stringifiedOutput + os.EOL,
1263
                                        {
1264
                                                encoding: 'utf8',
1265
                                        }
1266
                                );
1267
                        };
1268

1269
                        logFunc = () => this.log(stringifiedOutput);
2,484✔
1270
                }
1271
                return this._writeOutput(writeFunc, logFunc);
6,759✔
1272
        }
1273

1274
        /**
1275
         * Check if max-items has been reached.
1276
         *
1277
         * @param {number} maxItems Total number of items to return
1278
         * @param {number} itemsCount Current number of items
1279
         * @returns {boolean} True if limit has been reached, otherwise false
1280
         * @private
1281
         */
1282
        maxItemsReached(maxItems, itemsCount) {
1283
                return maxItems && itemsCount >= maxItems;
6,777✔
1284
        }
1285

1286
        /**
1287
         * Prepare the output data by:
1288
         *   1) Unrolling an iterator into an array
1289
         *   2) Filtering out unwanted object fields
1290
         *
1291
         * @param {*} obj The raw object containing output data
1292
         * @returns {*} The formatted output data
1293
         * @private
1294
         */
1295
        async _formatOutputObject(obj) {
1296
                let output = obj;
7,434✔
1297

1298
                // Pass primitive content types through
1299
                if (typeof output !== 'object' || output === null) {
7,434!
1300
                        return output;
×
1301
                }
1302

1303
                // Unroll iterator into array
1304
                if (typeof obj.next === 'function') {
7,434✔
1305
                        output = [];
1,494✔
1306
                        let entry = await obj.next();
1,494✔
1307
                        while (!entry.done) {
1,494✔
1308
                                output.push(entry.value);
6,777✔
1309

1310
                                if (
6,777✔
1311
                                        this.maxItemsReached(this.flags['max-items'], output.length)
1312
                                ) {
1313
                                        break;
45✔
1314
                                }
1315

1316
                                entry = await obj.next();
6,732✔
1317
                        }
1318
                        DEBUG.output('Unrolled iterable into %d entries', output.length);
1,494✔
1319
                }
1320

1321
                if (this.flags['id-only']) {
7,434✔
1322
                        output = Array.isArray(output)
270!
1323
                                ? this.filterOutput(output, 'id')
1324
                                : output.id;
1325
                } else {
1326
                        output = this.filterOutput(output, this.flags.fields);
7,164✔
1327
                }
1328

1329
                return output;
7,434✔
1330
        }
1331

1332
        /**
1333
         * Get the output format (and file extension) based on the settings and flags set
1334
         *
1335
         * @returns {string} The file extension/format to use for output
1336
         * @private
1337
         */
1338
        _getOutputFormat() {
1339
                if (this.flags.json) {
9,261✔
1340
                        return 'json';
4,284✔
1341
                }
1342

1343
                if (this.flags.csv) {
4,977✔
1344
                        return 'csv';
54✔
1345
                }
1346

1347
                if (this.flags.save || this.flags['save-to-file-path']) {
4,923✔
1348
                        return this.settings.boxReportsFileFormat || 'txt';
27!
1349
                }
1350

1351
                if (this.settings.outputJson) {
4,896!
1352
                        return 'json';
×
1353
                }
1354

1355
                return 'txt';
4,896✔
1356
        }
1357

1358
        /**
1359
         * Converts output data to a string based on the type of content and flags the user
1360
         * has specified regarding output format
1361
         *
1362
         * @param {*} outputData The data to output
1363
         * @returns {string} Promise resolving to the output data as a string
1364
         * @private
1365
         */
1366
        async _stringifyOutput(outputData) {
1367
                let outputFormat = this._getOutputFormat();
2,484✔
1368

1369
                if (typeof outputData !== 'object') {
2,484✔
1370
                        DEBUG.output('Primitive output cast to string');
270✔
1371
                        return String(outputData);
270✔
1372
                } else if (outputFormat === 'csv') {
2,214✔
1373
                        let csvString = await csvStringify(
27✔
1374
                                this.formatForTableAndCSVOutput(outputData)
1375
                        );
1376
                        // The CSV library puts a trailing newline at the end of the string, which is
1377
                        // redundant with the automatic newline added by oclif when writing to stdout
1378
                        DEBUG.output('Processed output as CSV');
27✔
1379
                        return csvString.replace(/\r?\n$/u, '');
27✔
1380
                } else if (Array.isArray(outputData)) {
2,187✔
1381
                        let str = outputData
63✔
1382
                                .map(
1383
                                        (o) => `${formatObjectHeader(o)}${os.EOL}${formatObject(o)}`
153✔
1384
                                )
1385
                                .join(os.EOL.repeat(2));
1386
                        DEBUG.output('Processed collection into human-readable output');
63✔
1387
                        return str;
63✔
1388
                }
1389

1390
                let str = formatObject(outputData);
2,124✔
1391
                DEBUG.output('Processed human-readable output');
2,124✔
1392
                return str;
2,124✔
1393
        }
1394

1395
        /**
1396
         * Generate an appropriate default filename for writing
1397
         * the output of this command to disk.
1398
         *
1399
         * @returns {string} The output file name
1400
         * @private
1401
         */
1402
        _getOutputFileName() {
1403
                let extension = this._getOutputFormat();
18✔
1404
                return `${this.id.replaceAll(':', '-')}-${dateTime.format(
18✔
1405
                        new Date(),
1406
                        'YYYY-MM-DD_HH_mm_ss_SSS'
1407
                )}.${extension}`;
1408
        }
1409

1410
        /**
1411
         * Write output to its final destination, either a file or stdout
1412
         * @param {Function} writeFunc Function used to save output to a file
1413
         * @param {Function} logFunc Function used to print output to stdout
1414
         * @returns {Promise<void>} A promise resolving when output is written
1415
         * @private
1416
         */
1417
        async _writeOutput(writeFunc, logFunc) {
1418
                if (this.flags.save) {
6,759✔
1419
                        DEBUG.output('Writing output to default location on disk');
9✔
1420
                        let filePath = path.join(
9✔
1421
                                this.settings.boxReportsFolderPath,
1422
                                this._getOutputFileName()
1423
                        );
1424
                        try {
9✔
1425
                                await writeFunc(filePath);
9✔
1426
                        } catch (error) {
1427
                                throw new BoxCLIError(
×
1428
                                        `Could not write output to file at ${filePath}`,
1429
                                        error
1430
                                );
1431
                        }
1432
                        this.info(chalk`{green Output written to ${filePath}}`);
9✔
1433
                } else if (this.flags['save-to-file-path']) {
6,750✔
1434
                        let savePath = this.flags['save-to-file-path'];
36✔
1435
                        if (fs.existsSync(savePath)) {
36!
1436
                                if (fs.statSync(savePath).isDirectory()) {
36✔
1437
                                        // Append default file name and write into the provided directory
1438
                                        savePath = path.join(savePath, this._getOutputFileName());
9✔
1439
                                        DEBUG.output(
9✔
1440
                                                'Output path is a directory, will write to %s',
1441
                                                savePath
1442
                                        );
1443
                                } else {
1444
                                        DEBUG.output('File already exists at %s', savePath);
27✔
1445
                                        // Ask if the user want to overwrite the file
1446
                                        let shouldOverwrite = await this.confirm(
27✔
1447
                                                `File ${savePath} already exists — overwrite?`
1448
                                        );
1449

1450
                                        if (!shouldOverwrite) {
27!
1451
                                                return;
×
1452
                                        }
1453
                                }
1454
                        }
1455
                        try {
36✔
1456
                                DEBUG.output(
36✔
1457
                                        'Writing output to specified location on disk: %s',
1458
                                        savePath
1459
                                );
1460
                                await writeFunc(savePath);
36✔
1461
                        } catch (error) {
1462
                                throw new BoxCLIError(
×
1463
                                        `Could not write output to file at ${savePath}`,
1464
                                        error
1465
                                );
1466
                        }
1467
                        this.info(chalk`{green Output written to ${savePath}}`);
36✔
1468
                } else {
1469
                        DEBUG.output('Writing output to terminal');
6,714✔
1470
                        await logFunc();
6,714✔
1471
                }
1472

1473
                DEBUG.output('Finished writing output');
6,759✔
1474
        }
1475

1476
        /**
1477
         * Ask a user to confirm something, respecting the default --yes flag
1478
         *
1479
         * @param {string} promptText The text of the prompt to the user
1480
         * @param {boolean} defaultValue The default value of the prompt
1481
         * @returns {Promise<boolean>} A promise resolving to a boolean that is true iff the user confirmed
1482
         */
1483
        async confirm(promptText, defaultValue = false) {
27✔
1484
                if (this.flags.yes) {
27✔
1485
                        return true;
18✔
1486
                }
1487

1488
                let answers = await inquirer.prompt([
9✔
1489
                        {
1490
                                name: 'confirmation',
1491
                                message: promptText,
1492
                                type: 'confirm',
1493
                                default: defaultValue,
1494
                        },
1495
                ]);
1496

1497
                return answers.confirmation;
9✔
1498
        }
1499

1500
        /**
1501
         * Writes output to stderr — this should be used for informational output.  For example, a message
1502
         * stating that an item has been deleted.
1503
         *
1504
         * @param {string} content The message to output
1505
         * @returns {void}
1506
         */
1507
        info(content) {
1508
                if (!this.flags.quiet) {
1,098✔
1509
                        process.stderr.write(`${content}${os.EOL}`);
1,089✔
1510
                }
1511
        }
1512

1513
        /**
1514
         * Writes output to stderr — this should be used for informational output.  For example, a message
1515
         * stating that an item has been deleted.
1516
         *
1517
         * @param {string} content The message to output
1518
         * @returns {void}
1519
         */
1520
        log(content) {
1521
                if (!this.flags.quiet) {
2,475✔
1522
                        process.stdout.write(`${content}${os.EOL}`);
2,457✔
1523
                }
1524
        }
1525

1526
        /**
1527
         * Writes stream output to stderr — this should be used for informational output.  For example, a message
1528
         * stating that an item has been deleted.
1529
         *
1530
         * @param {ReadableStream} content The message to output
1531
         * @returns {void}
1532
         */
1533
        async logStream(content) {
1534
                if (!this.flags.quiet) {
4,239!
1535
                        // For Node 12 when process.stdout is in pipeline it's not emitting end event correctly and it freezes.
1536
                        // See - https://github.com/nodejs/node/issues/34059
1537
                        // Using promise for now.
1538
                        content.pipe(process.stdout);
4,239✔
1539

1540
                        await new Promise((resolve, reject) => {
4,239✔
1541
                                content
4,239✔
1542
                                        .on('end', () => {
1543
                                                process.stdout.write(os.EOL);
4,239✔
1544
                                                resolve();
4,239✔
1545
                                        })
1546
                                        .on('error', (err) => {
1547
                                                reject(err);
×
1548
                                        });
1549
                        });
1550
                }
1551
        }
1552

1553
        /**
1554
         * Wraps filtered error in an error with a user-friendly description
1555
         *
1556
         * @param {Error} err  The thrown error
1557
         * @returns {Error} Error wrapped in an error with user friendly description
1558
         */
1559
        wrapError(err) {
1560
                let messageMap = {
360✔
1561
                        'invalid_grant - Refresh token has expired':
1562
                                'Your refresh token has expired. \nPlease run this command "box login --name <ENVIRONMENT_NAME> --reauthorize" to reauthorize selected environment and then run your command again.',
1563
                        'Expired Auth: Auth code or refresh token has expired':
1564
                                'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG: tokens are refreshed automatically, so this usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.',
1565
                };
1566

1567
                for (const key in messageMap) {
360✔
1568
                        if (err.message.includes(key)) {
720!
1569
                                return new BoxCLIError(messageMap[key], err);
×
1570
                        }
1571
                }
1572

1573
                return err;
360✔
1574
        }
1575

1576
        /**
1577
         * Handles an error thrown within a command
1578
         *
1579
         * @param {Error} err  The thrown error
1580
         * @returns {void}
1581
         */
1582
        async catch(err) {
1583
                const AUTH_FAILED_HINT =
1584
                        'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG: tokens are refreshed automatically, so a 401 usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.';
333✔
1585
                if (
333!
1586
                        err instanceof BoxTsErrors.BoxApiError &&
333!
1587
                        err.responseInfo &&
1588
                        err.responseInfo.body
1589
                ) {
1590
                        const responseInfo = err.responseInfo;
×
1591
                        let errorMessage = `Unexpected API Response [${responseInfo.body.status} ${responseInfo.body.message} | ${responseInfo.body.request_id}] ${responseInfo.body.code} - ${responseInfo.body.message}`;
×
1592
                        if (responseInfo.body.status === 401) {
×
1593
                                errorMessage += `\n${AUTH_FAILED_HINT}`;
×
1594
                        }
1595
                        err = new BoxCLIError(errorMessage, err);
×
1596
                }
1597
                if (err instanceof BoxTsErrors.BoxSdkError) {
333!
1598
                        try {
×
1599
                                let errorObj = JSON.parse(err.message);
×
1600
                                if (errorObj.message) {
×
1601
                                        err = new BoxCLIError(errorObj.message, err);
×
1602
                                }
1603
                        } catch (error) {
1604
                                DEBUG.execute('Error parsing BoxSdkError message: %s', error);
×
1605
                        }
1606
                }
1607
                try {
333✔
1608
                        // Let the oclif default handler run first, since it handles the help and version flags there
1609
                        /* eslint-disable promise/no-promise-in-callback */
1610
                        DEBUG.execute('Running framework error handler');
333✔
1611
                        await super.catch(this.wrapError(err));
333✔
1612
                } catch (error) {
1613
                        // The oclif default catch handler rethrows most errors; handle those here
1614
                        DEBUG.execute('Handling re-thrown error in base command handler');
333✔
1615

1616
                        if (error.code === 'EEXIT') {
333!
1617
                                // oclif throws this when it handled the error itself and wants to exit, so just let it do that
1618
                                DEBUG.execute('Got EEXIT code, exiting immediately');
×
1619
                                return;
×
1620
                        }
1621
                        let contextInfo;
1622
                        if (
333✔
1623
                                error.response &&
531✔
1624
                                error.response.body &&
1625
                                error.response.body.context_info
1626
                        ) {
1627
                                contextInfo = formatObject(error.response.body.context_info);
9✔
1628
                                // Remove color codes from context info
1629
                                // eslint-disable-next-line no-control-regex
1630
                                contextInfo = contextInfo.replaceAll(/\u001B\[\d+m/gu, '');
9✔
1631
                                // Remove \n with os.EOL
1632
                                contextInfo = contextInfo.replaceAll('\n', os.EOL);
9✔
1633
                        }
1634
                        let statusHint = '';
333✔
1635
                        const statusCode = error.statusCode || error.response?.statusCode;
333✔
1636
                        if (statusCode === 401) {
333!
1637
                                statusHint = AUTH_FAILED_HINT;
×
1638
                        }
1639
                        let errorMsg = chalk`{redBright ${
333✔
1640
                                this.flags && this.flags.verbose ? error.stack : error.message
999✔
1641
                        }${os.EOL}${contextInfo ? contextInfo + os.EOL : ''}${statusHint ? statusHint + os.EOL : ''}}`;
666!
1642

1643
                        // Write the error message but let the process exit gracefully with error code so stderr gets written out
1644
                        // @NOTE: Exiting the process in the callback enables tests to mock out stderr and run to completion!
1645

1646
                        process.stderr.write(errorMsg, () => {
333✔
1647
                                process.exitCode = 2;
333✔
1648
                        });
1649
                }
1650
        }
1651

1652
        /**
1653
         * Final hook that executes for all commands, regardless of if an error occurred
1654
         * @param {Error} [err] An error, if one occurred
1655
         * @returns {void}
1656
         */
1657
        async finally(/* err */) {
1658
                // called after run and catch regardless of whether or not the command errored
1659
        }
1660

1661
        /**
1662
         * Filter out unwanted fields from the output object(s)
1663
         *
1664
         * @param {Object|Object[]} output The output object(s)
1665
         * @param {string} [fields] Comma-separated list of fields to include
1666
         * @returns {Object|Object[]} The filtered object(s) for output
1667
         */
1668
        filterOutput(output, fields) {
1669
                if (!fields) {
7,164✔
1670
                        return output;
6,588✔
1671
                }
1672
                fields = [
576✔
1673
                        ...REQUIRED_FIELDS,
1674
                        ...fields.split(',').filter((f) => !REQUIRED_FIELDS.includes(f)),
711✔
1675
                ];
1676
                DEBUG.output('Filtering output with fields: %O', fields);
576✔
1677
                if (Array.isArray(output)) {
576✔
1678
                        output = output.map((o) =>
342✔
1679
                                typeof o === 'object' ? _.pick(o, fields) : o
1,404!
1680
                        );
1681
                } else if (typeof output === 'object') {
234!
1682
                        output = _.pick(output, fields);
234✔
1683
                }
1684
                return output;
576✔
1685
        }
1686

1687
        /**
1688
         * Flatten nested objects for output to a table/CSV
1689
         *
1690
         * @param {Object[]} objectArray The objects that will be output
1691
         * @returns {Array[]} The formatted output
1692
         */
1693
        formatForTableAndCSVOutput(objectArray) {
1694
                let formattedData = [];
27✔
1695
                if (!Array.isArray(objectArray)) {
27!
1696
                        objectArray = [objectArray];
×
1697
                        DEBUG.output('Creating tabular output from single object');
×
1698
                }
1699

1700
                let keyPaths = [];
27✔
1701
                for (let object of objectArray) {
27✔
1702
                        keyPaths = _.union(keyPaths, this.getNestedKeys(object));
126✔
1703
                }
1704

1705
                DEBUG.output('Found %d keys for tabular output', keyPaths.length);
27✔
1706
                formattedData.push(keyPaths);
27✔
1707
                for (let object of objectArray) {
27✔
1708
                        let row = [];
126✔
1709
                        if (typeof object === 'object') {
126!
1710
                                for (let keyPath of keyPaths) {
126✔
1711
                                        let value = _.get(object, keyPath);
1,584✔
1712
                                        if (value === null || value === undefined) {
1,584✔
1713
                                                row.push('');
180✔
1714
                                        } else {
1715
                                                row.push(value);
1,404✔
1716
                                        }
1717
                                }
1718
                        } else {
1719
                                row.push(object);
×
1720
                        }
1721
                        DEBUG.output('Processed row with %d values', row.length);
126✔
1722
                        formattedData.push(row);
126✔
1723
                }
1724
                DEBUG.output(
27✔
1725
                        'Processed %d rows of tabular output',
1726
                        formattedData.length - 1
1727
                );
1728
                return formattedData;
27✔
1729
        }
1730

1731
        /**
1732
         * Extracts all keys from an object and flattens them
1733
         *
1734
         * @param {Object} object The object to extract flattened keys from
1735
         * @returns {string[]} The array of flattened keys
1736
         */
1737
        getNestedKeys(object) {
1738
                let keys = [];
405✔
1739
                if (typeof object === 'object') {
405!
1740
                        for (let key in object) {
405✔
1741
                                if (
1,683✔
1742
                                        typeof object[key] === 'object' &&
1,962✔
1743
                                        !Array.isArray(object[key])
1744
                                ) {
1745
                                        let subKeys = this.getNestedKeys(object[key]);
279✔
1746
                                        subKeys = subKeys.map((x) => `${key}.${x}`);
1,026✔
1747
                                        keys = [...keys, ...subKeys];
279✔
1748
                                } else {
1749
                                        keys.push(key);
1,404✔
1750
                                }
1751
                        }
1752
                }
1753
                return keys;
405✔
1754
        }
1755

1756
        /**
1757
         * Converts time interval shorthand like 5w, -3d, etc to timestamps. It also ensures any timestamp
1758
         * passed in is properly formatted for API calls.
1759
         *
1760
         * @param {string} time The command lint input string for the datetime
1761
         * @returns {string} The full RFC3339-formatted datetime string in UTC
1762
         */
1763
        static normalizeDateString(time) {
1764
                // Attempt to parse date as timestamp or string
1765
                let newDate = /^\d+$/u.test(time)
1,152✔
1766
                        ? dateTime.parse(Number.parseInt(time, 10) * 1000)
1767
                        : dateTime.parse(time);
1768
                if (!dateTime.isValid(newDate)) {
1,152✔
1769
                        let parsedOffset = time.match(/^(-?)((?:\d+[smhdwMy])+)$/u);
261✔
1770
                        if (parsedOffset) {
261✔
1771
                                let sign = parsedOffset[1] === '-' ? -1 : 1,
216✔
1772
                                        offset = parsedOffset[2];
216✔
1773

1774
                                // Transform a string like "-1d2h3m" into an array of arg arrays, e.g.:
1775
                                // [ [-1, "d"], [-2, "h"], [-3, "m"] ]
1776
                                let argPairs = _.chunk(offset.split(/(\d+)/u).slice(1), 2).map(
216✔
1777
                                        (pair) => [sign * Number.parseInt(pair[0], 10), pair[1]]
234✔
1778
                                );
1779

1780
                                // Successively apply the offsets to the current time
1781
                                newDate = new Date();
216✔
1782
                                for (const args of argPairs) {
216✔
1783
                                        newDate = offsetDate(newDate, ...args);
234✔
1784
                                }
1785
                        } else if (time === 'now') {
45!
1786
                                newDate = new Date();
45✔
1787
                        } else {
1788
                                throw new BoxCLIError(`Cannot parse date format "${time}"`);
×
1789
                        }
1790
                }
1791

1792
                // Format the timezone to RFC3339 format for the Box API
1793
                // Also always use UTC timezone for consistency in tests
1794
                return newDate.toISOString().replace(/\.\d{3}Z$/u, '+00:00');
1,152✔
1795
        }
1796

1797
        /**
1798
         * Writes updated settings to disk
1799
         *
1800
         * @param {Object} updatedSettings The settings object to write
1801
         * @returns {void}
1802
         */
1803
        updateSettings(updatedSettings) {
1804
                this.settings = Object.assign(this.settings, updatedSettings);
×
1805
                try {
×
1806
                        fs.writeFileSync(
×
1807
                                SETTINGS_FILE_PATH,
1808
                                JSON.stringify(this.settings, null, 4),
1809
                                'utf8'
1810
                        );
1811
                } catch (error) {
1812
                        throw new BoxCLIError(
×
1813
                                `Could not write settings file ${SETTINGS_FILE_PATH}`,
1814
                                error
1815
                        );
1816
                }
1817
                return this.settings;
×
1818
        }
1819

1820
        /**
1821
         * Read the current set of environments from disk
1822
         *
1823
         * @returns {Object} The parsed environment information
1824
         */
1825
        async getEnvironments() {
1826
                if (this.supportsSecureStorage) {
23,634✔
1827
                        DEBUG.init(
15,756✔
1828
                                'Attempting secure storage read via %s service="%s" account="%s"',
1829
                                secureStorage.backend,
1830
                                ENVIRONMENTS_KEYCHAIN_SERVICE,
1831
                                ENVIRONMENTS_KEYCHAIN_ACCOUNT
1832
                        );
1833
                        try {
15,756✔
1834
                                const password = await secureStorage.getPassword(
15,756✔
1835
                                        ENVIRONMENTS_KEYCHAIN_SERVICE,
1836
                                        ENVIRONMENTS_KEYCHAIN_ACCOUNT
1837
                                );
1838
                                if (password) {
15,756✔
1839
                                        DEBUG.init(
15,750✔
1840
                                                'Successfully loaded environments from secure storage (%s)',
1841
                                                secureStorage.backend
1842
                                        );
1843
                                        return JSON.parse(password);
15,750✔
1844
                                }
1845
                                DEBUG.init(
6✔
1846
                                        'Secure storage returned empty result for service="%s" account="%s"',
1847
                                        ENVIRONMENTS_KEYCHAIN_SERVICE,
1848
                                        ENVIRONMENTS_KEYCHAIN_ACCOUNT
1849
                                );
1850
                        } catch (error) {
1851
                                DEBUG.init(
×
1852
                                        'Failed to read from secure storage (%s), falling back to file: %O',
1853
                                        secureStorage.backend,
1854
                                        getDebugErrorDetails(error)
1855
                                );
1856
                        }
1857
                } else {
1858
                        DEBUG.init(
7,878✔
1859
                                'Skipping secure storage read: platform=%s available=%s',
1860
                                process.platform,
1861
                                secureStorage.available
1862
                        );
1863
                }
1864

1865
                // Try to read from file (fallback or no secure storage)
1866
                try {
7,884✔
1867
                        if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) {
7,884✔
1868
                                DEBUG.init(
7,875✔
1869
                                        'Attempting environments fallback file read at %s',
1870
                                        ENVIRONMENTS_FILE_PATH
1871
                                );
1872
                                return JSON.parse(fs.readFileSync(ENVIRONMENTS_FILE_PATH));
7,875✔
1873
                        }
1874
                        DEBUG.init(
9✔
1875
                                'Environments fallback file does not exist at %s',
1876
                                ENVIRONMENTS_FILE_PATH
1877
                        );
1878
                } catch (error) {
1879
                        DEBUG.init(
×
1880
                                'Failed to read environments from file: %O',
1881
                                getDebugErrorDetails(error)
1882
                        );
1883
                }
1884

1885
                // No environments found in either location
1886
                throw new BoxCLIError(
9✔
1887
                        `Could not read environments. No environments found in secure storage or file ${ENVIRONMENTS_FILE_PATH}`
1888
                );
1889
        }
1890

1891
        /**
1892
         * Writes updated environment information to disk
1893
         *
1894
         * @param {Object} updatedEnvironments The environment information to write
1895
         * @param {Object} environments use to override current environment
1896
         * @returns {void}
1897
         */
1898
        async updateEnvironments(updatedEnvironments, environments) {
1899
                if (environments === undefined) {
7,875!
1900
                        environments = await this.getEnvironments();
×
1901
                }
1902
                Object.assign(environments, updatedEnvironments);
7,875✔
1903

1904
                let storedInSecureStorage = false;
7,875✔
1905

1906
                if (this.supportsSecureStorage) {
7,875✔
1907
                        DEBUG.init(
5,250✔
1908
                                'Attempting secure storage write via %s service="%s" account="%s"',
1909
                                secureStorage.backend,
1910
                                ENVIRONMENTS_KEYCHAIN_SERVICE,
1911
                                ENVIRONMENTS_KEYCHAIN_ACCOUNT
1912
                        );
1913
                        try {
5,250✔
1914
                                await secureStorage.setPassword(
5,250✔
1915
                                        ENVIRONMENTS_KEYCHAIN_SERVICE,
1916
                                        ENVIRONMENTS_KEYCHAIN_ACCOUNT,
1917
                                        JSON.stringify(environments)
1918
                                );
1919
                                storedInSecureStorage = true;
5,250✔
1920
                                DEBUG.init(
5,250✔
1921
                                        'Stored environment configuration in secure storage (%s)',
1922
                                        secureStorage.backend
1923
                                );
1924
                                if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) {
5,250!
UNCOV
1925
                                        fs.unlinkSync(ENVIRONMENTS_FILE_PATH);
×
1926
                                        DEBUG.init(
×
1927
                                                'Removed environment configuration file after migrating to secure storage'
1928
                                        );
1929
                                }
1930
                        } catch (error) {
UNCOV
1931
                                DEBUG.init(
×
1932
                                        'Could not store credentials in secure storage (%s), falling back to file: %O',
1933
                                        secureStorage.backend,
1934
                                        getDebugErrorDetails(error)
1935
                                );
1936
                        }
1937
                } else {
1938
                        DEBUG.init(
2,625✔
1939
                                'Skipping secure storage write: platform=%s available=%s',
1940
                                process.platform,
1941
                                secureStorage.available
1942
                        );
1943
                }
1944

1945
                // Write to file if secure storage failed or not available
1946
                if (!storedInSecureStorage) {
7,875✔
1947
                        try {
2,625✔
1948
                                let fileContents = JSON.stringify(environments, null, 4);
2,625✔
1949
                                fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8');
2,625✔
1950

1951
                                if (process.platform === 'linux' && this.supportsSecureStorage) {
2,625!
UNCOV
1952
                                        this.info(
×
1953
                                                'Could not store credentials in secure storage, falling back to file.' +
1954
                                                        ' To enable secure storage on Linux, install libsecret-1-dev package.'
1955
                                        );
1956
                                }
1957
                        } catch (error) {
1958
                                throw new BoxCLIError(
×
1959
                                        `Could not write environments config file ${ENVIRONMENTS_FILE_PATH}`,
1960
                                        error
1961
                                );
1962
                        }
1963
                }
1964

1965
                return environments;
7,875✔
1966
        }
1967

1968
        /**
1969
         * Initialize the CLI by creating the necessary configuration files on disk
1970
         * in the users' home directory, then read and parse the CLI settings file.
1971
         *
1972
         * @returns {Object} The parsed settings
1973
         * @private
1974
         */
1975
        async _loadSettings() {
1976
                try {
7,875✔
1977
                        if (!fs.existsSync(CONFIG_FOLDER_PATH)) {
7,875✔
1978
                                mkdirp.sync(CONFIG_FOLDER_PATH);
9✔
1979
                                DEBUG.init('Created config folder at %s', CONFIG_FOLDER_PATH);
9✔
1980
                        }
1981

1982
                        // Check if environments exist (in secure storage or file)
1983
                        let environmentsExist = false;
7,875✔
1984
                        try {
7,875✔
1985
                                const environments = await this.getEnvironments();
7,875✔
1986
                                // Check if there are any environments configured
1987
                                if (
7,866!
1988
                                        environments &&
23,598✔
1989
                                        environments.environments &&
1990
                                        Object.keys(environments.environments).length > 0
1991
                                ) {
1992
                                        environmentsExist = true;
×
1993
                                        DEBUG.init('Found existing environments in storage');
×
1994
                                }
1995
                        } catch (error) {
1996
                                // No environments found, need to create defaults
1997
                                DEBUG.init('No existing environments found: %s', error.message);
9✔
1998
                        }
1999

2000
                        if (!environmentsExist) {
7,875!
2001
                                // Create default environments (will be stored in secure storage if available)
2002
                                await this.updateEnvironments(
7,875✔
2003
                                        {},
2004
                                        this._getDefaultEnvironments()
2005
                                );
2006
                                DEBUG.init('Created default environments configuration');
7,875✔
2007
                        }
2008

2009
                        if (!fs.existsSync(SETTINGS_FILE_PATH)) {
7,875✔
2010
                                let settingsJSON = JSON.stringify(
9✔
2011
                                        this._getDefaultSettings(),
2012
                                        null,
2013
                                        4
2014
                                );
2015
                                fs.writeFileSync(SETTINGS_FILE_PATH, settingsJSON, 'utf8');
9✔
2016
                                DEBUG.init(
9✔
2017
                                        'Created settings file at %s %O',
2018
                                        SETTINGS_FILE_PATH,
2019
                                        settingsJSON
2020
                                );
2021
                        }
2022
                } catch (error) {
2023
                        throw new BoxCLIError(
×
2024
                                'Could not initialize CLI home directory',
2025
                                error
2026
                        );
2027
                }
2028

2029
                let settings;
2030
                try {
7,875✔
2031
                        settings = JSON.parse(fs.readFileSync(SETTINGS_FILE_PATH));
7,875✔
2032
                        settings = Object.assign(this._getDefaultSettings(), settings);
7,875✔
2033
                        DEBUG.init('Loaded settings %O', settings);
7,875✔
2034
                } catch (error) {
2035
                        throw new BoxCLIError(
×
2036
                                `Could not read CLI settings file at ${SETTINGS_FILE_PATH}`,
2037
                                error
2038
                        );
2039
                }
2040

2041
                try {
7,875✔
2042
                        if (!fs.existsSync(settings.boxReportsFolderPath)) {
7,875✔
2043
                                mkdirp.sync(settings.boxReportsFolderPath);
9✔
2044
                                DEBUG.init(
9✔
2045
                                        'Created reports folder at %s',
2046
                                        settings.boxReportsFolderPath
2047
                                );
2048
                        }
2049
                        if (!fs.existsSync(settings.boxDownloadsFolderPath)) {
7,875✔
2050
                                mkdirp.sync(settings.boxDownloadsFolderPath);
9✔
2051
                                DEBUG.init(
9✔
2052
                                        'Created downloads folder at %s',
2053
                                        settings.boxDownloadsFolderPath
2054
                                );
2055
                        }
2056
                } catch (error) {
2057
                        throw new BoxCLIError(
×
2058
                                'Failed creating CLI working directory',
2059
                                error
2060
                        );
2061
                }
2062

2063
                return settings;
7,875✔
2064
        }
2065

2066
        /**
2067
         * Get the default settings object
2068
         *
2069
         * @returns {Object} The default settings object
2070
         * @private
2071
         */
2072
        _getDefaultSettings() {
2073
                return {
7,884✔
2074
                        boxReportsFolderPath: path.join(
2075
                                os.homedir(),
2076
                                'Documents/Box-Reports'
2077
                        ),
2078
                        boxReportsFileFormat: 'txt',
2079
                        boxDownloadsFolderPath: path.join(
2080
                                os.homedir(),
2081
                                'Downloads/Box-Downloads'
2082
                        ),
2083
                        outputJson: false,
2084
                        enableProxy: false,
2085
                        proxy: {
2086
                                url: null,
2087
                                username: null,
2088
                                password: null,
2089
                        },
2090
                        enableAnalyticsClient: false,
2091
                        analyticsClient: {
2092
                                name: null,
2093
                        },
2094
                };
2095
        }
2096

2097
        /**
2098
         * Get the default environments object
2099
         *
2100
         * @returns {Object} The default environments object
2101
         * @private
2102
         */
2103
        _getDefaultEnvironments() {
2104
                return {
7,875✔
2105
                        default: null,
2106
                        environments: {},
2107
                };
2108
        }
2109
}
2110

2111
BoxCommand.flags = {
153✔
2112
        token: Flags.string({
2113
                char: 't',
2114
                description: 'Provide a token to perform this call',
2115
        }),
2116
        'as-user': Flags.string({ description: 'Provide an ID for a user' }),
2117
        // @NOTE: This flag is not read anywhere directly; the chalk library automatically turns off color when it's passed
2118
        'no-color': Flags.boolean({
2119
                description: 'Turn off colors for logging',
2120
        }),
2121
        json: Flags.boolean({
2122
                description: 'Output formatted JSON',
2123
                exclusive: ['csv'],
2124
        }),
2125
        csv: Flags.boolean({
2126
                description: 'Output formatted CSV',
2127
                exclusive: ['json'],
2128
        }),
2129
        save: Flags.boolean({
2130
                char: 's',
2131
                description: 'Save report to default reports folder on disk',
2132
                exclusive: ['save-to-file-path'],
2133
        }),
2134
        'save-to-file-path': Flags.string({
2135
                description: 'Override default file path to save report',
2136
                exclusive: ['save'],
2137
                parse: utils.parsePath,
2138
        }),
2139
        fields: Flags.string({
2140
                description: 'Comma separated list of fields to show',
2141
        }),
2142
        'bulk-file-path': Flags.string({
2143
                description: 'File path to bulk .csv or .json objects',
2144
                parse: utils.parsePath,
2145
        }),
2146
        help: Flags.help({
2147
                char: 'h',
2148
                description: 'Show CLI help',
2149
        }),
2150
        verbose: Flags.boolean({
2151
                char: 'v',
2152
                description: 'Show verbose output, which can be helpful for debugging',
2153
        }),
2154
        yes: Flags.boolean({
2155
                char: 'y',
2156
                description: 'Automatically respond yes to all confirmation prompts',
2157
        }),
2158
        quiet: Flags.boolean({
2159
                char: 'q',
2160
                description: 'Suppress any non-error output to stderr',
2161
        }),
2162
};
2163

2164
BoxCommand.minFlags = _.pick(BoxCommand.flags, [
153✔
2165
        'no-color',
2166
        'help',
2167
        'verbose',
2168
        'quiet',
2169
]);
2170

2171
module.exports = BoxCommand;
153✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc