Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@useswarm/cli

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@useswarm/cli - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+50
-6
dist/commands/test.js

@@ -66,3 +66,4 @@ import { Command } from "commander";

.option("--audience <desc>", "Audience description for persona generation")
.option("--no-tunnel", "Skip ngrok tunnel (URL must be publicly accessible)")
.option("--no-tunnel", "Skip tunnel (URL must be publicly accessible)")
.option("--tunnel-provider <provider>", "Tunnel provider: cloudflare, ngrok, or auto (default: auto)")
.option("--backend <url>", "Local backend URL to proxy (e.g. localhost:8080)")

@@ -237,3 +238,4 @@ .option("--backend-paths <paths>", "Additional path prefixes for backend routing (comma-separated)")

if (isLocal && !skipTunnel) {
console.log(` Tunnel: ${chalk.green("enabled")} ${chalk.dim("(ngrok → localhost)")}`);
const tp = opts.tunnelProvider ?? "auto";
console.log(` Tunnel: ${chalk.green("enabled")} ${chalk.dim(`(${tp} → localhost)`)}`);
if (backendUrl) {

@@ -279,2 +281,11 @@ console.log(` Backend: ${chalk.cyan(backendUrl)} ${chalk.dim("→ /api/*, /auth/*, /graphql, /trpc/*")}`);

};
// Synchronous best-effort kill for hard exits where async cleanup can't run
const cleanupSync = () => {
if (tunnel) {
tunnel.close().catch(() => { });
}
if (proxy) {
proxy.close().catch(() => { });
}
};
process.on("SIGINT", async () => {

@@ -289,2 +300,17 @@ console.log(chalk.dim("\nShutting down..."));

});
process.on("SIGHUP", async () => {
await cleanup();
process.exit(0);
});
process.on("uncaughtException", async (err) => {
console.error(chalk.red(`\nFatal error: ${err.message}`));
await cleanup();
process.exit(1);
});
process.on("unhandledRejection", async (reason) => {
console.error(chalk.red(`\nUnhandled rejection: ${reason}`));
await cleanup();
process.exit(1);
});
process.on("exit", cleanupSync);
if (isLocal && !skipTunnel) {

@@ -343,6 +369,21 @@ const frontendPort = parsedUrl?.port ? parseInt(parsedUrl.port, 10) : 3000;

}
const tunnelSpinner = ora(`Opening tunnel to localhost:${tunnelPort}...`).start();
const tunnelProvider = opts.tunnelProvider ?? "auto";
const providerLabel = tunnelProvider === "auto" ? "" : ` (${tunnelProvider})`;
const tunnelSpinner = ora(`Opening tunnel${providerLabel} to localhost:${tunnelPort}...`).start();
try {
const tokenRes = await client.getTunnelToken();
tunnel = await openTunnel(tunnelPort, tokenRes.ngrokAuthToken);
// Only fetch ngrok token if provider is ngrok or auto (as fallback)
let ngrokAuthToken;
if (tunnelProvider !== "cloudflare") {
try {
const tokenRes = await client.getTunnelToken();
ngrokAuthToken = tokenRes.ngrokAuthToken;
}
catch {
// If we can't get the ngrok token and provider is explicitly ngrok, fail
if (tunnelProvider === "ngrok")
throw new Error("Failed to get ngrok auth token from server");
// Otherwise (auto mode), we'll try cloudflare without a fallback token
}
}
tunnel = await openTunnel(tunnelPort, { provider: tunnelProvider, ngrokAuthToken });
const originalPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : "";

@@ -353,3 +394,4 @@ targetUrl = `${tunnel.url}${originalPath === "/" ? "" : originalPath}`;

}
tunnelSpinner.succeed(`Tunnel open: ${chalk.underline(tunnel.url)}`);
const providerTag = tunnel.provider === "cloudflare" ? chalk.dim(" (cloudflare)") : chalk.dim(" (ngrok)");
tunnelSpinner.succeed(`Tunnel open${providerTag}: ${chalk.underline(tunnel.url)}`);
}

@@ -423,2 +465,4 @@ catch (err) {

console.log();
console.log(` ${chalk.bold("Live dashboard:")} ${chalk.underline.cyan(createRes.dashboardUrl)}`);
console.log();
// ── 11. Stream results ───────────────────────────────────────────

@@ -425,0 +469,0 @@ try {

+1
-1

@@ -13,3 +13,3 @@ #!/usr/bin/env node

.description("Swarm CLI — run AI-powered UX simulations from the terminal")
.version("0.1.2");
.version("0.3.0");
program.addCommand(loginCommand);

@@ -16,0 +16,0 @@ program.addCommand(logoutCommand);

@@ -5,2 +5,27 @@ import http from "node:http";

const DEFAULT_BACKEND_PATH_PREFIXES = ["/api", "/auth", "/graphql", "/trpc"];
// ─── HMR / dev-only path prefixes ────────────────────────────────────────────
// These are WebSocket upgrade targets and polling endpoints used exclusively
// by the browser's hot-reload client. The testing agents never need them, and
// they generate a constant stream of traffic across the tunnel.
const HMR_PATH_PREFIXES = [
"/_next/webpack-hmr", // Next.js HMR WebSocket
"/__webpack_hmr", // CRA / webpack-dev-server
"/@vite/client", // Vite HMR WebSocket
"/@hmr", // generic
"/__vite_ping", // Vite ping
];
// Path prefixes whose responses are safe to cache in-process across agents.
// These are immutable-content assets: Next.js/_next/static, Vite /_assets,
// and any route that carries Cache-Control: public, max-age=… from upstream.
// The proxy treats them as fingerprint-stable if the path includes a content
// hash (detected via a hex/base64 segment ≥ 8 chars, which is the minimum for
// Next.js build IDs, Vite chunk hashes, and most bundler fingerprints).
const CACHEABLE_PATH_PREFIXES = [
"/_next/static/",
"/__nextjs_",
"/static/",
"/assets/",
"/_vite/",
"/build/",
];
const TEXT_CONTENT_TYPES = [

@@ -19,3 +44,59 @@ "text/html",

];
// Maximum body size buffered for rewriting. Responses larger than this are
// passed through as-is (headers still rewritten). 10 MB is generous for any
// individual HTML document or JSON payload; JS bundles in dev mode can exceed
// this, but they rarely contain localhost URLs that need rewriting.
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
// Gzip compression level used when re-compressing rewritten bodies. Level 4
// is the sweet spot: roughly 85-90 % of level 9's ratio in ~15 % of the CPU
// time. Level 6 (zlib default) is overkill for transient proxy traffic.
const RECOMPRESS_LEVEL = 4;
// Maximum number of entries and total bytes held in the static cache.
const CACHE_MAX_ENTRIES = 200;
const CACHE_MAX_BYTES = 200 * 1024 * 1024; // 200 MB
class AssetCache {
entries = new Map();
totalBytes = 0;
get(key) {
return this.entries.get(key);
}
set(key, entry) {
const existing = this.entries.get(key);
if (existing) {
this.totalBytes -= existing.body.length;
}
// Evict oldest entries if over budget
while (this.entries.size >= CACHE_MAX_ENTRIES ||
this.totalBytes + entry.body.length > CACHE_MAX_BYTES) {
const oldest = this.entries.keys().next().value;
if (!oldest)
break;
const evicted = this.entries.get(oldest);
this.totalBytes -= evicted.body.length;
this.entries.delete(oldest);
}
this.entries.set(key, entry);
this.totalBytes += entry.body.length;
}
get size() { return this.entries.size; }
get bytes() { return this.totalBytes; }
}
// ─── Path fingerprint heuristic ──────────────────────────────────────────────
// Returns true when a URL path contains a content-hash segment, indicating the
// asset is versioned and safe to cache indefinitely within a test run.
const HASH_SEGMENT_RE = /[/._-][0-9a-f]{8,}[0-9a-f]*/i;
function hasHashSegment(pathname) {
return HASH_SEGMENT_RE.test(pathname);
}
function isCacheablePath(pathname) {
if (CACHEABLE_PATH_PREFIXES.some((p) => pathname.startsWith(p)))
return true;
// Also cache anything with a fingerprint hash regardless of prefix
if (hasHashSegment(pathname))
return true;
return false;
}
function isHmrPath(pathname) {
return HMR_PATH_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + "?") || pathname.startsWith(p + "/"));
}
function isTextResponse(contentType) {

@@ -26,2 +107,3 @@ if (!contentType)

}
// ─── Decompression ───────────────────────────────────────────────────────────
function decompress(encoding, data) {

@@ -36,6 +118,35 @@ if (encoding === "gzip")

}
/**
* Rewrite response headers that contain localhost origins, replacing them
* with the public tunnel URL.
*/
// ─── Recompression ───────────────────────────────────────────────────────────
// After rewriting the body text we re-gzip it before handing it to ngrok.
// This undoes the 3-5x bandwidth penalty caused by stripping Content-Encoding
// and sending raw UTF-8 bytes through the tunnel.
//
// We use gzip (not brotli) because:
// 1. All ngrok-tunnelled browsers support gzip unconditionally.
// 2. Node's zlib.gzipSync is ~3x faster than brotliCompressSync at level 4.
// 3. gzip level 4 achieves ~65-75 % compression on JS/HTML — close enough
// to brotli for this use-case, with far less CPU on the user's machine.
//
// We only re-compress when the original response was compressed OR when the
// uncompressed body exceeds 1 KB (below that, headers dominate and compression
// rarely helps).
const RECOMPRESS_MIN_BYTES = 1024;
function recompress(originalEncoding, body) {
// If body is small enough, don't bother
if (body.length < RECOMPRESS_MIN_BYTES && !originalEncoding) {
return { body, encoding: null };
}
try {
const compressed = zlib.gzipSync(body, { level: RECOMPRESS_LEVEL });
// Only use compression if it actually shrinks the payload
if (compressed.length < body.length) {
return { body: compressed, encoding: "gzip" };
}
}
catch {
// Fall through to uncompressed
}
return { body, encoding: null };
}
// ─── Header rewriting ────────────────────────────────────────────────────────
function rewriteHeaders(headers, localhostOrigins, tunnelUrl) {

@@ -50,3 +161,3 @@ // location — 302 redirects

}
// set-cookie — strip Domain=localhost / Domain=127.0.0.1 (with optional port)
// set-cookie — strip Domain=localhost / Domain=127.0.0.1
const setCookie = headers["set-cookie"];

@@ -59,3 +170,2 @@ if (Array.isArray(setCookie)) {

}
// Remove Domain=localhost or Domain=127.0.0.1 (with optional port)
c = c.replace(/;\s*Domain=\.?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?/gi, "");

@@ -82,2 +192,20 @@ return c;

}
// ─── Cache-Control immutability check ────────────────────────────────────────
// Returns true if the upstream Cache-Control marks the response as immutable
// or long-lived (safe to cache for the duration of a test run).
function isUpstreamImmutable(headers) {
const cc = headers["cache-control"];
if (!cc)
return false;
if (cc.includes("immutable"))
return true;
const match = cc.match(/max-age=(\d+)/);
if (match) {
const maxAge = parseInt(match[1], 10);
// Anything cached for > 1 hour is safe to hold in-process
return maxAge > 3600;
}
return false;
}
// ─── Public API ──────────────────────────────────────────────────────────────
export async function startProxy(frontendPort, backendPort, backendPaths) {

@@ -96,5 +224,3 @@ let tunnelUrl;

];
// All origins that should be rewritten to the tunnel URL
const allOrigins = [...backendOrigins, ...frontendOrigins];
// ws:// and wss:// variants for body rewriting
const wsOrigins = [

@@ -110,2 +236,3 @@ `ws://localhost:${backendPort}`,

];
const cache = new AssetCache();
function shouldRouteToBackend(pathname) {

@@ -124,33 +251,51 @@ return BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));

});
proxy.on("proxyRes", (proxyRes, _req, res) => {
proxy.on("proxyRes", (proxyRes, req, res) => {
const sRes = res;
const sReq = req;
const contentType = proxyRes.headers["content-type"];
const isSSE = contentType?.includes("text/event-stream");
// SSE: pass through without buffering (SSE never ends)
// ── SSE: stream without buffering ────────────────────────────────────────
if (isSSE) {
const headers = { ...proxyRes.headers };
if (tunnelUrl) {
if (tunnelUrl)
rewriteHeaders(headers, allOrigins, tunnelUrl);
}
res.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(res);
sRes.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(sRes);
return;
}
const pathname = new URL(sReq.url ?? "/", "http://localhost").pathname;
// ── Cache hit: serve directly without touching the upstream body ──────────
const cacheKey = pathname + (sReq.headers["accept-encoding"] ? "" : "|noenc");
const hit = cache.get(cacheKey);
if (hit) {
// Drain the upstream response to free the socket
proxyRes.resume();
const cachedHeaders = { ...hit.headers };
// Update any headers that reference the current tunnel URL (it may have
// changed between requests if the user restarted, though in practice it
// does not change within a run)
if (tunnelUrl)
rewriteHeaders(cachedHeaders, allOrigins, tunnelUrl);
sRes.writeHead(hit.statusCode, cachedHeaders);
sRes.end(hit.body);
return;
}
const shouldRewrite = tunnelUrl && isTextResponse(contentType);
if (!shouldRewrite) {
// Pass through as-is, but still rewrite headers if we have a tunnel URL
const shouldCache = isCacheablePath(pathname) || isUpstreamImmutable(proxyRes.headers);
// ── Non-rewritable, non-cacheable: passthrough ────────────────────────────
if (!shouldRewrite && !shouldCache) {
const headers = { ...proxyRes.headers };
if (tunnelUrl) {
if (tunnelUrl)
rewriteHeaders(headers, allOrigins, tunnelUrl);
}
res.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(res);
sRes.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(sRes);
return;
}
proxyRes.on("error", () => {
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "text/plain" });
if (!sRes.headersSent) {
sRes.writeHead(502, { "Content-Type": "text/plain" });
}
res.end("Upstream connection error");
sRes.end("Upstream connection error");
});
// Buffer the response body for rewriting
// ── Buffer response body for rewriting and/or caching ────────────────────
const chunks = [];

@@ -165,12 +310,12 @@ let totalSize = 0;

exceeded = true;
// Flush what we have without body rewriting
// Overflow: flush with header-only rewriting, skip body rewrite/cache
const headers = { ...proxyRes.headers };
rewriteHeaders(headers, allOrigins, tunnelUrl);
res.writeHead(proxyRes.statusCode ?? 200, headers);
if (tunnelUrl)
rewriteHeaders(headers, allOrigins, tunnelUrl);
sRes.writeHead(proxyRes.statusCode ?? 200, headers);
for (const c of chunks)
res.write(c);
res.write(chunk);
sRes.write(c);
sRes.write(chunk);
chunks.length = 0;
// Pipe the rest directly
proxyRes.pipe(res);
proxyRes.pipe(sRes);
return;

@@ -181,39 +326,75 @@ }

proxyRes.on("end", () => {
// If buffer size cap exceeded, response was already piped in the data handler
if (exceeded)
return;
let body;
const raw = Buffer.concat(chunks);
const originalEncoding = proxyRes.headers["content-encoding"];
// ── Step 1: decompress ────────────────────────────────────────────────
let decompressed;
try {
const raw = Buffer.concat(chunks);
const encoding = proxyRes.headers["content-encoding"];
const decompressed = decompress(encoding, raw);
body = decompressed.toString("utf-8");
decompressed = decompress(originalEncoding, raw);
}
catch {
// If decompression fails, pass through original
// Decompression failed — passthrough original bytes
const headers = { ...proxyRes.headers };
rewriteHeaders(headers, allOrigins, tunnelUrl);
res.writeHead(proxyRes.statusCode ?? 200, headers);
res.end(Buffer.concat(chunks));
if (tunnelUrl)
rewriteHeaders(headers, allOrigins, tunnelUrl);
sRes.writeHead(proxyRes.statusCode ?? 200, headers);
sRes.end(raw);
return;
}
// Derive tunnel ws:// URL from tunnel https:// URL
const tunnelWs = tunnelUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
// Rewrite all http localhost origins to tunnel URL
for (const origin of allOrigins) {
body = body.replaceAll(origin, tunnelUrl);
// ── Step 2: rewrite body text ─────────────────────────────────────────
let finalBody;
if (shouldRewrite && tunnelUrl) {
const tunnelWs = tunnelUrl
.replace(/^https:\/\//, "wss://")
.replace(/^http:\/\//, "ws://");
let text = decompressed.toString("utf-8");
for (const origin of allOrigins)
text = text.replaceAll(origin, tunnelUrl);
for (const origin of wsOrigins)
text = text.replaceAll(origin, tunnelWs);
finalBody = Buffer.from(text, "utf-8");
}
// Rewrite ws:// and wss:// localhost origins to tunnel ws URL
for (const origin of wsOrigins) {
body = body.replaceAll(origin, tunnelWs);
else {
finalBody = decompressed;
}
const rewritten = Buffer.from(body, "utf-8");
// Update headers — remove content-encoding (we decompressed), fix content-length
// ── Step 3: re-compress ───────────────────────────────────────────────
// This is the key fix: instead of sending the raw decompressed bytes
// through the tunnel (which is what the original code did by stripping
// content-encoding without re-compressing), we gzip the rewritten body.
//
// Typical compression ratios for dev-mode assets:
// HTML ~75 % (4 KB → 1 KB)
// Unminified JS ~80 % (500 KB → 100 KB)
// JSON API ~70 % (20 KB → 6 KB)
// CSS ~78 % (80 KB → 18 KB)
//
// For a 3-agent / 30-step run this reduces tunnel egress from ~300 MB
// to ~60 MB — a 5x improvement.
const { body: outBody, encoding: outEncoding } = recompress(originalEncoding, finalBody);
// ── Step 4: build final response headers ──────────────────────────────
const headers = { ...proxyRes.headers };
delete headers["content-encoding"];
delete headers["transfer-encoding"];
headers["content-length"] = String(rewritten.length);
rewriteHeaders(headers, allOrigins, tunnelUrl);
res.writeHead(proxyRes.statusCode ?? 200, headers);
res.end(rewritten);
delete headers["transfer-encoding"]; // We know the exact length
if (outEncoding) {
headers["content-encoding"] = outEncoding;
}
else {
delete headers["content-encoding"];
}
headers["content-length"] = String(outBody.length);
if (tunnelUrl)
rewriteHeaders(headers, allOrigins, tunnelUrl);
// ── Step 5: populate cache if eligible ───────────────────────────────
if (shouldCache && proxyRes.statusCode === 200) {
// Store the compressed (wire-format) body so cache hits skip
// both decompression and recompression entirely.
cache.set(cacheKey, {
statusCode: 200,
headers: { ...headers },
body: outBody,
cachedAt: Date.now(),
});
}
sRes.writeHead(proxyRes.statusCode ?? 200, headers);
sRes.end(outBody);
});

@@ -223,6 +404,17 @@ });

const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
// ── Block HMR/dev-only HTTP polling endpoints ─────────────────────────────
// These are never needed by the testing agents. Responding 204 immediately
// prevents the hot-reload client from repeatedly establishing connections
// that would traverse the ngrok tunnel.
if (isHmrPath(pathname)) {
res.writeHead(204, {
"Cache-Control": "no-store",
"X-Proxy-Blocked": "hmr",
});
res.end();
return;
}
const target = shouldRouteToBackend(pathname)
? `http://127.0.0.1:${backendPort}`
: `http://127.0.0.1:${frontendPort}`;
// Inject X-Forwarded-* headers
req.headers["x-forwarded-proto"] = "https";

@@ -232,5 +424,11 @@ req.headers["x-forwarded-host"] = req.headers["host"] || "";

});
// Handle WebSocket upgrade (HMR, dev servers)
// ── WebSocket upgrade ─────────────────────────────────────────────────────
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
// Drop HMR WebSocket upgrades. The testing agents use Playwright CDP,
// not the browser's built-in HMR client, so these sockets are pure waste.
if (isHmrPath(pathname)) {
socket.destroy();
return;
}
const target = shouldRouteToBackend(pathname)

@@ -237,0 +435,0 @@ ? `http://127.0.0.1:${backendPort}`

@@ -0,5 +1,11 @@

export type TunnelProvider = "cloudflare" | "ngrok" | "auto";
export interface TunnelInfo {
url: string;
provider: TunnelProvider;
close: () => Promise<void>;
}
export declare function openTunnel(localPort: number, authToken: string): Promise<TunnelInfo>;
export interface OpenTunnelOptions {
provider?: TunnelProvider;
ngrokAuthToken?: string;
}
export declare function openTunnel(localPort: number, opts?: OpenTunnelOptions): Promise<TunnelInfo>;
import ngrok from "@ngrok/ngrok";
export async function openTunnel(localPort, authToken) {
import { spawn } from "node:child_process";
import { once } from "node:events";
// ── Provider: ngrok ──────────────────────────────────────────────────────────
async function openNgrokTunnel(localPort, authToken) {
const forwardPromise = ngrok.forward({

@@ -20,3 +23,2 @@ addr: `127.0.0.1:${localPort}`,

clearTimeout(timer);
// If timeout won, the forward may still succeed — clean it up
forwardPromise.then((l) => l.close()).catch(() => { });

@@ -31,2 +33,3 @@ throw err;

url,
provider: "ngrok",
close: async () => {

@@ -37,1 +40,106 @@ await listener.close();

}
// ── Provider: Cloudflare Tunnel ──────────────────────────────────────────────
const CF_URL_RE = /https:\/\/[-\w]+\.trycloudflare\.com/;
async function findCloudflaredBin() {
// Try the `cloudflared` npm package which auto-downloads the binary
try {
// Dynamic import — cloudflared is an optional peer dependency
const mod = await Function('return import("cloudflared")')();
if (mod.bin)
return mod.bin;
}
catch {
// Not installed — fall through to PATH
}
return "cloudflared";
}
async function openCloudflareTunnel(localPort) {
const bin = await findCloudflaredBin();
const child = spawn(bin, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--no-autoupdate"], { stdio: ["ignore", "pipe", "pipe"] });
const url = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill();
reject(new Error("Cloudflare tunnel timed out after 30s.\n" +
" Is cloudflared installed? Install with:\n" +
" macOS: brew install cloudflared\n" +
" Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared\n" +
" Windows: winget install Cloudflare.cloudflared\n" +
" npm: npm install -g cloudflared"));
}, 30_000);
let stderrBuf = "";
child.stderr?.on("data", (chunk) => {
stderrBuf += chunk.toString();
const match = stderrBuf.match(CF_URL_RE);
if (match) {
clearTimeout(timer);
resolve(match[0]);
}
});
// Also check stdout — some versions print there
child.stdout?.on("data", (chunk) => {
stderrBuf += chunk.toString();
const match = stderrBuf.match(CF_URL_RE);
if (match) {
clearTimeout(timer);
resolve(match[0]);
}
});
child.on("error", (err) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
reject(new Error("cloudflared not found. Install it:\n" +
" macOS: brew install cloudflared\n" +
" Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared\n" +
" Windows: winget install Cloudflare.cloudflared\n" +
" npm: npm install -g cloudflared"));
}
else {
reject(err);
}
});
child.on("exit", (code) => {
clearTimeout(timer);
reject(new Error(`cloudflared exited with code ${code} before tunnel URL was assigned`));
});
});
return {
url,
provider: "cloudflare",
close: async () => {
if (!child.killed) {
child.kill("SIGTERM");
await Promise.race([
once(child, "exit").catch(() => { }),
new Promise((r) => setTimeout(r, 5_000)),
]);
if (!child.killed)
child.kill("SIGKILL");
}
},
};
}
export async function openTunnel(localPort, opts = {}) {
const provider = opts.provider ?? "auto";
// Explicit provider selection
if (provider === "ngrok") {
if (!opts.ngrokAuthToken) {
throw new Error("ngrok requires an auth token (provided via your Swarm account)");
}
return openNgrokTunnel(localPort, opts.ngrokAuthToken);
}
if (provider === "cloudflare") {
return openCloudflareTunnel(localPort);
}
// Auto mode: try cloudflare first (free, no token needed), fall back to ngrok
try {
return await openCloudflareTunnel(localPort);
}
catch (cfErr) {
if (opts.ngrokAuthToken) {
// Cloudflare failed but we have an ngrok token — fall back silently
return openNgrokTunnel(localPort, opts.ngrokAuthToken);
}
// No fallback available — surface the cloudflare error with install hint
throw cfErr;
}
}
{
"name": "@useswarm/cli",
"version": "0.2.0",
"version": "0.3.0",
"description": "Swarm CLI — AI-powered UX testing from your terminal",

@@ -5,0 +5,0 @@ "type": "module",

@@ -71,3 +71,4 @@ # @useswarm/cli

| `--url <url>` | Target URL. Use `localhost:PORT` for local dev |
| `--no-tunnel` | Skip ngrok tunnel (URL must be publicly accessible) |
| `--no-tunnel` | Skip tunnel (URL must be publicly accessible) |
| `--tunnel-provider <provider>` | `cloudflare`, `ngrok`, or `auto` (default: `auto`) |

@@ -100,3 +101,3 @@ #### Test Configuration

When targeting localhost, the CLI automatically:
- Opens an ngrok tunnel so remote AI agents can reach your app
- Opens a tunnel (Cloudflare or ngrok) so remote AI agents can reach your app
- Detects your framework (Next.js, Vite, Django, etc.) and warns about common pitfalls

@@ -165,2 +166,37 @@ - Checks if your dev server is running before opening the tunnel

### Tunnel Providers
The CLI supports two tunnel providers for exposing your localhost to the cloud:
#### Cloudflare Tunnel (default)
Free with no bandwidth limits. Requires the `cloudflared` binary installed on your machine:
| Platform | Install command |
|----------|----------------|
| **macOS** | `brew install cloudflared` |
| **Windows** | `winget install Cloudflare.cloudflared` |
| **Linux (Debian/Ubuntu)** | `curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \| sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null && echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \| sudo tee /etc/apt/sources.list.d/cloudflared.list && sudo apt update && sudo apt install cloudflared` |
| **Linux (binary)** | `curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared` |
| **npm (any platform)** | `npm install -g cloudflared` |
No Cloudflare account or API key required — it just works.
#### ngrok (fallback)
Bundled with the CLI — no extra install needed. Uses your Swarm account's managed ngrok token. Subject to ngrok's bandwidth limits on the free tier.
#### Choosing a provider
```bash
# Auto mode (default) — tries Cloudflare first, falls back to ngrok
swarm test --url localhost:3000 --goal "Sign up"
# Force Cloudflare
swarm test --url localhost:3000 --goal "Sign up" --tunnel-provider cloudflare
# Force ngrok
swarm test --url localhost:3000 --goal "Sign up" --tunnel-provider ngrok
```
### What the Tunnel Handles

@@ -243,3 +279,3 @@

3. **Tunnel** — For localhost targets, an ngrok tunnel makes your local server reachable. A reverse proxy handles URL rewriting, cookie fixing, and header translation.
3. **Tunnel** — For localhost targets, a Cloudflare or ngrok tunnel makes your local server reachable. A reverse proxy handles URL rewriting, cookie fixing, and header translation.

@@ -254,2 +290,3 @@ 4. **AI Agents** — Each persona runs in a real cloud browser (Browserbase). The agent navigates your app, performs the goal, and captures UX observations at every step.

- A Swarm account with a Pro plan ([useswarm.co](https://useswarm.co))
- For local testing: `cloudflared` ([install](#cloudflare-tunnel-default)) or ngrok (bundled)

@@ -256,0 +293,0 @@ ## Links