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.1.2
to
0.2.0
+8
dist/lib/prompt.d.ts
export declare function prompt(question: string): Promise<string>;
export declare function promptPassword(question: string): Promise<string>;
export declare function promptSelect(question: string, options: Array<{
label: string;
value: string;
hint?: string;
}>): Promise<string>;
export declare function promptConfirm(question: string, defaultYes?: boolean): Promise<boolean>;
import * as readline from "readline";
function createInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
export function prompt(question) {
const rl = createInterface();
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
export function promptPassword(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Mute output while typing password
const stdout = process.stdout;
const originalWrite = stdout.write.bind(stdout);
let muted = false;
stdout.write = ((...args) => {
if (muted) {
const chunk = args[0];
if (typeof chunk === "string" && chunk.includes(question)) {
return originalWrite(...args);
}
return true;
}
return originalWrite(...args);
});
// Write the question, then start muting
originalWrite(question);
muted = true;
rl.question("", (answer) => {
muted = false;
stdout.write = originalWrite;
originalWrite("\n");
rl.close();
resolve(answer.trim());
});
});
}
export async function promptSelect(question, options) {
console.log(question);
for (let i = 0; i < options.length; i++) {
const hint = options[i].hint ? ` ${options[i].hint}` : "";
console.log(` ${i + 1}) ${options[i].label}${hint}`);
}
while (true) {
const answer = await prompt(` Choice [1-${options.length}]: `);
const idx = parseInt(answer, 10) - 1;
if (idx >= 0 && idx < options.length) {
return options[idx].value;
}
console.log(` Please enter a number between 1 and ${options.length}.`);
}
}
export async function promptConfirm(question, defaultYes = true) {
const hint = defaultYes ? "[Y/n]" : "[y/N]";
const answer = await prompt(`${question} ${hint} `);
if (answer === "")
return defaultYes;
return answer.toLowerCase().startsWith("y");
}
export interface ProxyInfo {
port: number;
setTunnelUrl: (url: string) => void;
close: () => Promise<void>;
}
export declare function startProxy(frontendPort: number, backendPort: number, backendPaths?: string[]): Promise<ProxyInfo>;
import http from "node:http";
import zlib from "node:zlib";
import httpProxy from "http-proxy";
const DEFAULT_BACKEND_PATH_PREFIXES = ["/api", "/auth", "/graphql", "/trpc"];
const TEXT_CONTENT_TYPES = [
"text/html",
"text/javascript",
"application/javascript",
"application/json",
"text/css",
"application/manifest+json",
"text/xml",
"application/xml",
"application/xhtml+xml",
"text/plain",
"application/ld+json",
];
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
function isTextResponse(contentType) {
if (!contentType)
return false;
return TEXT_CONTENT_TYPES.some((t) => contentType.includes(t));
}
function decompress(encoding, data) {
if (encoding === "gzip")
return zlib.gunzipSync(data);
if (encoding === "br")
return zlib.brotliDecompressSync(data);
if (encoding === "deflate")
return zlib.inflateSync(data);
return data;
}
/**
* Rewrite response headers that contain localhost origins, replacing them
* with the public tunnel URL.
*/
function rewriteHeaders(headers, localhostOrigins, tunnelUrl) {
// location — 302 redirects
if (typeof headers["location"] === "string") {
let loc = headers["location"];
for (const origin of localhostOrigins) {
loc = loc.replaceAll(origin, tunnelUrl);
}
headers["location"] = loc;
}
// set-cookie — strip Domain=localhost / Domain=127.0.0.1 (with optional port)
const setCookie = headers["set-cookie"];
if (Array.isArray(setCookie)) {
headers["set-cookie"] = setCookie.map((cookie) => {
let c = cookie;
for (const origin of localhostOrigins) {
c = c.replaceAll(origin, tunnelUrl);
}
// 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, "");
return c;
});
}
// access-control-allow-origin
if (typeof headers["access-control-allow-origin"] === "string") {
let acao = headers["access-control-allow-origin"];
for (const origin of localhostOrigins) {
acao = acao.replaceAll(origin, tunnelUrl);
}
headers["access-control-allow-origin"] = acao;
}
// content-security-policy
if (typeof headers["content-security-policy"] === "string") {
let csp = headers["content-security-policy"];
for (const origin of localhostOrigins) {
csp = csp.replaceAll(origin, tunnelUrl);
}
headers["content-security-policy"] = csp;
}
}
export async function startProxy(frontendPort, backendPort, backendPaths) {
let tunnelUrl;
const BACKEND_PATH_PREFIXES = backendPaths
? [...backendPaths, ...DEFAULT_BACKEND_PATH_PREFIXES]
: DEFAULT_BACKEND_PATH_PREFIXES;
const backendOrigins = [
`http://localhost:${backendPort}`,
`http://127.0.0.1:${backendPort}`,
];
const frontendOrigins = [
`http://localhost:${frontendPort}`,
`http://127.0.0.1:${frontendPort}`,
];
// All origins that should be rewritten to the tunnel URL
const allOrigins = [...backendOrigins, ...frontendOrigins];
// ws:// and wss:// variants for body rewriting
const wsOrigins = [
`ws://localhost:${backendPort}`,
`ws://127.0.0.1:${backendPort}`,
`ws://localhost:${frontendPort}`,
`ws://127.0.0.1:${frontendPort}`,
`wss://localhost:${backendPort}`,
`wss://127.0.0.1:${backendPort}`,
`wss://localhost:${frontendPort}`,
`wss://127.0.0.1:${frontendPort}`,
];
function shouldRouteToBackend(pathname) {
return BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
}
const proxy = httpProxy.createProxyServer({
selfHandleResponse: true,
changeOrigin: true,
});
proxy.on("error", (err, _req, res) => {
if (res && "writeHead" in res && !res.headersSent) {
res.writeHead(502, { "Content-Type": "text/plain" });
res.end(`Proxy error: ${err.message}`);
}
});
proxy.on("proxyRes", (proxyRes, _req, res) => {
const contentType = proxyRes.headers["content-type"];
const isSSE = contentType?.includes("text/event-stream");
// SSE: pass through without buffering (SSE never ends)
if (isSSE) {
const headers = { ...proxyRes.headers };
if (tunnelUrl) {
rewriteHeaders(headers, allOrigins, tunnelUrl);
}
res.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(res);
return;
}
const shouldRewrite = tunnelUrl && isTextResponse(contentType);
if (!shouldRewrite) {
// Pass through as-is, but still rewrite headers if we have a tunnel URL
const headers = { ...proxyRes.headers };
if (tunnelUrl) {
rewriteHeaders(headers, allOrigins, tunnelUrl);
}
res.writeHead(proxyRes.statusCode ?? 200, headers);
proxyRes.pipe(res);
return;
}
proxyRes.on("error", () => {
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "text/plain" });
}
res.end("Upstream connection error");
});
// Buffer the response body for rewriting
const chunks = [];
let totalSize = 0;
let exceeded = false;
proxyRes.on("data", (chunk) => {
if (exceeded)
return;
totalSize += chunk.length;
if (totalSize > MAX_BUFFER_SIZE) {
exceeded = true;
// Flush what we have without body rewriting
const headers = { ...proxyRes.headers };
rewriteHeaders(headers, allOrigins, tunnelUrl);
res.writeHead(proxyRes.statusCode ?? 200, headers);
for (const c of chunks)
res.write(c);
res.write(chunk);
chunks.length = 0;
// Pipe the rest directly
proxyRes.pipe(res);
return;
}
chunks.push(chunk);
});
proxyRes.on("end", () => {
// If buffer size cap exceeded, response was already piped in the data handler
if (exceeded)
return;
let body;
try {
const raw = Buffer.concat(chunks);
const encoding = proxyRes.headers["content-encoding"];
const decompressed = decompress(encoding, raw);
body = decompressed.toString("utf-8");
}
catch {
// If decompression fails, pass through original
const headers = { ...proxyRes.headers };
rewriteHeaders(headers, allOrigins, tunnelUrl);
res.writeHead(proxyRes.statusCode ?? 200, headers);
res.end(Buffer.concat(chunks));
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);
}
// Rewrite ws:// and wss:// localhost origins to tunnel ws URL
for (const origin of wsOrigins) {
body = body.replaceAll(origin, tunnelWs);
}
const rewritten = Buffer.from(body, "utf-8");
// Update headers — remove content-encoding (we decompressed), fix content-length
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);
});
});
const server = http.createServer((req, res) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
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";
req.headers["x-forwarded-host"] = req.headers["host"] || "";
proxy.web(req, res, { target });
});
// Handle WebSocket upgrade (HMR, dev servers)
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const target = shouldRouteToBackend(pathname)
? `http://127.0.0.1:${backendPort}`
: `http://127.0.0.1:${frontendPort}`;
proxy.ws(req, socket, head, { target }, (err) => {
if (err)
socket.destroy();
});
});
return new Promise((resolve, reject) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Failed to get proxy port"));
return;
}
resolve({
port: addr.port,
setTunnelUrl: (url) => {
if (/[\r\n\x00]/.test(url))
throw new Error("Invalid tunnel URL");
new URL(url); // throws on malformed
tunnelUrl = url;
},
close: async () => {
proxy.close();
await new Promise((r) => server.close(() => r()));
},
});
});
server.on("error", reject);
});
}
# @useswarm/cli
AI-powered UX testing from your terminal. Run real browser agents against your app — locally or in production — and get actionable feedback in minutes.
## Install
```bash
npm install -g @useswarm/cli
```
Requires Node.js 18+.
## Quick Start
```bash
# 1. Authenticate
swarm login
# 2. Run a test (interactive — just answer the prompts)
swarm test
# 3. Or pass everything as flags
swarm test --url localhost:3000 --goal "Sign up for a new account" --agents 3
```
## Commands
### `swarm`
Run with no arguments for an interactive menu.
```
$ swarm
Swarm CLI — AI-powered UX testing
Logged in as Jane (Acme Corp)
What would you like to do?
1) Run a UX test
2) List saved swarms
3) Log out
```
### `swarm login`
Authenticate via your browser using a device code flow.
```bash
swarm login # uses default API
swarm login --api-url https://... # custom API endpoint
```
### `swarm logout`
Remove stored credentials.
### `swarm test`
Run a UX simulation. All options are interactive when omitted — just run `swarm test` and follow the prompts.
```bash
swarm test [options]
```
#### Target
| Flag | Description |
|------|-------------|
| `--url <url>` | Target URL. Use `localhost:PORT` for local dev |
| `--no-tunnel` | Skip ngrok tunnel (URL must be publicly accessible) |
#### Test Configuration
| Flag | Description | Default |
|------|-------------|---------|
| `--goal <goal>` | What the user should try to do | *(prompted)* |
| `--description <desc>` | Describe your target audience | `general web users` |
| `--agents <n>` | Number of AI agents to generate | `3` |
| `--max-steps <n>` | Maximum steps per agent | `30` |
| `--swarm <id>` | Use personas from an existing swarm | |
| `--audience <desc>` | Audience description for persona generation | |
#### AI Model
| Flag | Description | Default |
|------|-------------|---------|
| `--provider <name>` | `openai` or `anthropic` | `openai` |
| `--model <name>` | Model name override | provider default |
#### Local Dev Setup
| Flag | Description |
|------|-------------|
| `--backend <port\|url>` | Separate backend server (e.g. `8080`, `localhost:8080`) |
| `--backend-paths <paths>` | Additional backend route prefixes (comma-separated) |
When targeting localhost, the CLI automatically:
- Opens an ngrok tunnel so remote AI agents can reach your app
- Detects your framework (Next.js, Vite, Django, etc.) and warns about common pitfalls
- Checks if your dev server is running before opening the tunnel
- Rewrites localhost URLs in responses, cookies, headers, and CSP
#### Authentication
| Flag | Description |
|------|-------------|
| `--login-url <url>` | Login page URL (e.g. `/login`) |
| `--username <user>` | Username or email |
| `--password <pass>` | Password *(insecure — visible in `ps`)* |
| `--password-stdin` | Read password from stdin (for CI/CD) |
The interactive prompt masks password input. For scripts:
```bash
echo "$PASS" | swarm test --url localhost:3000 --goal "..." --username user@example.com --password-stdin
```
#### Other
| Flag | Description |
|------|-------------|
| `-y, --yes` | Skip confirmation prompts (non-interactive mode) |
### `swarm list-swarms`
List your saved persona swarms.
```bash
swarm list-swarms
```
## Localhost Testing
The CLI is designed to work with any local dev setup out of the box.
### Single Server (Next.js, Nuxt, SvelteKit, Remix)
Most fullstack frameworks serve frontend and API routes on one port. Just point at it:
```bash
swarm test --url localhost:3000 --goal "Complete the onboarding flow"
```
The CLI detects fullstack frameworks and warns you if `--backend` is unnecessary.
### Separate Frontend + Backend
If your frontend (port 3000) and API (port 8080) are separate processes:
```bash
swarm test --url localhost:3000 --backend 8080
```
The CLI spins up a reverse proxy that routes `/api/*`, `/auth/*`, `/graphql`, `/trpc/*` to your backend, and everything else to your frontend — all behind a single tunnel URL.
Need custom API paths?
```bash
swarm test --url localhost:3000 --backend 8080 --backend-paths "/v1,/socket.io,/webhooks"
```
### What the Tunnel Handles
When testing localhost, the CLI automatically:
- **URL rewriting** — Rewrites `http://localhost:PORT` references in HTML, JS, JSON, CSS, manifests, and WebSocket URLs so remote browsers can load all resources
- **Cookie domain stripping** — Strips `Domain=localhost` from `Set-Cookie` headers so cookies work on the tunnel domain
- **Header rewriting** — Fixes `Location` redirects, `Access-Control-Allow-Origin`, and `Content-Security-Policy` headers
- **Host header** — Sends `Host: localhost:PORT` to your server so framework host validation (Django `ALLOWED_HOSTS`, Rails `config.hosts`) works
- **HTTPS** — Tunnel is HTTPS-only with `X-Forwarded-Proto: https` forwarded
- **WebSocket** — Full WebSocket support (HMR, real-time features)
- **SSE passthrough** — Server-Sent Events are streamed without buffering
### Framework Tips
| Framework | Notes |
|-----------|-------|
| **Next.js** | Don't use `--backend` — API routes are on the same port. Add `allowedOrigins` in `next.config.js` for Server Actions if needed |
| **Vite** | If you have `server.proxy` in `vite.config.ts`, skip `--backend` — Vite handles it |
| **Django** | Add the tunnel URL to `CSRF_TRUSTED_ORIGINS` and `ALLOWED_HOSTS` |
| **Rails** | Check `config.hosts` includes the tunnel hostname, or use `config.hosts.clear` in development |
### Authenticated Testing
Test flows that require login:
```bash
swarm test --url localhost:3000 --login-url /login --username test@example.com
# Password will be prompted interactively (masked)
```
The AI agent navigates to the login page, fills in credentials, and then performs the test goal. Credentials are encrypted end-to-end (AES-256-GCM) and never stored in plaintext.
## CI/CD Usage
Run headless with all flags and `-y` to skip prompts:
```bash
swarm test \
--url https://staging.example.com \
--goal "Complete checkout flow" \
--description "Mobile-first shoppers" \
--agents 5 \
--max-steps 40 \
--no-tunnel \
-y
```
With authentication:
```bash
echo "$TEST_PASSWORD" | swarm test \
--url https://staging.example.com \
--goal "Update profile settings" \
--username test@example.com \
--password-stdin \
--no-tunnel \
-y
```
## Configuration
Credentials are stored in `~/.config/swarm/config.json` after `swarm login`. The API URL can be overridden:
```bash
# Via login flag
swarm login --api-url https://api.example.com
# Via environment variable
SWARM_API_URL=https://api.example.com swarm test
```
## How It Works
1. **`swarm login`** — Device code auth flow. Opens your browser, you click "Authorize", the CLI receives an API key.
2. **`swarm test`** — Creates a test run with AI-generated personas (or from a saved swarm). Each persona is a unique user profile with different demographics, tech literacy, and behavioral traits.
3. **Tunnel** — For localhost targets, an ngrok tunnel makes your local server reachable. A reverse proxy handles URL rewriting, cookie fixing, and header translation.
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.
5. **Results** — Live-streamed to your terminal via SSE. When all agents finish, you get a synthesis report with prioritized UX issues and a judge verdict.
## Requirements
- Node.js 18+
- A Swarm account with a Pro plan ([useswarm.co](https://useswarm.co))
## Links
- [Dashboard](https://useswarm.co)
- [Documentation](https://docs.useswarm.co)
- [GitHub](https://useswarm.co)
+4
-0

@@ -65,2 +65,6 @@ import { Command } from "commander";

}
if (poll.status === "consumed") {
pollSpinner.fail("Code already used but key exchange failed. Run `swarm login` again.");
process.exit(1);
}
}

@@ -67,0 +71,0 @@ catch {

import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
import * as net from "net";
import * as fs from "fs";
import * as path from "path";
import { ApiClient } from "../lib/api-client.js";
import { requireAuth } from "../lib/config.js";
import { openTunnel } from "../lib/tunnel.js";
import { startProxy } from "../lib/proxy.js";
import { renderStep, renderRunComplete, renderSynthesis, renderJudge, renderDone } from "../lib/renderer.js";
import { prompt, promptPassword, promptSelect, promptConfirm } from "../lib/prompt.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
// ── Helpers ──────────────────────────────────────────────────────────────────
function isLocalhostHostname(hostname) {
return (hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname === "::1" ||
hostname.endsWith(".local") ||
hostname.endsWith(".localhost"));
}
async function checkPort(port, host = "127.0.0.1") {
return new Promise((resolve) => {
let resolved = false;
const done = (val) => { if (!resolved) {
resolved = true;
resolve(val);
} };
const socket = net.createConnection({ port, host, timeout: 2000 });
socket.on("connect", () => { socket.destroy(); done(true); });
socket.on("error", () => done(false));
socket.on("timeout", () => { socket.destroy(); done(false); });
});
}
function detectFramework(cwd) {
if (fs.existsSync(path.join(cwd, "next.config.js")) ||
fs.existsSync(path.join(cwd, "next.config.mjs")) ||
fs.existsSync(path.join(cwd, "next.config.ts")))
return "nextjs";
if (fs.existsSync(path.join(cwd, "vite.config.ts")) ||
fs.existsSync(path.join(cwd, "vite.config.js")))
return "vite";
if (fs.existsSync(path.join(cwd, "nuxt.config.ts")))
return "nuxt";
if (fs.existsSync(path.join(cwd, "svelte.config.js")))
return "sveltekit";
if (fs.existsSync(path.join(cwd, "remix.config.js")) || fs.existsSync(path.join(cwd, "remix.config.ts")))
return "remix";
if (fs.existsSync(path.join(cwd, "manage.py")))
return "django";
if (fs.existsSync(path.join(cwd, "Gemfile")))
return "rails";
return null;
}
const FULLSTACK_FRAMEWORKS = new Set(["nextjs", "nuxt", "sveltekit", "remix"]);
// ── Command ──────────────────────────────────────────────────────────────────
export const testCommand = new Command("test")
.description("Run a UX simulation against a URL")
.requiredOption("--url <url>", "Target URL (use localhost:PORT for local dev)")
.requiredOption("--goal <goal>", "What the user should try to do")
.option("--description <desc>", "Describe your target audience", "general web users")
.option("--url <url>", "Target URL (use localhost:PORT for local dev)")
.option("--goal <goal>", "What the user should try to do")
.option("--description <desc>", "Describe your target audience")
.option("--swarm <id>", "Use an existing swarm's personas")
.option("--agents <count>", "Number of AI agents to generate", "3")
.option("--agents <count>", "Number of AI agents to generate")
.option("--audience <desc>", "Audience description for persona generation")
.option("--no-tunnel", "Skip ngrok tunnel (URL must be publicly accessible)")
.option("--backend <url>", "Local backend URL to proxy (e.g. localhost:8080)")
.option("--backend-paths <paths>", "Additional path prefixes for backend routing (comma-separated)")
.option("--login-url <url>", "Login page URL (enables authenticated testing)")
.option("--username <username>", "Username/email for login")
.option("--password <password>", "Password for login (insecure — prefer interactive prompt)")
.option("--password-stdin", "Read password from stdin (for scripting)")
.option("--max-steps <n>", "Maximum steps per agent (default: 30)", "30")
.option("--provider <provider>", "AI provider: openai or anthropic")
.option("--model <model>", "Model name override")
.option("-y, --yes", "Skip confirmation prompts")
.action(async (opts) => {
const config = requireAuth();
const client = new ApiClient(config.apiUrl, config.apiKey);
let targetUrl = opts.url;
const interactive = !opts.yes;
// ── 1. Gather target URL ─────────────────────────────────────────
let targetUrl = opts.url ?? "";
if (!targetUrl) {
targetUrl = await prompt(chalk.bold(" Target URL: "));
if (!targetUrl) {
console.error(chalk.red("URL is required."));
process.exit(1);
}
}
// Normalize
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
targetUrl = `http://${targetUrl}`;
}
let parsedUrl;
try {
parsedUrl = new URL(targetUrl);
}
catch { }
const hostname = parsedUrl?.hostname ?? "";
const isLocal = isLocalhostHostname(hostname);
// ── 2. Localhost setup (backend, paths, tunnel) ──────────────────
let backendUrl = opts.backend;
let backendPaths = opts.backendPaths?.split(",").map((s) => s.trim().replace(/\/?\*+$/, ""));
let skipTunnel = opts.tunnel === false;
if (isLocal && interactive && !opts.backend && !skipTunnel) {
const framework = detectFramework(process.cwd());
if (framework) {
console.log(chalk.dim(`\n Detected: ${chalk.bold(framework)}`));
}
// Ask about architecture
const archChoice = await promptSelect(chalk.bold("\n How is your local app set up?"), [
{
label: "Single server (frontend + API on one port)",
value: "single",
hint: framework && FULLSTACK_FRAMEWORKS.has(framework) ? chalk.green("← likely this") : undefined,
},
{
label: "Separate frontend and backend servers",
value: "split",
hint: framework && !FULLSTACK_FRAMEWORKS.has(framework) ? chalk.green("← likely this") : undefined,
},
{
label: "Already publicly accessible (skip tunnel)",
value: "public",
},
]);
if (archChoice === "split") {
const rawBackend = await prompt(chalk.bold(" Backend port or URL ") + chalk.dim("[8080]") + chalk.bold(": "));
backendUrl = rawBackend || "8080";
// Ask about custom API paths
const defaultPaths = "/api/*, /auth/*, /graphql, /trpc/*";
console.log(chalk.dim(`\n Default backend paths: ${defaultPaths}`));
const extraPaths = await prompt(chalk.bold(" Additional backend paths ") + chalk.dim("(comma-separated, press Enter to skip)") + chalk.bold(": "));
if (extraPaths) {
backendPaths = extraPaths.split(",").map((s) => s.trim().replace(/\/?\*+$/, ""));
}
}
else if (archChoice === "public") {
skipTunnel = true;
}
// Warn about framework-specific gotchas
if (archChoice === "split" && framework && FULLSTACK_FRAMEWORKS.has(framework)) {
console.log(chalk.yellow(`\n ⚠ ${framework} typically serves API routes on the same port as the frontend.` +
`\n Make sure your backend is actually a separate server, not built-in API routes.\n`));
}
}
// ── 3. Goal ──────────────────────────────────────────────────────
let goal = opts.goal ?? "";
if (!goal) {
goal = await prompt(chalk.bold(" Goal (what should the user try to do?): "));
if (!goal) {
console.error(chalk.red("Goal is required."));
process.exit(1);
}
}
// ── 4. Audience description ──────────────────────────────────────
let description = opts.description ?? "";
if (!description) {
description = await prompt(chalk.bold(" Audience description ") + chalk.dim("(press Enter for 'general web users')") + chalk.bold(": "));
if (!description)
description = "general web users";
}
// ── 5. Persona source ────────────────────────────────────────────
let swarmId = opts.swarm;
let agentCount;
let audienceDescription;
if (!swarmId && !opts.agents) {
let swarmsList = [];
try {
const res = await client.listSwarms();
swarmsList = res.swarms;
}
catch { }
if (swarmsList.length > 0) {
const options = [
{ label: "Generate new personas", value: "__generate__", hint: chalk.dim("(AI-powered)") },
...swarmsList.map((s) => ({
label: `${s.name} (${Array.isArray(s.personas) ? s.personas.length : "?"} personas)`,
value: s.id,
})),
];
console.log();
const choice = await promptSelect(chalk.bold(" Persona source:"), options);
if (choice !== "__generate__") {
swarmId = choice;
}
}
}
if (!swarmId) {
const rawCount = opts.agents ?? (await prompt(chalk.bold(" Number of agents ") + chalk.dim("[3]") + chalk.bold(": ")));
agentCount = parseInt(rawCount || "3", 10);
if (isNaN(agentCount) || agentCount < 1)
agentCount = 3;
audienceDescription = opts.audience ?? description;
}
// ── 6. Auth (login credentials) ──────────────────────────────────
let authUsername = opts.username;
let authPassword = opts.password;
let loginUrl = opts.loginUrl;
if (opts.passwordStdin) {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
authPassword = Buffer.concat(chunks).toString("utf8").trim();
}
if (!authUsername && !authPassword && interactive) {
const wantAuth = await promptConfirm(chalk.bold("\n Does this test require login credentials?"), false);
if (wantAuth) {
loginUrl = await prompt(chalk.bold(" Login page URL ") + chalk.dim("(optional, press Enter to skip)") + chalk.bold(": ")) || undefined;
authUsername = await prompt(chalk.bold(" Username/email: "));
authPassword = await promptPassword(chalk.bold(" Password: "));
}
}
if (authUsername && !authPassword) {
authPassword = await promptPassword(chalk.bold(" Password: "));
}
if (authPassword && !authUsername) {
authUsername = await prompt(chalk.bold(" Username/email: "));
}
// ── 7. Confirmation ──────────────────────────────────────────────
if (interactive) {
console.log();
console.log(chalk.bold(" ── Test Configuration ──"));
console.log(` URL: ${chalk.cyan(targetUrl)}`);
console.log(` Goal: ${goal}`);
console.log(` Audience: ${description}`);
if (swarmId) {
console.log(` Personas: ${chalk.dim("from swarm")} ${swarmId}`);
}
else {
console.log(` Agents: ${agentCount}`);
console.log(` Max steps: ${opts.maxSteps || 30}`);
}
if (isLocal && !skipTunnel) {
console.log(` Tunnel: ${chalk.green("enabled")} ${chalk.dim("(ngrok → localhost)")}`);
if (backendUrl) {
console.log(` Backend: ${chalk.cyan(backendUrl)} ${chalk.dim("→ /api/*, /auth/*, /graphql, /trpc/*")}`);
if (backendPaths?.length) {
console.log(` Extra paths: ${chalk.dim(backendPaths.join(", "))}`);
}
}
}
else if (skipTunnel) {
console.log(` Tunnel: ${chalk.dim("disabled")}`);
}
if (authUsername) {
console.log(` Auth: ${chalk.green("enabled")} (${authUsername})`);
if (loginUrl) {
console.log(` Login URL: ${loginUrl}`);
}
}
console.log();
const confirmed = await promptConfirm(chalk.bold(" Start test?"));
if (!confirmed) {
console.log(chalk.dim(" Cancelled."));
process.exit(0);
}
}
// ── 8. Tunnel + proxy setup ──────────────────────────────────────
let tunnel;
const closeTunnel = async () => {
let proxy;
let cleaningUp = false;
const cleanup = async () => {
if (cleaningUp)
return;
cleaningUp = true;
if (tunnel) {

@@ -28,23 +272,78 @@ await tunnel.close().catch(() => { });

}
if (proxy) {
await proxy.close().catch(() => { });
proxy = undefined;
}
};
process.on("SIGINT", async () => {
console.log(chalk.dim("\nShutting down..."));
await closeTunnel();
await cleanup();
process.exit(0);
});
process.on("SIGTERM", async () => {
await closeTunnel();
await cleanup();
process.exit(0);
});
const isLocalhost = targetUrl.includes("localhost") ||
targetUrl.includes("127.0.0.1") ||
targetUrl.match(/:\d+/) !== null;
if (isLocalhost && opts.tunnel !== false) {
const portMatch = targetUrl.match(/:(\d+)/);
const port = portMatch ? parseInt(portMatch[1], 10) : 3000;
const tunnelSpinner = ora(`Opening tunnel to localhost:${port}...`).start();
if (isLocal && !skipTunnel) {
const frontendPort = parsedUrl?.port ? parseInt(parsedUrl.port, 10) : 3000;
let tunnelPort = frontendPort;
// Pre-flight connectivity check
const frontendReachable = await checkPort(frontendPort);
if (!frontendReachable) {
console.log(chalk.yellow(`\n ⚠ Cannot reach localhost:${frontendPort} — is your dev server running?\n`));
if (interactive) {
const proceed = await promptConfirm(chalk.bold(" Continue anyway?"), false);
if (!proceed) {
console.log(chalk.dim(" Cancelled."));
process.exit(0);
}
}
}
// Start reverse proxy if backend provided
if (backendUrl) {
let backendPort = 8080;
if (/^\d+$/.test(backendUrl)) {
backendPort = parseInt(backendUrl, 10);
}
else {
try {
const bu = new URL(backendUrl.startsWith("http") ? backendUrl : `http://${backendUrl}`);
backendPort = bu.port ? parseInt(bu.port, 10) : 8080;
}
catch { }
}
const backendReachable = await checkPort(backendPort);
if (!backendReachable) {
console.log(chalk.yellow(`\n ⚠ Cannot reach localhost:${backendPort} — is your backend running?\n`));
if (interactive) {
const proceed = await promptConfirm(chalk.bold(" Continue anyway?"), false);
if (!proceed) {
console.log(chalk.dim(" Cancelled."));
process.exit(0);
}
}
}
const proxySpinner = ora("Starting reverse proxy...").start();
try {
proxy = await startProxy(frontendPort, backendPort, backendPaths);
tunnelPort = proxy.port;
proxySpinner.succeed(`Reverse proxy started\n` +
` Frontend: ${chalk.cyan(`localhost:${frontendPort}`)}\n` +
` Backend: ${chalk.cyan(`localhost:${backendPort}`)} ${chalk.dim("→ /api/*, /auth/*, /graphql, /trpc/*" + (backendPaths?.length ? `, ${backendPaths.join(", ")}` : ""))}`);
}
catch (err) {
proxySpinner.fail("Failed to start reverse proxy");
console.error(chalk.red(err.message));
process.exit(1);
}
}
const tunnelSpinner = ora(`Opening tunnel to localhost:${tunnelPort}...`).start();
try {
const tokenRes = await client.getTunnelToken();
tunnel = await openTunnel(port, tokenRes.ngrokAuthToken);
targetUrl = tunnel.url;
tunnel = await openTunnel(tunnelPort, tokenRes.ngrokAuthToken);
const originalPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : "";
targetUrl = `${tunnel.url}${originalPath === "/" ? "" : originalPath}`;
if (proxy) {
proxy.setTunnelUrl(tunnel.url);
}
tunnelSpinner.succeed(`Tunnel open: ${chalk.underline(tunnel.url)}`);

@@ -55,19 +354,49 @@ }

console.error(chalk.red(err.message));
await cleanup();
process.exit(1);
}
// Framework-specific warnings (after tunnel is open)
const framework = detectFramework(process.cwd());
if (framework === "django") {
console.log(chalk.yellow(`\n ⚠ Django: add to settings.py:` +
`\n CSRF_TRUSTED_ORIGINS = ["${tunnel.url}"]` +
`\n ALLOWED_HOSTS = ["*"] # or add the tunnel hostname\n`));
}
}
// ── 9. Build request body ────────────────────────────────────────
const maxSteps = parseInt(opts.maxSteps || "30", 10);
const body = {
targetUrl,
goal: opts.goal,
userDescription: opts.description,
goal,
userDescription: description,
maxSteps,
...(opts.provider ? { provider: opts.provider } : {}),
...(opts.model ? { model: opts.model } : {}),
};
if (opts.swarm) {
body.swarmId = opts.swarm;
if (swarmId) {
body.swarmId = swarmId;
}
else {
body.generatePersonas = {
agentCount: parseInt(opts.agents, 10),
audienceDescription: opts.audience ?? opts.description,
agentCount: agentCount ?? 3,
audienceDescription: audienceDescription ?? description,
};
}
if (authUsername && authPassword) {
if (loginUrl && tunnel) {
try {
const parsed = new URL(loginUrl.startsWith("http") ? loginUrl : `http://${loginUrl}`);
if (isLocalhostHostname(parsed.hostname)) {
loginUrl = `${tunnel.url}${parsed.pathname}${parsed.search}`;
}
}
catch { }
}
body.auth = {
...(loginUrl ? { loginUrl } : {}),
username: authUsername,
password: authPassword,
};
}
// ── 10. Create test ──────────────────────────────────────────────
const createSpinner = ora("Creating test run...").start();

@@ -81,3 +410,3 @@ let createRes;

console.error(chalk.red(err.message));
await closeTunnel();
await cleanup();
process.exit(1);

@@ -91,2 +420,3 @@ }

console.log();
// ── 11. Stream results ───────────────────────────────────────────
try {

@@ -97,6 +427,14 @@ await streamResults(client, createRes.batchId, config.apiKey, createRes.dashboardUrl);

console.error(chalk.red(`\nStream error: ${err.message}`));
console.log(chalk.dim(`\nPoll results: swarm status ${createRes.batchId}`));
console.log(chalk.dim(`\nFalling back to polling...`));
try {
await pollResults(client, createRes.batchId, createRes.dashboardUrl);
}
catch (pollErr) {
console.error(chalk.red(`\nPolling failed: ${pollErr.message}`));
console.log(chalk.dim(`\nView results: ${createRes.dashboardUrl}`));
}
}
await closeTunnel();
await cleanup();
});
// ── SSE Streaming ────────────────────────────────────────────────────────────
async function streamResults(client, batchId, apiKey, dashboardUrl) {

@@ -118,2 +456,3 @@ const url = client.streamUrl(batchId);

.pipeThrough(new EventSourceParserStream());
let receivedDone = false;
for await (const event of stream) {

@@ -140,2 +479,3 @@ const eventType = event.event;

renderDone(dashboardUrl);
receivedDone = true;
return;

@@ -148,13 +488,25 @@ }

}
renderDone(dashboardUrl);
if (!receivedDone) {
console.log(chalk.dim("\nStream disconnected, falling back to polling..."));
await pollResults(client, batchId, dashboardUrl);
}
}
// ── Polling Fallback ─────────────────────────────────────────────────────────
async function pollResults(client, batchId, dashboardUrl) {
const spinner = ora("Waiting for results...").start();
while (true) {
const TERMINAL_STATUSES = new Set(["completed", "failed", "terminated"]);
const MAX_POLL_MS = 60 * 60 * 1000; // 60 min safety net
const pollStart = Date.now();
let consecutiveErrors = 0;
while (Date.now() - pollStart < MAX_POLL_MS) {
await new Promise((r) => setTimeout(r, 5000));
try {
const status = await client.getBatchStatus(batchId);
consecutiveErrors = 0;
spinner.text = `Running... ${status.completedRuns}/${status.totalRuns} agents complete`;
if (status.status === "completed" || status.status === "failed") {
if (TERMINAL_STATUSES.has(status.status)) {
spinner.stop();
if (status.status === "terminated") {
console.log(chalk.yellow("\nTest run was terminated."));
}
if (status.synthesis) {

@@ -181,6 +533,12 @@ renderSynthesis({

}
catch {
// Keep polling on error
catch (err) {
consecutiveErrors++;
if (consecutiveErrors >= 5) {
spinner.fail("Lost connection to server");
throw new Error(`Polling failed after ${consecutiveErrors} consecutive errors: ${err.message}`);
}
}
}
spinner.fail("Polling timed out after 60 minutes");
renderDone(dashboardUrl);
}
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
import { loginCommand, logoutCommand } from "./commands/login.js";
import { testCommand } from "./commands/test.js";
import { listSwarmsCommand } from "./commands/list-swarms.js";
import { promptSelect } from "./lib/prompt.js";
import { getConfig } from "./lib/config.js";
const program = new Command();

@@ -10,3 +13,3 @@ program

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

@@ -16,2 +19,33 @@ program.addCommand(logoutCommand);

program.addCommand(listSwarmsCommand);
// When no subcommand is given, show interactive menu
program.action(async () => {
const config = getConfig();
console.log();
console.log(chalk.bold(" Swarm CLI") + chalk.dim(" — AI-powered UX testing"));
console.log();
if (config) {
console.log(chalk.dim(" Logged in as ") +
chalk.cyan(config.user.name) +
chalk.dim(` (${config.organization.name})`));
console.log();
const choice = await promptSelect(chalk.bold(" What would you like to do?"), [
{ label: "Run a UX test", value: "test" },
{ label: "List saved swarms", value: "list-swarms" },
{ label: "Log out", value: "logout" },
]);
// Re-parse with the chosen command
program.parse(["node", "swarm", choice]);
}
else {
console.log(chalk.dim(" Not logged in."));
console.log();
const choice = await promptSelect(chalk.bold(" What would you like to do?"), [
{ label: "Log in", value: "login" },
{ label: "Exit", value: "exit" },
]);
if (choice === "exit")
return;
program.parse(["node", "swarm", choice]);
}
});
program.parse();
import ngrok from "@ngrok/ngrok";
export async function openTunnel(localPort, authToken) {
const listener = await ngrok.forward({
addr: localPort,
const forwardPromise = ngrok.forward({
addr: `127.0.0.1:${localPort}`,
authtoken: authToken,
host_header: `localhost:${localPort}`,
schemes: ["https"],
});
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("Tunnel creation timed out after 30s — check your network/firewall")), 30000);
});
let listener;
try {
listener = await Promise.race([forwardPromise, timeoutPromise]);
clearTimeout(timer);
}
catch (err) {
clearTimeout(timer);
// If timeout won, the forward may still succeed — clean it up
forwardPromise.then((l) => l.close()).catch(() => { });
throw err;
}
const url = listener.url();

@@ -8,0 +25,0 @@ if (!url) {

@@ -37,2 +37,5 @@ export interface InitiateResponse {

userDescription: string;
maxSteps?: number;
provider?: "openai" | "anthropic";
model?: string;
swarmId?: string;

@@ -43,2 +46,7 @@ generatePersonas?: {

};
auth?: {
loginUrl?: string;
username: string;
password: string;
};
}

@@ -45,0 +53,0 @@ export interface CreateTestResponse {

+16
-8
{
"name": "@useswarm/cli",
"version": "0.1.2",
"version": "0.2.0",
"description": "Swarm CLI — AI-powered UX testing from your terminal",

@@ -19,6 +19,12 @@ "type": "module",

},
"keywords": ["ux", "testing", "ai", "swarm", "cli"],
"keywords": [
"ux",
"testing",
"ai",
"swarm",
"cli"
],
"repository": {
"type": "git",
"url": "https://github.com/aaladaruncc/my-stagehand-app",
"url": "https://useswarm.co",
"directory": "packages/cli"

@@ -28,11 +34,13 @@ },

"dependencies": {
"@ngrok/ngrok": "^1.4.1",
"chalk": "^5.4.0",
"commander": "^13.0.0",
"chalk": "^5.4.0",
"ora": "^8.2.0",
"conf": "^13.0.0",
"eventsource-parser": "^3.0.0",
"http-proxy": "^1.18.1",
"open": "^10.1.0",
"conf": "^13.0.0",
"@ngrok/ngrok": "^1.4.1",
"eventsource-parser": "^3.0.0"
"ora": "^8.2.0"
},
"devDependencies": {
"@types/http-proxy": "^1.17.17",
"tsx": "^4.0.0",

@@ -39,0 +47,0 @@ "typescript": "^5.0.0"