Skip to content

Instantly share code, notes, and snippets.

@tdchien
Created April 2, 2026 10:54
Show Gist options
  • Select an option

  • Save tdchien/1770ff468279ce13db5f2a26fab3bba9 to your computer and use it in GitHub Desktop.

Select an option

Save tdchien/1770ff468279ce13db5f2a26fab3bba9 to your computer and use it in GitHub Desktop.
Script to convert export from Hoppscoth to Bruno
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function safeName(name = "untitled") {
return String(name)
.trim()
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "untitled";
}
function escapeBruString(value = "") {
return String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function parseUrlParts(rawUrl = "") {
try {
const u = new URL(rawUrl);
return {
protocol: u.protocol.replace(":", ""),
host: u.host,
pathname: u.pathname || "/",
search: u.search || "",
};
} catch {
return {
protocol: "",
host: "",
pathname: rawUrl || "/",
search: "",
};
}
}
function buildQueryFromUrl(rawUrl = "") {
try {
const u = new URL(rawUrl);
const items = [];
for (const [key, value] of u.searchParams.entries()) {
items.push({ key, value, enabled: true });
}
return items;
} catch {
return [];
}
}
function normalizeHeaders(headers) {
if (!Array.isArray(headers)) return [];
return headers
.filter(h => h && (h.key || h.name))
.map(h => ({
key: h.key || h.name || "",
value: h.value ?? "",
enabled: h.active !== false && h.enabled !== false
}));
}
function normalizeParams(params) {
if (!Array.isArray(params)) return [];
return params
.filter(p => p && p.key)
.map(p => ({
key: p.key,
value: p.value ?? "",
enabled: p.active !== false && p.enabled !== false
}));
}
function detectBody(req) {
const body = req.body || {};
const contentTypeHeader = (req.headers || []).find(
h => (h.key || "").toLowerCase() === "content-type"
);
const contentType = contentTypeHeader?.value?.toLowerCase() || "";
if (typeof body === "string") {
return { type: "text", content: body };
}
if (body?.body != null && body?.contentType === "application/json") {
return { type: "json", content: typeof body.body === "string" ? body.body : JSON.stringify(body.body, null, 2) };
}
if (body?.body != null && contentType.includes("application/json")) {
return { type: "json", content: typeof body.body === "string" ? body.body : JSON.stringify(body.body, null, 2) };
}
if (body?.body != null) {
return { type: "text", content: typeof body.body === "string" ? body.body : JSON.stringify(body.body, null, 2) };
}
return null;
}
function requestToBru(req) {
const name = req.name || "Untitled Request";
const method = (req.method || "GET").toUpperCase();
const url = req.endpoint || req.url || "";
const headers = normalizeHeaders(req.headers);
const params = normalizeParams(req.params);
const urlQuery = buildQueryFromUrl(url);
const mergedParams = [...urlQuery];
for (const p of params) {
if (!mergedParams.some(x => x.key === p.key && x.value === p.value)) {
mergedParams.push(p);
}
}
const body = detectBody(req);
let out = "";
out += `meta {\n`;
out += ` name: ${name}\n`;
out += ` type: http\n`;
out += ` seq: 1\n`;
out += `}\n\n`;
out += `http {\n`;
out += ` method: ${method}\n`;
out += ` url: ${url}\n`;
out += `}\n\n`;
if (mergedParams.length) {
out += `params:query {\n`;
for (const p of mergedParams) {
if (!p.enabled) continue;
out += ` ${p.key}: ${p.value}\n`;
}
out += `}\n\n`;
}
if (headers.length) {
out += `headers {\n`;
for (const h of headers) {
if (!h.enabled) continue;
out += ` ${h.key}: ${h.value}\n`;
}
out += `}\n\n`;
}
if (body && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
out += `body:${body.type} {\n`;
out += ` ${body.type === "json" ? "raw" : "raw"}: '''\n${body.content}\n'''\n`;
out += `}\n\n`;
}
return out.trim() + "\n";
}
function writeRequest(req, dir, seqRef) {
const filename = `${safeName(req.name || `request-${seqRef.value}`)}.bru`;
const filePath = path.join(dir, filename);
let content = requestToBru(req);
content = content.replace("seq: 1", `seq: ${seqRef.value++}`);
fs.writeFileSync(filePath, content, "utf8");
}
function processFolder(folder, parentDir, seqRef) {
const folderDir = path.join(parentDir, safeName(folder.name || "folder"));
ensureDir(folderDir);
if (Array.isArray(folder.requests)) {
for (const req of folder.requests) {
writeRequest(req, folderDir, seqRef);
}
}
if (Array.isArray(folder.folders)) {
for (const child of folder.folders) {
processFolder(child, folderDir, seqRef);
}
}
}
function convertCollection(collection, outputBaseDir) {
const collectionName = collection.name || "hoppscotch-collection";
const rootDir = path.join(outputBaseDir, safeName(collectionName));
ensureDir(rootDir);
const brunoJson = {
version: "1",
name: collectionName,
type: "collection"
};
fs.writeFileSync(
path.join(rootDir, "bruno.json"),
JSON.stringify(brunoJson, null, 2),
"utf8"
);
const seqRef = { value: 1 };
if (Array.isArray(collection.requests)) {
for (const req of collection.requests) {
writeRequest(req, rootDir, seqRef);
}
}
if (Array.isArray(collection.folders)) {
for (const folder of collection.folders) {
processFolder(folder, rootDir, seqRef);
}
}
return rootDir;
}
function main() {
const [, , inputFile, outputDir = "./bruno-output"] = process.argv;
if (!inputFile) {
console.error("Usage: node hoppscotch-to-bruno.js <hoppscotch.json> [output-dir]");
process.exit(1);
}
const data = readJson(inputFile);
const collections = Array.isArray(data) ? data : [data];
ensureDir(outputDir);
for (const collection of collections) {
const out = convertCollection(collection, outputDir);
console.log(`Converted: ${collection.name || "unnamed"} -> ${out}`);
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment