feat(cli): persist env, global-env, and collection var changes from scripts (#8387)

* feat(variables): implement variable persistence and updates for environment and collection files

- Introduced new utility functions for applying and persisting variable updates, enhancing the management of environment and collection variables.
- Updated the run command to include descriptors for environment files, allowing for better tracking of variable changes.
- Enhanced the runSingleRequest function to synchronize variable updates and persist changes to the appropriate files.
- Added comprehensive tests to ensure the correct functionality of variable merging and persistence logic across different file formats.

* fix(variables): refine YAML handling and data type inference for environment variables

- Updated the run command to exclusively recognize '.yml' files, removing support for '.yaml' extensions.
- Enhanced the persistence logic to correctly handle data type inference for newly added environment and collection variables.
- Added comprehensive tests to validate the correct writing of variables to YAML and .bru files, ensuring accurate data type preservation and updates.
- Improved the handling of existing data types when variables are modified, ensuring consistency across different formats.

* feat(tests): add integration tests for typed variable persistence in CLI

- Introduced a new test suite to validate the persistence of typed environment and collection variables set via scripts in the CLI.
- Implemented a local server to facilitate HTTP requests during tests, ensuring accurate simulation of variable management.
- Enhanced the test logic to verify that the correct data type annotations are written to both .bru and YAML files after CLI execution.
- Added comprehensive assertions to confirm the expected disk state for both global and collection variables across different sandboxes.

* refactor(runSingleRequest): remove synchronization of variable updates after post response script execution

- Eliminated the syncVariableUpdates call from the runSingleRequest function, streamlining the variable handling process.
- Adjusted the logic to focus on executing post response variables without unnecessary synchronization, improving performance and clarity.

* feat(env-vars): implement transient environment variable handling and improve persistence resilience

- Added support for transient environment variable overrides via the `--env-var` CLI option, ensuring these values are not persisted to disk.
- Enhanced the `mergeScriptVarsIntoEnvList` function to prevent overridden variables from being written back, preserving existing entries.
- Updated the `persistVariableUpdates` function to handle potential disk write errors gracefully, logging warnings instead of failing the execution.
- Introduced comprehensive tests to validate the behavior of transient variables and ensure correct persistence logic under various scenarios.

* fix(persist-variables): enhance variable persistence logic and testing

- Improved the `persistEnvFile` function to preserve additional fields (uid, dataType, custom metadata) during JSON writes, ensuring no data loss for unrecognized entries.
- Updated the `persistVariableUpdates` function to respect `envVarOverrides` when writing to the global environment file, preventing transient values from being persisted.
- Added regression tests to validate the preservation of additional fields and the correct handling of environment variable overrides in persistence scenarios.

* refactor(tests): streamline fixture writing and enhance variable persistence tests

- Renamed the `writeFileSyncMkdirP` function to `writeFixtureFile` for clarity, emphasizing its role in writing fixture files with parent directory creation.
- Replaced multiple calls to `writeFileSyncMkdirP` with the new `writeFixtureFile` function to improve code readability and maintainability.
- Added tests to verify the deletion of environment variables from disk when removed from the environment map, ensuring accurate persistence behavior.
- Implemented checks to prevent runtime variables from being persisted, safeguarding against unintended data leaks.

* test(env-file): add integration test for JSON environment variable persistence

- Implemented a new test to verify that typed environment variables are correctly persisted to a JSON file using the --env-file option.
- Ensured that existing entries retain their uid and custom fields during the persistence process, validating the shape-preservation guarantee.
- Enhanced the test to check that new typed variables from the script are accurately written and that untouched entries remain unchanged.

* test(env-file): add integration tests for YAML and .bru variable persistence

- Implemented new tests to verify the persistence of typed environment variables to both YAML and .bru files using the --env-file option.
- Ensured that typed values are correctly serialized with appropriate annotations in the output files, validating the correct handling of different formats.
- Enhanced coverage for the persistence behavior of environment variables, confirming that existing entries remain unchanged while new variables are accurately written.

* test(env-file): enhance JSON environment variable persistence tests

- Added a new test to verify the preservation of native typed values in JSON environment files when unrelated keys are touched during script execution.
- Ensured that typed values maintain their original types and are auto-annotated with the correct dataType, validating the integrity of the environment variable persistence process.
- Expanded the typed-value inference tests to cover cross-type transitions, confirming that existing dataType annotations are ignored when new values are written.

* refactor(env-vars): transition env-var overrides to Map for improved persistence handling

- Changed the `envVarOverrides` from a Set to a Map to track injected values, enhancing the logic for distinguishing between transient and deliberate variable writes.
- Updated related functions to accommodate the new Map structure, ensuring that only matching overrides are filtered out during persistence.
- Enhanced documentation and tests to reflect the new behavior, confirming that deliberate script writes with different values are correctly persisted.

* test(integration): refine comments and enhance clarity in typed persistence tests

* test(integration): add tests for variable persistence in tests blocks and error handling

- Implemented new tests to verify that variables set within `tests {}` blocks are correctly persisted to the environment and collection files.
- Added a test to ensure that variables written before an error in a `tests {}` block are still saved, confirming the integrity of partial results during execution.
- Enhanced existing tests with detailed comments for clarity and understanding of the persistence behavior in various scenarios.

* test(integration): add collection variable persistence test for error handling

- Introduced a new test to verify that collection variables set before an error in a `tests {}` block are correctly persisted to the collection file.
- Enhanced existing tests to ensure that both environment and collection variables maintain their expected values during error scenarios, confirming the robustness of variable persistence.

* test(integration): add test for environment and collection variable persistence from post-response expressions

- Introduced a new test to verify that environment and collection variables mutated as a side effect of `vars:post-response` expressions are correctly persisted.
- Enhanced the `runSingleRequest` function to synchronize variable updates after executing post-response scripts, ensuring that changes are reflected in the environment and collection files.
- This change improves the robustness of variable handling in the CLI, aligning it with the expected behavior of the desktop application.

* refactor(persist-variables): streamline JSON parsing and enhance variable persistence tests

- Simplified the JSON parsing logic in the `persistEnvFile` function by removing unnecessary try-catch blocks, ensuring that malformed files are handled gracefully.
- Updated tests to verify the persistence of new collection variable types, including numbers, booleans, and objects, ensuring that typed variables are correctly serialized and maintained.
- Enhanced existing tests to confirm that plain string variables remain unchanged during persistence, improving the robustness of variable handling in the CLI.

* jsdoc change
This commit is contained in:
sanish chirayath
2026-06-30 20:36:29 +05:30
committed by GitHub
parent 2f0f2e1c79
commit 4ab68fc71f
5 changed files with 2079 additions and 6 deletions

View File

@@ -360,6 +360,20 @@ const handler = async function (argv) {
const runtimeVariables = {};
let envVars = {};
let envFileDescriptor = null;
let globalEnvFileDescriptor = null;
// --env-var overrides as Map<name, injected value>. The persistence layer compares the
// script's resulting value against the injected value to tell a leaked override (same
// value passed through unchanged) apart from a deliberate same-named script write that
// must reach disk. Typical use: CI injects a secret the CLI can't decrypt at rest.
const envVarOverrides = new Map();
const resolveEnvFileFormat = (filePath) => {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.json') return 'json';
if (ext === '.yml') return 'yml';
return 'bru';
};
// Helper to load environment variables from a file
const loadEnvFromFile = (filePath, nameOverride) => {
@@ -374,11 +388,11 @@ const handler = async function (argv) {
const rawName = normalizedEnv?.name;
const trimmedName = typeof rawName === 'string' ? rawName.trim() : '';
result.__name__ = trimmedName || path.basename(filePath, '.json');
} else if (fileExt === '.yml' || fileExt === '.yaml') {
} else if (fileExt === '.yml') {
const content = fs.readFileSync(filePath, 'utf8');
const envJson = parseEnvironment(content, { format: 'yml' });
result = getEnvVars(envJson);
result.__name__ = nameOverride || path.basename(filePath, fileExt);
result.__name__ = nameOverride || path.basename(filePath, '.yml');
} else {
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(content, { format: 'bru' });
@@ -398,6 +412,7 @@ const handler = async function (argv) {
}
try {
envVars = loadEnvFromFile(envFilePath);
envFileDescriptor = { path: envFilePath, format: resolveEnvFileFormat(envFilePath) };
} catch (err) {
console.error(chalk.red(`Failed to parse environment file: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
@@ -415,6 +430,7 @@ const handler = async function (argv) {
try {
const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env);
envVars = { ...envVars, ...collectionEnvVars };
envFileDescriptor = { path: collectionEnvFilePath, format: collection.format };
} catch (err) {
console.error(chalk.red(`Failed to parse Environment file: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
@@ -470,6 +486,7 @@ const handler = async function (argv) {
const globalEnvJson = parseEnvironment(globalEnvContent, { format: 'yml' });
globalEnvVars = getEnvVars(globalEnvJson);
globalEnvVars.__name__ = globalEnv;
globalEnvFileDescriptor = { path: globalEnvFilePath, format: 'yml' };
} catch (err) {
console.error(chalk.red(`Failed to parse global environment: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
@@ -498,6 +515,7 @@ const handler = async function (argv) {
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE);
}
envVars[match[1]] = match[2];
envVarOverrides.set(match[1], match[2]);
}
}
}
@@ -618,6 +636,15 @@ const handler = async function (argv) {
const runtime = getJsSandboxRuntime(sandbox);
const collectionRootFile = collection.format === 'yml' ? 'opencollection.yml' : 'collection.bru';
const collectionRootPath = path.join(collectionPath, collectionRootFile);
const persistPaths = {
envFile: envFileDescriptor,
globalEnvFile: globalEnvFileDescriptor,
collectionRootPath,
envVarOverrides
};
// Fetch system proxy once for all requests (skip if --noproxy flag is set)
if (!noproxy) {
try {
@@ -647,7 +674,8 @@ const handler = async function (argv) {
runtime,
collection,
runSingleRequestByPathname,
globalEnvVars
globalEnvVars,
persistPaths
);
resolve(res?.response);
}
@@ -674,7 +702,8 @@ const handler = async function (argv) {
runtime,
collection,
runSingleRequestByPathname,
globalEnvVars
globalEnvVars,
persistPaths
);
const isLastRun = currentRequestIndex === requestItems.length - 1;

View File

@@ -9,6 +9,7 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const { applyVariableUpdates, persistVariableUpdates } = require('../utils/persist-variables');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { setupProxyAgents } = require('../utils/proxy-util');
@@ -93,8 +94,31 @@ const runSingleRequest = async function (
runtime,
collection,
runSingleRequestByPathname,
globalEnvVars = {}
globalEnvVars = {},
persistPaths = {}
) {
const syncVariableUpdates = (result, currentRequest) => {
if (!result) return;
applyVariableUpdates(result, {
envVariables,
runtimeVariables,
globalEnvVars,
request: currentRequest
});
// Persistence is a side effect — never tank the run for it. In CI, env files may sit on
// read-only mounts or the user's shell may lack write permissions; log and continue.
try {
persistVariableUpdates(result, {
envFile: persistPaths.envFile,
globalEnvFile: persistPaths.globalEnvFile,
collection,
collectionRootPath: persistPaths.collectionRootPath,
envVarOverrides: persistPaths.envVarOverrides
});
} catch (err) {
console.warn(chalk.yellow(`Warning: failed to persist variable updates: ${err.message}`));
}
};
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
@@ -228,6 +252,7 @@ const runSingleRequest = async function (
scriptingConfig,
runSingleRequestByPathname,
collectionName);
syncVariableUpdates(result, request);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
@@ -281,6 +306,9 @@ const runSingleRequest = async function (
// Extract partial results from the error (tests that passed before the error)
preRequestTestResults = error?.partialResults?.results || [];
// Persist any variable changes the script made before erroring
syncVariableUpdates(error?.partialResults, request);
// Preserve nextRequestName if it was set before the error
if (error?.partialResults?.nextRequestName !== undefined) {
nextRequestName = error.partialResults.nextRequestName;
@@ -742,7 +770,7 @@ const runSingleRequest = async function (
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
const result = varsRuntime.runPostResponseVars(
postResponseVars,
request,
response,
@@ -751,6 +779,9 @@ const runSingleRequest = async function (
collectionPath,
processEnvVars
);
// Expressions can invoke bru.setEnvVar / setGlobalEnvVar / setCollectionVar as a side effect,
// mirroring how the desktop app surfaces these mutations after the vars block.
syncVariableUpdates(result, request);
}
// run post response script
@@ -771,6 +802,7 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
syncVariableUpdates(result, request);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
@@ -802,6 +834,8 @@ const runSingleRequest = async function (
}
];
syncVariableUpdates(error?.partialResults, request);
if (error?.partialResults?.nextRequestName !== undefined) {
nextRequestName = error.partialResults.nextRequestName;
}
@@ -847,6 +881,7 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
syncVariableUpdates(result, request);
testResults = get(result, 'results', []);
if (result?.nextRequestName !== undefined) {
@@ -879,6 +914,8 @@ const runSingleRequest = async function (
}
];
syncVariableUpdates(error?.partialResults, request);
if (error?.partialResults?.nextRequestName !== undefined) {
nextRequestName = error.partialResults.nextRequestName;
}

View File

@@ -0,0 +1,421 @@
const fs = require('fs');
const { stringifyEnvironment, stringifyCollection, parseEnvironment } = require('@usebruno/filestore');
const { getDataTypeFromValue } = require('@usebruno/common').utils;
const { parseEnvironmentJson } = require('./environment');
/**
* Bruno stashes the env name inside the vars map under `__name__`; it is metadata, never a real variable.
*/
const INTERNAL_KEYS = new Set(['__name__']);
/**
* Sets or removes the `dataType` field on a variable based on the JS type of `value`.
* `string` is the implicit default on disk — omit the field rather than writing `dataType: string`.
*
* @param {{ name: string, value: any, dataType?: string }} variable - Variable entry to mutate in place.
* @param {any} value - JS value whose type drives the inference.
* @returns {object} The same `variable` reference, after mutation.
*
* @example
* applyInferredDataType({ name: 'port', value: 3000 }, 3000);
* → { name: 'port', value: 3000, dataType: 'number' }
*
* applyInferredDataType({ name: 'host', value: 'x', dataType: 'number' }, 'x');
* → { name: 'host', value: 'x' } // dataType deleted
*/
const applyInferredDataType = (variable, value) => {
const inferred = getDataTypeFromValue(value);
if (inferred === 'string') {
delete variable.dataType;
} else {
variable.dataType = inferred;
}
return variable;
};
/**
* Returns a shallow copy of `vars` with internal keys (`__name__`) removed.
*
* @param {Object<string, any>} vars - Flat name→value map; may be null/undefined.
* @returns {Object<string, any>} New object without internal keys.
*
* @example
* stripInternal({ token: 'abc', __name__: 'dev' });
* → { token: 'abc' }
*/
const stripInternal = (vars) => {
const out = {};
for (const [k, v] of Object.entries(vars || {})) {
if (!INTERNAL_KEYS.has(k)) out[k] = v;
}
return out;
};
/**
* In-place replace of `target`'s contents with `source`'s, while preserving `target.__name__`.
* Callers hold long-lived references to `target`, so a fresh object would not propagate.
* Keys missing from `source` are deleted — this is how `bru.deleteEnvVar` flows through.
*
* @param {Object<string, any>} target - Object mutated in place.
* @param {Object<string, any>} source - Desired end state (minus internal keys).
* @returns {void}
*
* @example
* const target = { a: 1, b: 2, __name__: 'dev' };
* overwriteMap(target, { a: 9, c: 3 });
* -> target is now { a: 9, c: 3, __name__: 'dev' }
* -> b deleted, c added, __name__ preserved
*/
const overwriteMap = (target, source) => {
const preservedName = target.__name__;
for (const key of Object.keys(target)) {
if (INTERNAL_KEYS.has(key)) continue;
if (!(key in source)) delete target[key];
}
for (const [key, value] of Object.entries(source)) {
if (INTERNAL_KEYS.has(key)) continue;
target[key] = value;
}
if (preservedName !== undefined && target.__name__ === undefined) {
target.__name__ = preservedName;
}
};
/**
* Sync a runtime result into the in-memory maps the next request will read. No disk I/O.
* Env / runtime / global maps are mutated by reference; collection vars are replaced on the request.
*
* @param {{
* envVariables?: Object,
* runtimeVariables?: Object,
* collectionVariables?: Object,
* globalEnvironmentVariables?: Object
* } | null} result - Runtime return value; any field may be null to indicate "unchanged".
* @param {{
* envVariables: Object,
* runtimeVariables: Object,
* globalEnvVars: Object,
* request: Object
* }} ctx - The long-lived maps and the current request object to update.
* @returns {void}
*
* @example
* After a script calls `bru.setEnvVar('token', 'abc')`:
* const result = {
* envVariables: { token: 'abc' },
* runtimeVariables: null,
* collectionVariables: null,
* globalEnvironmentVariables: null
* };
* applyVariableUpdates(result, { envVariables, runtimeVariables, globalEnvVars, request });
* -> envVariables now contains `token: 'abc'`, and the next request sees it.
*/
const applyVariableUpdates = (result, { envVariables, runtimeVariables, globalEnvVars, request }) => {
if (!result) return;
if (result.envVariables && envVariables) {
overwriteMap(envVariables, result.envVariables);
}
if (result.runtimeVariables && runtimeVariables) {
overwriteMap(runtimeVariables, result.runtimeVariables);
}
if (result.globalEnvironmentVariables && globalEnvVars) {
overwriteMap(globalEnvVars, result.globalEnvironmentVariables);
}
// Collection vars live on the per-request object, not a shared map — replace the field outright.
if (result.collectionVariables && request) {
request.collectionVariables = stripInternal(result.collectionVariables);
}
};
/**
* Reconcile the env file's array of variable entries against the script's flat name→value map.
* - Disabled entries are always preserved (user intent — toggled off, not deleted).
* - Enabled entries absent from script output are dropped (so `bru.deleteEnvVar` reaches disk).
* - New script keys are appended as enabled text vars with inferred dataType.
* - `overrides`: names supplied via CLI `--env-var` keyed to their injected value. The leaked
* override value never reaches disk and the file's entry is preserved, but a deliberate
* script write of a *different* value for the same name still persists.
*
* @param {Array<{ name: string, value: any, enabled?: boolean, type?: string, secret?: boolean, dataType?: string }>} variables
* Existing entries from the env file.
* @param {Object<string, any>} scriptVarsRaw - Flat map from the script runtime; may include `__name__`.
* @param {{ overrides?: Map<string, string> }} [options] - Names → injected override values.
* @returns {Array<object>} New array of merged variable entries.
*
* @example
* const variables = [
* { name: 'host', value: 'old', enabled: true, type: 'text', secret: false },
* { name: 'stale', value: 'gone', enabled: true, type: 'text', secret: false },
* { name: 'off', value: 'kept', enabled: false, type: 'text', secret: false }
* ];
* const scriptVarsRaw = { host: 'new', port: 3000, __name__: 'dev' };
* mergeScriptVarsIntoEnvList(variables, scriptVarsRaw);
* → [
* { name: 'host', value: 'new', enabled: true, type: 'text', secret: false }, // updated
* { name: 'off', value: 'kept', enabled: false, type: 'text', secret: false }, // preserved (disabled)
* { name: 'port', value: 3000, enabled: true, type: 'text', secret: false, dataType: 'number' } // appended
* ]
* -> `stale` was dropped (enabled but absent from script output).
*
* @example
* With CLI override `--env-var token=transient`:
* mergeScriptVarsIntoEnvList(
* [{ name: 'token', value: 'real', enabled: true, type: 'text', secret: true }],
* { token: 'transient', other: 'x' },
* { overrides: new Map([['token', 'transient']]) }
* );
* → [
* { name: 'token', value: 'real', enabled: true, type: 'text', secret: true }, // preserved unchanged
* { name: 'other', value: 'x', enabled: true, type: 'text', secret: false } // appended
* ]
*
* @example
* Script *deliberately* sets the overridden key to a new value:
* mergeScriptVarsIntoEnvList(
* [{ name: 'token', value: 'real', enabled: true, type: 'text', secret: true }],
* { token: 'rotated' },
* { overrides: new Map([['token', 'transient']]) }
* );
* → [{ name: 'token', value: 'rotated', enabled: true, type: 'text', secret: true }] // persisted
*/
const mergeScriptVarsIntoEnvList = (variables, scriptVarsRaw, options = {}) => {
const overrides = options.overrides instanceof Map ? options.overrides : new Map();
const scriptVars = stripInternal(scriptVarsRaw);
// Drop a script value only when it still matches the injected override — a different
// value means the script deliberately wrote it (e.g. `bru.setEnvVar('token', 'rotated')`)
// and must reach disk.
for (const [key, overrideValue] of overrides) {
if (key in scriptVars && scriptVars[key] === overrideValue) {
delete scriptVars[key];
}
}
const scriptKeys = new Set(Object.keys(scriptVars));
const next = (variables || [])
.filter((v) => {
if (v.enabled === false) return true;
if (scriptKeys.has(v.name)) return true;
// Keep the file's entry for an overridden name even if the script didn't echo it back.
if (overrides.has(v.name)) return true;
return false;
})
.map((v) => {
if (v.enabled === false || !scriptKeys.has(v.name)) return v;
return applyInferredDataType({ ...v, value: scriptVars[v.name] }, scriptVars[v.name]);
});
// Skip names that already appear as enabled entries; a same-named disabled entry still gets a fresh enabled row appended.
const presentEnabled = new Set(next.filter((v) => v.enabled !== false).map((v) => v.name));
for (const key of scriptKeys) {
if (presentEnabled.has(key)) continue;
const entry = { name: key, value: scriptVars[key], type: 'text', enabled: true, secret: false };
next.push(applyInferredDataType(entry, scriptVars[key]));
}
return next;
};
/**
* Same shape as {@link mergeScriptVarsIntoEnvList}, with collection-var conventions:
* `type: 'request'`, no `secret` field, no `__name__` to strip.
*
* @param {Array<{ name: string, value: any, enabled?: boolean, type?: string, dataType?: string }>} variables
* Existing entries from the collection root.
* @param {Object<string, any>} scriptVars - Flat map from the script runtime.
* @returns {Array<object>} New array of merged collection-var entries.
*
* @example
* mergeScriptVarsIntoCollectionVarsList(
* [{ name: 'region', value: 'us', enabled: true, type: 'request' }],
* { region: 'eu', retries: 3 }
* );
* → [
* { name: 'region', value: 'eu', enabled: true, type: 'request' },
* { name: 'retries', value: 3, enabled: true, type: 'request', dataType: 'number' }
* ]
*/
const mergeScriptVarsIntoCollectionVarsList = (variables, scriptVars) => {
const scriptKeys = new Set(Object.keys(scriptVars || {}));
const next = (variables || [])
.filter((v) => (v.enabled === false ? true : scriptKeys.has(v.name)))
.map((v) => {
if (v.enabled === false || !scriptKeys.has(v.name)) return v;
return applyInferredDataType({ ...v, value: scriptVars[v.name] }, scriptVars[v.name]);
});
const presentEnabled = new Set(next.filter((v) => v.enabled !== false).map((v) => v.name));
for (const key of scriptKeys) {
if (presentEnabled.has(key)) continue;
const entry = { name: key, value: scriptVars[key], type: 'request', enabled: true };
next.push(applyInferredDataType(entry, scriptVars[key]));
}
return next;
};
/**
* Idempotency guard: skip the write when serialized output is byte-identical.
*
* @param {string} filePath - Absolute path to write.
* @param {string} content - Serialized new content.
* @param {string} existing - Current on-disk content.
* @returns {boolean} `true` if the file was written, `false` if unchanged.
*
* @example
* writeIfChanged('Prod.bru', 'vars { x: 1 }', 'vars { x: 1 }'); // → false (no write)
* writeIfChanged('Prod.bru', 'vars { x: 2 }', 'vars { x: 1 }'); // → true (file updated)
*/
const writeIfChanged = (filePath, content, existing) => {
if (content === existing) return false;
fs.writeFileSync(filePath, content);
return true;
};
/**
* Read → merge → write one env file. Used for both per-env and global-env files
* (same shape, different descriptor).
*
* @param {{ path: string, format: 'json' | 'yml' | 'bru' } | null | undefined} envFile - Env file descriptor; no-op when missing.
* @param {Object<string, any>} scriptVars - Flat map of vars the script declared.
* @param {{ overrides?: Map<string, string> }} [options] - `--env-var name=value` entries keyed by name → injected value; forwarded to `mergeScriptVarsIntoEnvList` to keep override values off disk.
* @returns {void}
*
* @example
* persistEnvFile(
* { path: '/coll/environments/Dev.bru', format: 'bru' },
* { host: 'api.example.com', port: 3000 }
* );
* -> on-disk before:
* vars {
* host: localhost
* }
* -> on-disk after:
* vars {
* host: api.example.com
* @number
* port: 3000
* }
*/
const persistEnvFile = (envFile, scriptVars, options = {}) => {
// No descriptor means no `--env` flag was passed — nothing to persist to.
if (!envFile || !envFile.path) return;
const { path: filePath, format } = envFile;
if (!fs.existsSync(filePath)) return;
if (format === 'json') {
const existingRaw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(existingRaw);
// Validate shape, but merge against the raw `parsed.variables` so per-entry fields the
// CLI doesn't recognize (uid, dataType, custom metadata) survive on entries the script
// didn't touch — parseEnvironmentJson's normalizer would otherwise strip them.
parseEnvironmentJson(parsed);
const rawVariables = Array.isArray(parsed.variables) ? parsed.variables.filter(Boolean) : [];
const mergedVars = mergeScriptVarsIntoEnvList(rawVariables, scriptVars, options);
// Spread preserves any top-level fields the user has beyond `variables` (name, metadata, etc.).
const next = { ...parsed, variables: mergedVars };
const content = JSON.stringify(next, null, 2) + '\n';
writeIfChanged(filePath, content, existingRaw);
return;
}
const existingRaw = fs.readFileSync(filePath, 'utf8');
// Bru parser expects \n line endings; yml parser is tolerant.
const sourceForParse = format === 'bru' ? existingRaw.replace(/\r\n/g, '\n') : existingRaw;
const parsed = parseEnvironment(sourceForParse, { format });
const mergedVars = mergeScriptVarsIntoEnvList(parsed.variables || [], scriptVars, options);
const next = { ...parsed, variables: mergedVars };
const content = stringifyEnvironment(next, { format });
writeIfChanged(filePath, content, existingRaw);
};
/**
* Mutate the in-memory collection root and write it out. The mutation matters:
* subsequent requests in the same iteration read `collection.root` and must see the updated vars.
*
* @param {{ root?: object, format: 'bru' | 'yml', brunoConfig?: object }} collection - Loaded collection; mutated in place.
* @param {Object<string, any>} scriptCollVars - Flat map of collection vars the script declared.
* @param {string} collectionRootPath - Absolute path to the collection root file (`collection.bru` / `opencollection.yml`).
* @returns {void}
*
* @example
* -> collection.root.request.vars.req before: [{ name: 'region', value: 'us', enabled: true, type: 'request' }]
* persistCollectionVars(collection, { region: 'eu', retries: 3 }, '/coll/collection.bru');
* -> collection.root.request.vars.req after:
* [
* { name: 'region', value: 'eu', enabled: true, type: 'request' },
* { name: 'retries', value: 3, enabled: true, type: 'request', dataType: 'number' }
* ]
* -> `collection.bru` on disk is rewritten with the same content.
*/
const persistCollectionVars = (collection, scriptCollVars, collectionRootPath) => {
if (!collection || !collectionRootPath) return;
const collectionRoot = collection.root || {};
collectionRoot.request = collectionRoot.request || {};
collectionRoot.request.vars = collectionRoot.request.vars || {};
const existingVars = collectionRoot.request.vars.req || [];
const merged = mergeScriptVarsIntoCollectionVarsList(existingVars, scriptCollVars);
collectionRoot.request.vars.req = merged;
collection.root = collectionRoot;
const format = collection.format;
const content = stringifyCollection(collectionRoot, collection.brunoConfig || {}, { format });
const existing = fs.existsSync(collectionRootPath) ? fs.readFileSync(collectionRootPath, 'utf8') : null;
if (existing !== null) {
writeIfChanged(collectionRootPath, content, existing);
} else {
fs.writeFileSync(collectionRootPath, content);
}
};
/**
* Disk-write counterpart to {@link applyVariableUpdates}.
* Runtime vars are intentionally not persisted — they are ephemeral by definition.
*
* @param {{
* envVariables?: Object,
* collectionVariables?: Object,
* globalEnvironmentVariables?: Object,
* runtimeVariables?: Object
* } | null} result - Runtime return value; any field may be null to indicate "unchanged".
* @param {{
* envFile?: { path: string, format: 'json' | 'yml' | 'bru' },
* globalEnvFile?: { path: string, format: 'yml' },
* collection: object,
* collectionRootPath: string,
* envVarOverrides?: Map<string, string>
* }} targets - Where each kind of var should land on disk. `envVarOverrides` maps each
* CLI `--env-var name=value` to its injected value; that value is never persisted, but a
* deliberate same-named script write with a different value still reaches disk.
* `globalEnvFile.format` is yml-only because the CLI's `--global-env <name>` flag looks
* up `<workspace>/environments/<name>.yml` (no JSON/bru equivalent exists today).
* @returns {void}
*
* @example
* Script runs `bru.setEnvVar('token', 'abc')` and `bru.setCollectionVar('region', 'eu')`.
* const result = {
* envVariables: { token: 'abc' },
* collectionVariables: { region: 'eu' },
* runtimeVariables: null,
* globalEnvironmentVariables: null
* };
* persistVariableUpdates(result, { envFile, globalEnvFile, collection, collectionRootPath });
* -> writes `token: abc` into the active env file and `region: eu` into the collection root file.
*/
const persistVariableUpdates = (result, { envFile, globalEnvFile, collection, collectionRootPath, envVarOverrides }) => {
if (!result) return;
const envOpts = envVarOverrides ? { overrides: envVarOverrides } : undefined;
if (result.envVariables) persistEnvFile(envFile, result.envVariables, envOpts);
// Defense-in-depth: the bru runtime keeps envVariables and globalEnvironmentVariables as
// separate maps and never auto-syncs between them, so the override can't reach the global
// env map through normal flow. Still, pass the override filter through — if a user script
// ever copies via `bru.setGlobalEnvVar(k, bru.getVar(k))`, the override value won't land
// on disk in the global env file.
if (result.globalEnvironmentVariables) persistEnvFile(globalEnvFile, result.globalEnvironmentVariables, envOpts);
if (result.collectionVariables) persistCollectionVars(collection, result.collectionVariables, collectionRootPath);
};
module.exports = {
applyVariableUpdates,
persistVariableUpdates,
mergeScriptVarsIntoEnvList,
mergeScriptVarsIntoCollectionVarsList
};