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

box / boxcli / 19504729128

19 Nov 2025 02:27PM UTC coverage: 85.591% (+0.01%) from 85.581%
19504729128

Pull #603

github

web-flow
Merge 7c8937cd2 into b65d3b937
Pull Request #603: feat: support auto update using Github releases

1329 of 1751 branches covered (75.9%)

Branch coverage included in aggregate %.

142 of 163 new or added lines in 4 files covered. (87.12%)

1 existing line in 1 file now uncovered.

4736 of 5335 relevant lines covered (88.77%)

616.34 hits per line

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

80.38
/src/util.js
1
'use strict';
2

3
const _ = require('lodash');
828✔
4
const BoxCLIError = require('./cli-error');
828✔
5
const os = require('node:os');
828✔
6
const path = require('node:path');
828✔
7
const fs = require('node:fs');
828✔
8
const { mkdirp } = require('mkdirp');
828✔
9

10
const REQUIRED_CONFIG_VALUES = Object.freeze([
828✔
11
        'boxAppSettings.clientID',
12
        'boxAppSettings.clientSecret',
13
        'boxAppSettings.appAuth.publicKeyID',
14
        'boxAppSettings.appAuth.passphrase',
15
        'enterpriseID',
16
]);
17

18
const REQUIRED_CONFIG_VALUES_CCG = Object.freeze([
828✔
19
        'boxAppSettings.clientID',
20
        'boxAppSettings.clientSecret',
21
        'enterpriseID',
22
]);
23

24
const NUMBER_REGEX = /^[-+]?\d*\.?\d+$/u;
828✔
25
const UNESCAPED_COMMA_REGEX = /(?<![^\\](?:\\\\)*\\),/gu;
828✔
26
const UNESCAPED_SUBSCRIPT_REGEX =
27
        /(?<![^\\](?:\\\\)*\\)\[(.*?)(?<![^\\](?:\\\\)*\\)\]/gu;
828✔
28

29
const PATH_ESCAPES = Object.freeze({
828✔
30
        '/': '~1',
31
        '~': '~0',
32
});
33

34
/**
35
 * Unescape a string that no longer needs to be escaped with slashes,
36
 * optionally replacing certain escaped characters with new
37
 * escape codes.
38
 *
39
 * @param {string} str The string to unescape
40
 * @param {Object} replacements A mapping of escaped characters to new escape codes
41
 * @returns {string} The unescaped string
42
 * @private
43
 */
44
function unescapeString(str, replacements = {}) {
2,169✔
45
        let ret = '',
4,509✔
46
                chars = [...str];
4,509✔
47

48
        while (chars.length > 0) {
4,509✔
49
                let char = chars.shift();
28,521✔
50
                if (char === '\\') {
28,521✔
51
                        let nextChar = chars.shift();
252✔
52
                        nextChar = replacements[nextChar] || nextChar;
252✔
53
                        if (nextChar) {
252!
54
                                ret += nextChar;
252✔
55
                        }
56
                } else {
57
                        ret += char;
28,269✔
58
                }
59
        }
60

61
        return ret;
4,509✔
62
}
63

64
/**
65
 * Parse a metadata key into the correct format, including escaping
66
 * for JSON Patch operations.
67
 *
68
 * This method accepts two formats:
69
 * 1) /path/to\/from/key => /path/to~1from/key
70
 * 2) path[to][] => /path/to/-
71
 *
72
 * @param {string} value The value to parse as a metadata key
73
 * @returns {string} The parsed key
74
 * @private
75
 */
76
function parseKey(value) {
77
        if (value.startsWith('/')) {
2,304✔
78
                // Treat as path
79
                return unescapeString(value, PATH_ESCAPES);
972✔
80
        }
81
        // Treat as key
82
        let parts = value.split(UNESCAPED_SUBSCRIPT_REGEX);
1,332✔
83
        if (parts.at(-1) === '') {
1,332✔
84
                parts = parts.slice(0, -1);
117✔
85
        }
86
        return `/${parts
1,332✔
87
                .map((s) => (s ? unescapeString(s, PATH_ESCAPES) : '-'))
1,449✔
88
                .join('/')}`;
89
}
90

91
/**
92
 * Parses a metadata value from command-line input string
93
 * to actual value.
94
 *
95
 * This method supports the following formats:
96
 * 1) "#123.4" => 123.4
97
 * 2) "[foo,bar]" => [ "foo", "bar" ]
98
 * 3) Everything else => unescaped string
99
 *
100
 * @param {string} value The command-line value to parse
101
 * @returns {string|number|string[]} The parsed value
102
 * @private
103
 */
104
function parseValue(value) {
105
        if (value.startsWith('#')) {
1,827✔
106
                // Try parsing as number
107
                let valueStr = unescapeString(value.slice(1));
594✔
108
                if (NUMBER_REGEX.test(valueStr)) {
594✔
109
                        let parsedValue = Number.parseFloat(valueStr);
522✔
110
                        if (!Number.isNaN(parsedValue)) {
522!
111
                                return parsedValue;
522✔
112
                        }
113
                }
114

115
                // Parsing failed, fall back to string value
116
        } else if (value.startsWith('[') && value.endsWith(']')) {
1,233✔
117
                // Handle as array
118
                let interiorStr = value.slice(1, -1);
450✔
119

120
                if (interiorStr.length === 0) {
450✔
121
                        return [];
90✔
122
                }
123

124
                return interiorStr
360✔
125
                        .split(UNESCAPED_COMMA_REGEX)
126
                        .map((el) => unescapeString(el));
720✔
127
        }
128

129
        // No other parsing applied; treat as string
130
        return unescapeString(value);
855✔
131
}
132

133
/**
134
 * Parses a metadata command line string into the correct operation
135
 * object.
136
 *
137
 * This method performs the following transformations:
138
 * 1) key=value => {path: '/key', value: 'value'}
139
 * 2) key1>key2 => {from: '/key1', path: '/key2'}
140
 *
141
 * @param {string} input The input string from the command line
142
 * @returns {Object} The parsed metadata operation object
143
 * @private
144
 */
145
function parseMetadataString(input) {
146
        let chars = [...input];
2,115✔
147
        let op = {};
2,115✔
148

149
        // Find the splitting point, if one exists
150
        let splitIndex = chars.findIndex((char, index, arr) => {
2,115✔
151
                if (char === '>' || char === '=') {
18,990✔
152
                        let escaped = false;
2,088✔
153
                        for (let i = index - 1; i >= 0; i--) {
2,088✔
154
                                if (arr[i] === '\\') {
2,196✔
155
                                        escaped = !escaped;
108✔
156
                                } else {
157
                                        break;
2,088✔
158
                                }
159
                        }
160

161
                        if (!escaped) {
2,088✔
162
                                return true;
2,016✔
163
                        }
164
                }
165

166
                return false;
16,974✔
167
        });
168

169
        if (splitIndex === -1) {
2,115✔
170
                // This is just a key, return it
171
                op.path = parseKey(chars.join(''));
99✔
172
                return op;
99✔
173
        }
174

175
        let separator = chars[splitIndex];
2,016✔
176
        if (separator === '>') {
2,016✔
177
                op.from = parseKey(chars.slice(0, splitIndex).join(''));
189✔
178
                op.path = parseKey(chars.slice(splitIndex + 1).join(''));
189✔
179
        } else {
180
                op.path = parseKey(chars.slice(0, splitIndex).join(''));
1,827✔
181
                op.value = parseValue(chars.slice(splitIndex + 1).join(''));
1,827✔
182
        }
183

184
        return op;
2,016✔
185
}
186

187
/**
188
 * Parse a string into a JSON object
189
 *
190
 * @param {string} inputString The string to parse
191
 * @param {string[]} keys The keys to parse from the string
192
 * @returns {Object} The parsed object
193
 */
194
function parseStringToObject(inputString, keys) {
195
        const result = {};
432✔
196

197
        while (inputString.length > 0) {
432✔
198
                inputString = inputString.trim();
1,512✔
199
                let parsedKey = inputString.split('=')[0];
1,512✔
200
                inputString = inputString.slice(
1,512✔
201
                        Math.max(0, inputString.indexOf('=') + 1)
202
                );
203

204
                // Find the next key or the end of the string
205
                let nextKeyIndex = inputString.length;
1,512✔
206
                for (let key of keys) {
1,512✔
207
                        let keyIndex = inputString.indexOf(key);
5,832✔
208
                        if (keyIndex !== -1 && keyIndex < nextKeyIndex) {
5,832✔
209
                                nextKeyIndex = keyIndex;
1,080✔
210
                        }
211
                }
212

213
                let parsedValue = inputString
1,512✔
214
                        .slice(0, Math.max(0, nextKeyIndex))
215
                        .trim();
216
                if (parsedValue.endsWith(',') && nextKeyIndex !== inputString.length) {
1,512✔
217
                        parsedValue = parsedValue.slice(
1,080✔
218
                                0,
219
                                Math.max(0, parsedValue.length - 1)
220
                        );
221
                }
222
                if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) {
1,512!
NEW
223
                        parsedValue = parsedValue.slice(1, -1);
×
224
                }
225

226
                if (!keys.includes(parsedKey)) {
1,512!
227
                        throw new BoxCLIError(
×
228
                                `Invalid key '${parsedKey}'. Valid keys are ${keys.join(', ')}`
229
                        );
230
                }
231

232
                result[parsedKey] = parsedValue;
1,512✔
233
                inputString = inputString.slice(Math.max(0, nextKeyIndex));
1,512✔
234
        }
235
        return result;
432✔
236
}
237

238
/**
239
 * Check if directory exists and creates it if shouldCreate flag was passed.
240
 *
241
 * @param {string} dirPath Directory path to check and create
242
 * @param {boolean} shouldCreate Flag indicating if the directory should be created
243
 * @returns {Promise<void>} empty promise
244
 * @throws BoxCLIError
245
 */
246
async function checkDir(dirPath, shouldCreate) {
247
        if (!fs.existsSync(dirPath)) {
207✔
248
                if (shouldCreate) {
126✔
249
                        await mkdirp(dirPath);
108✔
250
                } else {
251
                        throw new BoxCLIError(
18✔
252
                                `The ${dirPath} path does not exist. Either create it, or pass the --create-path flag set to true`
253
                        );
254
                }
255
        }
256
}
257

258
async function readFileAsync(path, options) {
259
        return new Promise((resolve, reject) => {
×
260
                fs.readFile(path, options || {}, (err, result) => {
×
261
                        if (err) {
×
262
                                return reject(err);
×
263
                        }
264
                        return resolve(result);
×
265
                });
266
        });
267
}
268

269
async function writeFileAsync(file, data, options) {
270
        return new Promise((resolve, reject) => {
9✔
271
                fs.writeFile(file, data, options || {}, (err, result) => {
9!
272
                        if (err) {
9!
273
                                return reject(err);
×
274
                        }
275
                        return resolve(result);
9✔
276
                });
277
        });
278
}
279

280
async function readdirAsync(path, options) {
281
        return new Promise((resolve, reject) => {
36✔
282
                fs.readdir(path, options || {}, (err, result) => {
36✔
283
                        if (err) {
36!
284
                                return reject(err);
×
285
                        }
286
                        return resolve(result);
36✔
287
                });
288
        });
289
}
290

291
async function unlinkAsync(path) {
292
        return new Promise((resolve, reject) => {
×
293
                fs.unlink(path, (err, result) => {
×
294
                        if (err) {
×
295
                                return reject(err);
×
296
                        }
297
                        return resolve(result);
×
298
                });
299
        });
300
}
301

302
module.exports = {
828✔
303
        /**
304
         * Validates the a configuration object has all required properties
305
         * @param {Object} configObj The config object to validate
306
         * @param {boolean} isCCG Whether the config object is used for CCG auth
307
         * @returns {void}
308
         * @throws BoxCLIError
309
         */
310
        validateConfigObject(configObj, isCCG) {
311
                let checkProp = _.propertyOf(configObj);
×
312
                let requiredConfigValues = isCCG
×
313
                        ? REQUIRED_CONFIG_VALUES_CCG
314
                        : REQUIRED_CONFIG_VALUES;
315
                let missingProp = requiredConfigValues.find((key) => !checkProp(key));
×
316

317
                if (missingProp) {
×
318
                        throw new BoxCLIError(`Config object missing key ${missingProp}`);
×
319
                }
320
        },
321

322
        /**
323
         * Parses a path argument or flag value into a full absolute path
324
         *
325
         * @param {string} value The raw path string from the command line flag or arg
326
         * @returns {string} The resolved absolute path
327
         */
328
        parsePath(value) {
329
                // Check for homedir and expand if necessary
330
                // @NOTE: This can occur when the user passes a path via --flag="~/my-stuff" syntax, since the shell
331
                // doesn't get a chance to expand the tilde
332
                value = value.replace(
981✔
333
                        /^~(\/|\\|$)/u,
334
                        (match, ending) => os.homedir() + ending
18✔
335
                );
336
                return path.resolve(value);
981✔
337
        },
338

339
        /**
340
         * Unescape slashes from the given string.
341
         *
342
         * @param {string} value The raw string which can contains escaped slashes
343
         * @returns {string} A string with unescaped escaping in newline and tab characters.
344
         */
345
        unescapeSlashes(value) {
346
                try {
333✔
347
                        return JSON.parse(`"${value}"`);
333✔
348
                } catch {
349
                        return value;
18✔
350
                }
351
        },
352

353
        /**
354
         * Parses the key=val string format for metadata into an object {key: val}
355
         *
356
         * @param {string} value The string containing metadata key and value
357
         * @returns {Object} The parsed metadata key and value
358
         */
359
        parseMetadata(value) {
360
                let op = parseMetadataString(value);
999✔
361
                if (!op.hasOwnProperty('path') || !op.hasOwnProperty('value')) {
999!
362
                        throw new BoxCLIError('Metadata must be in the form key=value');
×
363
                }
364

365
                let pathSegments = op.path.slice(1).split('/');
999✔
366
                if (pathSegments.length !== 1) {
999!
367
                        throw new BoxCLIError(
×
368
                                `Metadata value must be assigned to a top-level key, instead got ${op.path}`
369
                        );
370
                }
371

372
                let key = pathSegments[0];
999✔
373

374
                return { [key]: op.value };
999✔
375
        },
376

377
        /**
378
         * Parses strings like key=value or key1>key2 into objects
379
         * used to help define JSON patch operations.
380
         *
381
         * key=value => {path: key, value: value}
382
         * key1>key2 => {from: key1, path: key2}
383
         * other => {path: other}
384
         *
385
         * @param {string} value The input string
386
         * @returns {Object} The parsed operation object
387
         */
388
        parseMetadataOp(value) {
389
                return parseMetadataString(value);
1,116✔
390
        },
391
        parseStringToObject,
392
        checkDir,
393
        readFileAsync,
394
        writeFileAsync,
395
        readdirAsync,
396
        unlinkAsync,
397
};
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