diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ca12c74a..961bf1f5 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -14,10 +14,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node: ['14', '16', '18'] - exclude: - - os: macos-latest - node: 14 + node: ['18', '20', '22'] name: Node ${{ matrix.node }} on ${{ matrix.os }} steps: - name: Checkout diff --git a/README.md b/README.md index 0543a33c..828553fb 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@ # Box CLI [![Project Status](http://opensource.box.com/badges/active.svg)](http://opensource.box.com/badges) -![Platform](https://img.shields.io/badge/node-14--18-blue) +![Platform](https://img.shields.io/badge/node-18--22-blue) [![Coverage](https://coveralls.io/repos/github/box/boxcli/badge.svg?branch=main)](https://coveralls.io/github/box/boxcli?branch=main) > 🚨**NEW MAJOR VERSION ALERT** > -> We’re excited to announce that by the end of January 2025, we’ll be releasing Box CLI 4.0.0! This new major version introduces exciting features and improvements, including: +> We’re excited to announce that we have just released Box CLI 4.0.0! This new major version introduces exciting features and improvements, including: > * Upgrading the oclif framework from v1 to v4 > * Adding support for Node 20 and 22, while dropping support for Node 14 and 16 > -> Stay tuned! +> Please refer to the [CHANGELOG](CHANGELOG.md) for more information on the changes in this release. The Box CLI is a user-friendly command line tool which allows both technical and non-technical users to leverage the Box API to perform routine or bulk actions. There is no need to write any code, as these actions are executed through a [set of commands](#command-topics). @@ -196,7 +196,8 @@ A current release is on the leading edge of our SDK development, and is intended | Version | Supported Environments | State | First Release | EOL/Terminated | |---------|-------------------------|-----------|---------------|----------------| -| 3 | Node.js >= 14 | Supported | 01 Feb 2022 | TBD | +| 4 | Node.js >= 18 | | | | +| 3 | Node.js >= 16 | Supported | 01 Feb 2022 | TBD | | 2 | | EOL | 14 Dec 2018 | 01 Feb 2022 | | 1 | | EOL | 01 Nov 2017 | 14 Dec 2018 | diff --git a/package.json b/package.json index 2f78fc13..6f42402d 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,13 @@ "@oclif/plugin-not-found": "^1.2.0", "archiver": "^3.0.0", "box-node-sdk": "^3.7.0", + "box-typescript-sdk-gen": "^1.11.0", "chalk": "^2.4.1", "cli-progress": "^2.1.0", "csv": "^6.3.3", "date-fns": "^1.29.0", "debug": "^4.3.4", - "express": "^4.17.1", + "express": "^4.21.1", "fs-extra": "^10.1.0", "inquirer": "^6.2.0", "js-yaml": "^3.13.1", @@ -63,12 +64,11 @@ "mockery": "^2.1.0", "nock": "^10.0.0", "nyc": "^15.1.0", - "pkg": "^5.5.2", "sinon": "^15.0.1", "standard-version": "^9.5.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" }, "files": [ "/bin", @@ -128,9 +128,6 @@ } } }, - "pkg": { - "scripts": "./src/**/*.js" - }, "scripts": { "test": "nyc mocha \"test/**/*.test.js\"", "posttest": "npm run lint", @@ -138,8 +135,7 @@ "prepack": "oclif-dev manifest && oclif-dev readme --multi && npm shrinkwrap && git checkout origin/main -- package-lock.json", "postpack": "rm -f oclif.manifest.json && rm -f npm-shrinkwrap.json", "version": "oclif-dev readme --multi && git add README.md && git add docs", - "build": "oclif-dev pack:macos && rm -rf tmp/ && oclif-dev pack:win && rm -rf tmp/ && npm run binaries", - "binaries": "pkg --targets node12-macos-x64,node12-win-x64,node12-linux-x64 --out-path ./dist .", + "build": "oclif-dev pack:macos && rm -rf tmp/ && oclif-dev pack:win && rm -rf tmp/", "changelog": "node node_modules/standard-version/bin/cli.js --skip.commit --skip.push --skip.tag --dry-run" }, "overrides": { diff --git a/src/box-command.js b/src/box-command.js index 69982d0c..c6fa6fe8 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -15,6 +15,8 @@ const csvParse = util.promisify(csv.parse); const csvStringify = util.promisify(csv.stringify); const dateTime = require('date-fns'); const BoxSDK = require('box-node-sdk'); +const BoxTSSDK = require('box-typescript-sdk-gen'); +const BoxTsErrors = require('box-typescript-sdk-gen/lib/box/errors'); const BoxCLIError = require('./cli-error'); const CLITokenCache = require('./token-cache'); const utils = require('./util'); @@ -153,7 +155,9 @@ function offsetDate(date, timeLength, timeUnit) { * @private */ function formatKey(key) { + // Converting camel case to snake case and then to title case return key + .replace(/[A-Z]/gu, letter => `_${letter.toLowerCase()}`) .split('_') .map((s) => KEY_MAPPINGS[s] || _.capitalize(s)) .join(' '); @@ -171,6 +175,11 @@ function formatObjectKeys(obj) { return obj; } + // If type is Date, convert to ISO string + if (obj instanceof Date) { + return obj.toISOString(); + } + // Don't format metadata objects to avoid mangling keys if (obj.$type) { return obj; @@ -260,6 +269,7 @@ class BoxCommand extends Command { this.args = args; this.settings = await this._loadSettings(); this.client = await this.getClient(); + this.tsClient = await this.getTsClient(); if (this.isBulk) { this.constructor.args = originalArgs; @@ -823,6 +833,168 @@ class BoxCommand extends Command { return client; } + /** + * Instantiate the TypeScript SDK client for making API calls + * + * @returns {BoxTSSDK.BoxClient} The TypeScript SDK client for making API calls in the command + */ + async getTsClient() { + // Allow some commands (e.g. configure:environments:add, login) to skip client setup so they can run + if (this.constructor.noClient) { + return null; + } + let environmentsObj = await this.getEnvironments(); + const environment = + environmentsObj.environments[environmentsObj.default] || {}; + const { authMethod } = environment; + + let client; + if (this.flags.token) { + DEBUG.init('Using passed in token %s', this.flags.token); + let tsSdkAuth = new BoxTSSDK.BoxDeveloperTokenAuth({ + token: this.flags.token, + }); + client = new BoxTSSDK.BoxClient({ + auth: tsSdkAuth, + }); + client = this._configureTsSdk(client, SDK_CONFIG); + } else if (authMethod === 'ccg') { + DEBUG.init('Using Client Credentials Grant Authentication'); + + const { clientId, clientSecret, ccgUser } = environment; + + if (!clientId || !clientSecret) { + throw new BoxCLIError( + 'You need to have a default environment with clientId and clientSecret in order to use CCG' + ); + } + + let configObj; + try { + configObj = JSON.parse(fs.readFileSync(environment.boxConfigFilePath)); + } catch (ex) { + throw new BoxCLIError('Could not read environments config file', ex); + } + + const { enterpriseID } = configObj; + const tokenCache = environment.cacheTokens === false + ? null + : new CLITokenCache(environmentsObj.default); + let ccgConfig = new BoxTSSDK.CcgConfig(ccgUser ? { + clientId, + clientSecret, + userId: ccgUser, + tokenStorage: tokenCache, + } : { + clientId, + clientSecret, + enterpriseId: enterpriseID, + tokenStorage: tokenCache, + }); + let ccgAuth = new BoxTSSDK.BoxCcgAuth({config: ccgConfig}); + client = new BoxTSSDK.BoxClient({ + auth: ccgAuth, + }); + client = this._configureTsSdk(client, SDK_CONFIG); + } else if ( + environmentsObj.default && + environmentsObj.environments[environmentsObj.default].authMethod === + 'oauth20' + ) { + try { + DEBUG.init( + 'Using environment %s %O', + environmentsObj.default, + environment + ); + const tokenCache = new CLITokenCache(environmentsObj.default); + const oauthConfig = new BoxTSSDK.OAuthConfig({ + clientId: environment.clientId, + clientSecret: environment.clientSecret, + tokenStorage: tokenCache, + }); + const oauthAuth = new BoxTSSDK.BoxOAuth({ + config: oauthConfig, + }); + client = new BoxTSSDK.BoxClient({auth: oauthAuth}); + client = this._configureTsSdk(client, SDK_CONFIG); + } catch (err) { + throw new BoxCLIError( + `Can't load the default OAuth environment "${environmentsObj.default}". Please reauthorize selected environment, login again or provide a token.` + ); + } + } else if (environmentsObj.default) { + DEBUG.init( + 'Using environment %s %O', + environmentsObj.default, + environment + ); + let tokenCache = + environment.cacheTokens === false + ? null + : new CLITokenCache(environmentsObj.default); + let configObj; + try { + configObj = JSON.parse(fs.readFileSync(environment.boxConfigFilePath)); + } catch (ex) { + throw new BoxCLIError('Could not read environments config file', ex); + } + + if (!environment.hasInLinePrivateKey) { + try { + configObj.boxAppSettings.appAuth.privateKey = fs.readFileSync( + environment.privateKeyPath, + 'utf8' + ); + DEBUG.init( + 'Loaded JWT private key from %s', + environment.privateKeyPath + ); + } catch (ex) { + throw new BoxCLIError( + `Could not read private key file ${environment.privateKeyPath}`, + ex + ); + } + } + + const jwtConfig = new BoxTSSDK.JwtConfig({ + clientId: configObj.boxAppSettings.clientID, + clientSecret: configObj.boxAppSettings.clientSecret, + jwtKeyId: configObj.boxAppSettings.appAuth.publicKeyID, + privateKey: configObj.boxAppSettings.appAuth.privateKey, + privateKeyPassphrase: configObj.boxAppSettings.appAuth.passphrase, + enterpriseId: environment.enterpriseId, + tokenStorage: tokenCache, + }); + let jwtAuth = new BoxTSSDK.BoxJwtAuth({config: jwtConfig}); + client = new BoxTSSDK.BoxClient({auth: jwtAuth}); + + DEBUG.init('Initialized client from environment config'); + if (environment.useDefaultAsUser) { + client = client.withAsUserHeader(environment.defaultAsUserId); + DEBUG.init( + 'Impersonating default user ID %s', + environment.defaultAsUserId + ); + } + client = this._configureTsSdk(client, SDK_CONFIG); + } else { + // No environments set up yet! + throw new BoxCLIError( + `No default environment found. + It looks like you haven't configured the Box CLI yet. + See this command for help adding an environment: box configure:environments:add --help + Or, supply a token with your command with --token.`.replace(/^\s+/gmu, '') + ); + } + if (this.flags['as-user']) { + client = client.withAsUserHeader(this.flags['as-user']); + DEBUG.init('Impersonating user ID %s', this.flags['as-user']); + } + return client; + } + /** * Configures SDK by using values from settings.json file * @param {*} sdk to configure @@ -868,6 +1040,57 @@ class BoxCommand extends Command { } } + /** + * Configures TS SDK by using values from settings.json file + * + * @param {BoxTSSDK.BoxClient} client to configure + * @param {Object} config Additional options to use while building configuration + * @returns {BoxTSSDK.BoxClient} The configured client + */ + _configureTsSdk(client, config) { + let additionalHeaders = config.request.headers; + let customBaseURL = { + baseUrl: 'https://api.box.com', + uploadUrl: 'https://upload.box.com/api', + oauth2Url: 'https://account.box.com/api/oauth2', + }; + if (this.settings.enableProxy) { + // Not supported in TS SDK + } + if (this.settings.apiRootURL) { + customBaseURL.baseUrl = this.settings.apiRootURL; + } + if (this.settings.uploadAPIRootURL) { + customBaseURL.uploadUrl = this.settings.uploadAPIRootURL; + } + if (this.settings.authorizeRootURL) { + customBaseURL.oauth2Url = this.settings.authorizeRootURL; + } + client = client.withCustomBaseUrls(customBaseURL); + + if (this.settings.numMaxRetries) { + // Not supported in TS SDK + } + if (this.settings.retryIntervalMS) { + // Not supported in TS SDK + } + if (this.settings.uploadRequestTimeoutMS) { + // Not supported in TS SDK + } + if ( + this.settings.enableAnalyticsClient && + this.settings.analyticsClient.name + ) { + additionalHeaders['X-Box-UA'] = `${DEFAULT_ANALYTICS_CLIENT_NAME} ${this.settings.analyticsClient.name}`; + } else { + additionalHeaders['X-Box-UA'] = DEFAULT_ANALYTICS_CLIENT_NAME; + } + client = client.withExtraHeaders(additionalHeaders); + DEBUG.init('TS SDK configured with settings from settings.json'); + + return client; + } + /** * Format data for output to stdout * @param {*} content The content to output @@ -916,7 +1139,7 @@ class BoxCommand extends Command { }, }); - writeFunc = async (savePath) => { + writeFunc = async(savePath) => { await pipeline( stringifiedOutput, appendNewLineTransform, @@ -924,13 +1147,13 @@ class BoxCommand extends Command { ); }; - logFunc = async () => { + logFunc = async() => { await this.logStream(stringifiedOutput); }; } else { stringifiedOutput = await this._stringifyOutput(formattedOutputData); - writeFunc = async (savePath) => { + writeFunc = async(savePath) => { await utils.writeFileAsync(savePath, stringifiedOutput + os.EOL, { encoding: 'utf8', }); @@ -1246,6 +1469,21 @@ class BoxCommand extends Command { * @returns {void} */ async catch(err) { + if (err instanceof BoxTsErrors.BoxApiError && err.responseInfo && err.responseInfo.body) { + const responseInfo = err.responseInfo; + let errorMessage = `Unexpected API Response [${responseInfo.body.status} ${responseInfo.body.message} | ${responseInfo.body.request_id}] ${responseInfo.body.code} - ${responseInfo.body.message}`; + err = new BoxCLIError(errorMessage, err); + } + if (err instanceof BoxTsErrors.BoxSdkError) { + try { + let errorObj = JSON.parse(err.message); + if (errorObj.message) { + err = new BoxCLIError(errorObj.message, err); + } + } catch (ex) { + // eslint-disable-next-line no-empty + } + } try { // Let the oclif default handler run first, since it handles the help and version flags there /* eslint-disable promise/no-promise-in-callback */ diff --git a/src/token-cache.js b/src/token-cache.js index 5dd4a85b..07b12936 100644 --- a/src/token-cache.js +++ b/src/token-cache.js @@ -56,12 +56,53 @@ class CLITokenCache { * @returns {void} */ clear(callback) { - utils.unlinkAsync(this.filePath) // Pass success or error to the callback .then(callback) .catch(err => callback(new BoxCLIError('Failed to delete token cache', err))); } + + /** + * Write the token to disk, complatible with TS SDK + * @param {AccessToken} token The token to write + * @returns {Promise} A promise resolving to undefined + */ + store(token) { + // eslint-disable-next-line promise/avoid-new + return new Promise((resolve, reject) => { + const accquiredAtMS = (new Date()).getTime(); + const tokenInfo = { + accessToken: token.accessToken, + accessTokenTTLMS: token.expiresIn * 1000, + refreshToken: token.refreshToken, + acquiredAtMS: accquiredAtMS + }; + this.write(tokenInfo, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Read the token from disk, compatible with TS SDK + * @returns {Promise} A promise resolving to the token + */ + get() { + // eslint-disable-next-line promise/avoid-new + return new Promise((resolve, reject) => { + this.read((err, tokenInfo) => { + if (err) { + reject(err); + } else { + resolve(tokenInfo.accessToken ? tokenInfo : undefined); + } + }); + }); + } } module.exports = CLITokenCache;