feat(cli): add curl command to generate curl from .bru requests#7200
feat(cli): add curl command to generate curl from .bru requests#7200aldevv wants to merge 1 commit intousebruno:mainfrom
Conversation
Add a new `bru curl` CLI command that converts Bruno request files into curl commands with full environment variable interpolation support. - New `curl` command with --env, --env-file, --global-env, --env-var support - Extract shared env loading logic into reusable `env-loader.js` module - Add `curl-builder.js` for converting prepared requests to curl strings - Remove stray console.log debug statement from prepare-request.js - Add 35 unit tests for curl-builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WalkthroughAdds a new Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant CLI as CLI Parser
participant Collector as Collection Handler
participant EnvLoader as Environment Loader
participant Preparer as Request Preparer
participant Builder as Curl Builder
participant Output
User->>CLI: curl <path> --env-file ... --global-env ...
CLI->>Collector: locate request by path
Collector-->>CLI: request item + collection
CLI->>EnvLoader: loadEnvironments(envFile, globalEnv, envVar, ...)
EnvLoader-->>CLI: {envVars, globalEnvVars, processEnvVars}
CLI->>Preparer: prepare(request, envVars)
Preparer-->>CLI: prepared request with interpolated values
Note over CLI: Parse GraphQL vars, add URL prefix
CLI->>Builder: buildCurlCommand(prepared, filePaths)
Builder-->>CLI: curl command string
CLI->>Output: print curl command
Output-->>User: curl ... output
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/bruno-cli/src/utils/env-loader.js (1)
90-100:findWorkspacePathuses syncfs.existsSyncwhile the rest of the file uses asyncexists.Not a bug — both work fine in this CLI context — just an inconsistency in style. The sync variant is actually slightly more appropriate here since it's in a tight upward-traversal loop.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bruno-cli/src/utils/env-loader.js` around lines 90 - 100, The findWorkspacePath function currently uses synchronous fs.existsSync while the rest of the module uses async IO; make findWorkspacePath async (e.g., async function findWorkspacePath(startPath)) and replace the sync check with an awaited async existence check such as using fs.promises.access (wrapped in try/catch) or util.promisify(fs.exists) in the upward-traversal loop, returning the path when the file is found and null otherwise; update any callers to await findWorkspacePath as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/bruno-cli/src/utils/curl-builder.js`:
- Around line 47-55: The current code pushes shell comments (lines starting with
'#') into the parts array which becomes the continued curl command, causing
everything after the '#' to be treated as a shell comment and dropped; change
the logic so comments are not inserted into the command stream: stop pushing
comment strings into parts (the two conditionals that check request.oauth2 and
request.awsv4config), instead push those messages into a separate notes array
(e.g., notes.push(...)) and emit notes as standalone lines before the curl
command is printed/joined; ensure the rest of the code that builds the final
output uses parts only for curl args and emits notes.join('\n') + '\n' before
the curl invocation so body/data flags are not swallowed.
In `@packages/bruno-cli/tests/utils/curl-builder.spec.js`:
- Around line 302-318: Add tests to verify that when buildCurlCommand produces a
command with oauth2 (oauth2) or AWS SigV4 (awsv4config) and a request body
(mode: 'json' or similar, data provided), the generated curl string still
includes the body flag (e.g., '--data-raw') and that the shell comment like '#
Note: OAuth2' or '# Note: AWS SigV4' does not cause body arguments to be treated
as comments; specifically add two cases in the curl-builder.spec.js file calling
buildCurlCommand with method: 'POST', url, oauth2/awsv4config, mode: 'json', and
data, then assert result contains '--data-raw' (and optionally that the data
appears) to catch the bug in curl-builder.js where body args are being swallowed
by a '#' comment.
---
Nitpick comments:
In `@packages/bruno-cli/src/utils/env-loader.js`:
- Around line 90-100: The findWorkspacePath function currently uses synchronous
fs.existsSync while the rest of the module uses async IO; make findWorkspacePath
async (e.g., async function findWorkspacePath(startPath)) and replace the sync
check with an awaited async existence check such as using fs.promises.access
(wrapped in try/catch) or util.promisify(fs.exists) in the upward-traversal
loop, returning the path when the file is found and null otherwise; update any
callers to await findWorkspacePath as needed.
| // OAuth2 note | ||
| if (request.oauth2) { | ||
| parts.push('# Note: OAuth2 token must be fetched separately; add \'-H "Authorization: Bearer <token>"\''); | ||
| } | ||
|
|
||
| // AWS SigV4 note | ||
| if (request.awsv4config) { | ||
| parts.push('# Note: AWS SigV4 requires dynamic signing; headers will differ at runtime'); | ||
| } |
There was a problem hiding this comment.
Shell comments mid-command will swallow all subsequent parts.
When OAuth2 or AWS SigV4 notes are pushed into parts, the # character starts a shell comment that causes everything after it on the (logically continued) line to be ignored. If the request also has a body, the body arguments appear after the comment and will be silently dropped by the shell.
For example, a POST with OAuth2 + JSON body produces:
curl \
-X POST \
'https://...' \
# Note: OAuth2 token must be fetched separately... \
--data-raw '{"key":"value"}'
The shell treats everything from # onward as a comment — the --data-raw is never seen by curl.
Consider either moving comments before the curl command (separate lines) or omitting them from the parts array entirely and printing them as standalone lines.
🐛 Proposed fix — emit comments outside the command
+ const notes = [];
+
// OAuth2 note
if (request.oauth2) {
- parts.push('# Note: OAuth2 token must be fetched separately; add \'-H "Authorization: Bearer <token>"\'');
+ notes.push('# Note: OAuth2 token must be fetched separately; add -H "Authorization: Bearer <token>"');
}
// AWS SigV4 note
if (request.awsv4config) {
- parts.push('# Note: AWS SigV4 requires dynamic signing; headers will differ at runtime');
+ notes.push('# Note: AWS SigV4 requires dynamic signing; headers will differ at runtime');
}Then at the end, prepend notes:
- return parts.join(' \\\n ');
+ const cmd = parts.join(' \\\n ');
+ return notes.length ? notes.join('\n') + '\n' + cmd : cmd;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/bruno-cli/src/utils/curl-builder.js` around lines 47 - 55, The
current code pushes shell comments (lines starting with '#') into the parts
array which becomes the continued curl command, causing everything after the '#'
to be treated as a shell comment and dropped; change the logic so comments are
not inserted into the command stream: stop pushing comment strings into parts
(the two conditionals that check request.oauth2 and request.awsv4config),
instead push those messages into a separate notes array (e.g., notes.push(...))
and emit notes as standalone lines before the curl command is printed/joined;
ensure the rest of the code that builds the final output uses parts only for
curl args and emits notes.join('\n') + '\n' before the curl invocation so
body/data flags are not swallowed.
| it('should add a comment for OAuth2', () => { | ||
| const result = buildCurlCommand({ | ||
| method: 'GET', | ||
| url: 'https://example.com', | ||
| oauth2: { grantType: 'client_credentials' } | ||
| }); | ||
| expect(result).toContain('# Note: OAuth2'); | ||
| }); | ||
|
|
||
| it('should add a comment for AWS SigV4', () => { | ||
| const result = buildCurlCommand({ | ||
| method: 'GET', | ||
| url: 'https://example.com', | ||
| awsv4config: { region: 'us-east-1' } | ||
| }); | ||
| expect(result).toContain('# Note: AWS SigV4'); | ||
| }); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Missing test: OAuth2/AWS comment with a body.
Given the shell-comment bug noted in curl-builder.js, it would be valuable to add a test verifying that a request with both oauth2 (or awsv4config) and a body mode produces a valid, executable curl command. This would catch the current issue where body args are swallowed by the # comment.
it('should not lose body data when OAuth2 note is present', () => {
const result = buildCurlCommand({
method: 'POST',
url: 'https://example.com',
oauth2: { grantType: 'client_credentials' },
mode: 'json',
data: { key: 'value' }
});
expect(result).toContain('--data-raw');
});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/bruno-cli/tests/utils/curl-builder.spec.js` around lines 302 - 318,
Add tests to verify that when buildCurlCommand produces a command with oauth2
(oauth2) or AWS SigV4 (awsv4config) and a request body (mode: 'json' or similar,
data provided), the generated curl string still includes the body flag (e.g.,
'--data-raw') and that the shell comment like '# Note: OAuth2' or '# Note: AWS
SigV4' does not cause body arguments to be treated as comments; specifically add
two cases in the curl-builder.spec.js file calling buildCurlCommand with method:
'POST', url, oauth2/awsv4config, mode: 'json', and data, then assert result
contains '--data-raw' (and optionally that the data appears) to catch the bug in
curl-builder.js where body args are being swallowed by a '#' comment.
There was a problem hiding this comment.
Pull request overview
This PR adds a new bru curl command that converts Bruno request files into executable curl commands with full environment variable interpolation. The implementation extracts shared environment loading logic into a reusable module and provides comprehensive test coverage for the curl generation utility.
Changes:
- Adds
bru curlcommand to generate curl commands from .bru request files with environment variable interpolation support - Extracts environment loading logic from
run.jsinto a sharedenv-loader.jsutility module for reuse across commands - Removes debug console.log statement from
prepare-request.js
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
packages/bruno-cli/tests/utils/curl-builder.spec.js |
Adds 35 comprehensive unit tests covering all body modes, auth types, shell quoting, and formatting for the curl builder utility |
packages/bruno-cli/src/utils/env-loader.js |
Extracts environment loading logic into reusable module supporting --env-file, --env, --global-env, --env-var, and .env files |
packages/bruno-cli/src/utils/curl-builder.js |
Implements curl command generation with support for multiple body modes, digest/NTLM auth, and proper shell quoting |
packages/bruno-cli/src/runner/prepare-request.js |
Removes stray debug console.log statement |
packages/bruno-cli/src/commands/run.js |
Refactored to use shared env-loader module, removing ~150 lines of duplicated environment loading code |
packages/bruno-cli/src/commands/curl.js |
New command handler that processes request files, applies environment interpolation, and generates curl output |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| type: 'string' | ||
| }) | ||
| .option('env-file', { | ||
| describe: 'Path to environment file (.bru or .json) - absolute or relative', |
There was a problem hiding this comment.
The description for the --env-file option is incomplete. It mentions only ".bru or .json" formats, but the env-loader actually supports .yml and .yaml files as well. The description should be updated to include all supported formats.
| describe: 'Path to environment file (.bru or .json) - absolute or relative', | |
| describe: 'Path to environment file (.bru, .json, .yml or .yaml) - absolute or relative', |
| parts.push('--ntlm'); | ||
| parts.push(`--user ${shellQuote(`${user}:${password || ''}`)}`); | ||
| } | ||
|
|
There was a problem hiding this comment.
The curl-builder is missing support for basic authentication. When request.basicAuth is set (containing username and password), it should generate a curl command with --user 'username:password' flag. This auth type is handled similarly to digest and NTLM auth but is currently missing.
| // Basic auth | |
| if (request.basicAuth) { | |
| const { username, password } = request.basicAuth; | |
| parts.push(`--user ${shellQuote(`${username || ''}:${password || ''}`)}`); | |
| } |
| // OAuth2 note | ||
| if (request.oauth2) { | ||
| parts.push('# Note: OAuth2 token must be fetched separately; add \'-H "Authorization: Bearer <token>"\''); | ||
| } | ||
|
|
||
| // AWS SigV4 note | ||
| if (request.awsv4config) { | ||
| parts.push('# Note: AWS SigV4 requires dynamic signing; headers will differ at runtime'); | ||
| } |
There was a problem hiding this comment.
The comment lines for OAuth2 and AWS SigV4 will break multi-line curl commands. When parts are joined with line continuations using backslash, a comment line interrupts the continuation, causing the remaining options to be interpreted as a separate command. Consider either: (1) placing comments before the entire curl command, (2) appending them after the command with a semicolon, or (3) using a different format that doesn't break the shell command continuation.
| async function loadEnvironments({ collectionPath, collection, env, envFile, globalEnv, workspacePath, envVar }) { | ||
| let envVars = {}; | ||
|
|
||
| // Load --env-file if provided | ||
| if (envFile) { | ||
| const envFilePath = path.resolve(collectionPath, envFile); | ||
| if (!(await exists(envFilePath))) { | ||
| console.error(chalk.red(`Environment file not found: `) + chalk.dim(envFile)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); | ||
| } | ||
| try { | ||
| envVars = loadEnvFromFile(envFilePath); | ||
| } catch (err) { | ||
| console.error(chalk.red(`Failed to parse environment file: ${err.message}`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); | ||
| } | ||
| } | ||
|
|
||
| // Load --env and merge (collection env takes precedence) | ||
| if (env) { | ||
| const envExt = FORMAT_CONFIG[collection.format].ext; | ||
| const collectionEnvFilePath = path.join(collectionPath, 'environments', `${env}${envExt}`); | ||
| if (!(await exists(collectionEnvFilePath))) { | ||
| console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}${envExt}`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); | ||
| } | ||
| try { | ||
| const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env); | ||
| envVars = { ...envVars, ...collectionEnvVars }; | ||
| } catch (err) { | ||
| console.error(chalk.red(`Failed to parse Environment file: ${err.message}`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); | ||
| } | ||
| } | ||
|
|
||
| // Load --global-env | ||
| let globalEnvVars = {}; | ||
| if (globalEnv) { | ||
| const findWorkspacePath = (startPath) => { | ||
| let currentPath = startPath; | ||
| while (currentPath !== path.dirname(currentPath)) { | ||
| const workspaceYmlPath = path.join(currentPath, 'workspace.yml'); | ||
| if (fs.existsSync(workspaceYmlPath)) { | ||
| return currentPath; | ||
| } | ||
| currentPath = path.dirname(currentPath); | ||
| } | ||
| return null; | ||
| }; | ||
|
|
||
| if (!workspacePath) { | ||
| workspacePath = findWorkspacePath(collectionPath); | ||
| } | ||
|
|
||
| if (!workspacePath) { | ||
| console.error(chalk.red(`Workspace not found. Please specify a workspace path using --workspace-path or ensure the collection is inside a workspace directory.`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_GLOBAL_ENV_REQUIRES_WORKSPACE); | ||
| } | ||
|
|
||
| const workspaceExists = await exists(workspacePath); | ||
| if (!workspaceExists) { | ||
| console.error(chalk.red(`Workspace path not found: `) + chalk.dim(workspacePath)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_WORKSPACE_NOT_FOUND); | ||
| } | ||
|
|
||
| const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); | ||
| const workspaceYmlExists = await exists(workspaceYmlPath); | ||
| if (!workspaceYmlExists) { | ||
| console.error(chalk.red(`Invalid workspace: workspace.yml not found in `) + chalk.dim(workspacePath)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_WORKSPACE_NOT_FOUND); | ||
| } | ||
|
|
||
| const globalEnvFilePath = path.join(workspacePath, 'environments', `${globalEnv}.yml`); | ||
| const globalEnvFileExists = await exists(globalEnvFilePath); | ||
| if (!globalEnvFileExists) { | ||
| console.error(chalk.red(`Global environment not found: `) + chalk.dim(`environments/${globalEnv}.yml`)); | ||
| console.error(chalk.dim(`Workspace: ${workspacePath}`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_GLOBAL_ENV_NOT_FOUND); | ||
| } | ||
|
|
||
| try { | ||
| const globalEnvContent = fs.readFileSync(globalEnvFilePath, 'utf8'); | ||
| const globalEnvJson = parseEnvironment(globalEnvContent, { format: 'yml' }); | ||
| globalEnvVars = getEnvVars(globalEnvJson); | ||
| globalEnvVars.__name__ = globalEnv; | ||
| } catch (err) { | ||
| console.error(chalk.red(`Failed to parse global environment: ${err.message}`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); | ||
| } | ||
| } | ||
|
|
||
| // Load --env-var overrides | ||
| if (envVar) { | ||
| let processVars; | ||
| if (typeof envVar === 'string') { | ||
| processVars = [envVar]; | ||
| } else if (typeof envVar === 'object' && Array.isArray(envVar)) { | ||
| processVars = envVar; | ||
| } else { | ||
| console.error(chalk.red(`overridable environment variables not parsable: use name=value`)); | ||
| process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE); | ||
| } | ||
| if (processVars && Array.isArray(processVars)) { | ||
| for (const value of processVars.values()) { | ||
| // split the string at the first equals sign | ||
| const match = value.match(/^([^=]+)=(.*)$/); | ||
| if (!match) { | ||
| console.error( | ||
| chalk.red(`Overridable environment variable not correct: use name=value - presented: `) | ||
| + chalk.dim(`${value}`) | ||
| ); | ||
| process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE); | ||
| } | ||
| envVars[match[1]] = match[2]; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Load .env file at root of collection | ||
| const dotEnvPath = path.join(collectionPath, '.env'); | ||
| const dotEnvExists = await exists(dotEnvPath); | ||
| const processEnvVars = { | ||
| ...process.env | ||
| }; | ||
| if (dotEnvExists) { | ||
| const content = fs.readFileSync(dotEnvPath, 'utf8'); | ||
| const jsonData = parseDotEnv(content); | ||
|
|
||
| forOwn(jsonData, (value, key) => { | ||
| processEnvVars[key] = value; | ||
| }); | ||
| } | ||
|
|
||
| return { envVars, globalEnvVars, processEnvVars }; | ||
| } | ||
|
|
||
| module.exports = { loadEnvironments }; |
There was a problem hiding this comment.
The env-loader module extracts significant logic from run.js but has no test coverage. Consider adding tests for: environment file loading from different formats (.bru, .yml, .json), environment variable merging, global environment resolution with workspace detection, --env-var parsing, and .env file loading. This is especially important since this shared logic is now used by multiple commands.
closes #6670
Summary
bru curlCLI command that converts Bruno request files into curl commands with full environment variable interpolationrun.jsinto a reusableenv-loader.jsmodulecurl-builder.jsutility that handles all body modes (JSON, form, multipart, GraphQL, file), auth types (digest, NTLM), and proper shell quotingconsole.logdebug statement fromprepare-request.jsTest plan
curl-builder.jscovering all body modes, auth types, shell quoting, and formattingbru curl request.brugenerates valid curl outputbru curl request.bru --env localinterpolates environment variables correctlybru runstill works as expected after env-loader refactorSummary by CodeRabbit
Release Notes
New Features
curlcommand to generate portable curl commands from saved requests, with support for environment variables, workspace configurations, and file-based request bodies.Bug Fixes