From 4b11fd25e7bfb8672c432030cba99dc9a644d928 Mon Sep 17 00:00:00 2001 From: Artur Jankowski Date: Fri, 13 Mar 2026 15:20:09 +0100 Subject: [PATCH 1/3] fix: normalize inquirer signal-exit compatibility on Windows --- LICENSE-THIRD-PARTY.txt | 37 ++++---- package.json | 1 + src/box-command.js | 2 +- src/commands/ai/ask.js | 4 +- src/commands/ai/extract-structured.js | 4 +- src/commands/ai/extract.js | 4 +- src/commands/configure/environments/delete.js | 2 +- .../configure/environments/set-current.js | 2 +- src/commands/files/upload.js | 29 ++++-- src/commands/files/versions/upload.js | 5 +- src/commands/login.js | 7 +- src/commands/logout.js | 2 +- src/commands/search.js | 4 +- src/inquirer.js | 39 ++++++++ src/modules/upload.js | 21 ++--- test/commands/files.test.js | 6 +- test/commands/search.test.js | 21 +++-- test/inquirer.test.js | 94 +++++++++++++++++++ 18 files changed, 224 insertions(+), 60 deletions(-) create mode 100644 src/inquirer.js create mode 100644 test/inquirer.test.js diff --git a/LICENSE-THIRD-PARTY.txt b/LICENSE-THIRD-PARTY.txt index be688d81..1064a668 100644 --- a/LICENSE-THIRD-PARTY.txt +++ b/LICENSE-THIRD-PARTY.txt @@ -279,11 +279,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------- -The following npm package may be included in this product: +The following npm packages may be included in this product: - statuses@2.0.1 + - statuses@2.0.2 -This package contains the following license: +These packages each contain the following license: The MIT License (MIT) @@ -371,11 +372,12 @@ THE SOFTWARE. ----------- -The following npm package may be included in this product: +The following npm packages may be included in this product: - http-errors@2.0.0 + - http-errors@2.0.1 -This package contains the following license: +These packages each contain the following license: The MIT License (MIT) @@ -1931,7 +1933,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The following npm packages may be included in this product: - - body-parser@1.20.3 + - body-parser@1.20.4 - type-is@1.6.18 These packages each contain the following license: @@ -2813,7 +2815,7 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. The following npm packages may be included in this product: - - @cypress/request@3.0.9 + - @cypress/request@3.0.10 - aws-sign2@0.7.0 - forever-agent@0.6.1 - tunnel-agent@0.6.0 @@ -3136,12 +3138,11 @@ Apache-2.0 ----------- -The following npm packages may be included in this product: +The following npm package may be included in this product: - - qs@6.13.0 - - qs@6.14.0 + - qs@6.14.2 -These packages each contain the following license: +This package contains the following license: BSD 3-Clause License @@ -4147,7 +4148,7 @@ OTHER DEALINGS IN THE SOFTWARE. The following npm package may be included in this product: - - basic-ftp@5.0.5 + - basic-ftp@5.2.0 This package contains the following license: @@ -4858,7 +4859,7 @@ IN THE SOFTWARE. The following npm package may be included in this product: - - lodash@4.17.21 + - lodash@4.17.23 This package contains the following license: @@ -6719,7 +6720,7 @@ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The following npm package may be included in this product: - - diff@4.0.2 + - diff@4.0.4 This package contains the following license: @@ -6906,8 +6907,8 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. The following npm packages may be included in this product: - - minimatch@5.1.6 - - minimatch@9.0.5 + - minimatch@5.1.9 + - minimatch@9.0.9 These packages each contain the following license: @@ -7057,7 +7058,7 @@ The following npm packages may be included in this product: - ini@1.3.8 - isexe@2.0.0 - json-stringify-safe@5.0.1 - - minimatch@3.1.2 + - minimatch@3.1.5 - mute-stream@0.0.8 - mute-stream@2.0.0 - once@1.4.0 @@ -7445,7 +7446,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The following npm package may be included in this product: - - raw-body@2.5.2 + - raw-body@2.5.3 This package contains the following license: @@ -8113,7 +8114,7 @@ THE SOFTWARE. The following npm package may be included in this product: - - ajv@6.12.6 + - ajv@6.14.0 This package contains the following license: diff --git a/package.json b/package.json index 4f22d064..52f2f210 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "nyc": "^17.1.0", "oclif": "^4.22.38", "prettier": "^3.6.2", + "signal-exit": "^4.1.0", "sinon": "^19.0.2", "standard-version": "^9.5.0", "typescript": "^5.7.2" diff --git a/src/box-command.js b/src/box-command.js index 2d1461b8..08b28e88 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -32,7 +32,7 @@ const BoxCLIError = require('./cli-error'); const CLITokenCache = require('./token-cache'); const utils = require('./util'); const pkg = require('../package.json'); -const inquirer = require('inquirer'); +const inquirer = require('./inquirer'); const { stringifyStream } = require('@discoveryjs/json-ext'); const progress = require('cli-progress'); let keytar = null; diff --git a/src/commands/ai/ask.js b/src/commands/ai/ask.js index 8a487c42..5d8ecbe7 100644 --- a/src/commands/ai/ask.js +++ b/src/commands/ai/ask.js @@ -90,7 +90,9 @@ AiAskCommand.flags = { try { return JSON.parse(input); } catch (error) { - throw new Error(`Error parsing AI agent JSON: ${error.message}`); + throw new Error( + `Error parsing AI agent JSON: ${error.message}` + ); } }, }), diff --git a/src/commands/ai/extract-structured.js b/src/commands/ai/extract-structured.js index 50f7daa0..bb025a95 100644 --- a/src/commands/ai/extract-structured.js +++ b/src/commands/ai/extract-structured.js @@ -206,7 +206,9 @@ AiExtractStructuredCommand.flags = { try { return JSON.parse(input); } catch (error) { - throw new Error(`Error parsing AI agent JSON: ${error.message}`); + throw new Error( + `Error parsing AI agent JSON: ${error.message}` + ); } }, }), diff --git a/src/commands/ai/extract.js b/src/commands/ai/extract.js index 6b60fa0e..9440e916 100644 --- a/src/commands/ai/extract.js +++ b/src/commands/ai/extract.js @@ -91,7 +91,9 @@ AiExtractCommand.flags = { try { return JSON.parse(input); } catch (error) { - throw new Error(`Error parsing AI agent JSON: ${error.message}`); + throw new Error( + `Error parsing AI agent JSON: ${error.message}` + ); } }, }), diff --git a/src/commands/configure/environments/delete.js b/src/commands/configure/environments/delete.js index 254c21b2..bd078ed3 100644 --- a/src/commands/configure/environments/delete.js +++ b/src/commands/configure/environments/delete.js @@ -3,7 +3,7 @@ const { Args } = require('@oclif/core'); const BoxCommand = require('../../../box-command'); const BoxCLIError = require('../../../cli-error'); -const inquirer = require('inquirer'); +const inquirer = require('../../../inquirer'); class EnvironmentsDeleteCommand extends BoxCommand { async run() { diff --git a/src/commands/configure/environments/set-current.js b/src/commands/configure/environments/set-current.js index 47ff9ffa..29f9c3d0 100644 --- a/src/commands/configure/environments/set-current.js +++ b/src/commands/configure/environments/set-current.js @@ -2,7 +2,7 @@ const { Args } = require('@oclif/core'); const BoxCommand = require('../../../box-command'); -const inquirer = require('inquirer'); +const inquirer = require('../../../inquirer'); class EnvironmentsSetCurrentCommand extends BoxCommand { async run() { diff --git a/src/commands/files/upload.js b/src/commands/files/upload.js index f4e17e7c..8ff9a620 100644 --- a/src/commands/files/upload.js +++ b/src/commands/files/upload.js @@ -5,14 +5,20 @@ const { Flags, Args } = require('@oclif/core'); const fs = require('node:fs'); const path = require('node:path'); const BoxCLIError = require('../../cli-error'); -const { createReadStream, uploadFile, uploadNewFileVersion } = require('../../modules/upload'); +const { + createReadStream, + uploadFile, + uploadNewFileVersion, +} = require('../../modules/upload'); const DEBUG = require('../../debug'); 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.`); + 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']; @@ -41,8 +47,16 @@ class FilesUploadCommand extends BoxCommand { 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') { + 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 @@ -62,7 +76,9 @@ class FilesUploadCommand extends BoxCommand { ); } - DEBUG.output(`File already exists in folder; uploading as new version of file ${existingFileID}`); + DEBUG.output( + `File already exists in folder; uploading as new version of file ${existingFileID}` + ); // Re-create the stream since the first attempt consumed it const versionStream = createReadStream(args.path); @@ -79,7 +95,8 @@ class FilesUploadCommand extends BoxCommand { } } -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.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', diff --git a/src/commands/files/versions/upload.js b/src/commands/files/versions/upload.js index 42c52071..2618433a 100644 --- a/src/commands/files/versions/upload.js +++ b/src/commands/files/versions/upload.js @@ -3,7 +3,10 @@ const BoxCommand = require('../../../box-command'); const { Flags, Args } = require('@oclif/core'); const fs = require('node:fs'); -const { createReadStream, uploadNewFileVersion } = require('../../../modules/upload'); +const { + createReadStream, + uploadNewFileVersion, +} = require('../../../modules/upload'); class FilesUploadVersionsCommand extends BoxCommand { async run() { diff --git a/src/commands/login.js b/src/commands/login.js index c7915d48..b4fc03ee 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -9,7 +9,7 @@ const CLITokenCache = require('../token-cache'); const package_ = require('../../package.json'); const chalk = require('chalk'); const express = require('express'); -const inquirer = require('inquirer'); +const inquirer = require('../inquirer'); const path = require('node:path'); const ora = require('ora'); const http = require('node:http'); @@ -89,7 +89,10 @@ async function promptForAuthMethod(inquirerModule) { trimmedChoice.length > CLIENT_ID_MIN_LENGTH && trimmedChoice.length < CLIENT_ID_MAX_LENGTH ) { - return promptForPlatformAppCredentials(inquirerModule, trimmedChoice); + return promptForPlatformAppCredentials( + inquirerModule, + trimmedChoice + ); } // Invalid input — repeat the prompt diff --git a/src/commands/logout.js b/src/commands/logout.js index c05a9557..e5117df3 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -4,7 +4,7 @@ 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 inquirer = require('../inquirer'); const pkg = require('../../package.json'); const { Flags } = require('@oclif/core'); diff --git a/src/commands/search.js b/src/commands/search.js index ba0888a2..f4aae14c 100644 --- a/src/commands/search.js +++ b/src/commands/search.js @@ -44,9 +44,7 @@ class SearchCommand extends BoxCommand { 'Run: box search --help for all available filters.', ].join(os.EOL); - throw new BoxCLIError( - missingQueryMessage - ); + throw new BoxCLIError(missingQueryMessage); } if (flags.all && (flags.limit || flags['max-items'])) { diff --git a/src/inquirer.js b/src/inquirer.js new file mode 100644 index 00000000..dada85ea --- /dev/null +++ b/src/inquirer.js @@ -0,0 +1,39 @@ +'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'); diff --git a/src/modules/upload.js b/src/modules/upload.js index bc4fdecb..20e3298c 100644 --- a/src/modules/upload.js +++ b/src/modules/upload.js @@ -25,22 +25,18 @@ function runChunkedUpload(uploader, size) { uploader.on('chunkUploaded', (chunk) => { bytesUploaded += chunk.part.size; progressBar.update(bytesUploaded, { - speed: Math.floor( - bytesUploaded / (Date.now() - startTime) / 1000 - ), + speed: Math.floor(bytesUploaded / (Date.now() - startTime) / 1000), }); }); return uploader.start(); } -async function uploadFile(client, { folderID, name, stream, size, fileAttributes }) { +async function uploadFile( + client, + { folderID, name, stream, size, fileAttributes } +) { if (size < CHUNKED_UPLOAD_FILE_SIZE) { - return client.files.uploadFile( - folderID, - name, - stream, - fileAttributes - ); + return client.files.uploadFile(folderID, name, stream, fileAttributes); } const uploader = await client.files.getChunkedUploader( folderID, @@ -52,7 +48,10 @@ async function uploadFile(client, { folderID, name, stream, size, fileAttributes return runChunkedUpload(uploader, size); } -async function uploadNewFileVersion(client, { fileID, stream, size, fileAttributes }) { +async function uploadNewFileVersion( + client, + { fileID, stream, size, fileAttributes } +) { if (size < CHUNKED_UPLOAD_FILE_SIZE) { return client.files.uploadNewFileVersion( fileID, diff --git a/test/commands/files.test.js b/test/commands/files.test.js index 8fdf2127..c3246682 100644 --- a/test/commands/files.test.js +++ b/test/commands/files.test.js @@ -1946,9 +1946,9 @@ describe('Files', function () { request_id: 'abc123', context_info: { conflicts: { - type: 'file', - id: existingFileId, - name: testFileName, + type: 'file', + id: existingFileId, + name: testFileName, }, }, }; diff --git a/test/commands/search.test.js b/test/commands/search.test.js index 40c09f40..91d09574 100644 --- a/test/commands/search.test.js +++ b/test/commands/search.test.js @@ -441,14 +441,17 @@ describe('Search', function () { test.stderr() .command(['search', '--no-color', '--token=test']) - .it('when query is missing and no metadata filters are provided', (context) => { - assert.equal( - context.stderr, - `Missing required argument: [QUERY]${os.EOL}` + - `Usage: box search "your search terms"${os.EOL}` + - `Example: box search "quarterly report" --type file${os.EOL}` + - `Run: box search --help for all available filters.${os.EOL}` - ); - }); + .it( + 'when query is missing and no metadata filters are provided', + (context) => { + assert.equal( + context.stderr, + `Missing required argument: [QUERY]${os.EOL}` + + `Usage: box search "your search terms"${os.EOL}` + + `Example: box search "quarterly report" --type file${os.EOL}` + + `Run: box search --help for all available filters.${os.EOL}` + ); + } + ); }); }); diff --git a/test/inquirer.test.js b/test/inquirer.test.js new file mode 100644 index 00000000..3aebe0f8 --- /dev/null +++ b/test/inquirer.test.js @@ -0,0 +1,94 @@ +'use strict'; + +const { assert } = require('chai'); +const sinon = require('sinon'); + +const handler = () => {}; + +// The shim in src/inquirer normalizes signal-exit across versions: +// newer versions export an object with onExit(), while older behavior expects +// a callable function. These tests assert both compatibility paths. +describe('inquirer compatibility shim', function () { + const MODULE_UNDER_TEST = '../src/inquirer'; + const signalExitPath = require.resolve('signal-exit'); + const inquirerPath = require.resolve('inquirer'); + + let originalSignalExitCacheEntry; + let originalInquirerCacheEntry; + + beforeEach(function () { + // Snapshot and reset module cache so each test can inject a custom + // signal-exit export and re-run the shim from a clean state. + originalSignalExitCacheEntry = require.cache[signalExitPath]; + originalInquirerCacheEntry = require.cache[inquirerPath]; + delete require.cache[require.resolve(MODULE_UNDER_TEST)]; + }); + + afterEach(function () { + if (originalSignalExitCacheEntry) { + require.cache[signalExitPath] = originalSignalExitCacheEntry; + } else { + delete require.cache[signalExitPath]; + } + + if (originalInquirerCacheEntry) { + require.cache[inquirerPath] = originalInquirerCacheEntry; + } else { + delete require.cache[inquirerPath]; + } + + delete require.cache[require.resolve(MODULE_UNDER_TEST)]; + }); + + it('should wrap object-style signal-exit export as callable function', function () { + // Simulate modern signal-exit shape (object export with onExit method). + const onExitStub = sinon.stub().returns('cleanup-callback'); + const signalExitObjectExport = { + onExit: onExitStub, + load: sinon.stub(), + unload: sinon.stub(), + }; + + require.cache[signalExitPath] = { + id: signalExitPath, + filename: signalExitPath, + loaded: true, + exports: signalExitObjectExport, + }; + + const shimmedInquirer = require(MODULE_UNDER_TEST); + const rawInquirer = require('inquirer'); + const patchedSignalExit = require('signal-exit'); + + // The shim should not replace inquirer itself; only signal-exit behavior. + assert.strictEqual(shimmedInquirer, rawInquirer); + assert.strictEqual(typeof patchedSignalExit, 'function'); + assert.strictEqual(patchedSignalExit.onExit, onExitStub); + + const result = patchedSignalExit(handler, { alwaysLast: true }); + assert.strictEqual(result, 'cleanup-callback'); + assert.isTrue( + onExitStub.calledOnceWithExactly(handler, { alwaysLast: true }) + ); + }); + + it('should preserve function-style signal-exit export', function () { + // If signal-exit is already callable, shim should leave it untouched. + const signalExitFunctionExport = sinon + .stub() + .returns('already-callable'); + + require.cache[signalExitPath] = { + id: signalExitPath, + filename: signalExitPath, + loaded: true, + exports: signalExitFunctionExport, + }; + + require(MODULE_UNDER_TEST); + const patchedSignalExit = require('signal-exit'); + + assert.strictEqual(patchedSignalExit, signalExitFunctionExport); + assert.strictEqual(patchedSignalExit(), 'already-callable'); + }); +}); From c53999bc4666609ed2e2c933c878beb1f76d6853 Mon Sep 17 00:00:00 2001 From: Artur Jankowski Date: Fri, 13 Mar 2026 19:37:07 +0100 Subject: [PATCH 2/3] docs: Update descriptions for ai commands --- README.md | 2 +- docs/ai.md | 20 +++++++++++--------- package.json | 3 ++- src/commands/ai/ask.js | 2 +- src/commands/ai/extract-structured.js | 2 +- src/commands/ai/extract.js | 2 +- src/commands/ai/text-gen.js | 2 +- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c916e0a5..dccdf5f4 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Avatar URL: 'https://app.box.com/api/avatar/large/77777' # Command Topics -* [`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 * [`box collaboration-allowlist`](docs/collaboration-allowlist.md) - List collaboration allowlist entries * [`box collaborations`](docs/collaborations.md) - Manage collaborations diff --git a/docs/ai.md b/docs/ai.md index 990a3ee2..01c2dc08 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1,7 +1,7 @@ `box ai` ======== -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. * [`box ai:ask`](#box-aiask) * [`box ai:extract`](#box-aiextract) @@ -10,7 +10,7 @@ Sends an AI request to supported LLMs and returns an answer ## `box ai:ask` -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. ``` USAGE @@ -39,7 +39,7 @@ FLAGS --save-to-file-path= Override default file path to save report 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. EXAMPLES $ box ai:ask --items=id=12345,type=file --prompt "What is the status of this document?" @@ -49,7 +49,7 @@ _See code: [src/commands/ai/ask.js](https://github.com/box/boxcli/blob/v4.5.0/sr ## `box ai:extract` -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. ``` USAGE @@ -79,7 +79,8 @@ FLAGS --save-to-file-path= Override default file path to save report 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. EXAMPLES $ box ai:extract --items=id=12345,type=file --prompt "firstName, lastName, location, yearOfBirth, company" @@ -91,7 +92,7 @@ _See code: [src/commands/ai/extract.js](https://github.com/box/boxcli/blob/v4.5. ## `box ai:extract-structured` -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. ``` USAGE @@ -124,7 +125,7 @@ FLAGS 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. + a metadata template or a list of fields to ensure the structure. This is intended for direct use, not by AI agents. 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" @@ -136,7 +137,7 @@ _See code: [src/commands/ai/extract-structured.js](https://github.com/box/boxcli ## `box ai:text-gen` -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. ``` USAGE @@ -165,7 +166,8 @@ FLAGS --save-to-file-path= Override default file path to save report 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. EXAMPLES $ 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?" diff --git a/package.json b/package.json index 52f2f210..028b5138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@box/cli", - "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.", + "description": "Official command line interface for the Box API.", "keywords": [ "box", "cli", @@ -88,6 +88,7 @@ "/src" ], "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", "bin": "box", "additionalHelpFlags": [ diff --git a/src/commands/ai/ask.js b/src/commands/ai/ask.js index 5d8ecbe7..abebf8a1 100644 --- a/src/commands/ai/ask.js +++ b/src/commands/ai/ask.js @@ -29,7 +29,7 @@ class AiAskCommand extends BoxCommand { } 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 = [ 'box ai:ask --items=id=12345,type=file --prompt "What is the status of this document?"', ]; diff --git a/src/commands/ai/extract-structured.js b/src/commands/ai/extract-structured.js index bb025a95..c965ec94 100644 --- a/src/commands/ai/extract-structured.js +++ b/src/commands/ai/extract-structured.js @@ -41,7 +41,7 @@ class AiExtractStructuredCommand extends BoxCommand { } 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","basic_text":{"model":"azure__openai__gpt_4o_mini","prompt_template":"Answer using the provided content"}}\'', diff --git a/src/commands/ai/extract.js b/src/commands/ai/extract.js index 9440e916..e85b8446 100644 --- a/src/commands/ai/extract.js +++ b/src/commands/ai/extract.js @@ -27,7 +27,7 @@ class AiExtractCommand extends BoxCommand { } 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","basic_text":{"model":"azure__openai__gpt_4o_mini"}}\'', diff --git a/src/commands/ai/text-gen.js b/src/commands/ai/text-gen.js index 4225cb71..c62bf0a4 100644 --- a/src/commands/ai/text-gen.js +++ b/src/commands/ai/text-gen.js @@ -26,7 +26,7 @@ class AiTextGenCommand extends BoxCommand { } 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 = [ '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?"', ]; From 57c06a1a426e66315338485af747ece14411b77a Mon Sep 17 00:00:00 2001 From: Artur Jankowski Date: Fri, 13 Mar 2026 22:26:24 +0100 Subject: [PATCH 3/3] feat: Unify secure storage backend across platforms --- LICENSE-THIRD-PARTY.txt | 1 + package.json | 1 + src/box-command.js | 118 +++++++++++++++------- src/secure-storage.js | 196 ++++++++++++++++++++++++++++++++++++ src/token-cache.js | 87 ++++++++-------- test/secure-storage.test.js | 137 +++++++++++++++++++++++++ test/token-cache.test.js | 38 +++---- 7 files changed, 472 insertions(+), 106 deletions(-) create mode 100644 src/secure-storage.js create mode 100644 test/secure-storage.test.js diff --git a/LICENSE-THIRD-PARTY.txt b/LICENSE-THIRD-PARTY.txt index 1064a668..1adb038b 100644 --- a/LICENSE-THIRD-PARTY.txt +++ b/LICENSE-THIRD-PARTY.txt @@ -5235,6 +5235,7 @@ The following npm packages may be included in this product: - csv-stringify@6.6.0 - degenerator@5.0.1 - isarray@1.0.0 + - keychain@1.5.0 - netmask@2.0.2 - tr46@0.0.3 - undici-types@5.26.5 diff --git a/package.json b/package.json index 028b5138..912859c5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "fs-extra": "^10.1.0", "inquirer": "^8.2.7", "js-yaml": "^4.1.1", + "keychain": "^1.5.0", "keytar": "^7.9.0", "lodash": "^4.17.13", "mkdirp": "^3.0.1", diff --git a/src/box-command.js b/src/box-command.js index 08b28e88..f3fe8527 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -35,12 +35,7 @@ const pkg = require('../package.json'); const inquirer = require('./inquirer'); const { stringifyStream } = require('@discoveryjs/json-ext'); const progress = require('cli-progress'); -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'); const DEBUG = require('./debug'); const stream = require('node:stream'); @@ -96,9 +91,29 @@ const ENVIRONMENTS_FILE_PATH = path.join( CONFIG_FOLDER_PATH, 'box_environments.json' ); +const ENVIRONMENTS_KEYCHAIN_SERVICE = 'boxcli'; +const ENVIRONMENTS_KEYCHAIN_ACCOUNT = 'Box'; 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 * @param {string|boolean} value The value to parse @@ -300,8 +315,7 @@ class BoxCommand extends Command { this.disableRequiredArgsAndFlags(); } - this.supportsSecureStorage = - keytar && ['darwin', 'win32', 'linux'].includes(process.platform); + this.supportsSecureStorage = secureStorage.available; let { flags, args } = await this.parse(this.constructor); @@ -1809,34 +1823,62 @@ class BoxCommand extends Command { * @returns {Object} The parsed environment information */ async getEnvironments() { - // Try secure storage first on supported platforms 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 keytar.getPassword( - 'boxcli' /* service */, - 'Box' /* account */ + 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, falling back to file: %s', - error.message + 'Failed to read from secure storage (%s), falling back to file: %O', + secureStorage.backend, + getDebugErrorDetails(error) ); - // fallback to env file } + } else { + DEBUG.init( + 'Skipping secure storage read: platform=%s available=%s', + process.platform, + secureStorage.available + ); } // 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)); } + DEBUG.init( + 'Environments fallback file does not exist at %s', + ENVIRONMENTS_FILE_PATH + ); } catch (error) { DEBUG.init( - 'Failed to read environments from file: %s', - error.message + 'Failed to read environments from file: %O', + getDebugErrorDetails(error) ); } @@ -1861,32 +1903,43 @@ class BoxCommand extends Command { let storedInSecureStorage = false; - // Try secure storage first on supported platforms 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 keytar.setPassword( - 'boxcli' /* service */, - 'Box' /* account */, - JSON.stringify(environments) /* password */ + await secureStorage.setPassword( + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT, + JSON.stringify(environments) ); storedInSecureStorage = true; DEBUG.init( - 'Stored environment configuration in secure storage' + 'Stored environment configuration in secure storage (%s)', + secureStorage.backend ); - // Successfully stored in secure storage, remove the file if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) { fs.unlinkSync(ENVIRONMENTS_FILE_PATH); DEBUG.init( 'Removed environment configuration file after migrating to secure storage' ); } - } catch (keytarError) { - // fallback to file storage if secure storage fails + } catch (error) { DEBUG.init( - 'Could not store credentials in secure storage, falling back to file: %s', - keytarError.message + '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 + ); } // Write to file if secure storage failed or not available @@ -1895,13 +1948,10 @@ class BoxCommand extends Command { let fileContents = JSON.stringify(environments, null, 4); fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8'); - // Show warning to user if secure storage was attempted but failed - if (this.supportsSecureStorage) { + if (process.platform === 'linux' && this.supportsSecureStorage) { this.info( - `Could not store credentials in secure storage, falling back to file.` + - (process.platform === 'linux' - ? ' To enable secure storage on Linux, install libsecret-1-dev package.' - : '') + 'Could not store credentials in secure storage, falling back to file.' + + ' To enable secure storage on Linux, install libsecret-1-dev package.' ); } } catch (error) { diff --git a/src/secure-storage.js b/src/secure-storage.js new file mode 100644 index 00000000..46bdb6a5 --- /dev/null +++ b/src/secure-storage.js @@ -0,0 +1,196 @@ +'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} 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} + */ + 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} 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(); diff --git a/src/token-cache.js b/src/token-cache.js index 71a209ae..a4b7cac8 100644 --- a/src/token-cache.js +++ b/src/token-cache.js @@ -8,17 +8,11 @@ const path = require('node:path'); const BoxCLIError = require('./cli-error'); const utilities = require('./util'); const DEBUG = require('./debug'); - -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'); /** * Cache interface used by the Node SDK to cache tokens to disk in the user's home directory - * Supports secure storage via keytar with fallback to file system + * Supports secure storage with fallback to file system */ class CLITokenCache { /** @@ -27,16 +21,15 @@ class CLITokenCache { */ constructor(environmentName) { this.environmentName = environmentName; + this.secureStorage = secureStorage; this.filePath = path.join( os.homedir(), '.box', `${environmentName}_token_cache.json` ); - // Service and account for keytar - includes environment name for multiple environments - this.keytarService = `boxcli-token-${environmentName}`; - this.keytarAccount = 'Box'; - this.supportsSecureStorage = - keytar && ['darwin', 'win32', 'linux'].includes(process.platform); + this.serviceName = `boxcli-token-${environmentName}`; + this.accountName = 'Box'; + this.supportsSecureStorage = this.secureStorage.available; } /** @@ -45,16 +38,16 @@ class CLITokenCache { * @returns {void} */ read(callback) { - // Try secure storage first if available if (this.supportsSecureStorage) { - keytar - .getPassword(this.keytarService, this.keytarAccount) + this.secureStorage + .getPassword(this.serviceName, this.accountName) .then((tokenJson) => { if (tokenJson) { try { const tokenInfo = JSON.parse(tokenJson); DEBUG.init( - 'Loaded token from secure storage for environment: %s', + 'Loaded token from secure storage (%s) for environment: %s', + this.secureStorage.backend, this.environmentName ); return callback(null, tokenInfo); @@ -63,22 +56,27 @@ class CLITokenCache { 'Failed to parse token from secure storage, falling back to file: %s', parseError.message ); - // Fall through to file-based storage } } - // Token not in secure storage, try file + 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, falling back to file: %s', - error.message + 'Failed to read from secure storage (%s), falling back to file: %s', + this.secureStorage.backend, + error?.message || error ); - // Fall back to file-based storage this._readFromFile(callback); }); } else { - // Secure storage not available, use file + DEBUG.init( + 'Secure storage unavailable for token cache; reading token from file for environment: %s', + this.environmentName + ); this._readFromFile(callback); } } @@ -116,44 +114,43 @@ class CLITokenCache { write(tokenInfo, callback) { const output = JSON.stringify(tokenInfo, null, 4); - // Try secure storage first if available if (this.supportsSecureStorage) { - keytar - .setPassword(this.keytarService, this.keytarAccount, output) + this.secureStorage + .setPassword(this.serviceName, this.accountName, output) .then(() => { DEBUG.init( - 'Stored token in secure storage for environment: %s', + 'Stored token in secure storage (%s) for environment: %s', + this.secureStorage.backend, this.environmentName ); - // Clear the file-based cache if it exists (migration scenario) if (fs.existsSync(this.filePath)) { fs.unlinkSync(this.filePath); + DEBUG.init( + 'Migrated token from file to secure storage for environment: %s', + this.environmentName + ); } - return; - }) - .then(() => { - 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 for environment %s, falling back to file: %s', + 'Failed to write to secure storage (%s) for environment %s, falling back to file: %s', + this.secureStorage.backend, this.environmentName, - error.message + error?.message || error ); if (process.platform === 'linux') { DEBUG.init( 'To enable secure storage on Linux, install libsecret-1-dev package' ); } - // Fall back to file-based storage this._writeToFile(output, callback); }); } else { - // Secure storage not available, use file + DEBUG.init( + 'Secure storage unavailable for token cache; writing token to file for environment: %s', + this.environmentName + ); this._writeToFile(output, callback); } } @@ -185,11 +182,10 @@ class CLITokenCache { clear(callback) { const promises = []; - // Try to delete from secure storage if (this.supportsSecureStorage) { promises.push( - keytar - .deletePassword(this.keytarService, this.keytarAccount) + this.secureStorage + .deletePassword(this.serviceName, this.accountName) .then((deleted) => { if (!deleted) { DEBUG.init( @@ -226,10 +222,9 @@ class CLITokenCache { ); } - // Try to delete from file promises.push( utilities.unlinkAsync(this.filePath).catch((error) => { - if (error && error.code === 'ENOENT') { + if (error?.code === 'ENOENT') { DEBUG.init( 'No token file found on disk for environment: %s', this.environmentName @@ -254,12 +249,12 @@ class CLITokenCache { */ store(token) { return new Promise((resolve, reject) => { - const accquiredAtMS = Date.now(); + const acquiredAtMS = Date.now(); const tokenInfo = { accessToken: token.accessToken, accessTokenTTLMS: token.expiresIn * 1000, refreshToken: token.refreshToken, - acquiredAtMS: accquiredAtMS, + acquiredAtMS, }; this.write(tokenInfo, (error) => { if (error) { diff --git a/test/secure-storage.test.js b/test/secure-storage.test.js new file mode 100644 index 00000000..610ac5bd --- /dev/null +++ b/test/secure-storage.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const { assert } = require('chai'); +const sinon = require('sinon'); + +describe('SecureStorage', function () { + const sandbox = sinon.createSandbox(); + let secureStorage; + + afterEach(function () { + sandbox.verifyAndRestore(); + delete require.cache[require.resolve('../src/secure-storage')]; + }); + + describe('on macOS (darwin)', function () { + beforeEach(function () { + if (process.platform !== 'darwin') { + this.skip(); + } + secureStorage = require('../src/secure-storage'); + }); + + it('should use keychain backend on macOS', function () { + assert.equal(secureStorage.backend, 'keychain'); + assert.isTrue(secureStorage.available); + }); + + it('getPassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'getPassword') + .callsFake((opts, fn) => fn(null, 'stored-value')); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + + assert.equal(password, 'stored-value'); + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + }); + }); + + it('getPassword should return null when not found', async function () { + const kc = require('keychain'); + sandbox + .stub(kc, 'getPassword') + .callsFake((opts, fn) => fn(new Error('not found'), null)); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + + assert.isNull(password); + }); + + it('setPassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'setPassword') + .callsFake((opts, fn) => fn(null)); + + await secureStorage.setPassword('boxcli', 'Box', 'secret'); + + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + password: 'secret', + }); + }); + + it('deletePassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'deletePassword') + .callsFake((opts, fn) => fn(null)); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + + assert.isTrue(result); + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + }); + }); + + it('deletePassword should return false when not found', async function () { + const kc = require('keychain'); + sandbox + .stub(kc, 'deletePassword') + .callsFake((opts, fn) => + fn(new Error('Could not find password')) + ); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + + assert.isFalse(result); + }); + }); + + describe('availability checks', function () { + it('should report available=true on supported platform with backend', function () { + secureStorage = require('../src/secure-storage'); + if (secureStorage.backend) { + assert.isTrue(secureStorage.available); + } + }); + + it('getPassword should return null when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + assert.isNull(password); + }); + + it('setPassword should throw when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + try { + await secureStorage.setPassword('boxcli', 'Box', 'secret'); + assert.fail('Should have thrown'); + } catch (error) { + assert.include(error.message, 'not available'); + } + }); + + it('deletePassword should return false when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + assert.isFalse(result); + }); + }); +}); diff --git a/test/token-cache.test.js b/test/token-cache.test.js index 521ce561..22b54821 100644 --- a/test/token-cache.test.js +++ b/test/token-cache.test.js @@ -55,27 +55,20 @@ describe('CLITokenCache', function () { expect(tokenCache.filePath).to.equal(testFilePath); }); - it('should set correct keytar service name', function () { - expect(tokenCache.keytarService).to.equal( + it('should set correct secure storage service name', function () { + expect(tokenCache.serviceName).to.equal( `boxcli-token-${testEnvName}` ); }); - it('should set keytar account name', function () { - expect(tokenCache.keytarAccount).to.equal('Box'); + it('should set secure storage account name', function () { + expect(tokenCache.accountName).to.equal('Box'); }); it('should detect secure storage support on supported platforms', function () { - let keytar = null; - try { - keytar = require('keytar'); - } catch { - // keytar cannot be imported because the library is not provided for this operating system / architecture - } - const supportedPlatforms = ['darwin', 'win32', 'linux']; - const isSupportedOS = supportedPlatforms.includes(process.platform); + const secureStorage = require('../src/secure-storage'); expect(tokenCache.supportsSecureStorage).to.equal( - keytar && isSupportedOS + secureStorage.available ); }); }); @@ -344,12 +337,12 @@ describe('CLITokenCache', function () { }); }); - it('should use different keytar service names for different environments', function () { + it('should use different service names for different environments', function () { const env1Cache = new CLITokenCache('production'); const env2Cache = new CLITokenCache('development'); - expect(env1Cache.keytarService).to.equal('boxcli-token-production'); - expect(env2Cache.keytarService).to.equal( + expect(env1Cache.serviceName).to.equal('boxcli-token-production'); + expect(env2Cache.serviceName).to.equal( 'boxcli-token-development' ); }); @@ -401,9 +394,8 @@ describe('CLITokenCache', function () { } const unlinkStub = sinon.stub(utilities, 'unlinkAsync').resolves(); - const keytar = require('keytar'); const deletePasswordStub = sinon - .stub(keytar, 'deletePassword') + .stub(tokenCache.secureStorage, 'deletePassword') .rejects( Object.assign(new Error('Permission denied'), { code: 'EACCES', @@ -428,15 +420,12 @@ describe('CLITokenCache', function () { this.skip(); } - // Mock keytar to simulate failure - const keytar = require('keytar'); const setPasswordStub = sinon - .stub(keytar, 'setPassword') + .stub(tokenCache.secureStorage, 'setPassword') .rejects(new Error('Secure storage unavailable')); tokenCache.write(testTokenInfo, (error) => { expect(error).to.be.undefined; - // Should fallback to file expect(fs.existsSync(testFilePath)).to.be.true; setPasswordStub.restore(); @@ -449,7 +438,6 @@ describe('CLITokenCache', function () { this.skip(); } - // Create a file-based token const boxDir = path.join(os.homedir(), '.box'); if (!fs.existsSync(boxDir)) { fs.mkdirSync(boxDir, { recursive: true }); @@ -460,10 +448,8 @@ describe('CLITokenCache', function () { 'utf8' ); - // Mock keytar to simulate failure - const keytar = require('keytar'); const getPasswordStub = sinon - .stub(keytar, 'getPassword') + .stub(tokenCache.secureStorage, 'getPassword') .rejects(new Error('Secure storage unavailable')); tokenCache.read((error, tokenInfo) => {