Skip to content

feat(cli): add curl command to generate curl from .bru requests#7200

Open
aldevv wants to merge 1 commit intousebruno:mainfrom
aldevv:feat/cli-curl-command
Open

feat(cli): add curl command to generate curl from .bru requests#7200
aldevv wants to merge 1 commit intousebruno:mainfrom
aldevv:feat/cli-curl-command

Conversation

@aldevv
Copy link

@aldevv aldevv commented Feb 18, 2026

closes #6670

Summary

  • Add a new bru curl CLI command that converts Bruno request files into curl commands with full environment variable interpolation
  • Extract shared environment loading logic from run.js into a reusable env-loader.js module
  • Add curl-builder.js utility that handles all body modes (JSON, form, multipart, GraphQL, file), auth types (digest, NTLM), and proper shell quoting
  • Remove stray console.log debug statement from prepare-request.js

Test plan

  • 35 unit tests added for curl-builder.js covering all body modes, auth types, shell quoting, and formatting
  • Manual test: bru curl request.bru generates valid curl output
  • Manual test: bru curl request.bru --env local interpolates environment variables correctly
  • Manual test: bru run still works as expected after env-loader refactor

Summary by CodeRabbit

Release Notes

  • New Features

    • Added curl command to generate portable curl commands from saved requests, with support for environment variables, workspace configurations, and file-based request bodies.
  • Bug Fixes

    • Removed unintended debug output from request preparation.

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>
Copilot AI review requested due to automatic review settings February 18, 2026 16:52
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

Walkthrough

Adds a new curl CLI command that generates curl command strings from stored requests, supported by a centralized environment-loading utility. The command integrates with existing collection handling to resolve requests, load and interpolate environment variables, and build curl output. Also refactors the existing run command to use the new environment loader, and removes a debug statement from request preparation.

Changes

Cohort / File(s) Summary
New curl CLI command
packages/bruno-cli/src/commands/curl.js
Implements new curl command handler with yargs argument parsing, collection/request resolution, environment loading, request preparation, and curl command construction. Includes error handling for missing requests and folders.
Environment loading utilities
packages/bruno-cli/src/utils/env-loader.js
Centralized environment aggregation from multiple sources (env-file, collection env, global-env, env-var overrides, and root .env). Returns envVars, globalEnvVars, and processEnvVars with file format support (JSON, YAML, Bru) and workspace resolution.
Curl command builder
packages/bruno-cli/src/utils/curl-builder.js
Converts prepared Axios request config to curl command string, supporting HTTP methods, headers, multiple authentication types (digest, NTLM, OAuth2, AWS SigV4), and body modes (JSON, form-encoded, multipart, GraphQL, file). Includes shell-safe quoting.
Run command refactoring
packages/bruno-cli/src/commands/run.js
Replaces inline environment-loading logic with centralized loadEnvironments() call; removes legacy environment parsing branches and file-handling code scattered throughout. Preserves existing flow for reporters and request execution.
Debug cleanup
packages/bruno-cli/src/runner/prepare-request.js
Removes console.log statement that logged axiosRequest after WSSE header construction.
Curl-builder test suite
packages/bruno-cli/tests/utils/curl-builder.spec.js
Comprehensive Jest tests covering HTTP methods, URL quoting, headers, body modes (JSON, text, XML, form-encoded, multipart, GraphQL, file), authentication schemes, shell escaping edge cases, and output formatting.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • helloanoop
  • lohit-bruno
  • naman-bruno
  • bijin-bruno

Poem

🌀 From requests stored, curl strings are born,
🔧 Environments dance in harmony sworn,
🛠️ One loader to rule them all so bright,
📜 Building commands that feel just right! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a curl command to generate curl from .bru requests, which aligns directly with the PR objectives and changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/bruno-cli/src/utils/env-loader.js (1)

90-100: findWorkspacePath uses sync fs.existsSync while the rest of the file uses async exists.

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.

Comment on lines +47 to +55
// 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');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +302 to +318
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');
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 curl command to generate curl commands from .bru request files with environment variable interpolation support
  • Extracts environment loading logic from run.js into a shared env-loader.js utility 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',
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
describe: 'Path to environment file (.bru or .json) - absolute or relative',
describe: 'Path to environment file (.bru, .json, .yml or .yaml) - absolute or relative',

Copilot uses AI. Check for mistakes.
parts.push('--ntlm');
parts.push(`--user ${shellQuote(`${user}:${password || ''}`)}`);
}

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Basic auth
if (request.basicAuth) {
const { username, password } = request.basicAuth;
parts.push(`--user ${shellQuote(`${username || ''}:${password || ''}`)}`);
}

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +55
// 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');
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +188
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 };
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate curl commands via Bruno CLI

1 participant

Comments