| 'use strict'; | ||
| const BoxCommand = require('../box-command'); | ||
| const BoxSDK = require('box-node-sdk').default; | ||
| const CLITokenCache = require('../token-cache'); | ||
| const chalk = require('chalk'); | ||
| const inquirer = require('../inquirer'); | ||
| const pkg = require('../../package.json'); | ||
| const { Flags } = require('@oclif/core'); | ||
| const SDK_CONFIG = Object.freeze({ | ||
| analyticsClient: { version: pkg.version }, | ||
| request: { | ||
| headers: { 'User-Agent': `Box CLI v${pkg.version}` }, | ||
| }, | ||
| }); | ||
| function isInvalidTokenResponse(response) { | ||
| return ( | ||
| response?.statusCode === 400 && | ||
| response?.body?.error === 'invalid_token' | ||
| ); | ||
| } | ||
| function isSuccessResponse(response) { | ||
| return response?.statusCode === 200; | ||
| } | ||
| function getRevokeErrorMessage(thrownError, response) { | ||
| if (thrownError) { | ||
| return ( | ||
| thrownError.message || | ||
| 'Unexpected error. Cannot connect to Box servers.' | ||
| ); | ||
| } | ||
| return ( | ||
| response?.body?.error_description || | ||
| `Request failed with status ${response?.statusCode ?? response?.status}` || | ||
| 'Unknown error' | ||
| ); | ||
| } | ||
| class OAuthLogoutCommand extends BoxCommand { | ||
| async run() { | ||
| const environmentsObj = await this.getEnvironments(); | ||
| const currentEnv = environmentsObj?.default; | ||
| const environment = currentEnv | ||
| ? environmentsObj.environments[currentEnv] | ||
| : null; | ||
| if (!currentEnv || !environment) { | ||
| this.error( | ||
| 'No current environment found. Nothing to log out from.' | ||
| ); | ||
| } | ||
| const tokenCache = new CLITokenCache(currentEnv); | ||
| const tokenInfo = await tokenCache.get(); | ||
| const accessToken = tokenInfo?.accessToken; | ||
| if (!accessToken) { | ||
| this.info( | ||
| chalk`{green You are already logged out from "${currentEnv}" environment.}` | ||
| ); | ||
| return; | ||
| } | ||
| if (!this.flags.force) { | ||
| const confirmed = await this.confirm( | ||
| `Do you want to logout from "${currentEnv}" environment?`, | ||
| false | ||
| ); | ||
| if (!confirmed) { | ||
| this.info(chalk`{yellow Logout cancelled.}`); | ||
| return; | ||
| } | ||
| } | ||
| await this.revokeAndClearSession( | ||
| accessToken, | ||
| tokenCache, | ||
| currentEnv, | ||
| environment | ||
| ); | ||
| } | ||
| async revokeAndClearSession( | ||
| accessToken, | ||
| tokenCache, | ||
| currentEnv, | ||
| environment | ||
| ) { | ||
| while (true) { | ||
| let response; | ||
| let thrownError; | ||
| const { clientId, clientSecret } = | ||
| this.getClientCredentials(environment); | ||
| if (!clientId || !clientSecret) { | ||
| thrownError = new Error('Invalid client credentials.'); | ||
| response = undefined; | ||
| } else { | ||
| const sdk = new BoxSDK({ | ||
| clientID: clientId, | ||
| clientSecret, | ||
| ...SDK_CONFIG, | ||
| }); | ||
| try { | ||
| response = await sdk.revokeTokens(accessToken); | ||
| } catch (error) { | ||
| thrownError = error; | ||
| } | ||
| } | ||
| if (isSuccessResponse(response)) { | ||
| break; | ||
| } | ||
| if (isInvalidTokenResponse(response)) { | ||
| this.info( | ||
| chalk`{yellow Access token is already invalid. Clearing local session.}` | ||
| ); | ||
| break; | ||
| } | ||
| const action = await this.promptRevokeFailureAction( | ||
| thrownError, | ||
| response | ||
| ); | ||
| if (action === 'abort') { | ||
| this.info( | ||
| chalk`{yellow Logout aborted. Token was not revoked and remains cached.}` | ||
| ); | ||
| return; | ||
| } | ||
| if (action === 'clear') { | ||
| break; | ||
| } | ||
| } | ||
| await new Promise((resolve, reject) => { | ||
| tokenCache.clear((err) => (err ? reject(err) : resolve())); | ||
| }); | ||
| this.info( | ||
| chalk`{green Successfully logged out from "${currentEnv}" environment.}` | ||
| ); | ||
| } | ||
| getClientCredentials(environment) { | ||
| if (environment.boxConfigFilePath) { | ||
| try { | ||
| const fs = require('node:fs'); | ||
| const configObj = JSON.parse( | ||
| fs.readFileSync(environment.boxConfigFilePath) | ||
| ); | ||
| return { | ||
| clientId: configObj?.boxAppSettings?.clientID ?? '', | ||
| clientSecret: configObj?.boxAppSettings?.clientSecret ?? '', | ||
| }; | ||
| } catch { | ||
| // fall through to environment | ||
| } | ||
| } | ||
| return { | ||
| clientId: environment.clientId ?? '', | ||
| clientSecret: environment.clientSecret ?? '', | ||
| }; | ||
| } | ||
| async promptRevokeFailureAction(thrownError, response) { | ||
| const onRevokeFailure = this.flags['on-revoke-failure']; | ||
| if (onRevokeFailure) { | ||
| return onRevokeFailure; | ||
| } | ||
| const result = await inquirer.prompt([ | ||
| { | ||
| type: 'list', | ||
| name: 'action', | ||
| message: chalk`Could not revoke token: {red ${getRevokeErrorMessage(thrownError, response)}}\nWhat would you like to do?`, | ||
| choices: [ | ||
| { name: 'Try revoking again', value: 'retry' }, | ||
| { | ||
| name: 'Clear local session only (token remains valid on Box)', | ||
| value: 'clear', | ||
| }, | ||
| { name: 'Abort', value: 'abort' }, | ||
| ], | ||
| }, | ||
| ]); | ||
| return result.action; | ||
| } | ||
| } | ||
| // @NOTE: This command skips client setup, since it may be used when token is expired | ||
| OAuthLogoutCommand.noClient = true; | ||
| OAuthLogoutCommand.description = [ | ||
| 'Revoke the access token and clear local token cache.', | ||
| '', | ||
| 'For OAuth, run `box login` to authorize again.', | ||
| 'For CCG and JWT, a new token is fetched automatically on the next command.', | ||
| '', | ||
| 'Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt.', | ||
| ].join('\n'); | ||
| OAuthLogoutCommand.flags = { | ||
| ...BoxCommand.minFlags, | ||
| force: Flags.boolean({ | ||
| char: 'f', | ||
| description: 'Skip confirmation prompt', | ||
| }), | ||
| 'on-revoke-failure': Flags.string({ | ||
| description: | ||
| 'On revoke failure: "clear" clears local cache only, "abort" exits without clearing. Skips prompt.', | ||
| options: ['clear', 'abort'], | ||
| }), | ||
| }; | ||
| module.exports = OAuthLogoutCommand; |
| 'use strict'; | ||
| // restore-cursor@3 (used by inquirer@8) calls require('signal-exit') as a | ||
| // function, but signal-exit@4 exports an object with an onExit() method. | ||
| // | ||
| // Locally, npm nests signal-exit@3 under restore-cursor, so both versions | ||
| // coexist. In the standalone Windows build produced by oclif pack:win, module | ||
| // resolution ends up pointing restore-cursor to top-level signal-exit@4 | ||
| // (effectively like a flattened/deduped node_modules layout), which causes | ||
| // "signalExit is not a function" on Windows. | ||
| // | ||
| // Node's lookup order for require('signal-exit') from restore-cursor is: | ||
| // 1) restore-cursor/node_modules/signal-exit | ||
| // 2) parent node_modules/signal-exit | ||
| // If step (1) is absent in the packaged layout, step (2) resolves to v4. | ||
| // | ||
| // This started surfacing after dependency-tree changes included in v4.5.0 | ||
| // (notably commit 4f4254d), where top-level signal-exit moved to 4.1.0. | ||
| // | ||
| // This shim wraps the v4 object export as a callable function before inquirer | ||
| // (and its restore-cursor dependency) is loaded. | ||
| const SIGNAL_EXIT_ID = 'signal-exit'; | ||
| const signalExit = require(SIGNAL_EXIT_ID); | ||
| if ( | ||
| typeof signalExit !== 'function' && | ||
| typeof signalExit.onExit === 'function' | ||
| ) { | ||
| const compatSignalExit = (...args) => signalExit.onExit(...args); | ||
| Object.assign(compatSignalExit, signalExit); | ||
| const resolvedPath = require.resolve(SIGNAL_EXIT_ID); | ||
| if (require.cache[resolvedPath]) { | ||
| require.cache[resolvedPath].exports = compatSignalExit; | ||
| } | ||
| } | ||
| module.exports = require('inquirer'); |
| 'use strict'; | ||
| const BoxCLIError = require('./cli-error'); | ||
| function assertValidOAuthCode(code) { | ||
| if (typeof code !== 'string' || code.length === 0) { | ||
| throw new BoxCLIError( | ||
| `Invalid OAuth code received in callback. Got "${code}"` | ||
| ); | ||
| } | ||
| } | ||
| async function getTokenInfoByAuthCode(sdk, code, redirectUri, codeVerifier) { | ||
| if (!sdk?.tokenManager?.getTokens) { | ||
| throw new BoxCLIError( | ||
| 'OAuth token manager is unavailable; unable to complete token exchange.' | ||
| ); | ||
| } | ||
| try { | ||
| const grantPayload = { | ||
| grant_type: 'authorization_code', | ||
| code, | ||
| redirect_uri: redirectUri, | ||
| }; | ||
| if (codeVerifier) { | ||
| grantPayload.code_verifier = codeVerifier; | ||
| } | ||
| return await sdk.tokenManager.getTokens(grantPayload, null); | ||
| } catch (error) { | ||
| throw new BoxCLIError( | ||
| 'Failed to exchange auth code for tokens.', | ||
| error | ||
| ); | ||
| } | ||
| } | ||
| module.exports = { | ||
| assertValidOAuthCode, | ||
| getTokenInfoByAuthCode, | ||
| }; |
| 'use strict'; | ||
| const fs = require('node:fs'); | ||
| const progress = require('cli-progress'); | ||
| const BoxCLIError = require('../cli-error'); | ||
| const CHUNKED_UPLOAD_FILE_SIZE = 1024 * 1024 * 100; // 100 MiB | ||
| function createReadStream(filePath) { | ||
| try { | ||
| return fs.createReadStream(filePath); | ||
| } catch (error) { | ||
| throw new BoxCLIError(`Could not open file ${filePath}`, error); | ||
| } | ||
| } | ||
| function runChunkedUpload(uploader, size) { | ||
| const progressBar = new progress.Bar({ | ||
| format: '[{bar}] {percentage}% | ETA: {eta_formatted} | {value}/{total} | Speed: {speed} MB/s', | ||
| stopOnComplete: true, | ||
| }); | ||
| let bytesUploaded = 0; | ||
| const startTime = Date.now(); | ||
| progressBar.start(size, 0, { speed: 'N/A' }); | ||
| uploader.on('chunkUploaded', (chunk) => { | ||
| bytesUploaded += chunk.part.size; | ||
| progressBar.update(bytesUploaded, { | ||
| speed: Math.floor(bytesUploaded / (Date.now() - startTime) / 1000), | ||
| }); | ||
| }); | ||
| return uploader.start(); | ||
| } | ||
| async function uploadFile( | ||
| client, | ||
| { folderID, name, stream, size, fileAttributes } | ||
| ) { | ||
| if (size < CHUNKED_UPLOAD_FILE_SIZE) { | ||
| return client.files.uploadFile(folderID, name, stream, fileAttributes); | ||
| } | ||
| const uploader = await client.files.getChunkedUploader( | ||
| folderID, | ||
| size, | ||
| name, | ||
| stream, | ||
| { fileAttributes } | ||
| ); | ||
| return runChunkedUpload(uploader, size); | ||
| } | ||
| async function uploadNewFileVersion( | ||
| client, | ||
| { fileID, stream, size, fileAttributes } | ||
| ) { | ||
| if (size < CHUNKED_UPLOAD_FILE_SIZE) { | ||
| return client.files.uploadNewFileVersion( | ||
| fileID, | ||
| stream, | ||
| fileAttributes | ||
| ); | ||
| } | ||
| const uploader = await client.files.getNewVersionChunkedUploader( | ||
| fileID, | ||
| size, | ||
| stream, | ||
| { fileAttributes } | ||
| ); | ||
| return runChunkedUpload(uploader, size); | ||
| } | ||
| module.exports = { | ||
| CHUNKED_UPLOAD_FILE_SIZE, | ||
| createReadStream, | ||
| uploadFile, | ||
| uploadNewFileVersion, | ||
| }; |
| 'use strict'; | ||
| const { createHash, randomBytes } = require('node:crypto'); | ||
| const MIN_PKCE_CODE_VERIFIER_LENGTH = 43; | ||
| const MAX_PKCE_CODE_VERIFIER_LENGTH = 128; | ||
| function toBase64Url(buffer) { | ||
| return buffer | ||
| .toString('base64') | ||
| .replaceAll('+', '-') | ||
| .replaceAll('/', '_') | ||
| .replace(/=+$/u, ''); | ||
| } | ||
| function generatePKCE(verifierLength = MAX_PKCE_CODE_VERIFIER_LENGTH) { | ||
| if ( | ||
| !Number.isInteger(verifierLength) || | ||
| verifierLength < MIN_PKCE_CODE_VERIFIER_LENGTH || | ||
| verifierLength > MAX_PKCE_CODE_VERIFIER_LENGTH | ||
| ) { | ||
| throw new RangeError( | ||
| `PKCE code verifier length must be an integer in range ${MIN_PKCE_CODE_VERIFIER_LENGTH}-${MAX_PKCE_CODE_VERIFIER_LENGTH}.` | ||
| ); | ||
| } | ||
| // Generate enough entropy and trim to requested PKCE verifier length. | ||
| const codeVerifier = toBase64Url(randomBytes(verifierLength)).slice( | ||
| 0, | ||
| verifierLength | ||
| ); | ||
| const codeChallenge = toBase64Url( | ||
| createHash('sha256').update(codeVerifier).digest() | ||
| ); | ||
| return { codeVerifier, codeChallenge }; | ||
| } | ||
| module.exports = { | ||
| MIN_PKCE_CODE_VERIFIER_LENGTH, | ||
| MAX_PKCE_CODE_VERIFIER_LENGTH, | ||
| toBase64Url, | ||
| generatePKCE, | ||
| }; |
| 'use strict'; | ||
| const { promisify } = require('node:util'); | ||
| const DEBUG = require('./debug'); | ||
| const PLATFORM_DARWIN = 'darwin'; | ||
| const KEYTAR = 'keytar'; | ||
| const KEYCHAIN = 'keychain'; | ||
| /** | ||
| * Load an optional dependency and capture load errors. | ||
| * | ||
| * @param {string} packageName Package to load | ||
| * @param {boolean} shouldLoad Whether this package should be loaded | ||
| * @returns {{ loadedModule: unknown, loadError: unknown }} Result of loading | ||
| */ | ||
| function loadOptionalModule(packageName, shouldLoad = true) { | ||
| if (!shouldLoad) { | ||
| return { loadedModule: null, loadError: null }; | ||
| } | ||
| try { | ||
| return { loadedModule: require(packageName), loadError: null }; | ||
| } catch (error) { | ||
| return { loadedModule: null, loadError: error }; | ||
| } | ||
| } | ||
| const { loadedModule: keytarModule, loadError: keytarLoadError } = | ||
| loadOptionalModule(KEYTAR, process.platform !== PLATFORM_DARWIN); | ||
| const { loadedModule: keychainModule, loadError: keychainLoadError } = | ||
| loadOptionalModule(KEYCHAIN, process.platform === PLATFORM_DARWIN); | ||
| const isDarwin = process.platform === PLATFORM_DARWIN; | ||
| const SUPPORTED_SECURE_STORAGE_PLATFORMS = [PLATFORM_DARWIN, 'win32', 'linux']; | ||
| const isSecurePlatform = SUPPORTED_SECURE_STORAGE_PLATFORMS.includes( | ||
| process.platform | ||
| ); | ||
| /** | ||
| * Returns true when error indicates missing keychain/keytar entry. | ||
| * | ||
| * @param {unknown} error The caught error | ||
| * @returns {boolean} Whether this is a "secret not found" error | ||
| */ | ||
| function isSecretNotFoundError(error) { | ||
| const message = String(error?.message || '').toLowerCase(); | ||
| return ( | ||
| error?.code === 'ENOENT' || | ||
| message.includes('not found') || | ||
| message.includes('password not found') || | ||
| message.includes('item not found') || | ||
| message.includes('could not find password') | ||
| ); | ||
| } | ||
| /** | ||
| * Unified secure storage wrapper. | ||
| * | ||
| * On macOS uses the `keychain` npm module (which wraps `/usr/bin/security`). | ||
| * ACL (Access Control List) in Keychain is a per-secret allowlist of apps | ||
| * that can access the item without prompting. Using `keychain` avoids ACL | ||
| * prompts because the accessing process is always the stable system | ||
| * `security` binary, regardless of CLI binary identity/signature changes. | ||
| * If we used `keytar` on macOS, access would come from the current | ||
| * `node`/CLI executable identity; after signed-binary upgrades, macOS can | ||
| * treat it as a different app and show ACL prompts for existing items. | ||
| * That is why this module intentionally does not use `keytar` on macOS. | ||
| * | ||
| * On Windows/Linux uses `keytar` (native Keychain/Credential Vault/libsecret). | ||
| */ | ||
| class SecureStorage { | ||
| constructor() { | ||
| if (isDarwin && keychainModule) { | ||
| this.backend = KEYCHAIN; | ||
| this.available = true; | ||
| } else if (!isDarwin && isSecurePlatform && keytarModule) { | ||
| this.backend = KEYTAR; | ||
| this.available = true; | ||
| } else { | ||
| this.backend = null; | ||
| this.available = false; | ||
| } | ||
| DEBUG.init('Secure storage initialized %O', { | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| backend: this.backend, | ||
| available: this.available, | ||
| keytarLoaded: Boolean(keytarModule), | ||
| darwinKeychainLoaded: Boolean(keychainModule), | ||
| }); | ||
| if (!this.available) { | ||
| if (isDarwin && !keychainModule) { | ||
| DEBUG.init( | ||
| 'macOS keychain module not available: %s', | ||
| keychainLoadError?.message || 'unknown' | ||
| ); | ||
| } | ||
| if (!isDarwin && !keytarModule) { | ||
| DEBUG.init( | ||
| 'keytar module not available: %s', | ||
| keytarLoadError?.message || 'unknown' | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Read a password from secure storage. | ||
| * | ||
| * @param {string} service The service name | ||
| * @param {string} account The account name | ||
| * @returns {Promise<string|null>} The stored password, or null | ||
| */ | ||
| async getPassword(service, account) { | ||
| if (!this.available) { | ||
| return null; | ||
| } | ||
| if (this.backend === KEYCHAIN) { | ||
| try { | ||
| const getPasswordAsync = promisify( | ||
| keychainModule.getPassword.bind(keychainModule) | ||
| ); | ||
| const password = await getPasswordAsync({ | ||
| account, | ||
| service, | ||
| }); | ||
| return password || null; | ||
| } catch (error) { | ||
| if (isSecretNotFoundError(error)) { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| return keytarModule.getPassword(service, account); | ||
| } | ||
| /** | ||
| * Write a password to secure storage. | ||
| * | ||
| * @param {string} service The service name | ||
| * @param {string} account The account name | ||
| * @param {string} password The value to store | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async setPassword(service, account, password) { | ||
| if (!this.available) { | ||
| throw new Error('Secure storage is not available'); | ||
| } | ||
| if (this.backend === KEYCHAIN) { | ||
| const setPasswordAsync = promisify( | ||
| keychainModule.setPassword.bind(keychainModule) | ||
| ); | ||
| await setPasswordAsync({ account, service, password }); | ||
| return; | ||
| } | ||
| await keytarModule.setPassword(service, account, password); | ||
| } | ||
| /** | ||
| * Delete a password from secure storage. | ||
| * | ||
| * @param {string} service The service name | ||
| * @param {string} account The account name | ||
| * @returns {Promise<boolean>} true if deleted | ||
| */ | ||
| async deletePassword(service, account) { | ||
| if (!this.available) { | ||
| return false; | ||
| } | ||
| if (this.backend === KEYCHAIN) { | ||
| try { | ||
| const deletePasswordAsync = promisify( | ||
| keychainModule.deletePassword.bind(keychainModule) | ||
| ); | ||
| await deletePasswordAsync({ account, service }); | ||
| return true; | ||
| } catch (error) { | ||
| if (isSecretNotFoundError(error)) { | ||
| return false; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| return keytarModule.deletePassword(service, account); | ||
| } | ||
| } | ||
| module.exports = new SecureStorage(); |
+8
-5
| { | ||
| "name": "@box/cli", | ||
| "description": "Official command line interface for the Box API", | ||
| "description": "Official command line interface for the Box API.", | ||
| "keywords": [ | ||
@@ -12,3 +12,3 @@ "box", | ||
| ], | ||
| "version": "4.5.0", | ||
| "version": "4.6.0", | ||
| "author": "Box <oss@box.com>", | ||
@@ -36,3 +36,3 @@ "license": "Apache-2.0", | ||
| "archiver": "^3.0.0", | ||
| "box-node-sdk": "^4.1.1", | ||
| "box-node-sdk": "^4.3.0", | ||
| "chalk": "^2.4.1", | ||
@@ -43,3 +43,3 @@ "cli-progress": "^2.1.0", | ||
| "debug": "^4.4.0", | ||
| "express": "^4.21.1", | ||
| "express": "^4.22.1", | ||
| "fs-extra": "^10.1.0", | ||
@@ -62,2 +62,3 @@ "inquirer": "^8.2.7", | ||
| "babel-eslint": "^10.1.0", | ||
| "baseline-browser-mapping": "^2.9.11", | ||
| "chai": "^4.1.2", | ||
@@ -72,3 +73,3 @@ "chai-as-promised": "^7.1.1", | ||
| "generate-license-file": "^4.1.0", | ||
| "jsonwebtoken": "^9.0.2", | ||
| "jsonwebtoken": "^9.0.3", | ||
| "leche": "^2.3.0", | ||
@@ -81,2 +82,3 @@ "mocha": "^11.7.5", | ||
| "prettier": "^3.6.2", | ||
| "signal-exit": "^4.1.0", | ||
| "sinon": "^19.0.2", | ||
@@ -96,2 +98,3 @@ "standard-version": "^9.5.0", | ||
| "oclif": { | ||
| "description": "Official command line interface for the Box API. New here? Run 'box login -d' to sign in with your Box account in seconds. Already set up? Run 'box help' to explore all available commands.", | ||
| "commands": "./src/commands", | ||
@@ -98,0 +101,0 @@ "bin": "box", |
+80
-17
@@ -11,10 +11,2 @@ <p align="center"> | ||
| > 🚨**NEW MAJOR VERSION ALERT** | ||
| > | ||
| > 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 | ||
| > | ||
| > 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). | ||
@@ -32,9 +24,15 @@ | ||
| - [Box CLI](#box-cli) | ||
| - [Table of contents](#table-of-contents) | ||
| - [Getting Started](#getting-started) | ||
| - [CLI Installation](#cli-installation) | ||
| - [Windows \& macOS Installers](#windows--macos-installers) | ||
| - [Linux \& Node install](#linux--node-install) | ||
| - [Windows & macOS Installers](#windows--macos-installers) | ||
| - [Linux & Node install](#linux--node-install) | ||
| - [Quick Login with the Official Box CLI App](#quick-login-with-the-official-box-cli-app) | ||
| - [CLI and Server Authentication with JWT](#cli-and-server-authentication-with-jwt) | ||
| - [Logout](#logout) | ||
| - [Secure Storage](#secure-storage) | ||
| - [What is Stored Securely](#what-is-stored-securely) | ||
| - [Platform Support](#platform-support) | ||
| - [Linux Installation](#linux-installation) | ||
| - [Automatic Migration](#automatic-migration) | ||
| - [Data Location](#data-location) | ||
| - [Usage](#usage) | ||
@@ -53,4 +51,2 @@ - [Command Topics](#command-topics) | ||
| The most convenient way to get start with Box CLI is to follow [Box CLI with OAuth 2.0 guide][oauth-guide]. You will be guided how to configure Box application and install CLI on your machine. | ||
| ### CLI Installation | ||
@@ -69,6 +65,26 @@ Installers are available for Windows and macOS. However, the raw source-code is available if you would like to build the CLI in other environments. | ||
| ### Quick Login with the Official Box CLI App | ||
| After installation, run `box login` to sign in. You will be prompted with three options: | ||
| - **[1] Official Box CLI App** — No app setup required. Uses predefined scopes limited to content actions. | ||
| - **[2] Your own Platform OAuth app** — Enter your Client ID and Client Secret for custom scopes or specific configuration. | ||
| - **[q] Quit** — Exit the login flow. | ||
| You can also paste a Client ID directly at the prompt (any input between 16 and 99 characters is recognized as a Client ID). | ||
| **Quick start** — skip the prompt and go directly to authorization with the Official Box CLI App: | ||
| ```bash | ||
| box login -d | ||
| ``` | ||
| A browser window opens for authorization. Once you grant access, the CLI creates a new environment and you're ready to go. See the [authentication docs](docs/authentication.md) for more details. | ||
| **Platform App** — run `box login`, choose `[2]`, and enter your credentials from the [Box Developer Console][dev-console]. See the [Box CLI with OAuth 2.0 guide][oauth-guide] and the [authentication docs](docs/authentication.md) for setup instructions. | ||
| ### CLI and Server Authentication with JWT | ||
| Alternatively, to get started with the Box CLI, [download and install](#CLI-Installation) CLI, set up a Box application using Server Authentication with JWT and | ||
| download the JSON configuration file from the Configuration page of your platform app in the | ||
| download the JSON configuration file from the Configuration page of your Platform App in the | ||
| [Box Developer Console][dev-console] following [JWT CLI Guide][jwt-guide]. Then, set up the CLI by pointing it to your configuration file: | ||
@@ -93,2 +109,48 @@ | ||
| ### Logout | ||
| To sign out from the current environment, run: | ||
| ```bash | ||
| box logout | ||
| ``` | ||
| This revokes the access token on Box and clears the local token cache. For OAuth, run `box login` to authorize again. For CCG and JWT, a new token is fetched automatically on the next command. Use `-f` to skip the confirmation prompt, or `--on-revoke-failure=clear` / `--on-revoke-failure=abort` to control behavior when token revocation fails. See [`box logout`](docs/logout.md) for full details. | ||
| ## Secure Storage | ||
| The Box CLI uses secure storage to protect your sensitive data: | ||
| ### What is Stored Securely | ||
| - **Environment Configuration**: Client IDs, client secrets, and enterprise IDs, private keys and public keys | ||
| - **Authentication Tokens**: Access tokens and refresh tokens for all configured environments | ||
| ### Platform Support | ||
| | Platform | Secure Storage | Installation Required | | ||
| |----------|---------------|----------------------| | ||
| | **macOS** | Keychain | Built-in | | ||
| | **Windows** | Credential Manager | Built-in | | ||
| | **Linux** | Secret Service (libsecret) | May require installation | | ||
| ### Linux Installation | ||
| On Linux systems, you need to install `libsecret-1-dev` for secure storage. | ||
| **Note**: If libsecret is not installed, the CLI will automatically fall back to storing credentials in plain text files in `~/.box/`. You can still use the CLI, but for security, we recommend installing libsecret. | ||
| ### Automatic Migration | ||
| When you upgrade to a version with secure storage support: | ||
| - Existing plaintext credentials are automatically read | ||
| - On the next token refresh or configuration update, credentials are migrated to secure storage | ||
| - Old plaintext files are automatically deleted after successful migration | ||
| - No manual action required! | ||
| ### Data Location | ||
| - **Secure Storage**: Credentials stored in your OS keychain/credential manager | ||
| - **Fallback (if secure storage unavailable)**: `~/.box/box_environments.json` and `~/.box/{environment}_token_cache.json` | ||
| ## Usage | ||
@@ -144,3 +206,3 @@ | ||
| * [`box ai`](docs/ai.md) - Sends an AI request to supported LLMs and returns an answer | ||
| * [`box ai`](docs/ai.md) - Sends a request to supported LLMs using Box AI. This is intended for direct use, not by AI agents. | ||
| * [`box autocomplete`](docs/autocomplete.md) - Display autocomplete installation instructions | ||
@@ -161,3 +223,4 @@ * [`box collaboration-allowlist`](docs/collaboration-allowlist.md) - List collaboration allowlist entries | ||
| * [`box legal-hold-policies`](docs/legal-hold-policies.md) - List legal hold policies | ||
| * [`box login`](docs/login.md) - Sign in with OAuth and set a new environment or update an existing if reauthorize flag is used | ||
| * [`box login`](docs/login.md) - Sign in with OAuth 2.0 and create a new environment (or update an existing one with --reauthorize). | ||
| * [`box logout`](docs/logout.md) - Revoke the access token and clear local token cache. | ||
| * [`box metadata-cascade-policies`](docs/metadata-cascade-policies.md) - List the metadata cascade policies on a folder | ||
@@ -164,0 +227,0 @@ * [`box metadata-query`](docs/metadata-query.md) - Create a search using SQL-like syntax to return items that match specific metadata |
+193
-104
@@ -35,18 +35,6 @@ 'use strict'; | ||
| const pkg = require('../package.json'); | ||
| const inquirer = require('inquirer'); | ||
| const darwinKeychain = require('keychain'); | ||
| const inquirer = require('./inquirer'); | ||
| const { stringifyStream } = require('@discoveryjs/json-ext'); | ||
| const progress = require('cli-progress'); | ||
| const darwinKeychainSetPassword = promisify( | ||
| darwinKeychain.setPassword.bind(darwinKeychain) | ||
| ); | ||
| const darwinKeychainGetPassword = promisify( | ||
| darwinKeychain.getPassword.bind(darwinKeychain) | ||
| ); | ||
| let keytar = null; | ||
| try { | ||
| keytar = require('keytar'); | ||
| } catch { | ||
| // keytar cannot be imported because the library is not provided for this operating system / architecture | ||
| } | ||
| const secureStorage = require('./secure-storage'); | ||
@@ -107,2 +95,4 @@ const DEBUG = require('./debug'); | ||
| ); | ||
| const ENVIRONMENTS_KEYCHAIN_SERVICE = 'boxcli'; | ||
| const ENVIRONMENTS_KEYCHAIN_ACCOUNT = 'Box'; | ||
@@ -112,2 +102,20 @@ const DEFAULT_ANALYTICS_CLIENT_NAME = 'box-cli'; | ||
| /** | ||
| * Convert error objects to a stable debug-safe shape. | ||
| * | ||
| * @param {unknown} error A caught error object | ||
| * @returns {Object} A reduced object for DEBUG logging | ||
| */ | ||
| function getDebugErrorDetails(error) { | ||
| if (!error || typeof error !== 'object') { | ||
| return { message: String(error) }; | ||
| } | ||
| return { | ||
| name: error.name || 'Error', | ||
| code: error.code, | ||
| message: error.message || String(error), | ||
| stack: error.stack, | ||
| }; | ||
| } | ||
| /** | ||
| * Parse a string value from CSV into the correct boolean value | ||
@@ -306,3 +314,5 @@ * @param {string|boolean} value The value to parse | ||
| this.isBulk = true; | ||
| // eslint-disable-next-line unicorn/prefer-structured-clone | ||
| originalArgs = _.cloneDeep(this.constructor.args); | ||
| // eslint-disable-next-line unicorn/prefer-structured-clone | ||
| originalFlags = _.cloneDeep(this.constructor.flags); | ||
@@ -312,2 +322,4 @@ this.disableRequiredArgsAndFlags(); | ||
| this.supportsSecureStorage = secureStorage.available; | ||
| let { flags, args } = await this.parse(this.constructor); | ||
@@ -623,3 +635,3 @@ | ||
| let jsonFile = JSON.parse(fileContents); | ||
| parsedData = jsonFile.hasOwnProperty('entries') | ||
| parsedData = Object.hasOwn(jsonFile, 'entries') | ||
| ? jsonFile.entries | ||
@@ -660,6 +672,3 @@ : jsonFile; | ||
| // First, check if everything in the array is either all object or all non-object | ||
| let types = value.reduce( | ||
| (acc, t) => acc.concat(typeof t), | ||
| [] | ||
| ); | ||
| let types = value.map((t) => typeof t); | ||
| if ( | ||
@@ -1206,7 +1215,6 @@ types.some((t) => t !== 'object') && | ||
| // which happens when a command that outputs a collection gets run in bulk | ||
| formattedOutputData = ( | ||
| await Promise.all( | ||
| content.map((o) => this._formatOutputObject(o)) | ||
| ) | ||
| ).flat(); | ||
| const formattedOutputResults = await Promise.all( | ||
| content.map((o) => this._formatOutputObject(o)) | ||
| ); | ||
| formattedOutputData = formattedOutputResults.flat(); | ||
| DEBUG.output( | ||
@@ -1563,2 +1571,4 @@ 'Formatted %d output entries for display', | ||
| '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.', | ||
| 'Expired Auth: Auth code or refresh token has expired': | ||
| '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.', | ||
| }; | ||
@@ -1582,2 +1592,4 @@ | ||
| async catch(err) { | ||
| const AUTH_FAILED_HINT = | ||
| '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.'; | ||
| if ( | ||
@@ -1590,2 +1602,5 @@ err instanceof BoxTsErrors.BoxApiError && | ||
| let errorMessage = `Unexpected API Response [${responseInfo.body.status} ${responseInfo.body.message} | ${responseInfo.body.request_id}] ${responseInfo.body.code} - ${responseInfo.body.message}`; | ||
| if (responseInfo.body.status === 401) { | ||
| errorMessage += `\n${AUTH_FAILED_HINT}`; | ||
| } | ||
| err = new BoxCLIError(errorMessage, err); | ||
@@ -1630,5 +1645,10 @@ } | ||
| } | ||
| let statusHint = ''; | ||
| const statusCode = error.statusCode || error.response?.statusCode; | ||
| if (statusCode === 401) { | ||
| statusHint = AUTH_FAILED_HINT; | ||
| } | ||
| let errorMsg = chalk`{redBright ${ | ||
| this.flags && this.flags.verbose ? error.stack : error.message | ||
| }${os.EOL}${contextInfo ? contextInfo + os.EOL : ''}}`; | ||
| }${os.EOL}${contextInfo ? contextInfo + os.EOL : ''}${statusHint ? statusHint + os.EOL : ''}}`; | ||
@@ -1664,5 +1684,6 @@ // Write the error message but let the process exit gracefully with error code so stderr gets written out | ||
| } | ||
| fields = REQUIRED_FIELDS.concat( | ||
| fields.split(',').filter((f) => !REQUIRED_FIELDS.includes(f)) | ||
| ); | ||
| fields = [ | ||
| ...REQUIRED_FIELDS, | ||
| ...fields.split(',').filter((f) => !REQUIRED_FIELDS.includes(f)), | ||
| ]; | ||
| DEBUG.output('Filtering output with fields: %O', fields); | ||
@@ -1739,3 +1760,3 @@ if (Array.isArray(output)) { | ||
| subKeys = subKeys.map((x) => `${key}.${x}`); | ||
| keys = keys.concat(subKeys); | ||
| keys = [...keys, ...subKeys]; | ||
| } else { | ||
@@ -1774,6 +1795,6 @@ keys.push(key); | ||
| // Successively apply the offsets to the current time | ||
| newDate = argPairs.reduce( | ||
| (d, args) => offsetDate(d, ...args), | ||
| new Date() | ||
| ); | ||
| newDate = new Date(); | ||
| for (const args of argPairs) { | ||
| newDate = offsetDate(newDate, ...args); | ||
| } | ||
| } else if (time === 'now') { | ||
@@ -1820,44 +1841,65 @@ newDate = new Date(); | ||
| async getEnvironments() { | ||
| try { | ||
| switch (process.platform) { | ||
| case 'darwin': { | ||
| try { | ||
| const password = await darwinKeychainGetPassword({ | ||
| account: 'Box', | ||
| service: 'boxcli', | ||
| }); | ||
| return JSON.parse(password); | ||
| } catch { | ||
| // fallback to env file if not found | ||
| } | ||
| break; | ||
| if (this.supportsSecureStorage) { | ||
| DEBUG.init( | ||
| 'Attempting secure storage read via %s service="%s" account="%s"', | ||
| secureStorage.backend, | ||
| ENVIRONMENTS_KEYCHAIN_SERVICE, | ||
| ENVIRONMENTS_KEYCHAIN_ACCOUNT | ||
| ); | ||
| try { | ||
| const password = await secureStorage.getPassword( | ||
| ENVIRONMENTS_KEYCHAIN_SERVICE, | ||
| ENVIRONMENTS_KEYCHAIN_ACCOUNT | ||
| ); | ||
| if (password) { | ||
| DEBUG.init( | ||
| 'Successfully loaded environments from secure storage (%s)', | ||
| secureStorage.backend | ||
| ); | ||
| return JSON.parse(password); | ||
| } | ||
| DEBUG.init( | ||
| 'Secure storage returned empty result for service="%s" account="%s"', | ||
| ENVIRONMENTS_KEYCHAIN_SERVICE, | ||
| ENVIRONMENTS_KEYCHAIN_ACCOUNT | ||
| ); | ||
| } catch (error) { | ||
| DEBUG.init( | ||
| 'Failed to read from secure storage (%s), falling back to file: %O', | ||
| secureStorage.backend, | ||
| getDebugErrorDetails(error) | ||
| ); | ||
| } | ||
| } else { | ||
| DEBUG.init( | ||
| 'Skipping secure storage read: platform=%s available=%s', | ||
| process.platform, | ||
| secureStorage.available | ||
| ); | ||
| } | ||
| case 'win32': { | ||
| try { | ||
| if (!keytar) { | ||
| break; | ||
| } | ||
| const password = await keytar.getPassword( | ||
| 'boxcli' /* service */, | ||
| 'Box' /* account */ | ||
| ); | ||
| if (password) { | ||
| return JSON.parse(password); | ||
| } | ||
| } catch { | ||
| // fallback to env file if not found | ||
| } | ||
| break; | ||
| } | ||
| default: | ||
| // Try to read from file (fallback or no secure storage) | ||
| try { | ||
| if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) { | ||
| DEBUG.init( | ||
| 'Attempting environments fallback file read at %s', | ||
| ENVIRONMENTS_FILE_PATH | ||
| ); | ||
| return JSON.parse(fs.readFileSync(ENVIRONMENTS_FILE_PATH)); | ||
| } | ||
| return JSON.parse(fs.readFileSync(ENVIRONMENTS_FILE_PATH)); | ||
| DEBUG.init( | ||
| 'Environments fallback file does not exist at %s', | ||
| ENVIRONMENTS_FILE_PATH | ||
| ); | ||
| } catch (error) { | ||
| throw new BoxCLIError( | ||
| `Could not read environments config file ${ENVIRONMENTS_FILE_PATH}`, | ||
| error | ||
| DEBUG.init( | ||
| 'Failed to read environments from file: %O', | ||
| getDebugErrorDetails(error) | ||
| ); | ||
| } | ||
| // No environments found in either location | ||
| throw new BoxCLIError( | ||
| `Could not read environments. No environments found in secure storage or file ${ENVIRONMENTS_FILE_PATH}` | ||
| ); | ||
| } | ||
@@ -1877,38 +1919,67 @@ | ||
| Object.assign(environments, updatedEnvironments); | ||
| try { | ||
| let fileContents = JSON.stringify(environments, null, 4); | ||
| switch (process.platform) { | ||
| case 'darwin': { | ||
| await darwinKeychainSetPassword({ | ||
| account: 'Box', | ||
| service: 'boxcli', | ||
| password: JSON.stringify(environments), | ||
| }); | ||
| fileContents = ''; | ||
| break; | ||
| let storedInSecureStorage = false; | ||
| if (this.supportsSecureStorage) { | ||
| DEBUG.init( | ||
| 'Attempting secure storage write via %s service="%s" account="%s"', | ||
| secureStorage.backend, | ||
| ENVIRONMENTS_KEYCHAIN_SERVICE, | ||
| ENVIRONMENTS_KEYCHAIN_ACCOUNT | ||
| ); | ||
| try { | ||
| await secureStorage.setPassword( | ||
| ENVIRONMENTS_KEYCHAIN_SERVICE, | ||
| ENVIRONMENTS_KEYCHAIN_ACCOUNT, | ||
| JSON.stringify(environments) | ||
| ); | ||
| storedInSecureStorage = true; | ||
| DEBUG.init( | ||
| 'Stored environment configuration in secure storage (%s)', | ||
| secureStorage.backend | ||
| ); | ||
| if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) { | ||
| fs.unlinkSync(ENVIRONMENTS_FILE_PATH); | ||
| DEBUG.init( | ||
| 'Removed environment configuration file after migrating to secure storage' | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| DEBUG.init( | ||
| 'Could not store credentials in secure storage (%s), falling back to file: %O', | ||
| secureStorage.backend, | ||
| getDebugErrorDetails(error) | ||
| ); | ||
| } | ||
| } else { | ||
| DEBUG.init( | ||
| 'Skipping secure storage write: platform=%s available=%s', | ||
| process.platform, | ||
| secureStorage.available | ||
| ); | ||
| } | ||
| case 'win32': { | ||
| if (!keytar) { | ||
| break; | ||
| } | ||
| await keytar.setPassword( | ||
| 'boxcli' /* service */, | ||
| 'Box' /* account */, | ||
| JSON.stringify(environments) /* password */ | ||
| // Write to file if secure storage failed or not available | ||
| if (!storedInSecureStorage) { | ||
| try { | ||
| let fileContents = JSON.stringify(environments, null, 4); | ||
| fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8'); | ||
| if ( | ||
| process.platform === 'linux' && | ||
| this.supportsSecureStorage | ||
| ) { | ||
| this.info( | ||
| 'Could not store credentials in secure storage, falling back to file.' + | ||
| ' To enable secure storage on Linux, install libsecret-1-dev package.' | ||
| ); | ||
| fileContents = ''; | ||
| break; | ||
| } | ||
| default: | ||
| } catch (error) { | ||
| throw new BoxCLIError( | ||
| `Could not write environments config file ${ENVIRONMENTS_FILE_PATH}`, | ||
| error | ||
| ); | ||
| } | ||
| } | ||
| fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8'); | ||
| } catch (error) { | ||
| throw new BoxCLIError( | ||
| `Could not write environments config file ${ENVIRONMENTS_FILE_PATH}`, | ||
| error | ||
| ); | ||
| } | ||
| return environments; | ||
@@ -1930,3 +2001,23 @@ } | ||
| } | ||
| if (!fs.existsSync(ENVIRONMENTS_FILE_PATH)) { | ||
| // Check if environments exist (in secure storage or file) | ||
| let environmentsExist = false; | ||
| try { | ||
| const environments = await this.getEnvironments(); | ||
| // Check if there are any environments configured | ||
| if ( | ||
| environments && | ||
| environments.environments && | ||
| Object.keys(environments.environments).length > 0 | ||
| ) { | ||
| environmentsExist = true; | ||
| DEBUG.init('Found existing environments in storage'); | ||
| } | ||
| } catch (error) { | ||
| // No environments found, need to create defaults | ||
| DEBUG.init('No existing environments found: %s', error.message); | ||
| } | ||
| if (!environmentsExist) { | ||
| // Create default environments (will be stored in secure storage if available) | ||
| await this.updateEnvironments( | ||
@@ -1936,7 +2027,5 @@ {}, | ||
| ); | ||
| DEBUG.init( | ||
| 'Created environments config at %s', | ||
| ENVIRONMENTS_FILE_PATH | ||
| ); | ||
| DEBUG.init('Created default environments configuration'); | ||
| } | ||
| if (!fs.existsSync(SETTINGS_FILE_PATH)) { | ||
@@ -1943,0 +2032,0 @@ let settingsJSON = JSON.stringify( |
@@ -32,3 +32,3 @@ 'use strict'; | ||
| AiAskCommand.description = | ||
| 'Sends an AI request to supported LLMs and returns an answer'; | ||
| 'Sends a request to supported LLMs using Box AI. This is intended for direct use, not by AI agents.'; | ||
| AiAskCommand.examples = [ | ||
@@ -47,3 +47,4 @@ 'box ai:ask --items=id=12345,type=file --prompt "What is the status of this document?"', | ||
| required: true, | ||
| description: 'The items for the AI request', | ||
| description: | ||
| 'Items for the AI request. Format: id=FILE_ID,type=file (or content=TEXT,type=file). Supported keys: id, type, content.', | ||
| multiple: true, | ||
@@ -89,3 +90,3 @@ parse(input) { | ||
| description: | ||
| 'The AI agent to be used for the ask, provided as a JSON string. Example: {"type": "ai_agent_ask", "basicText": {"model": "openai__gpt_3_5_turbo"}}', | ||
| 'AI agent configuration as JSON. Example: {"type":"ai_agent_ask","basic_text":{"model":"azure__openai__gpt_4o_mini"}}', | ||
| parse(input) { | ||
@@ -95,3 +96,5 @@ try { | ||
| } catch (error) { | ||
| throw ('Error parsing AI agent ', error); | ||
| throw new Error( | ||
| `Error parsing AI agent JSON: ${error.message}` | ||
| ); | ||
| } | ||
@@ -98,0 +101,0 @@ }, |
@@ -44,6 +44,6 @@ 'use strict'; | ||
| AiExtractStructuredCommand.description = | ||
| 'Sends an AI request to supported Large Language Models (LLMs) and returns extracted metadata as a set of key-value pairs. For this request, you either need a metadata template or a list of fields you want to extract. Input is either a metadata template or a list of fields to ensure the structure.'; | ||
| 'Sends an AI request to supported Large Language Models (LLMs) and returns extracted metadata as a set of key-value pairs. For this request, you either need a metadata template or a list of fields you want to extract. Input is either a metadata template or a list of fields to ensure the structure. This is intended for direct use, not by AI agents.'; | ||
| AiExtractStructuredCommand.examples = [ | ||
| 'box ai:extract-structured --items="id=12345,type=file" --fields "key=hobby,type=multiSelect,description=Person hobby,prompt=What is your hobby?,displayName=Hobby,options=Guitar;Books"', | ||
| 'box ai:extract-structured --items="id=12345,type=file" --metadata-template="type=metadata_template,scope=enterprise,template_key=test" --ai-agent \'{"type":"ai_agent_extract_structured","basicText":{"llmEndpointParams":{"type":"openai_params","frequencyPenalty": 1.5,"presencePenalty": 1.5,"stop": "<|im_end|>","temperature": 0,"topP": 1},"model": "azure__openai__gpt_4o_mini","numTokensForCompletion": 8400,"promptTemplate": "It is, consider these travel options and answer the.","systemMessage": "You are a helpful travel assistant specialized in budget travel"}}}\'', | ||
| 'box ai:extract-structured --items="id=12345,type=file" --metadata-template="type=metadata_template,scope=enterprise,template_key=test" --ai-agent \'{"type":"ai_agent_extract_structured","basic_text":{"model":"azure__openai__gpt_4o_mini","prompt_template":"Answer using the provided content"}}\'', | ||
| ]; | ||
@@ -56,3 +56,4 @@ AiExtractStructuredCommand._endpoint = 'post_ai_extract_structured'; | ||
| required: true, | ||
| description: 'The items that LLM will process.', | ||
| description: | ||
| 'Items for structured extraction. Format: id=FILE_ID,type=file (or content=TEXT,type=file). Supported keys: id, type, content.', | ||
| multiple: true, | ||
@@ -134,3 +135,4 @@ parse(input) { | ||
| multiple: true, | ||
| description: 'The fields to be extracted from the provided items.', | ||
| description: | ||
| 'Fields to extract from the provided items. Use options=VALUE1;VALUE2 for multiSelect fields.', | ||
| parse(input) { | ||
@@ -205,3 +207,3 @@ const fields = {}; | ||
| description: | ||
| 'The AI agent to be used for the structured extraction, provided as a JSON string. Example: {"type": "ai_agent_extract_structured", "basicText": {"model": "azure__openai__gpt_4o_mini", "promptTemplate": "Answer the question based on {content}"}}', | ||
| 'AI agent configuration as JSON. Example: {"type":"ai_agent_extract_structured","basic_text":{"model":"azure__openai__gpt_4o_mini","prompt_template":"Answer the question based on {content}"}}', | ||
| parse(input) { | ||
@@ -211,3 +213,5 @@ try { | ||
| } catch (error) { | ||
| throw ('Error parsing AI agent ', error); | ||
| throw new Error( | ||
| `Error parsing AI agent JSON: ${error.message}` | ||
| ); | ||
| } | ||
@@ -214,0 +218,0 @@ }, |
@@ -30,6 +30,6 @@ 'use strict'; | ||
| AiExtractCommand.description = | ||
| 'Sends an AI request to supported Large Language Models (LLMs) and extracts metadata in form of key-value pairs'; | ||
| 'Sends an AI request to supported Large Language Models (LLMs) and extracts metadata in form of key-value pairs. This is intended for direct use, not by AI agents.'; | ||
| AiExtractCommand.examples = [ | ||
| 'box ai:extract --items=id=12345,type=file --prompt "firstName, lastName, location, yearOfBirth, company"', | ||
| 'box ai:extract --prompt "firstName, lastName, location, yearOfBirth, company" --items "id=12345,type=file" --ai-agent \'{"type":"ai_agent_extract","basicText":{"llmEndpointParams":{"type":"openai_params","frequencyPenalty": 1.5,"presencePenalty": 1.5,"stop": "<|im_end|>","temperature": 0,"topP": 1},"model": "azure__openai__gpt_4o_mini","numTokensForCompletion": 8400,"promptTemplate": "It is, consider these travel options and answer the.","systemMessage": "You are a helpful travel assistant specialized in budget travel"}}}\'', | ||
| 'box ai:extract --prompt "firstName, lastName, location, yearOfBirth, company" --items "id=12345,type=file" --ai-agent \'{"type":"ai_agent_extract","basic_text":{"model":"azure__openai__gpt_4o_mini"}}\'', | ||
| ]; | ||
@@ -48,3 +48,4 @@ AiExtractCommand._endpoint = 'post_ai_extract'; | ||
| required: true, | ||
| description: 'The items that LLM will process.', | ||
| description: | ||
| 'Items for extraction. Format: id=FILE_ID,type=file (or content=TEXT,type=file). Supported keys: id, type, content.', | ||
| multiple: true, | ||
@@ -90,3 +91,3 @@ parse(input) { | ||
| description: | ||
| 'The AI agent to be used for the extraction, provided as a JSON string. Example: {"type": "ai_agent_extract", "basicText": {"model": "azure__openai__gpt_4o_mini", "promptTemplate": "Answer the question based on {content}"}}', | ||
| 'AI agent configuration as JSON. Example: {"type":"ai_agent_extract","basic_text":{"model":"azure__openai__gpt_4o_mini","prompt_template":"Answer the question based on {content}"}}', | ||
| parse(input) { | ||
@@ -96,3 +97,5 @@ try { | ||
| } catch (error) { | ||
| throw ('Error parsing AI agent ', error); | ||
| throw new Error( | ||
| `Error parsing AI agent JSON: ${error.message}` | ||
| ); | ||
| } | ||
@@ -99,0 +102,0 @@ }, |
@@ -29,3 +29,3 @@ 'use strict'; | ||
| AiTextGenCommand.description = | ||
| 'Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text.'; | ||
| 'Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text. This is intended for direct use, not by AI agents.'; | ||
| AiTextGenCommand.examples = [ | ||
@@ -82,3 +82,3 @@ 'box ai:text-gen --dialogue-history=prompt="What is the status of this document?",answer="It is in review",created-at="2024-07-09T11:29:46.835Z" --items=id=12345,type=file --prompt="What is the status of this document?"', | ||
| description: | ||
| 'The items to be processed by the LLM, often files. The array can include exactly one element.', | ||
| 'Items for text generation. Format: id=FILE_ID,type=file (or content=TEXT,type=file). Supported keys: id, type, content. Exactly one item is supported.', | ||
| multiple: true, | ||
@@ -85,0 +85,0 @@ parse(input) { |
@@ -18,3 +18,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('can-view-path')) { | ||
| if (Object.hasOwn(flags, 'can-view-path')) { | ||
| parameters.body.can_view_path = flags['can-view-path']; | ||
@@ -21,0 +21,0 @@ } |
@@ -48,3 +48,3 @@ 'use strict'; | ||
| if (environmentsObject.environments.hasOwnProperty(environmentName)) { | ||
| if (Object.hasOwn(environmentsObject.environments, environmentName)) { | ||
| throw new BoxCLIError( | ||
@@ -130,3 +130,14 @@ 'There already is an environment with this name' | ||
| EnvironmentsAddCommand.description = 'Add a new Box environment'; | ||
| EnvironmentsAddCommand.description = | ||
| 'Add a new Box environment from a Box app config file (JWT or CCG).\n' + | ||
| 'Open your application in Box Developer Console to get/create config data:\n' + | ||
| 'https://cloud.app.box.com/developers/console\n' + | ||
| '\n' + | ||
| 'For OAuth (an alternative to server-side auth), add environment with: box login.\n' + | ||
| 'Quick start: box login -d (logs the user in via the Box Official CLI App).'; | ||
| EnvironmentsAddCommand.examples = [ | ||
| 'box configure:environments:add ~/Downloads/my_app_config.json', | ||
| 'box configure:environments:add ./config.json --name production --set-as-current', | ||
| 'box configure:environments:add ./config.json --ccg-auth --name ci-bot', | ||
| ]; | ||
@@ -149,3 +160,5 @@ EnvironmentsAddCommand.flags = { | ||
| description: | ||
| 'Add a CCG environment that will use service account. You will have to provide enterprise ID with client id and secret.', | ||
| 'Add a CCG environment that will use a service account.\n' + | ||
| 'Open your application in Box Developer Console and create this config JSON yourself.\n' + | ||
| 'Required fields: boxAppSettings.clientID, boxAppSettings.clientSecret, enterpriseID.', | ||
| }), | ||
@@ -168,3 +181,9 @@ 'ccg-user': Flags.string({ | ||
| hidden: false, | ||
| description: 'Provide a file path to configuration file', | ||
| description: | ||
| 'Path to the Box app configuration JSON file.\n' + | ||
| 'JWT: download this file from your application in Developer Console:\n' + | ||
| 'https://cloud.app.box.com/developers/console\n' + | ||
| 'CCG: create this JSON file yourself using values from your application\n' + | ||
| 'in Developer Console (Client ID and Client Secret from Configuration tab,\n' + | ||
| 'Enterprise ID from General Settings tab).', | ||
| }), | ||
@@ -171,0 +190,0 @@ }; |
@@ -6,3 +6,3 @@ 'use strict'; | ||
| const BoxCLIError = require('../../../cli-error'); | ||
| const inquirer = require('inquirer'); | ||
| const inquirer = require('../../../inquirer'); | ||
@@ -31,3 +31,3 @@ class EnvironmentsDeleteCommand extends BoxCommand { | ||
| if (environmentsObject.environments.hasOwnProperty(name)) { | ||
| if (Object.hasOwn(environmentsObject.environments, name)) { | ||
| delete environmentsObject.environments[name]; | ||
@@ -34,0 +34,0 @@ if (environmentsObject.default === name) { |
@@ -32,2 +32,3 @@ 'use strict'; | ||
| EnvironmentsGetCommand.noClient = true; | ||
| EnvironmentsGetCommand.aliases = ['configure:environments:list']; | ||
@@ -34,0 +35,0 @@ EnvironmentsGetCommand.description = 'Get a Box environment'; |
@@ -5,3 +5,3 @@ 'use strict'; | ||
| const BoxCommand = require('../../../box-command'); | ||
| const inquirer = require('inquirer'); | ||
| const inquirer = require('../../../inquirer'); | ||
@@ -26,3 +26,3 @@ class EnvironmentsSetCurrentCommand extends BoxCommand { | ||
| if (environmentsObject.environments.hasOwnProperty(name)) { | ||
| if (Object.hasOwn(environmentsObject.environments, name)) { | ||
| environmentsObject.default = name; | ||
@@ -29,0 +29,0 @@ await this.updateEnvironments(environmentsObject); |
@@ -65,3 +65,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('cache-tokens')) { | ||
| if (Object.hasOwn(flags, 'cache-tokens')) { | ||
| environment.cacheTokens = flags['cache-tokens']; | ||
@@ -68,0 +68,0 @@ } |
@@ -71,6 +71,6 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('output-json')) { | ||
| if (Object.hasOwn(flags, 'output-json')) { | ||
| settings.outputJson = flags['output-json']; | ||
| } | ||
| if (flags.hasOwnProperty('enable-proxy')) { | ||
| if (Object.hasOwn(flags, 'enable-proxy')) { | ||
| settings.enableProxy = flags['enable-proxy']; | ||
@@ -87,3 +87,3 @@ } | ||
| } | ||
| if (flags.hasOwnProperty(['enable-analytics-client'])) { | ||
| if (Object.hasOwn(flags, 'enable-analytics-client')) { | ||
| settings.enableAnalyticsClient = flags['enable-analytics-client']; | ||
@@ -90,0 +90,0 @@ } |
@@ -39,3 +39,3 @@ 'use strict'; | ||
| let shouldOverwrite = await this.confirm( | ||
| `File ${filePath} already exists — overwrite?` | ||
| `File ${filePath} already exists. Overwrite? (Use --overwrite or -y to skip this prompt.)` | ||
| ); | ||
@@ -118,3 +118,4 @@ | ||
| overwrite: Flags.boolean({ | ||
| description: 'Overwrite a file if it already exists', | ||
| description: | ||
| 'Overwrite a file if it already exists (prevents overwrite prompt)', | ||
| allowNo: true, | ||
@@ -121,0 +122,0 @@ }), |
@@ -14,3 +14,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('prevent-download')) { | ||
| if (Object.hasOwn(flags, 'prevent-download')) { | ||
| options.is_download_prevented = flags['prevent-download']; | ||
@@ -17,0 +17,0 @@ } |
@@ -14,3 +14,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('description')) { | ||
| if (Object.hasOwn(flags, 'description')) { | ||
| options.description = flags.description; | ||
@@ -17,0 +17,0 @@ } |
@@ -15,3 +15,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('description')) { | ||
| if (Object.hasOwn(flags, 'description')) { | ||
| updates.description = flags.description; | ||
@@ -18,0 +18,0 @@ } |
@@ -7,18 +7,21 @@ 'use strict'; | ||
| const path = require('node:path'); | ||
| const progress = require('cli-progress'); | ||
| const BoxCLIError = require('../../cli-error'); | ||
| const { | ||
| createReadStream, | ||
| uploadFile, | ||
| uploadNewFileVersion, | ||
| } = require('../../modules/upload'); | ||
| const DEBUG = require('../../debug'); | ||
| const CHUNKED_UPLOAD_FILE_SIZE = 1024 * 1024 * 100; // 100 MiB | ||
| class FilesUploadCommand extends BoxCommand { | ||
| async run() { | ||
| const { flags, args } = await this.parse(FilesUploadCommand); | ||
| if (!fs.existsSync(args.path)) { | ||
| throw new BoxCLIError( | ||
| `File not found: ${args.path}. Please check the file path and try again.` | ||
| ); | ||
| } | ||
| let size = fs.statSync(args.path).size; | ||
| let folderID = flags['parent-id']; | ||
| let stream; | ||
| try { | ||
| stream = fs.createReadStream(args.path); | ||
| } catch (error) { | ||
| throw new BoxCLIError(`Could not open file ${args.path}`, error); | ||
| } | ||
| let stream = createReadStream(args.path); | ||
| let fileAttributes = {}; | ||
@@ -35,36 +38,57 @@ let name; | ||
| // @TODO(2018-08-24): Consider adding --preserve-timestamps flag | ||
| let file; | ||
| if (size < CHUNKED_UPLOAD_FILE_SIZE) { | ||
| file = await this.client.files.uploadFile( | ||
| try { | ||
| file = await uploadFile(this.client, { | ||
| folderID, | ||
| name, | ||
| stream, | ||
| fileAttributes | ||
| size, | ||
| fileAttributes, | ||
| }); | ||
| } catch (error) { | ||
| const { statusCode, response } = error; | ||
| const body = response?.body; | ||
| if ( | ||
| !flags.overwrite || | ||
| statusCode !== 409 || | ||
| body?.code !== 'item_name_in_use' | ||
| ) { | ||
| if ( | ||
| !flags.overwrite && | ||
| statusCode === 409 && | ||
| body?.code === 'item_name_in_use' | ||
| ) { | ||
| throw new BoxCLIError( | ||
| 'A file with the same name already exists in the destination folder. Use --overwrite to replace it with a new version.', | ||
| error | ||
| ); | ||
| } | ||
| throw error; | ||
| } | ||
| const conflicts = body.context_info?.conflicts; | ||
| const existingFileID = Array.isArray(conflicts) | ||
| ? conflicts?.[0]?.id | ||
| : conflicts?.id; | ||
| if (!existingFileID) { | ||
| throw new BoxCLIError( | ||
| 'File already exists but could not determine the existing file ID from the conflict response. Try uploading a new version manually with files:versions:upload.' | ||
| ); | ||
| } | ||
| DEBUG.output( | ||
| `File already exists in folder; uploading as new version of file ${existingFileID}` | ||
| ); | ||
| } else { | ||
| let progressBar = new progress.Bar({ | ||
| format: '[{bar}] {percentage}% | ETA: {eta_formatted} | {value}/{total} | Speed: {speed} MB/s', | ||
| stopOnComplete: true, | ||
| }); | ||
| let uploader = await this.client.files.getChunkedUploader( | ||
| folderID, | ||
| // Re-create the stream since the first attempt consumed it | ||
| const versionStream = createReadStream(args.path); | ||
| file = await uploadNewFileVersion(this.client, { | ||
| fileID: existingFileID, | ||
| stream: versionStream, | ||
| size, | ||
| name, | ||
| stream, | ||
| { fileAttributes } | ||
| ); | ||
| let bytesUploaded = 0; | ||
| let startTime = Date.now(); | ||
| progressBar.start(size, 0, { speed: 'N/A' }); | ||
| uploader.on('chunkUploaded', (chunk) => { | ||
| bytesUploaded += chunk.part.size; | ||
| progressBar.update(bytesUploaded, { | ||
| speed: Math.floor( | ||
| bytesUploaded / (Date.now() - startTime) / 1000 | ||
| ), | ||
| }); | ||
| fileAttributes, | ||
| }); | ||
| file = await uploader.start(); | ||
| } | ||
@@ -76,5 +100,7 @@ | ||
| FilesUploadCommand.description = 'Upload a file'; | ||
| FilesUploadCommand.description = | ||
| 'Upload a file to a folder. Use --overwrite to automatically replace an existing file with the same name by uploading a new version'; | ||
| FilesUploadCommand.examples = [ | ||
| 'box files:upload /path/to/file.pdf --parent-id 22222', | ||
| 'box files:upload /path/to/file.pdf --parent-id 22222 --overwrite', | ||
| ]; | ||
@@ -97,3 +123,3 @@ FilesUploadCommand._endpoint = 'post_files_content'; | ||
| description: | ||
| 'The creation date of the file content. Use a timestamp or shorthand syntax 0t, like 5w for 5 weeks', | ||
| 'The creation date of the file content. Use a timestamp or shorthand syntax 0t, like 5w for 5 weeks. Not supported with --overwrite', | ||
| parse: (input) => BoxCommand.normalizeDateString(input), | ||
@@ -109,2 +135,6 @@ }), | ||
| }), | ||
| overwrite: Flags.boolean({ | ||
| description: | ||
| 'Overwrite the file if it already exists in the destination folder, by uploading a new file version', | ||
| }), | ||
| }; | ||
@@ -111,0 +141,0 @@ |
@@ -6,7 +6,7 @@ 'use strict'; | ||
| const fs = require('node:fs'); | ||
| const progress = require('cli-progress'); | ||
| const BoxCLIError = require('../../../cli-error'); | ||
| const { | ||
| createReadStream, | ||
| uploadNewFileVersion, | ||
| } = require('../../../modules/upload'); | ||
| const CHUNKED_UPLOAD_FILE_SIZE = 1024 * 1024 * 100; // 100 MiB | ||
| class FilesUploadVersionsCommand extends BoxCommand { | ||
@@ -16,10 +16,5 @@ async run() { | ||
| const { args } = await this.parse(FilesUploadVersionsCommand); | ||
| let size = fs.statSync(args.path).size; | ||
| let fileAttributes = {}; | ||
| let stream; | ||
| try { | ||
| stream = fs.createReadStream(args.path); | ||
| } catch (error) { | ||
| throw new BoxCLIError(`Could not open file ${args.path}`, error); | ||
| } | ||
| const size = fs.statSync(args.path).size; | ||
| const fileAttributes = {}; | ||
| const stream = createReadStream(args.path); | ||
@@ -34,33 +29,8 @@ if (flags['content-modified-at']) { | ||
| let file; | ||
| if (size < CHUNKED_UPLOAD_FILE_SIZE) { | ||
| file = await this.client.files.uploadNewFileVersion( | ||
| args.fileID, | ||
| stream, | ||
| fileAttributes | ||
| ); | ||
| } else { | ||
| let progressBar = new progress.Bar({ | ||
| format: '[{bar}] {percentage}% | ETA: {eta_formatted} | {value}/{total} | Speed: {speed} MB/s', | ||
| stopOnComplete: true, | ||
| }); | ||
| let uploader = await this.client.files.getNewVersionChunkedUploader( | ||
| args.fileID, | ||
| size, | ||
| stream, | ||
| { fileAttributes } | ||
| ); | ||
| let bytesUploaded = 0; | ||
| let startTime = Date.now(); | ||
| progressBar.start(size, 0, { speed: 'N/A' }); | ||
| uploader.on('chunkUploaded', (chunk) => { | ||
| bytesUploaded += chunk.part.size; | ||
| progressBar.update(bytesUploaded, { | ||
| speed: Math.floor( | ||
| bytesUploaded / (Date.now() - startTime) / 1000 | ||
| ), | ||
| }); | ||
| }); | ||
| file = await uploader.start(); | ||
| } | ||
| const file = await uploadNewFileVersion(this.client, { | ||
| fileID: args.fileID, | ||
| stream, | ||
| size, | ||
| fileAttributes, | ||
| }); | ||
| await this.output(file.entries[0]); | ||
@@ -67,0 +37,0 @@ } |
@@ -5,2 +5,3 @@ 'use strict'; | ||
| const { Flags, Args } = require('@oclif/core'); | ||
| const BoxCLIError = require('../../cli-error'); | ||
@@ -10,5 +11,10 @@ class FoldersDeleteCommand extends BoxCommand { | ||
| const { flags, args } = await this.parse(FoldersDeleteCommand); | ||
| if (args.id === '0') { | ||
| throw new BoxCLIError( | ||
| "Cannot delete folder '0': this is the root (All Files) folder and cannot be deleted." | ||
| ); | ||
| } | ||
| let options = {}; | ||
| if (flags.hasOwnProperty('recursive')) { | ||
| if (Object.hasOwn(flags, 'recursive')) { | ||
| options.recursive = flags.recursive; | ||
@@ -15,0 +21,0 @@ } |
@@ -49,3 +49,3 @@ 'use strict'; | ||
| this.maxDepth = | ||
| flags.hasOwnProperty('depth') && flags.depth >= 0 | ||
| Object.hasOwn(flags, 'depth') && flags.depth >= 0 | ||
| ? flags.depth | ||
@@ -52,0 +52,0 @@ : Number.POSITIVE_INFINITY; |
@@ -15,10 +15,10 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('can-non-owners-invite')) { | ||
| if (Object.hasOwn(flags, 'can-non-owners-invite')) { | ||
| updates.can_non_owners_invite = flags['can-non-owners-invite']; | ||
| } | ||
| if (flags.hasOwnProperty('can-non-owners-view-collaborators')) { | ||
| if (Object.hasOwn(flags, 'can-non-owners-view-collaborators')) { | ||
| updates.can_non_owners_view_collaborators = | ||
| flags['can-non-owners-view-collaborators']; | ||
| } | ||
| if (flags.hasOwnProperty('description')) { | ||
| if (Object.hasOwn(flags, 'description')) { | ||
| updates.description = flags.description; | ||
@@ -31,6 +31,6 @@ } | ||
| } | ||
| if (flags.hasOwnProperty('restrict-collaboration')) { | ||
| if (Object.hasOwn(flags, 'restrict-collaboration')) { | ||
| updates.can_non_owners_invite = !flags['restrict-collaboration']; | ||
| } | ||
| if (flags.hasOwnProperty('restrict-to-enterprise')) { | ||
| if (Object.hasOwn(flags, 'restrict-to-enterprise')) { | ||
| updates.is_collaboration_restricted_to_enterprise = | ||
@@ -42,3 +42,3 @@ flags['restrict-to-enterprise']; | ||
| } | ||
| if (flags.hasOwnProperty('sync')) { | ||
| if (Object.hasOwn(flags, 'sync')) { | ||
| updates.sync_state = flags.sync ? 'synced' : 'not_synced'; | ||
@@ -45,0 +45,0 @@ } |
@@ -9,5 +9,4 @@ 'use strict'; | ||
| const utilities = require('../../util'); | ||
| const { CHUNKED_UPLOAD_FILE_SIZE } = require('../../modules/upload'); | ||
| const CHUNKED_UPLOAD_FILE_SIZE = 1024 * 1024 * 100; // 100 MiB | ||
| class FoldersUploadCommand extends BoxCommand { | ||
@@ -14,0 +13,0 @@ async run() { |
@@ -11,15 +11,15 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('can-run-reports')) { | ||
| if (Object.hasOwn(flags, 'can-run-reports')) { | ||
| options.configurable_permissions.can_run_reports = | ||
| flags['can-run-reports']; | ||
| } | ||
| if (flags.hasOwnProperty('can-instant-login')) { | ||
| if (Object.hasOwn(flags, 'can-instant-login')) { | ||
| options.configurable_permissions.can_instant_login = | ||
| flags['can-instant-login']; | ||
| } | ||
| if (flags.hasOwnProperty('can-create-accounts')) { | ||
| if (Object.hasOwn(flags, 'can-create-accounts')) { | ||
| options.configurable_permissions.can_create_accounts = | ||
| flags['can-create-accounts']; | ||
| } | ||
| if (flags.hasOwnProperty('can-edit-accounts')) { | ||
| if (Object.hasOwn(flags, 'can-edit-accounts')) { | ||
| options.configurable_permissions.can_edit_accounts = | ||
@@ -26,0 +26,0 @@ flags['can-edit-accounts']; |
@@ -11,15 +11,15 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('can-run-reports')) { | ||
| if (Object.hasOwn(flags, 'can-run-reports')) { | ||
| options.configurable_permissions.can_run_reports = | ||
| flags['can-run-reports']; | ||
| } | ||
| if (flags.hasOwnProperty('can-instant-login')) { | ||
| if (Object.hasOwn(flags, 'can-instant-login')) { | ||
| options.configurable_permissions.can_instant_login = | ||
| flags['can-instant-login']; | ||
| } | ||
| if (flags.hasOwnProperty('can-create-accounts')) { | ||
| if (Object.hasOwn(flags, 'can-create-accounts')) { | ||
| options.configurable_permissions.can_create_accounts = | ||
| flags['can-create-accounts']; | ||
| } | ||
| if (flags.hasOwnProperty('can-edit-accounts')) { | ||
| if (Object.hasOwn(flags, 'can-edit-accounts')) { | ||
| options.configurable_permissions.can_edit_accounts = | ||
@@ -26,0 +26,0 @@ flags['can-edit-accounts']; |
@@ -17,4 +17,2 @@ 'use strict'; | ||
| GroupsTerminateSessionCommand.aliases = ['groups:terminate-session']; | ||
| GroupsTerminateSessionCommand.description = | ||
@@ -21,0 +19,0 @@ "Validates the roles and permissions of the group, and creates asynchronous jobs to terminate the group's sessions."; |
@@ -31,3 +31,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('disable-access-management')) { | ||
| if (Object.hasOwn(flags, 'disable-access-management')) { | ||
| body.options = { | ||
@@ -34,0 +34,0 @@ is_access_management_disabled: |
@@ -18,3 +18,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('manually-created')) { | ||
| if (Object.hasOwn(flags, 'manually-created')) { | ||
| options.is_manually_created = flags['manually-created']; | ||
@@ -21,0 +21,0 @@ } |
@@ -20,3 +20,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('disable-access-management')) { | ||
| if (Object.hasOwn(flags, 'disable-access-management')) { | ||
| body.options = { | ||
@@ -23,0 +23,0 @@ is_access_management_disabled: |
@@ -23,3 +23,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('ongoing')) { | ||
| if (Object.hasOwn(flags, 'ongoing')) { | ||
| options.is_ongoing = true; | ||
@@ -26,0 +26,0 @@ } |
+231
-30
@@ -12,3 +12,3 @@ 'use strict'; | ||
| const express = require('express'); | ||
| const inquirer = require('inquirer'); | ||
| const inquirer = require('../inquirer'); | ||
| const path = require('node:path'); | ||
@@ -18,3 +18,87 @@ const ora = require('ora'); | ||
| const { nanoid } = require('nanoid'); | ||
| const DEBUG = require('../debug'); | ||
| const { generatePKCE } = require('../pkce-support'); | ||
| const { | ||
| assertValidOAuthCode, | ||
| getTokenInfoByAuthCode, | ||
| } = require('../login-helper'); | ||
| const GENERIC_OAUTH_CLIENT_ID = 'udz8zp4yue87uk9dzq4xk425kkwvqvh1'; | ||
| const GENERIC_OAUTH_CLIENT_SECRET = 'iZ1MbvC3ZaF25nbJli7IsKdRHAxfu3fn'; | ||
| const SUPPORTED_DEFAULT_APP_PORTS = [3000, 3001, 4000, 5000, 8080]; | ||
| const DEFAULT_ENVIRONMENT_NAME = 'oauth'; | ||
| async function promptForPlatformAppCredentials(inquirerModule, clientId) { | ||
| if (!clientId) { | ||
| const answer = await inquirerModule.prompt([ | ||
| { | ||
| type: 'input', | ||
| name: 'clientId', | ||
| message: 'Enter the Client ID:', | ||
| }, | ||
| ]); | ||
| clientId = answer.clientId.trim(); | ||
| } | ||
| const { clientSecret } = await inquirerModule.prompt([ | ||
| { | ||
| type: 'input', | ||
| name: 'clientSecret', | ||
| message: 'Enter the Client Secret:', | ||
| }, | ||
| ]); | ||
| return { | ||
| useDefaultBoxApp: false, | ||
| clientId, | ||
| clientSecret, | ||
| }; | ||
| } | ||
| async function promptForAuthMethod(inquirerModule) { | ||
| const CLIENT_ID_MIN_LENGTH = 16; | ||
| const CLIENT_ID_MAX_LENGTH = 99; | ||
| while (true) { | ||
| const { choice } = await inquirerModule.prompt([ | ||
| { | ||
| type: 'input', | ||
| name: 'choice', | ||
| message: | ||
| 'How would you like to authenticate?\n[1] Log-in as a Box user (OAuth)\n[2] Use a Box Platform App\n[q] Quit\n? Enter 1, 2, or q:', | ||
| }, | ||
| ]); | ||
| const trimmedChoice = typeof choice === 'string' ? choice.trim() : ''; | ||
| if (trimmedChoice === '1') { | ||
| return { | ||
| useDefaultBoxApp: true, | ||
| clientId: GENERIC_OAUTH_CLIENT_ID, | ||
| clientSecret: GENERIC_OAUTH_CLIENT_SECRET, | ||
| }; | ||
| } | ||
| if (trimmedChoice === '2') { | ||
| return promptForPlatformAppCredentials(inquirerModule); | ||
| } | ||
| if (trimmedChoice.toLowerCase() === 'q') { | ||
| return null; | ||
| } | ||
| if ( | ||
| trimmedChoice.length > CLIENT_ID_MIN_LENGTH && | ||
| trimmedChoice.length < CLIENT_ID_MAX_LENGTH | ||
| ) { | ||
| return promptForPlatformAppCredentials( | ||
| inquirerModule, | ||
| trimmedChoice | ||
| ); | ||
| } | ||
| // Invalid input — repeat the prompt | ||
| } | ||
| } | ||
| class OAuthLoginCommand extends BoxCommand { | ||
@@ -27,46 +111,89 @@ async run() { | ||
| const { flags } = await this.parse(OAuthLoginCommand); | ||
| const forceDefaultBoxApp = flags['default-box-app']; | ||
| const forcePlatformApp = flags['platform-app']; | ||
| let useDefaultBoxApp = false; | ||
| const environmentsObject = await this.getEnvironments(); | ||
| const port = flags.port; | ||
| const redirectUri = `http://localhost:${port}/callback`; | ||
| const isUnsupportedDefaultAppPort = () => | ||
| useDefaultBoxApp && !SUPPORTED_DEFAULT_APP_PORTS.includes(port); | ||
| let environment; | ||
| if (this.flags.reauthorize) { | ||
| let targetEnvName = this.flags.name; | ||
| if ( | ||
| !environmentsObject.environments.hasOwnProperty(this.flags.name) | ||
| !Object.hasOwn(environmentsObject.environments, this.flags.name) | ||
| ) { | ||
| this.error(`The ${this.flags.name} environment does not exist`); | ||
| const currentEnv = | ||
| environmentsObject.environments[environmentsObject.default]; | ||
| if ( | ||
| this.flags.name === DEFAULT_ENVIRONMENT_NAME && | ||
| environmentsObject.default && | ||
| currentEnv?.authMethod === 'oauth20' | ||
| ) { | ||
| targetEnvName = environmentsObject.default; | ||
| } else { | ||
| this.info( | ||
| chalk`{red The "${this.flags.name}" environment does not exist}` | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
| environment = environmentsObject.environments[this.flags.name]; | ||
| environment = environmentsObject.environments[targetEnvName]; | ||
| if (environment.authMethod !== 'oauth20') { | ||
| this.error('The selected environment is not of type oauth20'); | ||
| this.info( | ||
| chalk`{red The selected environment is not of type oauth20}` | ||
| ); | ||
| return; | ||
| } | ||
| if (forceDefaultBoxApp) { | ||
| useDefaultBoxApp = true; | ||
| environment.clientId = GENERIC_OAUTH_CLIENT_ID; | ||
| environment.clientSecret = GENERIC_OAUTH_CLIENT_SECRET; | ||
| } else { | ||
| useDefaultBoxApp = | ||
| environment.clientId === GENERIC_OAUTH_CLIENT_ID && | ||
| environment.clientSecret === GENERIC_OAUTH_CLIENT_SECRET; | ||
| } | ||
| } else { | ||
| useDefaultBoxApp = forceDefaultBoxApp; | ||
| } | ||
| if (isUnsupportedDefaultAppPort()) { | ||
| this.info( | ||
| chalk`{cyan If you are not using the quickstart guide to set up ({underline https://developer.box.com/guides/tooling/cli/quick-start/}) then go to the Box Developer console ({underline https://cloud.app.box.com/developers/console}) and:}` | ||
| chalk`{red Unsupported port "${port}" for the Official Box CLI app flow. Supported ports: ${SUPPORTED_DEFAULT_APP_PORTS.join(', ')}}` | ||
| ); | ||
| return; | ||
| } | ||
| if (this.flags.reauthorize) { | ||
| // Keep the selected existing environment config for reauthorization. | ||
| } else if (useDefaultBoxApp) { | ||
| this.info(chalk`{cyan ----------------------------------------}`); | ||
| this.info( | ||
| chalk`{cyan 1. Select an application with OAuth user authentication method. Create a new Custom App if needed.}` | ||
| chalk`{cyan No app setup is required in Box Developer Console.}` | ||
| ); | ||
| this.info(chalk`{cyan Callback URL: {italic ${redirectUri}}}`); | ||
| this.info( | ||
| chalk`{cyan 2. Click on the Configuration tab and set the Redirect URI to: {italic ${redirectUri}}. Click outside the input field.}` | ||
| chalk`{cyan Supported callback ports for this flow: {bold 3000}, {bold 3001}, {bold 4000}, {bold 5000}, {bold 8080}.}` | ||
| ); | ||
| this.info(chalk`{cyan 3. Click on {bold Save Changes}.}`); | ||
| this.info( | ||
| chalk`{cyan You can change the port with {bold --port}, but only to one of the supported values above.}` | ||
| ); | ||
| this.info(chalk`{cyan ----------------------------------------}`); | ||
| const answers = await inquirer.prompt([ | ||
| { | ||
| type: 'input', | ||
| name: 'clientID', | ||
| message: 'What is the OAuth Client ID of your application?', | ||
| }, | ||
| { | ||
| type: 'input', | ||
| name: 'clientSecret', | ||
| message: | ||
| 'What is the OAuth Client Secret of your application?', | ||
| }, | ||
| ]); | ||
| environment = { | ||
| clientId: GENERIC_OAUTH_CLIENT_ID, | ||
| clientSecret: GENERIC_OAUTH_CLIENT_SECRET, | ||
| name: this.flags.name, | ||
| cacheTokens: true, | ||
| authMethod: 'oauth20', | ||
| }; | ||
| } else if (forcePlatformApp) { | ||
| const answers = await promptForPlatformAppCredentials(inquirer); | ||
| useDefaultBoxApp = false; | ||
| environment = { | ||
| clientId: answers.clientID, | ||
| clientId: answers.clientId, | ||
| clientSecret: answers.clientSecret, | ||
@@ -77,2 +204,23 @@ name: this.flags.name, | ||
| }; | ||
| } else { | ||
| const answers = await promptForAuthMethod(inquirer); | ||
| if (answers === null) { | ||
| return; | ||
| } | ||
| useDefaultBoxApp = answers.useDefaultBoxApp; | ||
| if (isUnsupportedDefaultAppPort()) { | ||
| this.info( | ||
| chalk`{red Unsupported port "${port}" for the Official Box CLI app flow. Supported ports: ${SUPPORTED_DEFAULT_APP_PORTS.join(', ')}}` | ||
| ); | ||
| return; | ||
| } | ||
| environment = { | ||
| clientId: answers.clientId, | ||
| clientSecret: answers.clientSecret, | ||
| name: this.flags.name, | ||
| cacheTokens: true, | ||
| authMethod: 'oauth20', | ||
| }; | ||
| } | ||
@@ -98,2 +246,3 @@ | ||
| const state = nanoid(32); | ||
| const pkce = useDefaultBoxApp ? generatePKCE() : null; | ||
@@ -107,5 +256,8 @@ app.get('/callback', async (request, res) => { | ||
| } | ||
| const tokenInfo = await sdk.getTokensAuthorizationCodeGrant( | ||
| assertValidOAuthCode(request.query.code); | ||
| const tokenInfo = await getTokenInfoByAuthCode( | ||
| sdk, | ||
| request.query.code, | ||
| null | ||
| redirectUri, | ||
| pkce?.codeVerifier | ||
| ); | ||
@@ -155,9 +307,20 @@ const tokenCache = new CLITokenCache(environmentName); | ||
| } catch (error) { | ||
| throw new BoxCLIError(error); | ||
| const statusCode = | ||
| error?.response?.statusCode ?? error?.response?.status; | ||
| const errorMessage = | ||
| error?.response?.body?.error_description || | ||
| (statusCode | ||
| ? `Request failed with status ${statusCode}` | ||
| : null) || | ||
| error?.message || | ||
| 'Unknown error'; | ||
| DEBUG.execute('Login error: %O', error); | ||
| this.info(chalk`{red Login failed: ${errorMessage}}`); | ||
| } finally { | ||
| server.close(); | ||
| server.closeAllConnections(); | ||
| } | ||
| }); | ||
| let spinner = ora({ | ||
| const spinner = ora({ | ||
| text: chalk`{bgCyan Opening browser for OAuth authentication. Please click {bold Grant access to Box} to continue.}`, | ||
@@ -176,2 +339,7 @@ spinner: 'bouncingBall', | ||
| redirect_uri: redirectUri, | ||
| ...(useDefaultBoxApp | ||
| ? { | ||
| code_challenge: pkce.codeChallenge, | ||
| } | ||
| : {}), | ||
| }); | ||
@@ -210,3 +378,5 @@ if (flags.code) { | ||
| this.info( | ||
| chalk`{yellow If you are redirect to files view, please make sure that your Redirect URI is set up correctly and restart the login command.}` | ||
| useDefaultBoxApp | ||
| ? chalk`{yellow If authorization fails, verify that you are using one of the supported ports for the Official Box CLI app flow and restart the login command.}` | ||
| : chalk`{yellow If you are redirected to the Files view, make sure your Redirect URI is configured correctly and restart the login command.}` | ||
| ); | ||
@@ -222,3 +392,13 @@ } | ||
| OAuthLoginCommand.description = | ||
| 'Sign in with OAuth and set a new environment or update an existing if reauthorize flag is used'; | ||
| 'Sign in with OAuth 2.0 and create a new environment (or update an existing one with --reauthorize).\n' + | ||
| '\n' + | ||
| 'Login options:\n' + | ||
| '\n' + | ||
| ' (1) Official Box CLI App\n' + | ||
| ' No app setup needed. Use --default-box-app (-d) to skip the prompt.\n' + | ||
| '\n' + | ||
| ' (2) Your own Platform OAuth App\n' + | ||
| ' Enter your Client ID and Client Secret when prompted. Use --platform-app to skip the prompt.\n' + | ||
| '\n' + | ||
| 'Quickstart: run "box login -d" to sign in immediately. A browser window will open for authorization. Once access is granted, the environment is created and set as default — you can start running commands right away.'; | ||
@@ -235,3 +415,3 @@ OAuthLoginCommand.flags = { | ||
| description: 'Set a name for the environment', | ||
| default: 'oauth', | ||
| default: DEFAULT_ENVIRONMENT_NAME, | ||
| }), | ||
@@ -243,2 +423,19 @@ port: Flags.integer({ | ||
| }), | ||
| 'default-box-app': Flags.boolean({ | ||
| char: 'd', | ||
| description: | ||
| 'Use the Official Box CLI app flow and proceed directly to authorization.\n' + | ||
| 'This is the fastest way to integrate with Box — no app creation in the Developer Console is needed.\n' + | ||
| 'Scopes are limited to content actions, allowing you to effectively operate with your files and folders.\n' + | ||
| 'This flow requires a local callback server on a supported port (3000, 3001, 4000, 5000, or 8080). The default port is 3000; use --port to change it.', | ||
| exclusive: ['platform-app'], | ||
| default: false, | ||
| }), | ||
| 'platform-app': Flags.boolean({ | ||
| description: | ||
| 'Skip the authentication method prompt and go directly to Platform App setup.\n' + | ||
| 'You will be prompted for Client ID and Client Secret.', | ||
| exclusive: ['default-box-app'], | ||
| default: false, | ||
| }), | ||
| reauthorize: Flags.boolean({ | ||
@@ -258,1 +455,5 @@ char: 'r', | ||
| module.exports = OAuthLoginCommand; | ||
| module.exports._test = { | ||
| promptForAuthMethod, | ||
| promptForPlatformAppCredentials, | ||
| }; |
@@ -32,6 +32,3 @@ 'use strict'; | ||
| ...combinedQueryParameters, | ||
| ...queryParameter.reduce( | ||
| (accumulator, current) => ({ ...accumulator, ...current }), | ||
| {} | ||
| ), | ||
| ...Object.assign({}, ...queryParameter), | ||
| }; | ||
@@ -42,6 +39,3 @@ } | ||
| ...combinedQueryParameters, | ||
| ...queryParameterArray.reduce( | ||
| (accumulator, current) => ({ ...accumulator, ...current }), | ||
| {} | ||
| ), | ||
| ...Object.assign({}, ...queryParameterArray), | ||
| }; | ||
@@ -69,2 +63,3 @@ } | ||
| 'box metadata-query enterprise_12345.someTemplate 5555 --query "amount >= :minAmount AND amount <= :maxAmount" --query-params minAmount=100f,maxAmount=200f --use-index amountAsc --order-by amount=ASC --extra-fields created_at,metadata.enterprise_1234.contracts', | ||
| 'box metadata-query enterprise_12345.contractTemplate 12345 --query "status = :status" --query-param status=active', | ||
| ]; | ||
@@ -155,3 +150,3 @@ MetadataQueryCommand._endpoint = 'post_metadata_queries_execute_read'; | ||
| description: | ||
| 'The template used in the query. Must be in the form scope.templateKey', | ||
| 'The template used in the query. Must be in the form scope.templateKey (for example enterprise_12345.contractTemplate)', | ||
| }), | ||
@@ -158,0 +153,0 @@ ancestorFolderId: Args.string({ |
@@ -125,5 +125,5 @@ 'use strict'; | ||
| // Add the last field if necessary and return | ||
| template.fields = template.fields | ||
| .concat(currentField) | ||
| .filter((op) => op !== null); | ||
| template.fields = [...template.fields, currentField].filter( | ||
| (op) => op !== null | ||
| ); | ||
| return template; | ||
@@ -130,0 +130,0 @@ } |
@@ -18,3 +18,7 @@ 'use strict'; | ||
| 'Get all metadata templates in your Enterprise'; | ||
| MetadataTemplatesListCommand.examples = ['box metadata-templates']; | ||
| MetadataTemplatesListCommand.examples = [ | ||
| 'box metadata-templates:list', | ||
| 'box metadata-templates:list --json', | ||
| 'box metadata-templates:list --fields templateKey,displayName,scope', | ||
| ]; | ||
| MetadataTemplatesListCommand._endpoint = 'get_metadata_templates_enterprise'; | ||
@@ -21,0 +25,0 @@ |
@@ -325,3 +325,3 @@ 'use strict'; | ||
| // Add the last field if necessary and return | ||
| return ops.concat(currentOp).filter((op) => op !== null); | ||
| return [...ops, currentOp].filter((op) => op !== null); | ||
| } | ||
@@ -328,0 +328,0 @@ |
@@ -20,3 +20,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('body') && flags.body !== '') { | ||
| if (Object.hasOwn(flags, 'body') && flags.body !== '') { | ||
| try { | ||
@@ -79,2 +79,7 @@ parameters.body = JSON.parse(flags.body); | ||
| ManualRequestCommand.description = 'Manually specify a Box API request'; | ||
| ManualRequestCommand.examples = [ | ||
| 'box request /users/me', | ||
| 'box request /folders/0/items --query "limit=5"', | ||
| 'box request /folders -X POST --body \'{"name":"New Folder","parent":{"id":"0"}}\'', | ||
| ]; | ||
@@ -81,0 +86,0 @@ ManualRequestCommand.flags = { |
@@ -18,6 +18,6 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('filter-field')) { | ||
| if (Object.hasOwn(flags, 'filter-field')) { | ||
| options.filter_fields = flags['filter-field']; | ||
| } | ||
| if (flags.hasOwnProperty('start-date-field')) { | ||
| if (Object.hasOwn(flags, 'start-date-field')) { | ||
| options.start_date_field = flags['start-date-field']; | ||
@@ -24,0 +24,0 @@ } |
@@ -17,6 +17,6 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('notify-owners')) { | ||
| if (Object.hasOwn(flags, 'notify-owners')) { | ||
| options.are_owners_notified = flags['notify-owners']; | ||
| } | ||
| if (flags.hasOwnProperty('allow-extension')) { | ||
| if (Object.hasOwn(flags, 'allow-extension')) { | ||
| options.can_owner_extend_retention = flags['allow-extension']; | ||
@@ -37,6 +37,6 @@ } | ||
| } | ||
| if (flags.hasOwnProperty('description')) { | ||
| if (Object.hasOwn(flags, 'description')) { | ||
| options.description = flags.description; | ||
| } | ||
| if (flags.hasOwnProperty('custom-notification-recipient')) { | ||
| if (Object.hasOwn(flags, 'custom-notification-recipient')) { | ||
| options.custom_notification_recipients = | ||
@@ -43,0 +43,0 @@ flags['custom-notification-recipient']; |
@@ -36,6 +36,6 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('notify-owners')) { | ||
| if (Object.hasOwn(flags, 'notify-owners')) { | ||
| updates.are_owners_notified = flags['notify-owners']; | ||
| } | ||
| if (flags.hasOwnProperty('allow-extension')) { | ||
| if (Object.hasOwn(flags, 'allow-extension')) { | ||
| updates.can_owner_extend_retention = flags['allow-extension']; | ||
@@ -42,0 +42,0 @@ } |
+27
-14
@@ -6,2 +6,3 @@ 'use strict'; | ||
| const _ = require('lodash'); | ||
| const os = require('node:os'); | ||
| const BoxCLIError = require('../cli-error'); | ||
@@ -30,3 +31,22 @@ const PaginationUtilities = require('../pagination-utils'); | ||
| const { flags, args } = await this.parse(SearchCommand); | ||
| const hasVerboseMetadataFilters = | ||
| flags['md-filter-scope'] && | ||
| flags['md-filter-template-key'] && | ||
| flags['md-filter-json']; | ||
| const hasMetadataFilters = hasVerboseMetadataFilters || flags.mdfilter; | ||
| const query = args.query?.trim(); | ||
| // Require at least a search query or metadata filters, unless running in bulk mode | ||
| // where parameters come from the bulk input file rather than command-line arguments. | ||
| if (!query && !hasMetadataFilters && !this.isBulk) { | ||
| const missingQueryMessage = [ | ||
| 'Missing required argument: [QUERY]', | ||
| 'Usage: box search "your search terms"', | ||
| 'Example: box search "quarterly report" --type file', | ||
| 'Run: box search --help for all available filters.', | ||
| ].join(os.EOL); | ||
| throw new BoxCLIError(missingQueryMessage); | ||
| } | ||
| if (flags.all && (flags.limit || flags['max-items'])) { | ||
@@ -64,7 +84,3 @@ throw new BoxCLIError( | ||
| } | ||
| if ( | ||
| flags['md-filter-scope'] && | ||
| flags['md-filter-template-key'] && | ||
| flags['md-filter-json'] | ||
| ) { | ||
| if (hasVerboseMetadataFilters) { | ||
| if ( | ||
@@ -186,4 +202,4 @@ flags['md-filter-scope'].length === | ||
| if ( | ||
| flags.hasOwnProperty('size-from') || | ||
| flags.hasOwnProperty('size-to') | ||
| Object.hasOwn(flags, 'size-from') || | ||
| Object.hasOwn(flags, 'size-to') | ||
| ) { | ||
@@ -205,3 +221,3 @@ options.size_range = `${_.get(flags, 'size-from', '')},${_.get(flags, 'size-to', '')}`; | ||
| if (flags.hasOwnProperty('include-recent-shared-links')) { | ||
| if (Object.hasOwn(flags, 'include-recent-shared-links')) { | ||
| options.include_recent_shared_links = | ||
@@ -211,6 +227,3 @@ flags['include-recent-shared-links']; | ||
| let results = await this.client.search.query( | ||
| args.query || null, | ||
| options | ||
| ); | ||
| let results = await this.client.search.query(query || null, options); | ||
@@ -264,3 +277,3 @@ await this.output(results); | ||
| description: | ||
| 'Metadata value to filter on, in the format <scope>.<templateKey>.<field>=<value>', | ||
| 'Metadata value to filter on, in the format <scope>.<templateKey>.<field>=<value>. For enterprise templates, scope is usually enterprise_<enterpriseID> (for example enterprise_123456).', | ||
| exclusive: [ | ||
@@ -362,3 +375,3 @@ 'md-filter-scope', | ||
| hidden: false, | ||
| description: 'The search term', | ||
| description: `The search term. Some queries with special characters (e.g. double quotes for exact match) may require escaping (e.g. box search '\\"query_term\\"').`, | ||
| default: '', | ||
@@ -365,0 +378,0 @@ }), |
@@ -19,3 +19,7 @@ 'use strict'; | ||
| TokensGetCommand.description = | ||
| 'Get a token. Returns the service account token by default'; | ||
| 'Generate a new access token. Returns a service account token for the default environment unless --user-id is specified.'; | ||
| TokensGetCommand.examples = [ | ||
| 'box tokens:get', | ||
| 'box tokens:get --user-id 12345', | ||
| ]; | ||
@@ -26,3 +30,3 @@ TokensGetCommand.flags = { | ||
| char: 'u', | ||
| description: 'Get a user token from a user ID', | ||
| description: 'Generate a user token for the specified user ID', | ||
| }), | ||
@@ -29,0 +33,0 @@ }; |
@@ -13,3 +13,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('sync-enable')) { | ||
| if (Object.hasOwn(flags, 'sync-enable')) { | ||
| options.is_sync_enabled = flags['sync-enable']; | ||
@@ -20,15 +20,15 @@ } | ||
| } | ||
| if (flags.hasOwnProperty('exempt-from-device-limits')) { | ||
| if (Object.hasOwn(flags, 'exempt-from-device-limits')) { | ||
| options.is_exempt_from_device_limits = | ||
| flags['exempt-from-device-limits']; | ||
| } | ||
| if (flags.hasOwnProperty('exempt-from-2fa')) { | ||
| if (Object.hasOwn(flags, 'exempt-from-2fa')) { | ||
| options.is_exempt_from_login_verification = | ||
| flags['exempt-from-2fa']; | ||
| } | ||
| if (flags.hasOwnProperty('restrict-external-collab')) { | ||
| if (Object.hasOwn(flags, 'restrict-external-collab')) { | ||
| options.is_external_collab_restricted = | ||
| flags['restrict-external-collab']; | ||
| } | ||
| if (flags.hasOwnProperty('can-see-managed-users')) { | ||
| if (Object.hasOwn(flags, 'can-see-managed-users')) { | ||
| options.can_see_managed_users = flags['can-see-managed-users']; | ||
@@ -35,0 +35,0 @@ } |
@@ -11,6 +11,6 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('notify')) { | ||
| if (Object.hasOwn(flags, 'notify')) { | ||
| options.notify = flags.notify; | ||
| } | ||
| if (flags.hasOwnProperty('force')) { | ||
| if (Object.hasOwn(flags, 'force')) { | ||
| options.force = flags.force; | ||
@@ -17,0 +17,0 @@ } |
@@ -12,3 +12,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('confirm')) { | ||
| if (Object.hasOwn(flags, 'confirm')) { | ||
| options.is_confirmed = flags.confirm; | ||
@@ -15,0 +15,0 @@ } |
@@ -21,4 +21,2 @@ 'use strict'; | ||
| UsersTerminateSessionCommand.aliases = ['users:terminate-session']; | ||
| UsersTerminateSessionCommand.description = | ||
@@ -25,0 +23,0 @@ "Validates the roles and permissions of the user, and creates asynchronous jobs to terminate the user's sessions."; |
@@ -18,3 +18,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('notify')) { | ||
| if (Object.hasOwn(flags, 'notify')) { | ||
| parameters.qs.notify = flags.notify; | ||
@@ -21,0 +21,0 @@ } |
@@ -11,3 +11,3 @@ 'use strict'; | ||
| if (flags.hasOwnProperty('sync-enable')) { | ||
| if (Object.hasOwn(flags, 'sync-enable')) { | ||
| updates.is_sync_enabled = flags['sync-enable']; | ||
@@ -18,18 +18,18 @@ } | ||
| } | ||
| if (flags.hasOwnProperty('exempt-from-device-limits')) { | ||
| if (Object.hasOwn(flags, 'exempt-from-device-limits')) { | ||
| updates.is_exempt_from_device_limits = | ||
| flags['exempt-from-device-limits']; | ||
| } | ||
| if (flags.hasOwnProperty('exempt-from-2fa')) { | ||
| if (Object.hasOwn(flags, 'exempt-from-2fa')) { | ||
| updates.is_exempt_from_login_verification = | ||
| flags['exempt-from-2fa']; | ||
| } | ||
| if (flags.hasOwnProperty('restrict-external-collab')) { | ||
| if (Object.hasOwn(flags, 'restrict-external-collab')) { | ||
| updates.is_external_collab_restricted = | ||
| flags['restrict-external-collab']; | ||
| } | ||
| if (flags.hasOwnProperty('can-see-managed-users')) { | ||
| if (Object.hasOwn(flags, 'can-see-managed-users')) { | ||
| updates.can_see_managed_users = flags['can-see-managed-users']; | ||
| } | ||
| if (flags.hasOwnProperty('notification-email')) { | ||
| if (Object.hasOwn(flags, 'notification-email')) { | ||
| updates.notification_email = | ||
@@ -36,0 +36,0 @@ flags['notification-email'] === '' |
@@ -37,6 +37,6 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('notify')) { | ||
| if (Object.hasOwn(flags, 'notify')) { | ||
| parameters.qs.notify = flags.notify; | ||
| } | ||
| if (flags.hasOwnProperty('can-view-path')) { | ||
| if (Object.hasOwn(flags, 'can-view-path')) { | ||
| parameters.body.can_view_path = flags['can-view-path']; | ||
@@ -43,0 +43,0 @@ } |
@@ -39,3 +39,3 @@ 'use strict'; | ||
| } | ||
| if (flags.hasOwnProperty('can-download')) { | ||
| if (Object.hasOwn(flags, 'can-download')) { | ||
| updates.shared_link.permissions.can_download = | ||
@@ -49,3 +49,3 @@ flags['can-download']; | ||
| if (arguments_.itemType === 'file') { | ||
| if (flags.hasOwnProperty('can-edit')) { | ||
| if (Object.hasOwn(flags, 'can-edit')) { | ||
| updates.shared_link.permissions.can_edit = flags['can-edit']; | ||
@@ -52,0 +52,0 @@ } |
+184
-13
@@ -5,2 +5,3 @@ 'use strict'; | ||
| const fs = require('node:fs'); | ||
| const os = require('node:os'); | ||
@@ -10,5 +11,8 @@ const path = require('node:path'); | ||
| const utilities = require('./util'); | ||
| const DEBUG = require('./debug'); | ||
| const secureStorage = require('./secure-storage'); | ||
| /** | ||
| * Cache interface used by the Node SDK to cache tokens to disk in the user's home directory | ||
| * Supports secure storage with fallback to file system | ||
| */ | ||
@@ -21,2 +25,4 @@ class CLITokenCache { | ||
| constructor(environmentName) { | ||
| this.environmentName = environmentName; | ||
| this.secureStorage = secureStorage; | ||
| this.filePath = path.join( | ||
@@ -27,6 +33,9 @@ os.homedir(), | ||
| ); | ||
| this.serviceName = `boxcli-token-${environmentName}`; | ||
| this.accountName = 'Box'; | ||
| this.supportsSecureStorage = this.secureStorage.available; | ||
| } | ||
| /** | ||
| * Read tokens from disk | ||
| * Read tokens from secure storage with fallback to file system | ||
| * @param {Function} callback The callback to pass resulting token info to | ||
@@ -36,5 +45,64 @@ * @returns {void} | ||
| read(callback) { | ||
| if (this.supportsSecureStorage) { | ||
| this.secureStorage | ||
| .getPassword(this.serviceName, this.accountName) | ||
| .then((tokenJson) => { | ||
| if (tokenJson) { | ||
| try { | ||
| const tokenInfo = JSON.parse(tokenJson); | ||
| DEBUG.init( | ||
| 'Loaded token from secure storage (%s) for environment: %s', | ||
| this.secureStorage.backend, | ||
| this.environmentName | ||
| ); | ||
| return callback(null, tokenInfo); | ||
| } catch (parseError) { | ||
| DEBUG.init( | ||
| 'Failed to parse token from secure storage, falling back to file: %s', | ||
| parseError.message | ||
| ); | ||
| } | ||
| } | ||
| DEBUG.init( | ||
| 'No token found in secure storage for environment: %s; trying file cache', | ||
| this.environmentName | ||
| ); | ||
| return this._readFromFile(callback); | ||
| }) | ||
| .catch((error) => { | ||
| DEBUG.init( | ||
| 'Failed to read from secure storage (%s), falling back to file: %s', | ||
| this.secureStorage.backend, | ||
| error?.message || error | ||
| ); | ||
| this._readFromFile(callback); | ||
| }); | ||
| } else { | ||
| DEBUG.init( | ||
| 'Secure storage unavailable for token cache; reading token from file for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| this._readFromFile(callback); | ||
| } | ||
| } | ||
| /** | ||
| * Read tokens from file system | ||
| * @param {Function} callback The callback to pass resulting token info to | ||
| * @returns {void} | ||
| * @private | ||
| */ | ||
| _readFromFile(callback) { | ||
| utilities | ||
| .readFileAsync(this.filePath, 'utf8') | ||
| .then((json) => JSON.parse(json)) | ||
| .then((json) => { | ||
| const tokenInfo = JSON.parse(json); | ||
| if (tokenInfo.accessToken) { | ||
| DEBUG.init( | ||
| 'Loaded token from file system for environment: %s (will be migrated to secure storage on next write)', | ||
| this.environmentName | ||
| ); | ||
| } | ||
| return tokenInfo; | ||
| }) | ||
| // If file is not present or not valid JSON, treat that as empty (but available) cache | ||
@@ -46,3 +114,3 @@ .catch(() => ({})) | ||
| /** | ||
| * Write tokens to disk | ||
| * Write tokens to secure storage with fallback to file system | ||
| * @param {Object} tokenInfo The token object to write | ||
@@ -53,3 +121,53 @@ * @param {Function} callback The callback to pass results to | ||
| write(tokenInfo, callback) { | ||
| let output = JSON.stringify(tokenInfo, null, 4); | ||
| const output = JSON.stringify(tokenInfo, null, 4); | ||
| if (this.supportsSecureStorage) { | ||
| this.secureStorage | ||
| .setPassword(this.serviceName, this.accountName, output) | ||
| .then(() => { | ||
| DEBUG.init( | ||
| 'Stored token in secure storage (%s) for environment: %s', | ||
| this.secureStorage.backend, | ||
| this.environmentName | ||
| ); | ||
| if (fs.existsSync(this.filePath)) { | ||
| fs.unlinkSync(this.filePath); | ||
| DEBUG.init( | ||
| 'Migrated token from file to secure storage for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| } | ||
| return callback(); | ||
| }) | ||
| .catch((error) => { | ||
| DEBUG.init( | ||
| 'Failed to write to secure storage (%s) for environment %s, falling back to file: %s', | ||
| this.secureStorage.backend, | ||
| this.environmentName, | ||
| error?.message || error | ||
| ); | ||
| if (process.platform === 'linux') { | ||
| DEBUG.init( | ||
| 'To enable secure storage on Linux, install libsecret-1-dev package' | ||
| ); | ||
| } | ||
| this._writeToFile(output, callback); | ||
| }); | ||
| } else { | ||
| DEBUG.init( | ||
| 'Secure storage unavailable for token cache; writing token to file for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| this._writeToFile(output, callback); | ||
| } | ||
| } | ||
| /** | ||
| * Write tokens to file system | ||
| * @param {string} output The JSON string to write | ||
| * @param {Function} callback The callback to pass results to | ||
| * @returns {void} | ||
| * @private | ||
| */ | ||
| _writeToFile(output, callback) { | ||
| utilities | ||
@@ -67,3 +185,3 @@ .writeFileAsync(this.filePath, output, 'utf8') | ||
| /** | ||
| * Delete the cache file from disk | ||
| * Delete the token from both secure storage and file system | ||
| * @param {Function} callback The callback to pass results to | ||
@@ -73,6 +191,59 @@ * @returns {void} | ||
| clear(callback) { | ||
| utilities | ||
| .unlinkAsync(this.filePath) | ||
| // Pass success or error to the callback | ||
| .then(callback) | ||
| const promises = []; | ||
| if (this.supportsSecureStorage) { | ||
| promises.push( | ||
| this.secureStorage | ||
| .deletePassword(this.serviceName, this.accountName) | ||
| .then((deleted) => { | ||
| if (!deleted) { | ||
| DEBUG.init( | ||
| 'No token found in secure storage for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| } | ||
| return deleted; | ||
| }) | ||
| .catch((error) => { | ||
| const message = String( | ||
| error?.message || '' | ||
| ).toLowerCase(); | ||
| const isMissingSecretError = | ||
| error?.code === 'ENOENT' || | ||
| message.includes('not found') || | ||
| message.includes('password not found') || | ||
| message.includes('item not found') || | ||
| message.includes('could not be found'); | ||
| if (isMissingSecretError) { | ||
| DEBUG.init( | ||
| 'No token found in secure storage for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| return null; | ||
| } | ||
| throw new BoxCLIError( | ||
| 'Failed to delete token from secure storage', | ||
| error | ||
| ); | ||
| }) | ||
| ); | ||
| } | ||
| promises.push( | ||
| utilities.unlinkAsync(this.filePath).catch((error) => { | ||
| if (error?.code === 'ENOENT') { | ||
| DEBUG.init( | ||
| 'No token file found on disk for environment: %s', | ||
| this.environmentName | ||
| ); | ||
| return; | ||
| } | ||
| throw new BoxCLIError('Failed to delete token file', error); | ||
| }) | ||
| ); | ||
| Promise.all(promises) | ||
| .then(() => callback()) | ||
| .catch((error) => | ||
@@ -84,3 +255,3 @@ callback(new BoxCLIError('Failed to delete token cache', error)) | ||
| /** | ||
| * Write the token to disk, complatible with TS SDK | ||
| * Write the token to storage, compatible with TS SDK | ||
| * @param {AccessToken} token The token to write | ||
@@ -91,3 +262,3 @@ * @returns {Promise<undefined>} A promise resolving to undefined | ||
| return new Promise((resolve, reject) => { | ||
| const accquiredAtMS = Date.now(); | ||
| const acquiredAtMS = Date.now(); | ||
| const tokenInfo = { | ||
@@ -97,3 +268,3 @@ accessToken: token.accessToken, | ||
| refreshToken: token.refreshToken, | ||
| acquiredAtMS: accquiredAtMS, | ||
| acquiredAtMS, | ||
| }; | ||
@@ -111,3 +282,3 @@ this.write(tokenInfo, (error) => { | ||
| /** | ||
| * Read the token from disk, compatible with TS SDK | ||
| * Read the token from storage, compatible with TS SDK | ||
| * @returns {Promise<undefined | AccessToken>} A promise resolving to the token | ||
@@ -114,0 +285,0 @@ */ |
+1
-1
@@ -361,3 +361,3 @@ 'use strict'; | ||
| let op = parseMetadataString(value); | ||
| if (!op.hasOwnProperty('path') || !op.hasOwnProperty('value')) { | ||
| if (!Object.hasOwn(op, 'path') || !Object.hasOwn(op, 'value')) { | ||
| throw new BoxCLIError('Metadata must be in the form key=value'); | ||
@@ -364,0 +364,0 @@ } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
2234021
2.23%245
2.51%72648
1.83%298
26.81%25
8.7%8
33.33%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated