@useswarm/cli
Advanced tools
| 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); | ||
| }); | ||
| } |
+255
| # @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) |
@@ -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 { |
+389
-31
| 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); | ||
| } |
+35
-1
| #!/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(); |
+19
-2
| 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) { |
+8
-0
@@ -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" |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
65164
168.3%24
26.32%1424
113.81%0
-100%256
Infinity%8
14.29%3
50%3
50%4
100%+ Added
+ Added
+ Added
+ Added
+ Added