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

box / boxcli / 23008494851

12 Mar 2026 03:00PM UTC coverage: 84.064% (-0.06%) from 84.12%
23008494851

Pull #643

github

web-flow
Merge dfbdd9d71 into a58ff34dd
Pull Request #643: feat: Improve developer and agent experience in `boxcli` commands

1422 of 1940 branches covered (73.3%)

Branch coverage included in aggregate %.

25 of 32 new or added lines in 12 files covered. (78.13%)

2 existing lines in 2 files now uncovered.

4903 of 5584 relevant lines covered (87.8%)

626.16 hits per line

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

75.41
/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
let keytar = null;
153✔
39
try {
153✔
40
        keytar = require('keytar');
153✔
41
} catch {
42
        // keytar cannot be imported because the library is not provided for this operating system / architecture
43
}
44

45
const DEBUG = require('./debug');
153✔
46
const stream = require('node:stream');
153✔
47
const pipeline = promisify(stream.pipeline);
153✔
48

49
const { Transform } = require('node:stream');
153✔
50

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

79
const REQUIRED_FIELDS = ['type', 'id'];
153✔
80

81
const SDK_CONFIG = Object.freeze({
153✔
82
        iterators: true,
83
        analyticsClient: {
84
                version: pkg.version,
85
        },
86
        request: {
87
                headers: {
88
                        'User-Agent': `Box CLI v${pkg.version}`,
89
                },
90
        },
91
});
92

93
const CONFIG_FOLDER_PATH = path.join(os.homedir(), '.box');
153✔
94
const SETTINGS_FILE_PATH = path.join(CONFIG_FOLDER_PATH, 'settings.json');
153✔
95
const ENVIRONMENTS_FILE_PATH = path.join(
153✔
96
        CONFIG_FOLDER_PATH,
97
        'box_environments.json'
98
);
99

100
const DEFAULT_ANALYTICS_CLIENT_NAME = 'box-cli';
153✔
101

102
/**
103
 * Parse a string value from CSV into the correct boolean value
104
 * @param {string|boolean} value The value to parse
105
 * @returns {boolean} The parsed value
106
 * @private
107
 */
108
function getBooleanFlagValue(value) {
109
        let trueValues = ['yes', 'y', 'true', '1', 't', 'on'];
1,593✔
110
        let falseValues = ['no', 'n', 'false', '0', 'f', 'off'];
1,593✔
111
        if (typeof value === 'boolean') {
1,593✔
112
                return value;
1,467✔
113
        } else if (trueValues.includes(value.toLowerCase())) {
126✔
114
                return true;
81✔
115
        } else if (falseValues.includes(value.toLowerCase())) {
45!
116
                return false;
45✔
117
        }
118
        let possibleValues = [...trueValues, ...falseValues].join(', ');
×
119
        throw new Error(
×
120
                `Incorrect boolean value "${value}" passed. Possible values are ${possibleValues}`
121
        );
122
}
123

124
/**
125
 * Removes all the undefined values from the object
126
 *
127
 * @param {Object} obj The object to format for display
128
 * @returns {Object} The formatted object output
129
 */
130
function removeUndefinedValues(obj) {
131
        if (typeof obj !== 'object' || obj === null) {
295,182✔
132
                return obj;
239,922✔
133
        }
134

135
        if (Array.isArray(obj)) {
55,260✔
136
                return obj.map((item) => removeUndefinedValues(item));
14,040✔
137
        }
138

139
        for (const key of Object.keys(obj)) {
48,402✔
140
                if (obj[key] === undefined) {
274,509✔
141
                        delete obj[key];
126✔
142
                } else {
143
                        obj[key] = removeUndefinedValues(obj[key]);
274,383✔
144
                }
145
        }
146

147
        return obj;
48,402✔
148
}
149

150
/**
151
 * Add or subtract a given offset from a date
152
 *
153
 * @param {Date} date The date to offset
154
 * @param {int} timeLength The number of time units to offset by
155
 * @param {string} timeUnit The unit of time to offset by, in single-character shorthand
156
 * @returns {Date} The date with offset applied
157
 */
158
function offsetDate(date, timeLength, timeUnit) {
159
        switch (timeUnit) {
234!
160
                case 's': {
161
                        return dateTime.addSeconds(date, timeLength);
36✔
162
                }
163
                case 'm': {
164
                        return dateTime.addMinutes(date, timeLength);
27✔
165
                }
166
                case 'h': {
167
                        return dateTime.addHours(date, timeLength);
36✔
168
                }
169
                case 'd': {
170
                        return dateTime.addDays(date, timeLength);
54✔
171
                }
172
                case 'w': {
173
                        return dateTime.addWeeks(date, timeLength);
27✔
174
                }
175
                case 'M': {
176
                        return dateTime.addMonths(date, timeLength);
27✔
177
                }
178
                case 'y': {
179
                        return dateTime.addYears(date, timeLength);
27✔
180
                }
181
                default: {
182
                        throw new Error(`Invalid time unit: ${timeUnit}`);
×
183
                }
184
        }
185
}
186

187
/**
188
 * Formats an API key (e.g. field name) for human-readable display
189
 *
190
 * @param {string} key The key to format
191
 * @returns {string} The formatted key
192
 * @private
193
 */
194
function formatKey(key) {
195
        // Converting camel case to snake case and then to title case
196
        return key
49,752✔
197
                .replaceAll(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`)
432✔
198
                .split('_')
199
                .map((s) => KEY_MAPPINGS[s] || _.capitalize(s))
67,266✔
200
                .join(' ');
201
}
202

203
/**
204
 * Formats an object's keys for human-readable output
205
 * @param {*} obj The thing to format
206
 * @returns {*} The formatted thing
207
 * @private
208
 */
209
function formatObjectKeys(obj) {
210
        // No need to process primitive values
211
        if (typeof obj !== 'object' || obj === null) {
53,217✔
212
                return obj;
42,912✔
213
        }
214

215
        // If type is Date, convert to ISO string
216
        if (obj instanceof Date) {
10,305✔
217
                return obj.toISOString();
36✔
218
        }
219

220
        // Don't format metadata objects to avoid mangling keys
221
        if (obj.$type) {
10,269✔
222
                return obj;
90✔
223
        }
224

225
        if (Array.isArray(obj)) {
10,179✔
226
                return obj.map((el) => formatObjectKeys(el));
1,323✔
227
        }
228

229
        let formattedObj = Object.create(null);
9,207✔
230
        for (const key of Object.keys(obj)) {
9,207✔
231
                let formattedKey = formatKey(key);
49,599✔
232
                formattedObj[formattedKey] = formatObjectKeys(obj[key]);
49,599✔
233
        }
234

235
        return formattedObj;
9,207✔
236
}
237

238
/**
239
 * Formats an object for output by prettifying its keys
240
 * and rendering it in a more human-readable form (i.e. YAML)
241
 *
242
 * @param {Object} obj The object to format for display
243
 * @returns {string} The formatted object output
244
 * @private
245
 */
246
function formatObject(obj) {
247
        let outputData = formatObjectKeys(obj);
2,295✔
248

249
        // Other objects are formatted as YAML for human-readable output
250
        let yamlString = yaml.dump(outputData, {
2,295✔
251
                indent: 4,
252
                noRefs: true,
253
        });
254

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

262
/**
263
 * Formats the object header, used to separate multiple objects in a collection
264
 *
265
 * @param {Object} obj The object to generate a header for
266
 * @returns {string} The header string
267
 * @private
268
 */
269
function formatObjectHeader(obj) {
270
        if (!obj.type || !obj.id) {
153!
271
                return chalk`{dim ----------}`;
×
272
        }
273
        return chalk`{dim ----- ${formatKey(obj.type)} ${obj.id} -----}`;
153✔
274
}
275

276
/**
277
 * Base class for all Box CLI commands
278
 */
279
class BoxCommand extends Command {
280
        // @TODO(2018-08-15): Move all fs methods used here to be async
281

282
        /**
283
         * Initialize before the command is run
284
         * @returns {void}
285
         */
286
        async init() {
287
                DEBUG.init('Initializing Box CLI');
7,875✔
288
                let originalArgs, originalFlags;
289
                if (
7,875✔
290
                        this.argv.some((arg) => arg.startsWith('--bulk-file-path')) &&
32,382✔
291
                        Object.keys(this.constructor.flags).includes('bulk-file-path')
292
                ) {
293
                        // Set up the command for bulk run
294
                        DEBUG.init('Preparing for bulk input');
324✔
295
                        this.isBulk = true;
324✔
296
                        // eslint-disable-next-line unicorn/prefer-structured-clone
297
                        originalArgs = _.cloneDeep(this.constructor.args);
324✔
298
                        // eslint-disable-next-line unicorn/prefer-structured-clone
299
                        originalFlags = _.cloneDeep(this.constructor.flags);
324✔
300
                        this.disableRequiredArgsAndFlags();
324✔
301
                }
302

303
                this.supportsSecureStorage =
7,875✔
304
                        keytar && ['darwin', 'win32', 'linux'].includes(process.platform);
13,125✔
305

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

308
                this.flags = flags;
7,875✔
309
                this.args = args;
7,875✔
310
                this.settings = await this._loadSettings();
7,875✔
311
                this.client = await this.getClient();
7,875✔
312
                this.tsClient = await this.getTsClient();
7,875✔
313

314
                if (this.isBulk) {
7,875✔
315
                        this.constructor.args = originalArgs;
324✔
316
                        this.constructor.flags = originalFlags;
324✔
317
                        this.bulkOutputList = [];
324✔
318
                        this.bulkErrors = [];
324✔
319
                        this._singleRun = this.run;
324✔
320
                        this.run = this.bulkOutputRun;
324✔
321
                }
322

323
                DEBUG.execute(
7,875✔
324
                        'Starting execution command: %s argv: %O',
325
                        this.id,
326
                        this.argv
327
                );
328
        }
329

330
        /**
331
         * Read in the input file and run the command once for each set of inputs
332
         * @returns {void}
333
         */
334
        async bulkOutputRun() {
335
                const allPossibleArgs = Object.keys(this.constructor.args || {});
324!
336
                const allPossibleFlags = Object.keys(this.constructor.flags || {});
324!
337
                // Map from matchKey (arg/flag name in all lower-case characters) => {type, fieldKey}
338
                let fieldMapping = Object.assign(
324✔
339
                        {},
340
                        ...allPossibleArgs.map((arg) => ({
486✔
341
                                [arg.toLowerCase()]: { type: 'arg', fieldKey: arg },
342
                        })),
343
                        ...allPossibleFlags.map((flag) => ({
8,082✔
344
                                [flag.replaceAll('-', '')]: { type: 'flag', fieldKey: flag },
345
                        }))
346
                );
347
                let bulkCalls = await this._parseBulkFile(
324✔
348
                        this.flags['bulk-file-path'],
349
                        fieldMapping
350
                );
351
                let bulkEntryIndex = 0;
279✔
352
                let progressBar = new progress.Bar({
279✔
353
                        format: '[{bar}] {percentage}% | {value}/{total}',
354
                        stopOnComplete: true,
355
                });
356
                progressBar.start(bulkCalls.length, 0);
279✔
357

358
                for (let bulkData of bulkCalls) {
279✔
359
                        this.argv = [];
603✔
360
                        bulkEntryIndex += 1;
603✔
361
                        this._getArgsForBulkInput(allPossibleArgs, bulkData);
603✔
362
                        this._setFlagsForBulkInput(bulkData);
603✔
363
                        await this._handleAsUserSettings(bulkData);
603✔
364
                        DEBUG.execute('Executing in bulk mode argv: %O', this.argv);
603✔
365
                        // @TODO(2018-08-29): Convert this to a promise queue to improve performance
366

367
                        try {
603✔
368
                                await this._singleRun();
603✔
369
                        } catch (error) {
370
                                // In bulk mode, we don't want to write directly to console and kill the command
371
                                // Instead, we should buffer the error output so subsequent commands might be able to succeed
372
                                DEBUG.execute(
27✔
373
                                        'Caught error from bulk input entry %d',
374
                                        bulkEntryIndex
375
                                );
376
                                this.bulkErrors.push({
27✔
377
                                        index: bulkEntryIndex,
378
                                        data: bulkData,
379
                                        error: this.wrapError(error),
380
                                });
381
                        }
382

383
                        progressBar.update(bulkEntryIndex);
603✔
384
                }
385
                this.isBulk = false;
279✔
386
                DEBUG.execute('Leaving bulk mode and writing final output');
279✔
387
                await this.output(this.bulkOutputList);
279✔
388
                this._handleBulkErrors();
279✔
389
        }
390

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

438
        /**
439
         * Set as-user header from the bulk file or use the default one.
440
         * @param {Array} bulkData Bulk data
441
         * @returns {Promise<void>} Returns nothing
442
         * @private
443
         */
444
        async _handleAsUserSettings(bulkData) {
445
                let asUser = bulkData.find((o) => o.fieldKey === 'as-user') || {};
1,647✔
446
                if (!_.isEmpty(asUser)) {
603✔
447
                        if (_.isNil(asUser.value)) {
27✔
448
                                let environmentsObj = await this.getEnvironments();
9✔
449
                                if (environmentsObj.default) {
9!
450
                                        let environment =
451
                                                environmentsObj.environments[environmentsObj.default];
×
452
                                        DEBUG.init(
×
453
                                                'Using environment %s %O',
454
                                                environmentsObj.default,
455
                                                environment
456
                                        );
457
                                        if (environment.useDefaultAsUser) {
×
458
                                                this.client.asUser(environment.defaultAsUserId);
×
459
                                                DEBUG.init(
×
460
                                                        'Impersonating default user ID %s',
461
                                                        environment.defaultAsUserId
462
                                                );
463
                                        } else {
464
                                                this.client.asSelf();
×
465
                                        }
466
                                } else {
467
                                        this.client.asSelf();
9✔
468
                                }
469
                        } else {
470
                                this.client.asUser(asUser.value);
18✔
471
                                DEBUG.init('Impersonating user ID %s', asUser.value);
18✔
472
                        }
473
                }
474
        }
475

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

512
        /**
513
         * For each possible arg, find the correct value between bulk input and values given on the command line.
514
         * @param {Array} allPossibleArgs All possible args
515
         * @param {Array} bulkData Bulk data
516
         * @returns {void}
517
         * @private
518
         */
519
        _getArgsForBulkInput(allPossibleArgs, bulkData) {
520
                for (let arg of allPossibleArgs) {
603✔
521
                        let bulkArg = bulkData.find((o) => o.fieldKey === arg) || {};
1,422✔
522
                        if (!_.isNil(bulkArg.value)) {
927✔
523
                                // Use value from bulk input file when available
524
                                this.argv.push(bulkArg.value);
756✔
525
                        } else if (this.args[arg]) {
171✔
526
                                // Fall back to value from command line
527
                                this.argv.push(this.args[arg]);
135✔
528
                        }
529
                }
530
        }
531

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

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

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

676
        /**
677
         * Returns bulk file contents
678
         * @param {String} filePath Path to bulk file
679
         * @returns {Buffer} Bulk file contents
680
         * @private
681
         */
682
        _readBulkFile(filePath) {
683
                try {
324✔
684
                        const fileContents = fs.readFileSync(filePath);
324✔
685
                        DEBUG.execute('Read bulk input file at %s', filePath);
324✔
686
                        return fileContents;
324✔
687
                } catch (error) {
688
                        throw new BoxCLIError(
×
689
                                `Could not open input file ${filePath}`,
690
                                error
691
                        );
692
                }
693
        }
694

695
        /**
696
         * Writes a given flag value to the command's argv array
697
         *
698
         * @param {string} flag The flag name
699
         * @param {*} flagValue The flag value
700
         * @returns {void}
701
         * @private
702
         */
703
        _addFlagToArgv(flag, flagValue) {
704
                if (_.isNil(flagValue)) {
3,177✔
705
                        return;
108✔
706
                }
707

708
                if (this.constructor.flags[flag].type === 'boolean') {
3,069✔
709
                        if (getBooleanFlagValue(flagValue)) {
1,593✔
710
                                this.argv.push(`--${flag}`);
1,494✔
711
                        } else {
712
                                this.argv.push(`--no-${flag}`);
99✔
713
                        }
714
                } else {
715
                        this.argv.push(`--${flag}=${flagValue}`);
1,476✔
716
                }
717
        }
718

719
        /**
720
         * Ensure that all args and flags for the command are not marked as required,
721
         * to avoid issues when filling in required values from the input file.
722
         * @returns {void}
723
         */
724
        disableRequiredArgsAndFlags() {
725
                if (this.constructor.args !== undefined) {
324!
726
                        for (const key of Object.keys(this.constructor.args)) {
324✔
727
                                this.constructor.args[key].required = false;
486✔
728
                        }
729
                }
730

731
                if (this.constructor.flags !== undefined) {
324!
732
                        for (const key of Object.keys(this.constructor.flags)) {
324✔
733
                                this.constructor.flags[key].required = false;
8,082✔
734
                        }
735
                }
736
        }
737

738
        /**
739
         * Instantiate the SDK client for making API calls
740
         *
741
         * @returns {BoxClient} The client for making API calls in the command
742
         */
743
        async getClient() {
744
                // Allow some commands (e.g. configure:environments:add, login) to skip client setup so they can run
745
                if (this.constructor.noClient) {
7,875!
746
                        return null;
×
747
                }
748
                let environmentsObj = await this.getEnvironments();
7,875✔
749
                const environment =
750
                        environmentsObj.environments[environmentsObj.default] || {};
7,875✔
751
                const { authMethod } = environment;
7,875✔
752

753
                let client;
754
                if (this.flags.token) {
7,875!
755
                        DEBUG.init('Using passed in token %s', this.flags.token);
7,875✔
756
                        let sdk = new BoxSDK({
7,875✔
757
                                clientID: '',
758
                                clientSecret: '',
759
                                ...SDK_CONFIG,
760
                        });
761
                        this._configureSdk(sdk, { ...SDK_CONFIG });
7,875✔
762
                        this.sdk = sdk;
7,875✔
763
                        client = sdk.getBasicClient(this.flags.token);
7,875✔
764
                } else if (authMethod === 'ccg') {
×
765
                        DEBUG.init('Using Client Credentials Grant Authentication');
×
766

767
                        const { clientId, clientSecret, ccgUser } = environment;
×
768

769
                        if (!clientId || !clientSecret) {
×
770
                                throw new BoxCLIError(
×
771
                                        'You need to have a default environment with clientId and clientSecret in order to use CCG'
772
                                );
773
                        }
774

775
                        let configObj;
776
                        try {
×
777
                                configObj = JSON.parse(
×
778
                                        fs.readFileSync(environment.boxConfigFilePath)
779
                                );
780
                        } catch (error) {
781
                                throw new BoxCLIError(
×
782
                                        'Could not read environments config file',
783
                                        error
784
                                );
785
                        }
786

787
                        const { enterpriseID } = configObj;
×
788
                        const sdk = new BoxSDK({
×
789
                                clientID: clientId,
790
                                clientSecret,
791
                                enterpriseID,
792
                                ...SDK_CONFIG,
793
                        });
794
                        this._configureSdk(sdk, { ...SDK_CONFIG });
×
795
                        this.sdk = sdk;
×
796
                        client = ccgUser
×
797
                                ? sdk.getCCGClientForUser(ccgUser)
798
                                : sdk.getAnonymousClient();
799
                } else if (
×
800
                        environmentsObj.default &&
×
801
                        environmentsObj.environments[environmentsObj.default].authMethod ===
802
                                'oauth20'
803
                ) {
804
                        try {
×
805
                                DEBUG.init(
×
806
                                        'Using environment %s %O',
807
                                        environmentsObj.default,
808
                                        environment
809
                                );
810
                                let tokenCache = new CLITokenCache(environmentsObj.default);
×
811

812
                                let sdk = new BoxSDK({
×
813
                                        clientID: environment.clientId,
814
                                        clientSecret: environment.clientSecret,
815
                                        ...SDK_CONFIG,
816
                                });
817
                                this._configureSdk(sdk, { ...SDK_CONFIG });
×
818
                                this.sdk = sdk;
×
819
                                let tokenInfo = await new Promise((resolve, reject) => {
×
820
                                        tokenCache.read((error, localTokenInfo) => {
×
821
                                                if (error) {
×
822
                                                        reject(error);
×
823
                                                } else {
824
                                                        resolve(localTokenInfo);
×
825
                                                }
826
                                        });
827
                                });
828
                                client = sdk.getPersistentClient(tokenInfo, tokenCache);
×
829
                        } catch {
830
                                throw new BoxCLIError(
×
831
                                        `Can't load the default OAuth environment "${environmentsObj.default}". Please reauthorize selected environment, login again or provide a token.`
832
                                );
833
                        }
834
                } else if (environmentsObj.default) {
×
835
                        DEBUG.init(
×
836
                                'Using environment %s %O',
837
                                environmentsObj.default,
838
                                environment
839
                        );
840
                        let tokenCache =
841
                                environment.cacheTokens === false
×
842
                                        ? null
843
                                        : new CLITokenCache(environmentsObj.default);
844
                        let configObj;
845
                        try {
×
846
                                configObj = JSON.parse(
×
847
                                        fs.readFileSync(environment.boxConfigFilePath)
848
                                );
849
                        } catch (error) {
850
                                throw new BoxCLIError(
×
851
                                        'Could not read environments config file',
852
                                        error
853
                                );
854
                        }
855

856
                        if (!environment.hasInLinePrivateKey) {
×
857
                                try {
×
858
                                        configObj.boxAppSettings.appAuth.privateKey =
×
859
                                                fs.readFileSync(environment.privateKeyPath, 'utf8');
860
                                        DEBUG.init(
×
861
                                                'Loaded JWT private key from %s',
862
                                                environment.privateKeyPath
863
                                        );
864
                                } catch (error) {
865
                                        throw new BoxCLIError(
×
866
                                                `Could not read private key file ${environment.privateKeyPath}`,
867
                                                error
868
                                        );
869
                                }
870
                        }
871

872
                        this.sdk = BoxSDK.getPreconfiguredInstance(configObj);
×
873
                        this._configureSdk(this.sdk, { ...SDK_CONFIG });
×
874

875
                        client = this.sdk.getAppAuthClient(
×
876
                                'enterprise',
877
                                environment.enterpriseId,
878
                                tokenCache
879
                        );
880
                        DEBUG.init('Initialized client from environment config');
×
881
                } else {
882
                        // No environments set up yet!
883
                        throw new BoxCLIError(
×
884
                                `No default environment found.
885
                                It looks like you haven't configured the Box CLI yet.
886
                                See this command for help adding an environment: box configure:environments:add --help
887
                                Or, supply a token with your command with --token.`.replaceAll(/^\s+/gmu, '')
888
                        );
889
                }
890

891
                // Using the as-user flag should have precedence over the environment setting
892
                if (this.flags['as-user']) {
7,875✔
893
                        client.asUser(this.flags['as-user']);
9✔
894
                        DEBUG.init(
9✔
895
                                'Impersonating user ID %s using the ID provided via the --as-user flag',
896
                                this.flags['as-user']
897
                        );
898
                } else if (!this.flags.token && environment.useDefaultAsUser) {
7,866!
899
                        // We don't want to use any environment settings if a token is passed in the command
900
                        client.asUser(environment.defaultAsUserId);
×
901
                        DEBUG.init(
×
902
                                'Impersonating default user ID %s using environment configuration',
903
                                environment.defaultAsUserId
904
                        );
905
                }
906
                return client;
7,875✔
907
        }
908

909
        /**
910
         * Instantiate the TypeScript SDK client for making API calls
911
         *
912
         * @returns {BoxTSSDK.BoxClient} The TypeScript SDK client for making API calls in the command
913
         */
914
        async getTsClient() {
915
                // Allow some commands (e.g. configure:environments:add, login) to skip client setup so they can run
916
                if (this.constructor.noClient) {
7,875!
917
                        return null;
×
918
                }
919
                let environmentsObj = await this.getEnvironments();
7,875✔
920
                const environment =
921
                        environmentsObj.environments[environmentsObj.default] || {};
7,875✔
922
                const { authMethod } = environment;
7,875✔
923

924
                let client;
925
                if (this.flags.token) {
7,875!
926
                        DEBUG.init('Using passed in token %s', this.flags.token);
7,875✔
927
                        let tsSdkAuth = new BoxTSSDK.BoxDeveloperTokenAuth({
7,875✔
928
                                token: this.flags.token,
929
                        });
930
                        client = new BoxTSSDK.BoxClient({
7,875✔
931
                                auth: tsSdkAuth,
932
                        });
933
                        client = this._configureTsSdk(client, SDK_CONFIG);
7,875✔
934
                } else if (authMethod === 'ccg') {
×
935
                        DEBUG.init('Using Client Credentials Grant Authentication');
×
936

937
                        const { clientId, clientSecret, ccgUser } = environment;
×
938

939
                        if (!clientId || !clientSecret) {
×
940
                                throw new BoxCLIError(
×
941
                                        'You need to have a default environment with clientId and clientSecret in order to use CCG'
942
                                );
943
                        }
944

945
                        let configObj;
946
                        try {
×
947
                                configObj = JSON.parse(
×
948
                                        fs.readFileSync(environment.boxConfigFilePath)
949
                                );
950
                        } catch (error) {
951
                                throw new BoxCLIError(
×
952
                                        'Could not read environments config file',
953
                                        error
954
                                );
955
                        }
956

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

1031
                        if (!environment.hasInLinePrivateKey) {
×
1032
                                try {
×
1033
                                        configObj.boxAppSettings.appAuth.privateKey =
×
1034
                                                fs.readFileSync(environment.privateKeyPath, 'utf8');
1035
                                        DEBUG.init(
×
1036
                                                'Loaded JWT private key from %s',
1037
                                                environment.privateKeyPath
1038
                                        );
1039
                                } catch (error) {
1040
                                        throw new BoxCLIError(
×
1041
                                                `Could not read private key file ${environment.privateKeyPath}`,
1042
                                                error
1043
                                        );
1044
                                }
1045
                        }
1046

1047
                        const jwtConfig = new BoxTSSDK.JwtConfig({
×
1048
                                clientId: configObj.boxAppSettings.clientID,
1049
                                clientSecret: configObj.boxAppSettings.clientSecret,
1050
                                jwtKeyId: configObj.boxAppSettings.appAuth.publicKeyID,
1051
                                privateKey: configObj.boxAppSettings.appAuth.privateKey,
1052
                                privateKeyPassphrase:
1053
                                        configObj.boxAppSettings.appAuth.passphrase,
1054
                                enterpriseId: environment.enterpriseId,
1055
                                tokenStorage: tokenCache,
1056
                        });
1057
                        let jwtAuth = new BoxTSSDK.BoxJwtAuth({ config: jwtConfig });
×
1058
                        client = new BoxTSSDK.BoxClient({ auth: jwtAuth });
×
1059

1060
                        DEBUG.init('Initialized client from environment config');
×
1061
                        if (environment.useDefaultAsUser) {
×
1062
                                client = client.withAsUserHeader(environment.defaultAsUserId);
×
1063
                                DEBUG.init(
×
1064
                                        'Impersonating default user ID %s',
1065
                                        environment.defaultAsUserId
1066
                                );
1067
                        }
1068
                        client = this._configureTsSdk(client, SDK_CONFIG);
×
1069
                } else {
1070
                        // No environments set up yet!
1071
                        throw new BoxCLIError(
×
1072
                                `No default environment found.
1073
                                It looks like you haven't configured the Box CLI yet.
1074
                                See this command for help adding an environment: box configure:environments:add --help
1075
                                Or, supply a token with your command with --token.`.replaceAll(/^\s+/gmu, '')
1076
                        );
1077
                }
1078
                if (this.flags['as-user']) {
7,875✔
1079
                        client = client.withAsUserHeader(this.flags['as-user']);
9✔
1080
                        DEBUG.init('Impersonating user ID %s', this.flags['as-user']);
9✔
1081
                }
1082
                return client;
7,875✔
1083
        }
1084

1085
        /**
1086
         * Configures SDK by using values from settings.json file
1087
         * @param {*} sdk to configure
1088
         * @param {*} config Additional options to use while building configuration
1089
         * @returns {void}
1090
         */
1091
        _configureSdk(sdk, config = {}) {
×
1092
                const clientSettings = { ...config };
7,875✔
1093
                if (this.settings.enableProxy) {
7,875!
1094
                        clientSettings.proxy = this.settings.proxy;
×
1095
                }
1096
                if (this.settings.apiRootURL) {
7,875!
1097
                        clientSettings.apiRootURL = this.settings.apiRootURL;
×
1098
                }
1099
                if (this.settings.uploadAPIRootURL) {
7,875!
1100
                        clientSettings.uploadAPIRootURL = this.settings.uploadAPIRootURL;
×
1101
                }
1102
                if (this.settings.authorizeRootURL) {
7,875!
1103
                        clientSettings.authorizeRootURL = this.settings.authorizeRootURL;
×
1104
                }
1105
                if (this.settings.numMaxRetries) {
7,875!
1106
                        clientSettings.numMaxRetries = this.settings.numMaxRetries;
×
1107
                }
1108
                if (this.settings.retryIntervalMS) {
7,875!
1109
                        clientSettings.retryIntervalMS = this.settings.retryIntervalMS;
×
1110
                }
1111
                if (this.settings.uploadRequestTimeoutMS) {
7,875!
1112
                        clientSettings.uploadRequestTimeoutMS =
×
1113
                                this.settings.uploadRequestTimeoutMS;
1114
                }
1115
                clientSettings.analyticsClient.name =
7,875✔
1116
                        this.settings.enableAnalyticsClient &&
15,750!
1117
                        this.settings.analyticsClient.name
1118
                                ? `${DEFAULT_ANALYTICS_CLIENT_NAME} ${this.settings.analyticsClient.name}`
1119
                                : DEFAULT_ANALYTICS_CLIENT_NAME;
1120

1121
                if (Object.keys(clientSettings).length > 0) {
7,875!
1122
                        DEBUG.init('SDK client settings %s', clientSettings);
7,875✔
1123
                        sdk.configure(clientSettings);
7,875✔
1124
                }
1125
        }
1126

1127
        /**
1128
         * Configures TS SDK by using values from settings.json file
1129
         *
1130
         * @param {BoxTSSDK.BoxClient} client to configure
1131
         * @param {Object} config Additional options to use while building configuration
1132
         * @returns {BoxTSSDK.BoxClient} The configured client
1133
         */
1134
        _configureTsSdk(client, config) {
1135
                let additionalHeaders = config.request.headers;
7,875✔
1136
                let customBaseURL = {
7,875✔
1137
                        baseUrl: 'https://api.box.com',
1138
                        uploadUrl: 'https://upload.box.com/api',
1139
                        oauth2Url: 'https://account.box.com/api/oauth2',
1140
                };
1141
                if (this.settings.enableProxy) {
7,875!
1142
                        client = client.withProxy(this.settings.proxy);
×
1143
                }
1144
                if (this.settings.apiRootURL) {
7,875!
1145
                        customBaseURL.baseUrl = this.settings.apiRootURL;
×
1146
                }
1147
                if (this.settings.uploadAPIRootURL) {
7,875!
1148
                        customBaseURL.uploadUrl = this.settings.uploadAPIRootURL;
×
1149
                }
1150
                if (this.settings.authorizeRootURL) {
7,875!
1151
                        customBaseURL.oauth2Url = this.settings.authorizeRootURL;
×
1152
                }
1153
                client = client.withCustomBaseUrls(customBaseURL);
7,875✔
1154

1155
                if (this.settings.numMaxRetries) {
7,875!
1156
                        // Not supported in TS SDK
1157
                }
1158
                if (this.settings.retryIntervalMS) {
7,875!
1159
                        // Not supported in TS SDK
1160
                }
1161
                if (this.settings.uploadRequestTimeoutMS) {
7,875!
1162
                        // Not supported in TS SDK
1163
                }
1164
                additionalHeaders['X-Box-UA'] =
7,875✔
1165
                        this.settings.enableAnalyticsClient &&
15,750!
1166
                        this.settings.analyticsClient.name
1167
                                ? `${DEFAULT_ANALYTICS_CLIENT_NAME} ${this.settings.analyticsClient.name}`
1168
                                : DEFAULT_ANALYTICS_CLIENT_NAME;
1169
                client = client.withExtraHeaders(additionalHeaders);
7,875✔
1170
                DEBUG.init('TS SDK configured with settings from settings.json');
7,875✔
1171

1172
                return client;
7,875✔
1173
        }
1174

1175
        /**
1176
         * Format data for output to stdout
1177
         * @param {*} content The content to output
1178
         * @returns {Promise<void>} A promise resolving when output is handled
1179
         */
1180
        async output(content) {
1181
                if (this.isBulk) {
7,335✔
1182
                        this.bulkOutputList.push(content);
576✔
1183
                        DEBUG.output(
576✔
1184
                                'Added command output to bulk list total: %d',
1185
                                this.bulkOutputList.length
1186
                        );
1187
                        return;
576✔
1188
                }
1189

1190
                let formattedOutputData;
1191
                if (Array.isArray(content)) {
6,759✔
1192
                        // Format each object individually and then flatten in case this an array of arrays,
1193
                        // which happens when a command that outputs a collection gets run in bulk
1194
                        const formattedOutputResults = await Promise.all(
405✔
1195
                                content.map((o) => this._formatOutputObject(o))
1,080✔
1196
                        );
1197
                        formattedOutputData = formattedOutputResults.flat();
405✔
1198
                        DEBUG.output(
405✔
1199
                                'Formatted %d output entries for display',
1200
                                content.length
1201
                        );
1202
                } else {
1203
                        formattedOutputData = await this._formatOutputObject(content);
6,354✔
1204
                        DEBUG.output('Formatted output content for display');
6,354✔
1205
                }
1206
                let outputFormat = this._getOutputFormat();
6,759✔
1207
                DEBUG.output('Using %s output format', outputFormat);
6,759✔
1208
                DEBUG.output(formattedOutputData);
6,759✔
1209

1210
                let writeFunc;
1211
                let logFunc;
1212
                let stringifiedOutput;
1213

1214
                // remove all the undefined values from the object
1215
                formattedOutputData = removeUndefinedValues(formattedOutputData);
6,759✔
1216

1217
                if (outputFormat === 'json') {
6,759✔
1218
                        stringifiedOutput = stringifyStream(formattedOutputData, null, 4);
4,275✔
1219

1220
                        let appendNewLineTransform = new Transform({
4,275✔
1221
                                transform(chunk, encoding, callback) {
1222
                                        callback(null, chunk);
36✔
1223
                                },
1224
                                flush(callback) {
1225
                                        this.push(os.EOL);
36✔
1226
                                        callback();
36✔
1227
                                },
1228
                        });
1229

1230
                        writeFunc = async (savePath) => {
4,275✔
1231
                                await pipeline(
36✔
1232
                                        stringifiedOutput,
1233
                                        appendNewLineTransform,
1234
                                        fs.createWriteStream(savePath, { encoding: 'utf8' })
1235
                                );
1236
                        };
1237

1238
                        logFunc = async () => {
4,275✔
1239
                                await this.logStream(stringifiedOutput);
4,239✔
1240
                        };
1241
                } else {
1242
                        stringifiedOutput =
2,484✔
1243
                                await this._stringifyOutput(formattedOutputData);
1244

1245
                        writeFunc = async (savePath) => {
2,484✔
1246
                                await utils.writeFileAsync(
9✔
1247
                                        savePath,
1248
                                        stringifiedOutput + os.EOL,
1249
                                        {
1250
                                                encoding: 'utf8',
1251
                                        }
1252
                                );
1253
                        };
1254

1255
                        logFunc = () => this.log(stringifiedOutput);
2,484✔
1256
                }
1257
                return this._writeOutput(writeFunc, logFunc);
6,759✔
1258
        }
1259

1260
        /**
1261
         * Check if max-items has been reached.
1262
         *
1263
         * @param {number} maxItems Total number of items to return
1264
         * @param {number} itemsCount Current number of items
1265
         * @returns {boolean} True if limit has been reached, otherwise false
1266
         * @private
1267
         */
1268
        maxItemsReached(maxItems, itemsCount) {
1269
                return maxItems && itemsCount >= maxItems;
6,777✔
1270
        }
1271

1272
        /**
1273
         * Prepare the output data by:
1274
         *   1) Unrolling an iterator into an array
1275
         *   2) Filtering out unwanted object fields
1276
         *
1277
         * @param {*} obj The raw object containing output data
1278
         * @returns {*} The formatted output data
1279
         * @private
1280
         */
1281
        async _formatOutputObject(obj) {
1282
                let output = obj;
7,434✔
1283

1284
                // Pass primitive content types through
1285
                if (typeof output !== 'object' || output === null) {
7,434!
1286
                        return output;
×
1287
                }
1288

1289
                // Unroll iterator into array
1290
                if (typeof obj.next === 'function') {
7,434✔
1291
                        output = [];
1,494✔
1292
                        let entry = await obj.next();
1,494✔
1293
                        while (!entry.done) {
1,494✔
1294
                                output.push(entry.value);
6,777✔
1295

1296
                                if (
6,777✔
1297
                                        this.maxItemsReached(this.flags['max-items'], output.length)
1298
                                ) {
1299
                                        break;
45✔
1300
                                }
1301

1302
                                entry = await obj.next();
6,732✔
1303
                        }
1304
                        DEBUG.output('Unrolled iterable into %d entries', output.length);
1,494✔
1305
                }
1306

1307
                if (this.flags['id-only']) {
7,434✔
1308
                        output = Array.isArray(output)
270!
1309
                                ? this.filterOutput(output, 'id')
1310
                                : output.id;
1311
                } else {
1312
                        output = this.filterOutput(output, this.flags.fields);
7,164✔
1313
                }
1314

1315
                return output;
7,434✔
1316
        }
1317

1318
        /**
1319
         * Get the output format (and file extension) based on the settings and flags set
1320
         *
1321
         * @returns {string} The file extension/format to use for output
1322
         * @private
1323
         */
1324
        _getOutputFormat() {
1325
                if (this.flags.json) {
9,261✔
1326
                        return 'json';
4,284✔
1327
                }
1328

1329
                if (this.flags.csv) {
4,977✔
1330
                        return 'csv';
54✔
1331
                }
1332

1333
                if (this.flags.save || this.flags['save-to-file-path']) {
4,923✔
1334
                        return this.settings.boxReportsFileFormat || 'txt';
27!
1335
                }
1336

1337
                if (this.settings.outputJson) {
4,896!
1338
                        return 'json';
×
1339
                }
1340

1341
                return 'txt';
4,896✔
1342
        }
1343

1344
        /**
1345
         * Converts output data to a string based on the type of content and flags the user
1346
         * has specified regarding output format
1347
         *
1348
         * @param {*} outputData The data to output
1349
         * @returns {string} Promise resolving to the output data as a string
1350
         * @private
1351
         */
1352
        async _stringifyOutput(outputData) {
1353
                let outputFormat = this._getOutputFormat();
2,484✔
1354

1355
                if (typeof outputData !== 'object') {
2,484✔
1356
                        DEBUG.output('Primitive output cast to string');
270✔
1357
                        return String(outputData);
270✔
1358
                } else if (outputFormat === 'csv') {
2,214✔
1359
                        let csvString = await csvStringify(
27✔
1360
                                this.formatForTableAndCSVOutput(outputData)
1361
                        );
1362
                        // The CSV library puts a trailing newline at the end of the string, which is
1363
                        // redundant with the automatic newline added by oclif when writing to stdout
1364
                        DEBUG.output('Processed output as CSV');
27✔
1365
                        return csvString.replace(/\r?\n$/u, '');
27✔
1366
                } else if (Array.isArray(outputData)) {
2,187✔
1367
                        let str = outputData
63✔
1368
                                .map(
1369
                                        (o) => `${formatObjectHeader(o)}${os.EOL}${formatObject(o)}`
153✔
1370
                                )
1371
                                .join(os.EOL.repeat(2));
1372
                        DEBUG.output('Processed collection into human-readable output');
63✔
1373
                        return str;
63✔
1374
                }
1375

1376
                let str = formatObject(outputData);
2,124✔
1377
                DEBUG.output('Processed human-readable output');
2,124✔
1378
                return str;
2,124✔
1379
        }
1380

1381
        /**
1382
         * Generate an appropriate default filename for writing
1383
         * the output of this command to disk.
1384
         *
1385
         * @returns {string} The output file name
1386
         * @private
1387
         */
1388
        _getOutputFileName() {
1389
                let extension = this._getOutputFormat();
18✔
1390
                return `${this.id.replaceAll(':', '-')}-${dateTime.format(
18✔
1391
                        new Date(),
1392
                        'YYYY-MM-DD_HH_mm_ss_SSS'
1393
                )}.${extension}`;
1394
        }
1395

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

1436
                                        if (!shouldOverwrite) {
27!
1437
                                                return;
×
1438
                                        }
1439
                                }
1440
                        }
1441
                        try {
36✔
1442
                                DEBUG.output(
36✔
1443
                                        'Writing output to specified location on disk: %s',
1444
                                        savePath
1445
                                );
1446
                                await writeFunc(savePath);
36✔
1447
                        } catch (error) {
1448
                                throw new BoxCLIError(
×
1449
                                        `Could not write output to file at ${savePath}`,
1450
                                        error
1451
                                );
1452
                        }
1453
                        this.info(chalk`{green Output written to ${savePath}}`);
36✔
1454
                } else {
1455
                        DEBUG.output('Writing output to terminal');
6,714✔
1456
                        await logFunc();
6,714✔
1457
                }
1458

1459
                DEBUG.output('Finished writing output');
6,759✔
1460
        }
1461

1462
        /**
1463
         * Ask a user to confirm something, respecting the default --yes flag
1464
         *
1465
         * @param {string} promptText The text of the prompt to the user
1466
         * @param {boolean} defaultValue The default value of the prompt
1467
         * @returns {Promise<boolean>} A promise resolving to a boolean that is true iff the user confirmed
1468
         */
1469
        async confirm(promptText, defaultValue = false) {
27✔
1470
                if (this.flags.yes) {
27✔
1471
                        return true;
18✔
1472
                }
1473

1474
                let answers = await inquirer.prompt([
9✔
1475
                        {
1476
                                name: 'confirmation',
1477
                                message: promptText,
1478
                                type: 'confirm',
1479
                                default: defaultValue,
1480
                        },
1481
                ]);
1482

1483
                return answers.confirmation;
9✔
1484
        }
1485

1486
        /**
1487
         * Writes output to stderr — this should be used for informational output.  For example, a message
1488
         * stating that an item has been deleted.
1489
         *
1490
         * @param {string} content The message to output
1491
         * @returns {void}
1492
         */
1493
        info(content) {
1494
                if (!this.flags.quiet) {
1,098✔
1495
                        process.stderr.write(`${content}${os.EOL}`);
1,089✔
1496
                }
1497
        }
1498

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

1512
        /**
1513
         * Writes stream output to stderr — this should be used for informational output.  For example, a message
1514
         * stating that an item has been deleted.
1515
         *
1516
         * @param {ReadableStream} content The message to output
1517
         * @returns {void}
1518
         */
1519
        async logStream(content) {
1520
                if (!this.flags.quiet) {
4,239!
1521
                        // For Node 12 when process.stdout is in pipeline it's not emitting end event correctly and it freezes.
1522
                        // See - https://github.com/nodejs/node/issues/34059
1523
                        // Using promise for now.
1524
                        content.pipe(process.stdout);
4,239✔
1525

1526
                        await new Promise((resolve, reject) => {
4,239✔
1527
                                content
4,239✔
1528
                                        .on('end', () => {
1529
                                                process.stdout.write(os.EOL);
4,239✔
1530
                                                resolve();
4,239✔
1531
                                        })
1532
                                        .on('error', (err) => {
1533
                                                reject(err);
×
1534
                                        });
1535
                        });
1536
                }
1537
        }
1538

1539
        /**
1540
         * Wraps filtered error in an error with a user-friendly description
1541
         *
1542
         * @param {Error} err  The thrown error
1543
         * @returns {Error} Error wrapped in an error with user friendly description
1544
         */
1545
        wrapError(err) {
1546
                let messageMap = {
360✔
1547
                        'invalid_grant - Refresh token has expired':
1548
                                '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.',
1549
                        'Expired Auth: Auth code or refresh token has expired':
1550
                                '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.',
1551
                };
1552

1553
                for (const key in messageMap) {
360✔
1554
                        if (err.message.includes(key)) {
720!
1555
                                return new BoxCLIError(messageMap[key], err);
×
1556
                        }
1557
                }
1558

1559
                return err;
360✔
1560
        }
1561

1562
        /**
1563
         * Handles an error thrown within a command
1564
         *
1565
         * @param {Error} err  The thrown error
1566
         * @returns {void}
1567
         */
1568
        async catch(err) {
1569
                const AUTH_FAILED_HINT =
1570
                        '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✔
1571
                if (
333!
1572
                        err instanceof BoxTsErrors.BoxApiError &&
333!
1573
                        err.responseInfo &&
1574
                        err.responseInfo.body
1575
                ) {
1576
                        const responseInfo = err.responseInfo;
×
1577
                        let errorMessage = `Unexpected API Response [${responseInfo.body.status} ${responseInfo.body.message} | ${responseInfo.body.request_id}] ${responseInfo.body.code} - ${responseInfo.body.message}`;
×
NEW
1578
                        if (responseInfo.body.status === 401) {
×
NEW
1579
                                errorMessage += `\n${AUTH_FAILED_HINT}`;
×
1580
                        }
UNCOV
1581
                        err = new BoxCLIError(errorMessage, err);
×
1582
                }
1583
                if (err instanceof BoxTsErrors.BoxSdkError) {
333!
1584
                        try {
×
1585
                                let errorObj = JSON.parse(err.message);
×
1586
                                if (errorObj.message) {
×
1587
                                        err = new BoxCLIError(errorObj.message, err);
×
1588
                                }
1589
                        } catch (error) {
1590
                                DEBUG.execute('Error parsing BoxSdkError message: %s', error);
×
1591
                        }
1592
                }
1593
                try {
333✔
1594
                        // Let the oclif default handler run first, since it handles the help and version flags there
1595
                        /* eslint-disable promise/no-promise-in-callback */
1596
                        DEBUG.execute('Running framework error handler');
333✔
1597
                        await super.catch(this.wrapError(err));
333✔
1598
                } catch (error) {
1599
                        // The oclif default catch handler rethrows most errors; handle those here
1600
                        DEBUG.execute('Handling re-thrown error in base command handler');
333✔
1601

1602
                        if (error.code === 'EEXIT') {
333!
1603
                                // oclif throws this when it handled the error itself and wants to exit, so just let it do that
1604
                                DEBUG.execute('Got EEXIT code, exiting immediately');
×
1605
                                return;
×
1606
                        }
1607
                        let contextInfo;
1608
                        if (
333✔
1609
                                error.response &&
531✔
1610
                                error.response.body &&
1611
                                error.response.body.context_info
1612
                        ) {
1613
                                contextInfo = formatObject(error.response.body.context_info);
9✔
1614
                                // Remove color codes from context info
1615
                                // eslint-disable-next-line no-control-regex
1616
                                contextInfo = contextInfo.replaceAll(/\u001B\[\d+m/gu, '');
9✔
1617
                                // Remove \n with os.EOL
1618
                                contextInfo = contextInfo.replaceAll('\n', os.EOL);
9✔
1619
                        }
1620
                        let statusHint = '';
333✔
1621
                        const statusCode = error.statusCode || error.response?.statusCode;
333✔
1622
                        if (statusCode === 401) {
333!
NEW
1623
                                statusHint = AUTH_FAILED_HINT;
×
1624
                        }
1625
                        let errorMsg = chalk`{redBright ${
333✔
1626
                                this.flags && this.flags.verbose ? error.stack : error.message
999✔
1627
                        }${os.EOL}${contextInfo ? contextInfo + os.EOL : ''}${statusHint ? statusHint + os.EOL : ''}}`;
666!
1628

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

1632
                        process.stderr.write(errorMsg, () => {
333✔
1633
                                process.exitCode = 2;
333✔
1634
                        });
1635
                }
1636
        }
1637

1638
        /**
1639
         * Final hook that executes for all commands, regardless of if an error occurred
1640
         * @param {Error} [err] An error, if one occurred
1641
         * @returns {void}
1642
         */
1643
        async finally(/* err */) {
1644
                // called after run and catch regardless of whether or not the command errored
1645
        }
1646

1647
        /**
1648
         * Filter out unwanted fields from the output object(s)
1649
         *
1650
         * @param {Object|Object[]} output The output object(s)
1651
         * @param {string} [fields] Comma-separated list of fields to include
1652
         * @returns {Object|Object[]} The filtered object(s) for output
1653
         */
1654
        filterOutput(output, fields) {
1655
                if (!fields) {
7,164✔
1656
                        return output;
6,588✔
1657
                }
1658
                fields = [
576✔
1659
                        ...REQUIRED_FIELDS,
1660
                        ...fields.split(',').filter((f) => !REQUIRED_FIELDS.includes(f)),
711✔
1661
                ];
1662
                DEBUG.output('Filtering output with fields: %O', fields);
576✔
1663
                if (Array.isArray(output)) {
576✔
1664
                        output = output.map((o) =>
342✔
1665
                                typeof o === 'object' ? _.pick(o, fields) : o
1,404!
1666
                        );
1667
                } else if (typeof output === 'object') {
234!
1668
                        output = _.pick(output, fields);
234✔
1669
                }
1670
                return output;
576✔
1671
        }
1672

1673
        /**
1674
         * Flatten nested objects for output to a table/CSV
1675
         *
1676
         * @param {Object[]} objectArray The objects that will be output
1677
         * @returns {Array[]} The formatted output
1678
         */
1679
        formatForTableAndCSVOutput(objectArray) {
1680
                let formattedData = [];
27✔
1681
                if (!Array.isArray(objectArray)) {
27!
1682
                        objectArray = [objectArray];
×
1683
                        DEBUG.output('Creating tabular output from single object');
×
1684
                }
1685

1686
                let keyPaths = [];
27✔
1687
                for (let object of objectArray) {
27✔
1688
                        keyPaths = _.union(keyPaths, this.getNestedKeys(object));
126✔
1689
                }
1690

1691
                DEBUG.output('Found %d keys for tabular output', keyPaths.length);
27✔
1692
                formattedData.push(keyPaths);
27✔
1693
                for (let object of objectArray) {
27✔
1694
                        let row = [];
126✔
1695
                        if (typeof object === 'object') {
126!
1696
                                for (let keyPath of keyPaths) {
126✔
1697
                                        let value = _.get(object, keyPath);
1,584✔
1698
                                        if (value === null || value === undefined) {
1,584✔
1699
                                                row.push('');
180✔
1700
                                        } else {
1701
                                                row.push(value);
1,404✔
1702
                                        }
1703
                                }
1704
                        } else {
1705
                                row.push(object);
×
1706
                        }
1707
                        DEBUG.output('Processed row with %d values', row.length);
126✔
1708
                        formattedData.push(row);
126✔
1709
                }
1710
                DEBUG.output(
27✔
1711
                        'Processed %d rows of tabular output',
1712
                        formattedData.length - 1
1713
                );
1714
                return formattedData;
27✔
1715
        }
1716

1717
        /**
1718
         * Extracts all keys from an object and flattens them
1719
         *
1720
         * @param {Object} object The object to extract flattened keys from
1721
         * @returns {string[]} The array of flattened keys
1722
         */
1723
        getNestedKeys(object) {
1724
                let keys = [];
405✔
1725
                if (typeof object === 'object') {
405!
1726
                        for (let key in object) {
405✔
1727
                                if (
1,683✔
1728
                                        typeof object[key] === 'object' &&
1,962✔
1729
                                        !Array.isArray(object[key])
1730
                                ) {
1731
                                        let subKeys = this.getNestedKeys(object[key]);
279✔
1732
                                        subKeys = subKeys.map((x) => `${key}.${x}`);
1,026✔
1733
                                        keys = [...keys, ...subKeys];
279✔
1734
                                } else {
1735
                                        keys.push(key);
1,404✔
1736
                                }
1737
                        }
1738
                }
1739
                return keys;
405✔
1740
        }
1741

1742
        /**
1743
         * Converts time interval shorthand like 5w, -3d, etc to timestamps. It also ensures any timestamp
1744
         * passed in is properly formatted for API calls.
1745
         *
1746
         * @param {string} time The command lint input string for the datetime
1747
         * @returns {string} The full RFC3339-formatted datetime string in UTC
1748
         */
1749
        static normalizeDateString(time) {
1750
                // Attempt to parse date as timestamp or string
1751
                let newDate = /^\d+$/u.test(time)
1,152✔
1752
                        ? dateTime.parse(Number.parseInt(time, 10) * 1000)
1753
                        : dateTime.parse(time);
1754
                if (!dateTime.isValid(newDate)) {
1,152✔
1755
                        let parsedOffset = time.match(/^(-?)((?:\d+[smhdwMy])+)$/u);
261✔
1756
                        if (parsedOffset) {
261✔
1757
                                let sign = parsedOffset[1] === '-' ? -1 : 1,
216✔
1758
                                        offset = parsedOffset[2];
216✔
1759

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

1766
                                // Successively apply the offsets to the current time
1767
                                newDate = new Date();
216✔
1768
                                for (const args of argPairs) {
216✔
1769
                                        newDate = offsetDate(newDate, ...args);
234✔
1770
                                }
1771
                        } else if (time === 'now') {
45!
1772
                                newDate = new Date();
45✔
1773
                        } else {
1774
                                throw new BoxCLIError(`Cannot parse date format "${time}"`);
×
1775
                        }
1776
                }
1777

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

1783
        /**
1784
         * Writes updated settings to disk
1785
         *
1786
         * @param {Object} updatedSettings The settings object to write
1787
         * @returns {void}
1788
         */
1789
        updateSettings(updatedSettings) {
1790
                this.settings = Object.assign(this.settings, updatedSettings);
×
1791
                try {
×
1792
                        fs.writeFileSync(
×
1793
                                SETTINGS_FILE_PATH,
1794
                                JSON.stringify(this.settings, null, 4),
1795
                                'utf8'
1796
                        );
1797
                } catch (error) {
1798
                        throw new BoxCLIError(
×
1799
                                `Could not write settings file ${SETTINGS_FILE_PATH}`,
1800
                                error
1801
                        );
1802
                }
1803
                return this.settings;
×
1804
        }
1805

1806
        /**
1807
         * Read the current set of environments from disk
1808
         *
1809
         * @returns {Object} The parsed environment information
1810
         */
1811
        async getEnvironments() {
1812
                // Try secure storage first on supported platforms
1813
                if (this.supportsSecureStorage) {
23,634✔
1814
                        try {
15,756✔
1815
                                const password = await keytar.getPassword(
15,756✔
1816
                                        'boxcli' /* service */,
1817
                                        'Box' /* account */
1818
                                );
1819
                                if (password) {
15,756✔
1820
                                        return JSON.parse(password);
15,750✔
1821
                                }
1822
                        } catch (error) {
1823
                                DEBUG.init(
×
1824
                                        'Failed to read from secure storage, falling back to file: %s',
1825
                                        error.message
1826
                                );
1827
                                // fallback to env file
1828
                        }
1829
                }
1830

1831
                // Try to read from file (fallback or no secure storage)
1832
                try {
7,884✔
1833
                        if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) {
7,884✔
1834
                                return JSON.parse(fs.readFileSync(ENVIRONMENTS_FILE_PATH));
7,875✔
1835
                        }
1836
                } catch (error) {
1837
                        DEBUG.init(
×
1838
                                'Failed to read environments from file: %s',
1839
                                error.message
1840
                        );
1841
                }
1842

1843
                // No environments found in either location
1844
                throw new BoxCLIError(
9✔
1845
                        `Could not read environments. No environments found in secure storage or file ${ENVIRONMENTS_FILE_PATH}`
1846
                );
1847
        }
1848

1849
        /**
1850
         * Writes updated environment information to disk
1851
         *
1852
         * @param {Object} updatedEnvironments The environment information to write
1853
         * @param {Object} environments use to override current environment
1854
         * @returns {void}
1855
         */
1856
        async updateEnvironments(updatedEnvironments, environments) {
1857
                if (environments === undefined) {
7,875!
1858
                        environments = await this.getEnvironments();
×
1859
                }
1860
                Object.assign(environments, updatedEnvironments);
7,875✔
1861

1862
                let storedInSecureStorage = false;
7,875✔
1863

1864
                // Try secure storage first on supported platforms
1865
                if (this.supportsSecureStorage) {
7,875✔
1866
                        try {
5,250✔
1867
                                await keytar.setPassword(
5,250✔
1868
                                        'boxcli' /* service */,
1869
                                        'Box' /* account */,
1870
                                        JSON.stringify(environments) /* password */
1871
                                );
1872
                                storedInSecureStorage = true;
5,250✔
1873
                                DEBUG.init(
5,250✔
1874
                                        'Stored environment configuration in secure storage'
1875
                                );
1876
                                // Successfully stored in secure storage, remove the file
1877
                                if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) {
5,250!
1878
                                        fs.unlinkSync(ENVIRONMENTS_FILE_PATH);
×
1879
                                        DEBUG.init(
×
1880
                                                'Removed environment configuration file after migrating to secure storage'
1881
                                        );
1882
                                }
1883
                        } catch (keytarError) {
1884
                                // fallback to file storage if secure storage fails
1885
                                DEBUG.init(
×
1886
                                        'Could not store credentials in secure storage, falling back to file: %s',
1887
                                        keytarError.message
1888
                                );
1889
                        }
1890
                }
1891

1892
                // Write to file if secure storage failed or not available
1893
                if (!storedInSecureStorage) {
7,875✔
1894
                        try {
2,625✔
1895
                                let fileContents = JSON.stringify(environments, null, 4);
2,625✔
1896
                                fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8');
2,625✔
1897

1898
                                // Show warning to user if secure storage was attempted but failed
1899
                                if (this.supportsSecureStorage) {
2,625!
1900
                                        this.info(
×
1901
                                                `Could not store credentials in secure storage, falling back to file.` +
1902
                                                        (process.platform === 'linux'
×
1903
                                                                ? ' To enable secure storage on Linux, install libsecret-1-dev package.'
1904
                                                                : '')
1905
                                        );
1906
                                }
1907
                        } catch (error) {
1908
                                throw new BoxCLIError(
×
1909
                                        `Could not write environments config file ${ENVIRONMENTS_FILE_PATH}`,
1910
                                        error
1911
                                );
1912
                        }
1913
                }
1914

1915
                return environments;
7,875✔
1916
        }
1917

1918
        /**
1919
         * Initialize the CLI by creating the necessary configuration files on disk
1920
         * in the users' home directory, then read and parse the CLI settings file.
1921
         *
1922
         * @returns {Object} The parsed settings
1923
         * @private
1924
         */
1925
        async _loadSettings() {
1926
                try {
7,875✔
1927
                        if (!fs.existsSync(CONFIG_FOLDER_PATH)) {
7,875✔
1928
                                mkdirp.sync(CONFIG_FOLDER_PATH);
9✔
1929
                                DEBUG.init('Created config folder at %s', CONFIG_FOLDER_PATH);
9✔
1930
                        }
1931

1932
                        // Check if environments exist (in secure storage or file)
1933
                        let environmentsExist = false;
7,875✔
1934
                        try {
7,875✔
1935
                                const environments = await this.getEnvironments();
7,875✔
1936
                                // Check if there are any environments configured
1937
                                if (
7,866!
1938
                                        environments &&
23,598✔
1939
                                        environments.environments &&
1940
                                        Object.keys(environments.environments).length > 0
1941
                                ) {
1942
                                        environmentsExist = true;
×
1943
                                        DEBUG.init('Found existing environments in storage');
×
1944
                                }
1945
                        } catch (error) {
1946
                                // No environments found, need to create defaults
1947
                                DEBUG.init('No existing environments found: %s', error.message);
9✔
1948
                        }
1949

1950
                        if (!environmentsExist) {
7,875!
1951
                                // Create default environments (will be stored in secure storage if available)
1952
                                await this.updateEnvironments(
7,875✔
1953
                                        {},
1954
                                        this._getDefaultEnvironments()
1955
                                );
1956
                                DEBUG.init('Created default environments configuration');
7,875✔
1957
                        }
1958

1959
                        if (!fs.existsSync(SETTINGS_FILE_PATH)) {
7,875✔
1960
                                let settingsJSON = JSON.stringify(
9✔
1961
                                        this._getDefaultSettings(),
1962
                                        null,
1963
                                        4
1964
                                );
1965
                                fs.writeFileSync(SETTINGS_FILE_PATH, settingsJSON, 'utf8');
9✔
1966
                                DEBUG.init(
9✔
1967
                                        'Created settings file at %s %O',
1968
                                        SETTINGS_FILE_PATH,
1969
                                        settingsJSON
1970
                                );
1971
                        }
1972
                } catch (error) {
1973
                        throw new BoxCLIError(
×
1974
                                'Could not initialize CLI home directory',
1975
                                error
1976
                        );
1977
                }
1978

1979
                let settings;
1980
                try {
7,875✔
1981
                        settings = JSON.parse(fs.readFileSync(SETTINGS_FILE_PATH));
7,875✔
1982
                        settings = Object.assign(this._getDefaultSettings(), settings);
7,875✔
1983
                        DEBUG.init('Loaded settings %O', settings);
7,875✔
1984
                } catch (error) {
1985
                        throw new BoxCLIError(
×
1986
                                `Could not read CLI settings file at ${SETTINGS_FILE_PATH}`,
1987
                                error
1988
                        );
1989
                }
1990

1991
                try {
7,875✔
1992
                        if (!fs.existsSync(settings.boxReportsFolderPath)) {
7,875✔
1993
                                mkdirp.sync(settings.boxReportsFolderPath);
9✔
1994
                                DEBUG.init(
9✔
1995
                                        'Created reports folder at %s',
1996
                                        settings.boxReportsFolderPath
1997
                                );
1998
                        }
1999
                        if (!fs.existsSync(settings.boxDownloadsFolderPath)) {
7,875✔
2000
                                mkdirp.sync(settings.boxDownloadsFolderPath);
9✔
2001
                                DEBUG.init(
9✔
2002
                                        'Created downloads folder at %s',
2003
                                        settings.boxDownloadsFolderPath
2004
                                );
2005
                        }
2006
                } catch (error) {
2007
                        throw new BoxCLIError(
×
2008
                                'Failed creating CLI working directory',
2009
                                error
2010
                        );
2011
                }
2012

2013
                return settings;
7,875✔
2014
        }
2015

2016
        /**
2017
         * Get the default settings object
2018
         *
2019
         * @returns {Object} The default settings object
2020
         * @private
2021
         */
2022
        _getDefaultSettings() {
2023
                return {
7,884✔
2024
                        boxReportsFolderPath: path.join(
2025
                                os.homedir(),
2026
                                'Documents/Box-Reports'
2027
                        ),
2028
                        boxReportsFileFormat: 'txt',
2029
                        boxDownloadsFolderPath: path.join(
2030
                                os.homedir(),
2031
                                'Downloads/Box-Downloads'
2032
                        ),
2033
                        outputJson: false,
2034
                        enableProxy: false,
2035
                        proxy: {
2036
                                url: null,
2037
                                username: null,
2038
                                password: null,
2039
                        },
2040
                        enableAnalyticsClient: false,
2041
                        analyticsClient: {
2042
                                name: null,
2043
                        },
2044
                };
2045
        }
2046

2047
        /**
2048
         * Get the default environments object
2049
         *
2050
         * @returns {Object} The default environments object
2051
         * @private
2052
         */
2053
        _getDefaultEnvironments() {
2054
                return {
7,875✔
2055
                        default: null,
2056
                        environments: {},
2057
                };
2058
        }
2059
}
2060

2061
BoxCommand.flags = {
153✔
2062
        token: Flags.string({
2063
                char: 't',
2064
                description: 'Provide a token to perform this call',
2065
        }),
2066
        'as-user': Flags.string({ description: 'Provide an ID for a user' }),
2067
        // @NOTE: This flag is not read anywhere directly; the chalk library automatically turns off color when it's passed
2068
        'no-color': Flags.boolean({
2069
                description: 'Turn off colors for logging',
2070
        }),
2071
        json: Flags.boolean({
2072
                description: 'Output formatted JSON',
2073
                exclusive: ['csv'],
2074
        }),
2075
        csv: Flags.boolean({
2076
                description: 'Output formatted CSV',
2077
                exclusive: ['json'],
2078
        }),
2079
        save: Flags.boolean({
2080
                char: 's',
2081
                description: 'Save report to default reports folder on disk',
2082
                exclusive: ['save-to-file-path'],
2083
        }),
2084
        'save-to-file-path': Flags.string({
2085
                description: 'Override default file path to save report',
2086
                exclusive: ['save'],
2087
                parse: utils.parsePath,
2088
        }),
2089
        fields: Flags.string({
2090
                description: 'Comma separated list of fields to show',
2091
        }),
2092
        'bulk-file-path': Flags.string({
2093
                description: 'File path to bulk .csv or .json objects',
2094
                parse: utils.parsePath,
2095
        }),
2096
        help: Flags.help({
2097
                char: 'h',
2098
                description: 'Show CLI help',
2099
        }),
2100
        verbose: Flags.boolean({
2101
                char: 'v',
2102
                description: 'Show verbose output, which can be helpful for debugging',
2103
        }),
2104
        yes: Flags.boolean({
2105
                char: 'y',
2106
                description: 'Automatically respond yes to all confirmation prompts',
2107
        }),
2108
        quiet: Flags.boolean({
2109
                char: 'q',
2110
                description: 'Suppress any non-error output to stderr',
2111
        }),
2112
};
2113

2114
BoxCommand.minFlags = _.pick(BoxCommand.flags, [
153✔
2115
        'no-color',
2116
        'help',
2117
        'verbose',
2118
        'quiet',
2119
]);
2120

2121
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