diff --git a/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js
new file mode 100644
index 000000000..70e9dc26c
--- /dev/null
+++ b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js
@@ -0,0 +1,34 @@
+import { useState, useCallback } from 'react';
+
+const toPairs = (converted, imported) => {
+ const convertedList = Array.isArray(converted) ? converted : [converted];
+ const importedList = Array.isArray(imported) ? imported : [imported];
+ return convertedList
+ .map((c, i) => ({
+ report: c?.packageReport,
+ collectionPath: importedList[i]?.path
+ }))
+ .filter((entry) => entry.report?.hasAny && entry.collectionPath);
+};
+
+const usePostmanPackagePrompt = () => {
+ const [queue, setQueue] = useState([]);
+
+ const clearPostmanPackagePrompt = useCallback(() => {
+ setQueue((prev) => prev.slice(1));
+ }, []);
+
+ const handleImportResolved = useCallback((convertedCollection, importedItem) => {
+ const pairs = toPairs(convertedCollection, importedItem);
+ if (pairs.length === 0) return;
+ setQueue((prev) => [...prev, ...pairs]);
+ }, []);
+
+ return {
+ postmanPackagePrompt: queue[0] || null,
+ clearPostmanPackagePrompt,
+ handleImportResolved
+ };
+};
+
+export default usePostmanPackagePrompt;
diff --git a/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js
new file mode 100644
index 000000000..3fa58a15a
--- /dev/null
+++ b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js
@@ -0,0 +1,113 @@
+import { renderHook, act } from '@testing-library/react';
+import usePostmanPackagePrompt from './index';
+
+const reportWith = (needsInstall = ['dayjs'], hasAny = true) => ({
+ hasAny,
+ needsInstall,
+ unsupported: [],
+ safeMode: [],
+ devMode: []
+});
+
+describe('usePostmanPackagePrompt', () => {
+ it('starts with no prompt', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('opens the prompt when the report is actionable and a collection path exists', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ const report = reportWith(['dayjs', 'zod']);
+
+ act(() => {
+ result.current.handleImportResolved({ packageReport: report }, { path: '/collections/demo' });
+ });
+
+ expect(result.current.postmanPackagePrompt).toEqual({
+ report,
+ collectionPath: '/collections/demo'
+ });
+ });
+
+ it('does not open when the report has nothing actionable', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ act(() => {
+ result.current.handleImportResolved(
+ { packageReport: reportWith([], false) },
+ { path: '/collections/demo' }
+ );
+ });
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('does not open when there is no packageReport (non-Postman import)', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ act(() => {
+ result.current.handleImportResolved({}, { path: '/collections/demo' });
+ });
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('does not open when the imported item has no path', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ act(() => {
+ result.current.handleImportResolved({ packageReport: reportWith() }, undefined);
+ });
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('clears an open prompt', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ act(() => {
+ result.current.handleImportResolved({ packageReport: reportWith() }, { path: '/c' });
+ });
+ expect(result.current.postmanPackagePrompt).not.toBeNull();
+
+ act(() => {
+ result.current.clearPostmanPackagePrompt();
+ });
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('queues a prompt per collection on bulk import and steps through them', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ const reportA = reportWith(['ajv']);
+ const reportB = reportWith(['zod']);
+
+ act(() => {
+ result.current.handleImportResolved(
+ [{ packageReport: reportA }, { packageReport: reportB }],
+ [{ path: '/c/a' }, { path: '/c/b' }]
+ );
+ });
+
+ expect(result.current.postmanPackagePrompt).toEqual({ report: reportA, collectionPath: '/c/a' });
+
+ act(() => result.current.clearPostmanPackagePrompt());
+ expect(result.current.postmanPackagePrompt).toEqual({ report: reportB, collectionPath: '/c/b' });
+
+ act(() => result.current.clearPostmanPackagePrompt());
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+
+ it('skips collections in a bulk import that have nothing actionable', () => {
+ const { result } = renderHook(() => usePostmanPackagePrompt());
+ const empty = reportWith([], false);
+ const actionable = reportWith(['ajv']);
+
+ act(() => {
+ result.current.handleImportResolved(
+ [{ packageReport: empty }, { packageReport: actionable }, { packageReport: empty }],
+ [{ path: '/c/empty1' }, { path: '/c/real' }, { path: '/c/empty2' }]
+ );
+ });
+
+ expect(result.current.postmanPackagePrompt).toEqual({
+ report: actionable,
+ collectionPath: '/c/real'
+ });
+
+ act(() => result.current.clearPostmanPackagePrompt());
+ expect(result.current.postmanPackagePrompt).toBeNull();
+ });
+});
diff --git a/packages/bruno-converters/src/postman/postman-package-detector.js b/packages/bruno-converters/src/postman/postman-package-detector.js
new file mode 100644
index 000000000..f4a103be3
--- /dev/null
+++ b/packages/bruno-converters/src/postman/postman-package-detector.js
@@ -0,0 +1,178 @@
+/**
+ * Detection, translation and classification of `pm.require()` / `require()`
+ * calls inside Postman scripts being imported into Bruno.
+ */
+
+// String literals inside pm.require / require - single, double, or backtick
+// quoted. We deliberately keep this simple and do not attempt to handle
+// template strings with interpolation; those are not a Postman pattern.
+const PM_REQUIRE_REGEX = /pm\.require\s*\(\s*(['"`])([^'"`]+)\1\s*\)/g;
+const BARE_REQUIRE_REGEX = /(? "lodash"
+ * "npm:lodash" -> "lodash"
+ * "npm:lodash@4.17.21" -> "lodash"
+ * "lodash/get" -> "lodash"
+ * "node:crypto" -> "crypto"
+ * "@scope/pkg" -> "@scope/pkg"
+ * "@scope/pkg/sub" -> "@scope/pkg"
+ * "npm:@scope/pkg@1.2.3" -> "@scope/pkg"
+ * "./helpers" -> null (relative, not a package)
+ *
+ * Returns null when the input doesn't resolve to a recognizable package.
+ */
+const normalizePackageName = (raw) => {
+ if (typeof raw !== 'string') return null;
+ let name = raw.trim();
+ if (!name) return null;
+ if (name.startsWith('./') || name.startsWith('../') || name.startsWith('/')) {
+ return null;
+ }
+ if (name.startsWith('npm:')) name = name.slice(4);
+ if (name.startsWith('node:')) name = name.slice(5);
+ // Scoped packages keep the leading '@'; only strip a *second* '@' as a version separator.
+ const searchStart = name.startsWith('@') ? 1 : 0;
+ const atIndex = name.indexOf('@', searchStart);
+ if (atIndex !== -1) name = name.slice(0, atIndex);
+ // Strip subpath imports so `lodash/get` and `@scope/pkg/sub` resolve to their package roots.
+ if (name.startsWith('@')) {
+ name = name.split('/').slice(0, 2).join('/');
+ } else {
+ name = name.split('/')[0];
+ }
+ return name || null;
+};
+
+const extractPackagesFromScript = (scriptSource) => {
+ if (scriptSource == null) {
+ return { translatedSource: scriptSource, packages: [] };
+ }
+ const sourceText = Array.isArray(scriptSource) ? scriptSource.join('\n') : String(scriptSource);
+ const packages = new Set();
+
+ const translated = sourceText.replace(PM_REQUIRE_REGEX, (_match, quote, rawName) => {
+ const pkg = normalizePackageName(rawName);
+ if (!pkg) {
+ // Malformed/relative - drop the pm. prefix but leave the argument alone.
+ return `require(${quote}${rawName}${quote})`;
+ }
+ packages.add(pkg);
+ return `require(${quote}${pkg}${quote})`;
+ });
+
+ BARE_REQUIRE_REGEX.lastIndex = 0;
+ let match;
+ while ((match = BARE_REQUIRE_REGEX.exec(translated)) !== null) {
+ const pkg = normalizePackageName(match[2]);
+ if (pkg) packages.add(pkg);
+ }
+
+ return { translatedSource: translated, packages: Array.from(packages) };
+};
+
+// Packages exposed in Bruno's safe-mode (QuickJS) sandbox via shims.
+// Source of truth: packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js
+const SAFE_MODE_PACKAGES = new Set([
+ 'uuid',
+ 'axios',
+ 'jsonwebtoken',
+ 'path',
+ 'nanoid'
+]);
+
+// Node.js built-ins. Available in Developer Mode via Node's CJS loader.
+const NODE_BUILTINS = new Set([
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
+ 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
+ 'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
+ 'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring',
+ 'readline', 'repl', 'stream', 'string_decoder', 'sys', 'timers', 'tls',
+ 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads',
+ 'zlib'
+]);
+
+// Libraries reliably available in Developer Mode without an explicit install.
+const BUNDLED_LIBRARIES = new Set([
+ 'chai',
+ 'moment',
+ 'lodash',
+ 'crypto-js'
+]);
+
+// Postman sandbox globals the Bruno translator turns into `require()` calls
+// (see postman-to-bruno-translator.js :: POSTMAN_LIBRARY_GLOBALS). Scripts
+// that use these as bare globals (`cheerio.load(...)`, `_.map(...)`) won't
+// surface in the raw `pm.require`/`require` pre-scan, so we re-scan the
+// translated source for these specific names. Listed explicitly so the
+// post-scan can't pick up mangled artifacts of the translator's
+// `s/\bpostman\b/pm/g` pass (e.g. `pm-collection` from `postman-collection`).
+const TRANSLATOR_INJECTED_GLOBALS = new Set([
+ 'cheerio',
+ 'tv4',
+ 'crypto-js',
+ 'lodash',
+ 'moment'
+]);
+
+// Packages that don't have a meaningful equivalent in Bruno, these are
+// Postman-specific runtime bits that ship with their app.
+const UNSUPPORTED_EXACT = new Set([
+ 'postman-collection',
+ 'postman-runtime',
+ 'postman-request',
+ 'newman'
+]);
+const UNSUPPORTED_PREFIXES = ['@postman/', '@team/'];
+
+const isUnsupported = (name) => {
+ if (UNSUPPORTED_EXACT.has(name)) return true;
+ return UNSUPPORTED_PREFIXES.some((prefix) => name.startsWith(prefix));
+};
+
+const classifyPackages = (packages) => {
+ const unique = Array.from(new Set((packages || []).filter(Boolean))).sort();
+ const report = {
+ safeMode: [],
+ devMode: [],
+ needsInstall: [],
+ unsupported: []
+ };
+
+ for (const name of unique) {
+ if (isUnsupported(name)) {
+ report.unsupported.push(name);
+ } else if (SAFE_MODE_PACKAGES.has(name)) {
+ report.safeMode.push(name);
+ } else if (NODE_BUILTINS.has(name) || BUNDLED_LIBRARIES.has(name)) {
+ report.devMode.push(name);
+ } else {
+ report.needsInstall.push(name);
+ }
+ }
+
+ return report;
+};
+
+const buildPackageReport = (packages) => {
+ const classified = classifyPackages(packages);
+ const hasAny
+ = classified.needsInstall.length
+ + classified.unsupported.length
+ + classified.devMode.length
+ > 0;
+ return { ...classified, hasAny };
+};
+
+export {
+ normalizePackageName,
+ extractPackagesFromScript,
+ classifyPackages,
+ buildPackageReport,
+ SAFE_MODE_PACKAGES,
+ NODE_BUILTINS,
+ BUNDLED_LIBRARIES,
+ TRANSLATOR_INJECTED_GLOBALS
+};
diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js
index 5f27cb598..67cae27fc 100644
--- a/packages/bruno-converters/src/postman/postman-to-bruno.js
+++ b/packages/bruno-converters/src/postman/postman-to-bruno.js
@@ -3,6 +3,11 @@ import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uui
import { transformExampleStatusInCollection } from '@usebruno/common';
import each from 'lodash/each';
import postmanTranslation from './postman-translations';
+import {
+ extractPackagesFromScript,
+ buildPackageReport,
+ TRANSLATOR_INJECTED_GLOBALS
+} from './postman-package-detector';
import { invalidVariableCharacterRegex } from '../constants/index';
const AUTH_TYPES = Object.freeze({
@@ -853,6 +858,83 @@ const getBodyTypeFromContentTypeHeader = (headers) => {
return 'text';
};
+const collectPackagesFromPostmanCollection = (postmanCollection) => {
+ const allPackages = new Set();
+
+ const collectFromEvents = (events) => {
+ if (!Array.isArray(events)) return;
+ events.forEach((event) => {
+ const exec = event?.script?.exec;
+ if (!exec) return;
+ const { packages } = extractPackagesFromScript(exec);
+ packages.forEach((pkg) => allPackages.add(pkg));
+ });
+ };
+
+ const visitItems = (items) => {
+ if (!Array.isArray(items)) return;
+ items.forEach((item) => {
+ collectFromEvents(item?.event);
+ if (item.item && item.item.length) {
+ visitItems(item.item);
+ }
+ });
+ };
+
+ collectFromEvents(postmanCollection?.event);
+ visitItems(postmanCollection?.item);
+
+ return Array.from(allPackages);
+};
+
+const rewriteRequiresInBrunoCollection = (brunoCollection) => {
+ const injected = new Set();
+
+ const processScriptString = (source) => {
+ const { translatedSource, packages } = extractPackagesFromScript(source);
+ for (const pkg of packages) {
+ if (TRANSLATOR_INJECTED_GLOBALS.has(pkg)) injected.add(pkg);
+ }
+ return translatedSource;
+ };
+
+ const processScriptField = (scriptObj, key) => {
+ if (!scriptObj || typeof scriptObj[key] !== 'string' || !scriptObj[key]) return;
+ const next = processScriptString(scriptObj[key]);
+ if (next !== scriptObj[key]) scriptObj[key] = next;
+ };
+
+ const visitRequest = (request) => {
+ if (!request) return;
+ if (request.script) {
+ processScriptField(request.script, 'req');
+ processScriptField(request.script, 'res');
+ }
+ if (typeof request.tests === 'string' && request.tests) {
+ const next = processScriptString(request.tests);
+ if (next !== request.tests) request.tests = next;
+ }
+ };
+
+ visitRequest(brunoCollection?.root?.request);
+
+ const visitItems = (items) => {
+ if (!Array.isArray(items)) return;
+ items.forEach((item) => {
+ if (item.type === 'folder') {
+ visitRequest(item?.root?.request);
+ visitItems(item.items);
+ } else {
+ visitRequest(item.request);
+ }
+ });
+ };
+
+ visitItems(brunoCollection.items);
+
+ return Array.from(injected);
+};
+
const importPostmanV2Collection = async (collection, { useWorkers = false }) => {
const brunoCollection = {
name: collection.info.name || 'Untitled Collection',
@@ -997,12 +1079,29 @@ const parsePostmanCollection = async (collection, { useWorkers = false }) => {
const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => {
try {
+ // Resolve the actual collection envelope (Postman wraps newer exports
+ // in a `{ collection: {...} }` shell) so the raw scan sees real events.
+ const rawCollectionForScan = postmanCollection?.collection?.info
+ ? postmanCollection.collection
+ : postmanCollection;
+ const rawPackages = collectPackagesFromPostmanCollection(rawCollectionForScan);
+
const { collection: parsedCollection, issues } = await parsePostmanCollection(postmanCollection, { useWorkers });
const transformedCollection = transformItemsInCollection(parsedCollection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
// Apply backward compatibility transformation for string status to number
const statusTransformedCollection = transformExampleStatusInCollection(hydratedCollection);
const validatedCollection = validateSchema(statusTransformedCollection);
+
+ // Rewrite any pm.require() calls that survived the Bruno-side translator
+ // so the imported scripts use plain require(). The post-scan also picks
+ // up translator-injected globals (cheerio, tv4, ...) - packages Postman
+ // exposed as sandbox globals that the raw pre-scan can't see. The
+ // schema is strict + noUnknown so we attach the report by mutating
+ // the already-validated collection.
+ const injectedPackages = rewriteRequiresInBrunoCollection(validatedCollection);
+ validatedCollection.packageReport = buildPackageReport([...rawPackages, ...injectedPackages]);
+
return { collection: validatedCollection, issues };
} catch (err) {
console.log(err);
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js
new file mode 100644
index 000000000..2071aacce
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js
@@ -0,0 +1,108 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBruno from '../../../src/postman/postman-to-bruno';
+
+const buildCollection = ({ folderEvent, requestEvent, collectionEvent } = {}) => ({
+ info: {
+ name: 'Pkg Detection Test',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ ...(collectionEvent ? { event: [collectionEvent] } : {}),
+ item: [
+ {
+ name: 'Sample Folder',
+ ...(folderEvent ? { event: [folderEvent] } : {}),
+ item: [
+ {
+ name: 'Sample Request',
+ ...(requestEvent ? { event: [requestEvent] } : {}),
+ request: {
+ method: 'GET',
+ url: { raw: 'https://example.com/', protocol: 'https', host: ['example', 'com'], path: [''] },
+ header: []
+ }
+ }
+ ]
+ }
+ ]
+});
+
+const preRequestEvent = (lines) => ({
+ listen: 'prerequest',
+ script: { type: 'text/javascript', exec: lines }
+});
+
+const testEvent = (lines) => ({
+ listen: 'test',
+ script: { type: 'text/javascript', exec: lines }
+});
+
+describe('postman-to-bruno :: package detection integration', () => {
+ it('rewrites pm.require to require in the converted scripts', async () => {
+ const collection = buildCollection({
+ requestEvent: preRequestEvent([
+ `const _ = pm.require('npm:lodash@4.17.21');`,
+ `const ajv = pm.require('ajv');`
+ ])
+ });
+
+ const { collection: converted } = await postmanToBruno(collection);
+ const requestScript = converted.items[0].items[0].request.script.req;
+
+ expect(requestScript).toContain(`require('lodash')`);
+ expect(requestScript).toContain(`require('ajv')`);
+ expect(requestScript).not.toContain('pm.require');
+ });
+
+ it('aggregates packages across collection, folder, and request scripts', async () => {
+ const collection = buildCollection({
+ collectionEvent: preRequestEvent([`const path = require('path');`]),
+ folderEvent: testEvent([`const _ = pm.require('lodash');`]),
+ requestEvent: preRequestEvent([`const ajv = pm.require('npm:ajv@8');`])
+ });
+
+ const { collection: converted } = await postmanToBruno(collection);
+ const report = converted.packageReport;
+
+ expect(report.hasAny).toBe(true);
+ expect(report.safeMode).toEqual(['path']);
+ expect(report.devMode).toEqual(['lodash']);
+ expect(report.needsInstall).toEqual(['ajv']);
+ expect(report.unsupported).toEqual([]);
+ });
+
+ it('attaches an empty packageReport when no requires are present', async () => {
+ const collection = buildCollection({
+ requestEvent: preRequestEvent([`console.log('no requires here');`])
+ });
+
+ const { collection: converted } = await postmanToBruno(collection);
+ expect(converted.packageReport).toBeDefined();
+ expect(converted.packageReport.hasAny).toBe(false);
+ });
+
+ it('flags Postman-specific packages as unsupported', async () => {
+ const collection = buildCollection({
+ requestEvent: testEvent([`const pc = pm.require('postman-collection');`])
+ });
+
+ const { collection: converted } = await postmanToBruno(collection);
+ expect(converted.packageReport.unsupported).toEqual(['postman-collection']);
+ expect(converted.packageReport.needsInstall).toEqual([]);
+ });
+
+ it('detects translator-injected sandbox globals (cheerio used as a bare identifier)', async () => {
+ // No explicit require - Postman exposes `cheerio` as a sandbox global.
+ // The Bruno translator injects `const cheerio = require('cheerio')`,
+ // which the post-translation scan should surface as needsInstall.
+ const collection = buildCollection({
+ requestEvent: testEvent([
+ `const $ = cheerio.load('
hi
');`,
+ `console.log($('div').text());`
+ ])
+ });
+
+ const { collection: converted } = await postmanToBruno(collection);
+ expect(converted.packageReport.needsInstall).toContain('cheerio');
+ expect(converted.packageReport.hasAny).toBe(true);
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js
new file mode 100644
index 000000000..756d375d9
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js
@@ -0,0 +1,235 @@
+import {
+ normalizePackageName,
+ extractPackagesFromScript,
+ classifyPackages,
+ buildPackageReport
+} from '../../../src/postman/postman-package-detector';
+
+describe('postman-package-detector :: normalizePackageName', () => {
+ test('returns plain package names unchanged', () => {
+ expect(normalizePackageName('lodash')).toBe('lodash');
+ });
+
+ test('strips npm: prefix', () => {
+ expect(normalizePackageName('npm:lodash')).toBe('lodash');
+ });
+
+ test('strips @version suffix', () => {
+ expect(normalizePackageName('lodash@4.17.21')).toBe('lodash');
+ });
+
+ test('strips both npm: prefix and @version suffix', () => {
+ expect(normalizePackageName('npm:lodash@4.17.21')).toBe('lodash');
+ });
+
+ test('preserves the leading @ of scoped packages', () => {
+ expect(normalizePackageName('@scope/pkg')).toBe('@scope/pkg');
+ });
+
+ test('strips @version from scoped packages without touching the scope', () => {
+ expect(normalizePackageName('npm:@scope/pkg@1.2.3')).toBe('@scope/pkg');
+ });
+
+ test('returns null for relative imports', () => {
+ expect(normalizePackageName('./helpers')).toBeNull();
+ expect(normalizePackageName('../shared/util')).toBeNull();
+ expect(normalizePackageName('/abs/path')).toBeNull();
+ });
+
+ test('strips node: prefix from Node builtin specifiers', () => {
+ expect(normalizePackageName('node:crypto')).toBe('crypto');
+ expect(normalizePackageName('node:fs/promises')).toBe('fs');
+ });
+
+ test('drops subpath imports to the package root', () => {
+ expect(normalizePackageName('lodash/get')).toBe('lodash');
+ expect(normalizePackageName('lodash/fp/map')).toBe('lodash');
+ });
+
+ test('drops subpath imports on scoped packages but keeps the scope', () => {
+ expect(normalizePackageName('@scope/pkg/sub')).toBe('@scope/pkg');
+ expect(normalizePackageName('npm:@scope/pkg/sub')).toBe('@scope/pkg');
+ });
+
+ test('returns null for non-string or empty inputs', () => {
+ expect(normalizePackageName(null)).toBeNull();
+ expect(normalizePackageName(undefined)).toBeNull();
+ expect(normalizePackageName(123)).toBeNull();
+ expect(normalizePackageName('')).toBeNull();
+ expect(normalizePackageName(' ')).toBeNull();
+ });
+});
+
+describe('postman-package-detector :: extractPackagesFromScript', () => {
+ test('rewrites pm.require to require and reports the package', () => {
+ const { translatedSource, packages } = extractPackagesFromScript(
+ `const _ = pm.require('lodash');`
+ );
+ expect(translatedSource).toBe(`const _ = require('lodash');`);
+ expect(packages).toEqual(['lodash']);
+ });
+
+ test('strips the npm: prefix during rewrite', () => {
+ const { translatedSource, packages } = extractPackagesFromScript(
+ `const _ = pm.require('npm:lodash');`
+ );
+ expect(translatedSource).toBe(`const _ = require('lodash');`);
+ expect(packages).toEqual(['lodash']);
+ });
+
+ test('strips the @version suffix during rewrite', () => {
+ const { translatedSource, packages } = extractPackagesFromScript(
+ `const _ = pm.require("npm:lodash@4.17.21");`
+ );
+ expect(translatedSource).toBe(`const _ = require("lodash");`);
+ expect(packages).toEqual(['lodash']);
+ });
+
+ test('preserves scoped packages and strips their version', () => {
+ const { translatedSource, packages } = extractPackagesFromScript(
+ `const x = pm.require('npm:@scope/pkg@1.2.3');`
+ );
+ expect(translatedSource).toBe(`const x = require('@scope/pkg');`);
+ expect(packages).toEqual(['@scope/pkg']);
+ });
+
+ test('detects plain require() calls without rewriting them', () => {
+ const { translatedSource, packages } = extractPackagesFromScript(
+ `const ajv = require('ajv');`
+ );
+ expect(translatedSource).toBe(`const ajv = require('ajv');`);
+ expect(packages).toEqual(['ajv']);
+ });
+
+ test('detects multiple packages across pm.require and require', () => {
+ const script = `
+ const _ = pm.require('lodash');
+ const cheerio = pm.require('npm:cheerio');
+ const xml2js = require('xml2js');
+ `;
+ const { translatedSource, packages } = extractPackagesFromScript(script);
+ expect(translatedSource).toContain(`require('lodash')`);
+ expect(translatedSource).toContain(`require('cheerio')`);
+ expect(translatedSource).toContain(`require('xml2js')`);
+ expect(translatedSource).not.toContain('pm.require');
+ expect(new Set(packages)).toEqual(new Set(['lodash', 'cheerio', 'xml2js']));
+ });
+
+ test('does not report relative requires as packages', () => {
+ const script = `
+ const helper = require('./helpers');
+ const shared = require('../shared');
+ const ajv = require('ajv');
+ `;
+ const { packages } = extractPackagesFromScript(script);
+ expect(packages).toEqual(['ajv']);
+ });
+
+ test('accepts the Postman script.exec array form', () => {
+ const { translatedSource, packages } = extractPackagesFromScript([
+ `const _ = pm.require('lodash');`,
+ `const x = require('xml2js');`
+ ]);
+ expect(translatedSource.split('\n')).toEqual([
+ `const _ = require('lodash');`,
+ `const x = require('xml2js');`
+ ]);
+ expect(new Set(packages)).toEqual(new Set(['lodash', 'xml2js']));
+ });
+
+ test('returns input unchanged for null / undefined script', () => {
+ expect(extractPackagesFromScript(null)).toEqual({
+ translatedSource: null,
+ packages: []
+ });
+ expect(extractPackagesFromScript(undefined)).toEqual({
+ translatedSource: undefined,
+ packages: []
+ });
+ });
+
+ test('does not falsely match identifiers ending in "require"', () => {
+ // e.g. `myrequire('foo')` or `obj.require('foo')` should not be picked up.
+ const script = `obj.require('foo'); myrequire('bar');`;
+ const { packages } = extractPackagesFromScript(script);
+ expect(packages).toEqual([]);
+ });
+});
+
+describe('postman-package-detector :: classifyPackages', () => {
+ test('routes safe-mode packages into safeMode bucket', () => {
+ const report = classifyPackages(['uuid', 'axios', 'jsonwebtoken', 'nanoid']);
+ expect(report.safeMode).toEqual(['axios', 'jsonwebtoken', 'nanoid', 'uuid']);
+ expect(report.needsInstall).toEqual([]);
+ });
+
+ test('routes Node builtins and bundled libs into devMode bucket', () => {
+ const report = classifyPackages(['fs', 'crypto', 'chai', 'moment', 'lodash']);
+ expect(report.devMode).toEqual(expect.arrayContaining(['chai', 'crypto', 'fs', 'lodash', 'moment']));
+ expect(report.needsInstall).toEqual([]);
+ });
+
+ test('routes unknown external packages into needsInstall bucket', () => {
+ const report = classifyPackages(['ajv', 'cheerio', 'xml2js', 'csv-parse']);
+ expect(report.needsInstall).toEqual(['ajv', 'cheerio', 'csv-parse', 'xml2js']);
+ });
+
+ test('flags Postman-specific packages as unsupported', () => {
+ const report = classifyPackages([
+ 'postman-collection',
+ '@postman/foo',
+ '@team/secret'
+ ]);
+ expect(report.unsupported).toEqual(expect.arrayContaining([
+ 'postman-collection',
+ '@postman/foo',
+ '@team/secret'
+ ]));
+ expect(report.needsInstall).toEqual([]);
+ });
+
+ test('dedupes inputs across all buckets', () => {
+ const report = classifyPackages(['ajv', 'ajv', 'lodash', 'lodash', 'uuid']);
+ expect(report.needsInstall).toEqual(['ajv']);
+ expect(report.devMode).toEqual(['lodash']);
+ expect(report.safeMode).toEqual(['uuid']);
+ });
+});
+
+describe('postman-package-detector :: buildPackageReport', () => {
+ test('sets hasAny=false when no packages are referenced', () => {
+ const report = buildPackageReport([]);
+ expect(report.hasAny).toBe(false);
+ });
+
+ test('sets hasAny=true when there is something to install', () => {
+ const report = buildPackageReport(['ajv']);
+ expect(report.hasAny).toBe(true);
+ expect(report.needsInstall).toEqual(['ajv']);
+ });
+
+ test('sets hasAny=true when there are unsupported packages to flag', () => {
+ const report = buildPackageReport(['postman-collection']);
+ expect(report.hasAny).toBe(true);
+ expect(report.unsupported).toEqual(['postman-collection']);
+ });
+
+ test('sets hasAny=true when only dev-mode libs are referenced', () => {
+ // Libraries like lodash work only in Developer Mode, so a Safe-Mode
+ // collection still needs a prompt — the modal decides whether to show a
+ // switch CTA based on the collection's current sandbox mode.
+ const report = buildPackageReport(['lodash']);
+ expect(report.hasAny).toBe(true);
+ expect(report.devMode).toEqual(['lodash']);
+ expect(report.needsInstall).toEqual([]);
+ });
+
+ test('sets hasAny=false when only safe-mode packages are referenced', () => {
+ // Safe-mode shims (uuid, axios, etc.) work out of the box regardless of
+ // sandbox mode, so surfacing a prompt would be noise.
+ const report = buildPackageReport(['uuid', 'path']);
+ expect(report.hasAny).toBe(false);
+ expect(report.needsInstall).toEqual([]);
+ expect(report.unsupported).toEqual([]);
+ });
+});
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 15006e6d5..9cade6668 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -59,6 +59,7 @@ const {
} = require('../utils/filesystem');
const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
+const { isValidNpmPackageName, runNpmInstall } = require('../utils/install-packages');
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
@@ -2137,6 +2138,25 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
+ ipcMain.handle('renderer:install-postman-packages', async (_event, collectionPathname, packages) => {
+ if (typeof collectionPathname !== 'string' || !collectionPathname) {
+ throw new Error('collectionPathname is required');
+ }
+ if (!Array.isArray(packages) || packages.length === 0) {
+ throw new Error('packages must be a non-empty array');
+ }
+ if (!fs.existsSync(collectionPathname) || !fs.statSync(collectionPathname).isDirectory()) {
+ throw new Error(`Collection path does not exist: ${collectionPathname}`);
+ }
+
+ const invalid = packages.filter((p) => !isValidNpmPackageName(p));
+ if (invalid.length > 0) {
+ throw new Error(`Invalid package name(s): ${invalid.join(', ')}`);
+ }
+
+ return runNpmInstall({ collectionPath: collectionPathname, packages });
+ });
+
ipcMain.handle('renderer:get-collection-json', async (event, collectionPath) => {
let variables = {};
let name = '';
diff --git a/packages/bruno-electron/src/utils/install-packages.js b/packages/bruno-electron/src/utils/install-packages.js
new file mode 100644
index 000000000..5f81d483f
--- /dev/null
+++ b/packages/bruno-electron/src/utils/install-packages.js
@@ -0,0 +1,107 @@
+const { spawn } = require('child_process');
+
+// npm package name grammar (scoped + unscoped). Conservative enough to prevent
+// shell-metachar smuggling even though spawn() runs without a shell.
+const NPM_NAME_REGEX = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
+
+const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // npm installs can legitimately take minutes
+const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; // bound captured stdout/stderr
+
+const isValidNpmPackageName = (name) => typeof name === 'string' && NPM_NAME_REGEX.test(name);
+
+// Keep only the trailing `cap` bytes - npm surfaces the actionable error at the
+// end of its output, so the tail is what we want to show the user.
+const appendCapped = (buffer, chunk, cap) => {
+ const next = buffer + chunk;
+ return next.length > cap ? next.slice(next.length - cap) : next;
+};
+
+/**
+ * Runs `npm install --save
` in a collection directory and resolves
+ * with a structured result. Never rejects - runtime failures (non-zero exit,
+ * npm-not-found, timeout) come back as `{ success: false, ... }` so callers
+ * can surface a useful message.
+ *
+ * `spawnFn` and `timeoutMs` are injectable for testing.
+ *
+ * @returns {Promise<{ success: boolean, exitCode: number, stdout: string,
+ * stderr: string, installed: string[], errorCode?: string }>}
+ */
+const runNpmInstall = ({
+ collectionPath,
+ packages,
+ spawnFn = spawn,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
+ npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
+}) => {
+ const installed = Array.from(new Set(packages));
+ const args = ['install', '--save', ...installed];
+
+ return new Promise((resolve) => {
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ let timer = null;
+
+ const finish = (result) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ resolve({ stdout, stderr, installed, ...result });
+ };
+
+ let child;
+ try {
+ child = spawnFn(npmCommand, args, { cwd: collectionPath, env: process.env, shell: false });
+ } catch (err) {
+ finish({ success: false, exitCode: -1, stderr: err.message, errorCode: 'SPAWN_FAILED' });
+ return;
+ }
+
+ timer = setTimeout(() => {
+ try {
+ child.kill();
+ } catch {
+ // ignore - process may have already exited
+ }
+ finish({
+ success: false,
+ exitCode: -1,
+ errorCode: 'TIMEOUT',
+ stderr: `${stderr}\nnpm install timed out after ${Math.round(timeoutMs / 1000)}s.`
+ });
+ }, timeoutMs);
+
+ child.stdout?.on('data', (chunk) => {
+ stdout = appendCapped(stdout, chunk.toString(), maxOutputBytes);
+ });
+ child.stderr?.on('data', (chunk) => {
+ stderr = appendCapped(stderr, chunk.toString(), maxOutputBytes);
+ });
+
+ child.on('error', (err) => {
+ const isMissingNpm = err.code === 'ENOENT';
+ finish({
+ success: false,
+ exitCode: -1,
+ errorCode: isMissingNpm ? 'NPM_NOT_FOUND' : 'SPAWN_ERROR',
+ stderr: isMissingNpm
+ ? 'npm was not found on your PATH. Install Node.js/npm, then try again or run the command manually.'
+ : `${stderr}\n${err.message}`
+ });
+ });
+
+ child.on('close', (code) => {
+ finish({ success: code === 0, exitCode: code });
+ });
+ });
+};
+
+module.exports = {
+ isValidNpmPackageName,
+ runNpmInstall,
+ NPM_NAME_REGEX,
+ DEFAULT_TIMEOUT_MS,
+ DEFAULT_MAX_OUTPUT_BYTES
+};
diff --git a/packages/bruno-electron/tests/utils/install-packages.spec.js b/packages/bruno-electron/tests/utils/install-packages.spec.js
new file mode 100644
index 000000000..e0d369211
--- /dev/null
+++ b/packages/bruno-electron/tests/utils/install-packages.spec.js
@@ -0,0 +1,179 @@
+const { EventEmitter } = require('events');
+const { isValidNpmPackageName, runNpmInstall } = require('../../src/utils/install-packages');
+
+// Minimal stand-in for a child_process handle: stdout/stderr are emitters and
+// the child itself emits 'close' / 'error'. Lets us drive npm outcomes
+// deterministically without spawning a real process.
+const makeFakeChild = () => {
+ const child = new EventEmitter();
+ child.stdout = new EventEmitter();
+ child.stderr = new EventEmitter();
+ child.kill = jest.fn();
+ return child;
+};
+
+describe('isValidNpmPackageName', () => {
+ test.each([
+ 'lodash',
+ 'dayjs',
+ 'uuid',
+ '@scope/pkg',
+ 'csv-parse',
+ 'package.name',
+ '@team/secret-sauce'
+ ])('accepts valid package name: %s', (name) => {
+ expect(isValidNpmPackageName(name)).toBe(true);
+ });
+
+ test.each([
+ ['empty string', ''],
+ ['whitespace', 'foo bar'],
+ ['shell injection', 'foo; rm -rf /'],
+ ['command substitution', '$(whoami)'],
+ ['leading dot', '.hidden'],
+ ['non-string', 123],
+ ['null', null],
+ ['undefined', undefined]
+ ])('rejects %s', (_label, name) => {
+ expect(isValidNpmPackageName(name)).toBe(false);
+ });
+});
+
+describe('runNpmInstall', () => {
+ test('resolves success on exit code 0 and captures stdout', async () => {
+ const child = makeFakeChild();
+ const spawnFn = jest.fn(() => child);
+
+ const promise = runNpmInstall({ collectionPath: '/coll', packages: ['dayjs'], spawnFn });
+ child.stdout.emit('data', Buffer.from('added 1 package'));
+ child.emit('close', 0);
+
+ const result = await promise;
+ expect(result.success).toBe(true);
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('added 1 package');
+ expect(result.installed).toEqual(['dayjs']);
+ });
+
+ test('passes the correct npm args, cwd, and runs without a shell', async () => {
+ const child = makeFakeChild();
+ const spawnFn = jest.fn(() => child);
+
+ const promise = runNpmInstall({
+ collectionPath: '/my/coll',
+ packages: ['dayjs', 'dayjs', 'zod'],
+ spawnFn,
+ npmCommand: 'npm'
+ });
+ child.emit('close', 0);
+ await promise;
+
+ expect(spawnFn).toHaveBeenCalledWith(
+ 'npm',
+ ['install', '--save', 'dayjs', 'zod'],
+ expect.objectContaining({ cwd: '/my/coll', shell: false })
+ );
+ });
+
+ test('dedupes packages in the result', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({ collectionPath: '/c', packages: ['a', 'a', 'b'], spawnFn: () => child });
+ child.emit('close', 0);
+ const result = await promise;
+ expect(result.installed).toEqual(['a', 'b']);
+ });
+
+ test('resolves failure on a non-zero exit and surfaces stderr', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({ collectionPath: '/c', packages: ['bad-pkg'], spawnFn: () => child });
+ child.stderr.emit('data', Buffer.from('npm ERR! 404 Not Found'));
+ child.emit('close', 1);
+
+ const result = await promise;
+ expect(result.success).toBe(false);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('404 Not Found');
+ });
+
+ test('reports NPM_NOT_FOUND when npm is missing from PATH (ENOENT)', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
+ const err = new Error('spawn npm ENOENT');
+ err.code = 'ENOENT';
+ child.emit('error', err);
+
+ const result = await promise;
+ expect(result.success).toBe(false);
+ expect(result.errorCode).toBe('NPM_NOT_FOUND');
+ expect(result.stderr).toMatch(/not found on your PATH/i);
+ });
+
+ test('reports SPAWN_ERROR for non-ENOENT spawn errors', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
+ const err = new Error('EACCES permission denied');
+ err.code = 'EACCES';
+ child.emit('error', err);
+
+ const result = await promise;
+ expect(result.success).toBe(false);
+ expect(result.errorCode).toBe('SPAWN_ERROR');
+ });
+
+ test('reports SPAWN_FAILED when spawn throws synchronously', async () => {
+ const spawnFn = jest.fn(() => {
+ throw new Error('boom');
+ });
+ const result = await runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn });
+ expect(result.success).toBe(false);
+ expect(result.errorCode).toBe('SPAWN_FAILED');
+ expect(result.stderr).toContain('boom');
+ });
+
+ test('times out and kills the process if npm never exits', async () => {
+ jest.useFakeTimers();
+ const child = makeFakeChild();
+ const promise = runNpmInstall({
+ collectionPath: '/c',
+ packages: ['a'],
+ spawnFn: () => child,
+ timeoutMs: 1000
+ });
+
+ jest.advanceTimersByTime(1000);
+ const result = await promise;
+
+ expect(result.success).toBe(false);
+ expect(result.errorCode).toBe('TIMEOUT');
+ expect(child.kill).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test('caps captured output to the trailing maxOutputBytes', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({
+ collectionPath: '/c',
+ packages: ['a'],
+ spawnFn: () => child,
+ maxOutputBytes: 10
+ });
+ child.stdout.emit('data', 'abcdefghijklmnop'); // 16 chars
+ child.emit('close', 0);
+
+ const result = await promise;
+ expect(result.stdout.length).toBeLessThanOrEqual(10);
+ expect(result.stdout).toBe('ghijklmnop'); // keeps the tail
+ });
+
+ test('only settles once even if close fires after error', async () => {
+ const child = makeFakeChild();
+ const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
+ const err = new Error('spawn npm ENOENT');
+ err.code = 'ENOENT';
+ child.emit('error', err);
+ child.emit('close', 1); // should be ignored
+
+ const result = await promise;
+ expect(result.errorCode).toBe('NPM_NOT_FOUND');
+ });
+});