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.3.7
to
0.4.0
+2
dist/commands/setup-tunnel.d.ts
import { Command } from "commander";
export declare const setupTunnelCommand: Command;
import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
import { ApiClient } from "../lib/api-client.js";
import { requireAuth, saveConfig } from "../lib/config.js";
import { provisionPersistentTunnel } from "../lib/persistent-tunnel.js";
export const setupTunnelCommand = new Command("setup-tunnel")
.description("Retry provisioning (or revoke) the stable per-user tunnel hostname so OAuth-protected " +
"localhost flows work without changing your provider allowlist on every test.")
.option("--revoke", "Revoke the existing tunnel + remove its Cloudflare resources")
.action(async (opts) => {
const config = requireAuth();
const client = new ApiClient(config.apiUrl, config.apiKey);
if (opts.revoke) {
await revokeFlow(client, config);
return;
}
await provisionPersistentTunnel(client, config, { fatal: true });
});
async function revokeFlow(client, config) {
const spinner = ora("Revoking persistent tunnel...").start();
let result;
try {
result = await client.revokeTunnel();
}
catch (err) {
spinner.fail("Failed to revoke tunnel");
const msg = err.message;
// Distinguish "nothing to revoke" from real failures so re-running is safe.
if (/no active tunnel/i.test(msg)) {
console.log(chalk.dim(" No active tunnel was provisioned for this account."));
}
else {
console.error(chalk.red(msg));
process.exit(1);
}
}
// Always clear local state so a future `login` or `setup-tunnel` re-provisions cleanly,
// even if the remote revoke 404'd.
saveConfig({
...config,
persistentTunnelToken: undefined,
persistentTunnelHostname: undefined,
});
if (result) {
spinner.succeed(`Revoked ${chalk.cyan(result.hostname)}`);
console.log();
console.log(chalk.dim(" The Cloudflare tunnel + DNS record have been removed."));
console.log(chalk.dim(" Future `swarm test` runs will fall back to ephemeral Quick Tunnels."));
console.log();
}
}
import { ApiClient } from "./api-client.js";
import type { SwarmConfig } from "../types.js";
export declare function provisionPersistentTunnel(client: ApiClient, config: SwarmConfig, opts?: {
fatal?: boolean;
quiet?: boolean;
}): Promise<SwarmConfig | null>;
import chalk from "chalk";
import ora from "ora";
import { saveConfig } from "./config.js";
export async function provisionPersistentTunnel(client, config, opts = {}) {
const fatal = opts.fatal ?? false;
const spinner = opts.quiet ? null : ora("Provisioning persistent tunnel...").start();
try {
const result = await client.provisionTunnel();
const nextConfig = {
...config,
persistentTunnelToken: result.runToken,
persistentTunnelHostname: result.hostname,
};
saveConfig(nextConfig);
spinner?.succeed(result.reused
? "Persistent tunnel already provisioned"
: "Persistent tunnel created");
if (!opts.quiet) {
console.log();
console.log(` ${chalk.bold("Hostname:")} ${chalk.cyan.underline(`https://${result.hostname}`)}`);
console.log();
console.log(chalk.dim(" Future `swarm test --url http://localhost:3000 ...` runs will reuse this hostname automatically."));
console.log(chalk.dim(" Revoke with: `swarm setup-tunnel --revoke`"));
console.log();
}
return nextConfig;
}
catch (err) {
const message = err.message;
if (fatal) {
spinner?.fail("Failed to provision tunnel");
console.error(chalk.red(message));
process.exit(1);
}
spinner?.warn("Persistent tunnel was not provisioned");
if (!opts.quiet) {
console.log(chalk.dim(` ${message}`));
console.log(chalk.dim(" Localhost tests will fall back to ephemeral tunnels until this is configured."));
console.log(chalk.dim(" You can retry later with: `swarm setup-tunnel`"));
console.log();
}
return null;
}
}
/**
* Customer-visible CLI messages for the URL interceptor.
*
* Centralised so reviewers can see the entire surface area in one place.
* Phrasing must be:
* - One sentence per situation (the user is staring at a terminal)
* - Actionable (always tell them what to do, not just what's wrong)
* - Linkable (reference docs/cli/interceptor.md anchors)
*
* See `docs/url-interceptor-implementation.md` §13 + §16 for design rationale.
*/
export type DisableReason = "flag" | "env" | "web" | "no-localhost" | "global-kill";
export declare const interceptorMessages: {
/** Printed once at startup when the interceptor is going to install. */
enabledLine: () => string;
/** Optional second line shown alongside `enabledLine` for verbose runs. */
enabledHint: () => string;
/** Printed when the interceptor will NOT install. */
disabledLine: (reason: DisableReason) => string;
/** Printed mid-run when the agent's browser hits a WebAuthn ceremony. */
webauthnDetected: () => string;
/** Printed mid-run when the agent's browser hits a SAML SSO flow. */
samlDetected: () => string;
/** Printed when an OAuth provider rejects the redirect URI. */
redirectUriMismatch: (provider: string) => string;
/** Printed when an OAuth popup posts to a hardcoded targetOrigin. */
popupPostMessage: () => string;
/** Printed when --debug-interceptor was passed and a trace was written. */
debugTraceWritten: (path: string) => string;
/** Printed when the interceptor failed to install (caught error). */
installFailed: (errMessage: string) => string;
/** Printed during run summary, with telemetry counters from the run. */
runSummary: (counts: {
rewrites: number;
bypasses: number;
errors: number;
p95Ms: number;
}) => string;
};
/**
* Customer-visible CLI messages for the URL interceptor.
*
* Centralised so reviewers can see the entire surface area in one place.
* Phrasing must be:
* - One sentence per situation (the user is staring at a terminal)
* - Actionable (always tell them what to do, not just what's wrong)
* - Linkable (reference docs/cli/interceptor.md anchors)
*
* See `docs/url-interceptor-implementation.md` §13 + §16 for design rationale.
*/
import chalk from "chalk";
const DOC_URL = "https://docs.useswarm.com/cli/interceptor";
const DISABLE_REASON_LABEL = {
flag: "(--no-interceptor)",
env: "(SWARM_DISABLE_INTERCEPTOR=1)",
web: "(web dashboard run)",
"no-localhost": "(target is not a localhost URL)",
"global-kill": "(globally disabled by ops)",
};
export const interceptorMessages = {
/** Printed once at startup when the interceptor is going to install. */
enabledLine: () => `${chalk.green("✓")} Cross-domain auth interceptor: enabled`,
/** Optional second line shown alongside `enabledLine` for verbose runs. */
enabledHint: () => chalk.dim(" ↳ Catches redirects from third-party auth providers back to localhost"),
/** Printed when the interceptor will NOT install. */
disabledLine: (reason) => {
const label = DISABLE_REASON_LABEL[reason];
const dim = chalk.dim(label);
if (reason === "global-kill") {
return `${chalk.yellow("⚠")} Cross-domain auth interceptor: ${chalk.bold("GLOBALLY DISABLED")} ${dim}`;
}
return `${chalk.dim("✗")} Cross-domain auth interceptor: disabled ${dim}`;
},
/** Printed mid-run when the agent's browser hits a WebAuthn ceremony. */
webauthnDetected: () => [
chalk.yellow("⚠ WebAuthn/passkey detected"),
" the interceptor cannot proxy origin-bound assertions.",
"",
` ${chalk.dim("Workaround: use --auth-cookies to skip the auth flow,")}`,
` ${chalk.dim("or test against a build with passkeys disabled.")}`,
` ${chalk.dim(`Docs: ${DOC_URL}#webauthn`)}`,
].join("\n"),
/** Printed mid-run when the agent's browser hits a SAML SSO flow. */
samlDetected: () => [
chalk.yellow("⚠ SAML SSO detected"),
" the interceptor cannot rewrite signed assertions.",
"",
` ${chalk.dim("Workaround: use --auth-cookies to skip the SSO flow,")}`,
` ${chalk.dim("or pre-register the tunnel URL with your IdP.")}`,
` ${chalk.dim(`Docs: ${DOC_URL}#saml`)}`,
].join("\n"),
/** Printed when an OAuth provider rejects the redirect URI. */
redirectUriMismatch: (provider) => [
chalk.yellow(`⚠ ${provider} rejected the redirect URI (likely strict allowlist)`),
"",
` ${chalk.dim("Either:")}`,
` ${chalk.dim(" (a) register the tunnel URL once with the provider, OR")}`,
` ${chalk.dim(" (b) use --auth-cookies to skip the auth flow entirely.")}`,
` ${chalk.dim(`Docs: ${DOC_URL}#strict-allowlist`)}`,
].join("\n"),
/** Printed when an OAuth popup posts to a hardcoded targetOrigin. */
popupPostMessage: () => [
chalk.yellow("⚠ Popup-based OAuth with hardcoded targetOrigin"),
" postMessage will be dropped by the browser.",
"",
` ${chalk.dim("Use --auth-cookies.")}`,
` ${chalk.dim(`Docs: ${DOC_URL}#popup-postmessage`)}`,
].join("\n"),
/** Printed when --debug-interceptor was passed and a trace was written. */
debugTraceWritten: (path) => chalk.dim(`Interceptor trace written to ${path}`),
/** Printed when the interceptor failed to install (caught error). */
installFailed: (errMessage) => [
chalk.yellow("⚠ Interceptor install failed — continuing without it"),
` ${chalk.dim(errMessage)}`,
` ${chalk.dim("Cross-domain auth flows may behave incorrectly. Re-run with --debug-interceptor for a trace.")}`,
].join("\n"),
/** Printed during run summary, with telemetry counters from the run. */
runSummary: (counts) => [
chalk.dim(`Interceptor: ${counts.rewrites} rewrites, ${counts.bypasses} bypasses, ${counts.errors} errors, p95 ${counts.p95Ms}ms`),
].join("\n"),
};
+6
-2

@@ -7,2 +7,3 @@ import { Command } from "commander";

import { saveConfig, getApiUrl, getConfig, clearConfig } from "../lib/config.js";
import { provisionPersistentTunnel } from "../lib/persistent-tunnel.js";
export const loginCommand = new Command("login")

@@ -48,3 +49,3 @@ .description("Authenticate with Swarm via browser")

pollSpinner.stop();
saveConfig({
const config = {
apiKey: poll.apiKey,

@@ -54,3 +55,4 @@ apiUrl,

organization: poll.organization,
});
};
saveConfig(config);
console.log(chalk.green("✓ Authenticated successfully!"));

@@ -61,2 +63,4 @@ console.log();

console.log();
const authedClient = new ApiClient(apiUrl, poll.apiKey);
await provisionPersistentTunnel(authedClient, config);
console.log(chalk.dim(" Run `swarm test` to start a UX simulation."));

@@ -63,0 +67,0 @@ return;

@@ -7,4 +7,5 @@ import { Command } from "commander";

import * as path from "path";
import { ApiClient } from "../lib/api-client.js";
import { requireAuth } from "../lib/config.js";
import { randomUUID } from "node:crypto";
import { ApiClient, TunnelLeaseConflictError } from "../lib/api-client.js";
import { requireAuth, saveConfig } from "../lib/config.js";
import { openTunnel } from "../lib/tunnel.js";

@@ -15,2 +16,3 @@ import { startProxy } from "../lib/proxy.js";

import { EventSourceParserStream } from "eventsource-parser/stream";
const HEARTBEAT_INTERVAL_MS = 30_000;
// ── Helpers ──────────────────────────────────────────────────────────────────

@@ -371,2 +373,13 @@ function isLocalhostHostname(hostname) {

let proxy;
// Persistent-tunnel session lease state. Populated when a stored
// persistent token is detected and the user is using localhost.
let persistentHandle;
let leaseClientId;
let heartbeatTimer;
if (isLocal && !skipTunnel && config.persistentTunnelToken && config.persistentTunnelHostname) {
persistentHandle = {
runToken: config.persistentTunnelToken,
hostname: config.persistentTunnelHostname,
};
}
let cleaningUp = false;

@@ -377,2 +390,12 @@ const cleanup = async () => {

cleaningUp = true;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
}
if (leaseClientId) {
// Best-effort release. If this fails, the server reaps the lease after
// 90s of missed heartbeats, so we don't block shutdown on it.
await client.releaseTunnelSession(leaseClientId).catch(() => { });
leaseClientId = undefined;
}
if (tunnel) {

@@ -389,2 +412,4 @@ await tunnel.close().catch(() => { });

const cleanupSync = () => {
if (heartbeatTimer)
clearInterval(heartbeatTimer);
if (tunnel) {

@@ -490,8 +515,54 @@ tunnel.close().catch(() => { });

const tunnelProvider = opts.tunnelProvider ?? "auto";
const providerLabel = tunnelProvider === "auto" ? "" : ` (${tunnelProvider})`;
// Persistent-tunnel branch: acquire a single-flight session lease BEFORE
// spawning cloudflared, so a second concurrent `swarm test` invocation
// gets a clean 409 instead of silently fighting over the same hostname.
if (persistentHandle) {
const leaseSpinner = ora("Acquiring persistent tunnel session...").start();
leaseClientId = randomUUID();
try {
await client.acquireTunnelSession(leaseClientId);
leaseSpinner.succeed("Acquired tunnel session");
}
catch (err) {
leaseClientId = undefined;
if (err instanceof TunnelLeaseConflictError) {
leaseSpinner.fail("Tunnel is already in use by another session");
const { heldBy, startedAt, hostname } = err.conflict;
console.error(chalk.red(`\n Hostname: https://${hostname}` +
(startedAt ? `\n Held since: ${startedAt}` : "") +
(heldBy ? `\n Held by: ${heldBy}` : "") +
`\n\n Wait for the other session to finish, or run` +
`\n ${chalk.cyan("swarm setup-tunnel --revoke && swarm setup-tunnel")}` +
`\n to forcibly reclaim it (will issue a new run-token).\n`));
await cleanup();
process.exit(1);
}
const msg = err.message;
leaseSpinner.fail("Failed to acquire tunnel session");
if (/no persistent tunnel/i.test(msg) || /404/.test(msg)) {
console.error(chalk.red(`\n Your stored persistent tunnel doesn't exist on the server anymore.` +
`\n Re-provision with: ${chalk.cyan("swarm setup-tunnel")}\n`));
// Clear stale local state so the next invocation falls back cleanly.
saveConfig({
...config,
persistentTunnelToken: undefined,
persistentTunnelHostname: undefined,
});
}
else {
console.error(chalk.red(msg));
}
await cleanup();
process.exit(1);
}
}
const providerLabel = persistentHandle
? " (persistent)"
: (tunnelProvider === "auto" ? "" : ` (${tunnelProvider})`);
const tunnelSpinner = ora(`Opening tunnel${providerLabel} to localhost:${tunnelPort}...`).start();
try {
// Only fetch ngrok token if provider is ngrok or auto (as fallback)
// Only fetch ngrok token if we're going to potentially use ngrok.
// Skip entirely for the persistent-tunnel path.
let ngrokAuthToken;
if (tunnelProvider !== "cloudflare") {
if (!persistentHandle && tunnelProvider !== "cloudflare") {
try {

@@ -508,3 +579,7 @@ const tokenRes = await client.getTunnelToken();

}
tunnel = await openTunnel(tunnelPort, { provider: tunnelProvider, ngrokAuthToken });
tunnel = await openTunnel(tunnelPort, {
provider: tunnelProvider,
ngrokAuthToken,
persistentTunnel: persistentHandle,
});
const originalPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : "";

@@ -515,3 +590,5 @@ targetUrl = `${tunnel.url}${originalPath === "/" ? "" : originalPath}`;

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

@@ -525,2 +602,19 @@ }

}
// Start lease heartbeat once the tunnel is actually up. If the heartbeat
// ever 410s (lease lost), we stop heartbeating and rely on cleanup; the
// server has already reaped, and the tunnel will keep working until the
// CF run-token is revoked.
if (persistentHandle && leaseClientId) {
const id = leaseClientId;
heartbeatTimer = setInterval(() => {
client.heartbeatTunnelSession(id).catch(() => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
}
});
}, HEARTBEAT_INTERVAL_MS);
// Don't let the heartbeat keep the process alive past the test.
heartbeatTimer.unref?.();
}
// Framework-specific warnings (after tunnel is open)

@@ -553,2 +647,7 @@ const framework = detectFramework(process.cwd());

}
// Plumb the persistent hostname through so the ux-agent runner can install
// its OAuth Location-header rewriter (chunk 4).
if (persistentHandle) {
body.persistentTunnelHostname = persistentHandle.hostname;
}
// Helper: rewrite a localhost URL to the public tunnel URL while preserving path/query

@@ -555,0 +654,0 @@ const rewriteToTunnel = (raw) => {

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

import { listSwarmsCommand } from "./commands/list-swarms.js";
import { setupTunnelCommand } from "./commands/setup-tunnel.js";
import { promptSelect } from "./lib/prompt.js";

@@ -24,2 +25,3 @@ import { getConfig } from "./lib/config.js";

program.addCommand(listSwarmsCommand);
program.addCommand(setupTunnelCommand);
// When no subcommand is given, show interactive menu

@@ -26,0 +28,0 @@ program.action(async () => {

@@ -1,2 +0,7 @@

import type { InitiateResponse, PollResponse, TunnelTokenResponse, SwarmsResponse, CreateTestRequest, CreateTestResponse, BatchStatusResponse } from "../types.js";
import type { InitiateResponse, PollResponse, TunnelTokenResponse, SwarmsResponse, CreateTestRequest, CreateTestResponse, BatchStatusResponse, PersistentTunnelInfo, GetTunnelResponse, AcquireSessionResponse, SessionConflictResponse } from "../types.js";
/** Error thrown when the persistent-tunnel session lease is already held. */
export declare class TunnelLeaseConflictError extends Error {
conflict: SessionConflictResponse;
constructor(conflict: SessionConflictResponse);
}
export declare class ApiClient {

@@ -15,2 +20,15 @@ private baseUrl;

streamUrl(batchId: string): string;
provisionTunnel(): Promise<PersistentTunnelInfo>;
getTunnel(): Promise<GetTunnelResponse>;
revokeTunnel(): Promise<{
revoked: boolean;
hostname: string;
}>;
/**
* Atomic single-flight lease. Throws `TunnelLeaseConflictError` on 409 so
* the caller can surface the conflict (who's holding it, since when) cleanly.
*/
acquireTunnelSession(clientId: string): Promise<AcquireSessionResponse>;
heartbeatTunnelSession(clientId: string): Promise<void>;
releaseTunnelSession(clientId: string): Promise<void>;
}

@@ -0,1 +1,10 @@

/** Error thrown when the persistent-tunnel session lease is already held. */
export class TunnelLeaseConflictError extends Error {
conflict;
constructor(conflict) {
super(conflict.error);
this.name = "TunnelLeaseConflictError";
this.conflict = conflict;
}
}
export class ApiClient {

@@ -68,2 +77,50 @@ baseUrl;

}
// ── Persistent tunnels ────────────────────────────────────────────────────
async provisionTunnel() {
return this.request("POST", "/tunnels/provision");
}
async getTunnel() {
return this.request("GET", "/tunnels/me");
}
async revokeTunnel() {
return this.request("DELETE", "/tunnels/me");
}
/**
* Atomic single-flight lease. Throws `TunnelLeaseConflictError` on 409 so
* the caller can surface the conflict (who's holding it, since when) cleanly.
*/
async acquireTunnelSession(clientId) {
const res = await fetch(`${this.baseUrl}/api/cli/tunnels/me/session`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ clientId }),
});
if (res.status === 409) {
const body = (await res.json());
throw new TunnelLeaseConflictError(body);
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json();
}
async heartbeatTunnelSession(clientId) {
await this.request("POST", "/tunnels/me/heartbeat", { clientId });
}
async releaseTunnelSession(clientId) {
// DELETE with JSON body — Hono supports it; standard fetch sends it through.
const res = await fetch(`${this.baseUrl}/api/cli/tunnels/me/session`, {
method: "DELETE",
headers: this.headers(),
body: JSON.stringify({ clientId }),
});
if (!res.ok && res.status !== 404) {
// Best-effort release: a 404 just means there was nothing to release,
// which is fine. Anything else is logged but not fatal — the heartbeat
// staleness check will reap the lease in 90s anyway.
const body = await res.text().catch(() => "");
throw new Error(`Release lease failed: HTTP ${res.status} ${body}`);
}
}
}

@@ -25,2 +25,4 @@ import Conf from "conf";

},
persistentTunnelToken: { type: "string" },
persistentTunnelHostname: { type: "string" },
},

@@ -27,0 +29,0 @@ },

@@ -163,10 +163,21 @@ import http from "node:http";

// ─── Header rewriting ────────────────────────────────────────────────────────
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function replaceAllCaseInsensitive(value, search, replacement) {
return value.replace(new RegExp(escapeRegExp(search), "gi"), replacement);
}
function replaceOriginReferences(value, fromOrigins, toOrigin) {
let next = value;
const encodedToOrigin = encodeURIComponent(toOrigin);
for (const fromOrigin of fromOrigins) {
next = next.replaceAll(fromOrigin, toOrigin);
next = replaceAllCaseInsensitive(next, encodeURIComponent(fromOrigin), encodedToOrigin);
}
return next;
}
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;
headers["location"] = replaceOriginReferences(headers["location"], localhostOrigins, tunnelUrl);
}

@@ -177,6 +188,3 @@ // set-cookie — strip Domain=localhost / Domain=127.0.0.1

headers["set-cookie"] = setCookie.map((cookie) => {
let c = cookie;
for (const origin of localhostOrigins) {
c = c.replaceAll(origin, tunnelUrl);
}
let c = replaceOriginReferences(cookie, localhostOrigins, tunnelUrl);
c = c.replace(/;\s*Domain=\.?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?/gi, "");

@@ -188,15 +196,7 @@ return c;

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;
headers["access-control-allow-origin"] = replaceOriginReferences(headers["access-control-allow-origin"], localhostOrigins, tunnelUrl);
}
// 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;
headers["content-security-policy"] = replaceOriginReferences(headers["content-security-policy"], localhostOrigins, tunnelUrl);
}

@@ -399,6 +399,4 @@ }

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);
text = replaceOriginReferences(text, allOrigins, tunnelUrl);
text = replaceOriginReferences(text, wsOrigins, tunnelWs);
finalBody = Buffer.from(text, "utf-8");

@@ -520,5 +518,5 @@ }

const text = original.toString("utf8");
// Replace both the exact tunnel URL and any subdomain/host reference
// a frontend might have grabbed from window.location.origin.
const rewritten = text.split(tunnelUrl).join(localOrigin);
// Replace plain and URL-encoded tunnel origins that a frontend might
// have grabbed from window.location.origin.
const rewritten = replaceOriginReferences(text, [tunnelUrl], localOrigin);
if (rewritten !== text) {

@@ -525,0 +523,0 @@ modified = Buffer.from(rewritten, "utf8");

@@ -1,2 +0,2 @@

export type TunnelProvider = "cloudflare" | "ngrok" | "auto";
export type TunnelProvider = "cloudflare" | "ngrok" | "cloudflare-named" | "auto";
export interface TunnelInfo {

@@ -7,6 +7,14 @@ url: string;

}
/** Run-token + stable hostname for a named Cloudflare tunnel. */
export interface PersistentTunnelHandle {
runToken: string;
hostname: string;
}
export interface OpenTunnelOptions {
provider?: TunnelProvider;
ngrokAuthToken?: string;
/** When provided, opens a persistent named Cloudflare tunnel instead of
* whichever ephemeral provider was selected. Takes precedence over `provider`. */
persistentTunnel?: PersistentTunnelHandle;
}
export declare function openTunnel(localPort: number, opts?: OpenTunnelOptions): Promise<TunnelInfo>;

@@ -118,3 +118,78 @@ import ngrok from "@ngrok/ngrok";

}
// ── Provider: Cloudflare *named* tunnel (persistent) ─────────────────────────
//
// Unlike Quick Tunnels, we already know the hostname ahead of time — it was
// provisioned via the API and stored locally. cloudflared connects out using
// the run-token JWT; we just wait for it to register a connection.
const CF_NAMED_READY_RE = /Registered tunnel connection|connection .+ registered/i;
async function openCloudflareNamedTunnel(localPort, handle) {
const bin = await findCloudflaredBin();
const child = spawn(bin, [
"tunnel",
"--no-autoupdate",
"run",
"--token",
handle.runToken,
"--url",
`http://127.0.0.1:${localPort}`,
], { stdio: ["ignore", "pipe", "pipe"] });
await new Promise((resolve, reject) => {
// Slightly longer than Quick Tunnel — named tunnels do an extra account-token
// exchange against the CF edge before the first connection registers.
const timer = setTimeout(() => {
child.kill();
reject(new Error("Persistent tunnel didn't come up within 30s. " +
"Check: (a) cloudflared is installed, (b) your network can reach " +
"Cloudflare's edge, (c) the run-token hasn't been revoked (try `swarm setup-tunnel --revoke` and re-provision)."));
}, 30_000);
let buf = "";
const onData = (chunk) => {
buf += chunk.toString();
if (CF_NAMED_READY_RE.test(buf)) {
clearTimeout(timer);
resolve();
}
};
child.stderr?.on("data", onData);
child.stdout?.on("data", onData);
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 the persistent tunnel registered.\n` +
(buf ? `Last output:\n${buf.slice(-500)}` : "")));
});
});
return {
url: `https://${handle.hostname}`,
provider: "cloudflare-named",
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 = {}) {
// Persistent tunnel short-circuits all provider selection.
if (opts.persistentTunnel) {
return openCloudflareNamedTunnel(localPort, opts.persistentTunnel);
}
const provider = opts.provider ?? "auto";

@@ -131,2 +206,5 @@ // Explicit provider selection

}
if (provider === "cloudflare-named") {
throw new Error("cloudflare-named requires a persistentTunnel handle. Run `swarm login` or `swarm setup-tunnel` to retry provisioning.");
}
// Auto mode: try cloudflare first (free, no token needed), fall back to ngrok

@@ -133,0 +211,0 @@ try {

@@ -23,2 +23,31 @@ export interface InitiateResponse {

}
export interface PersistentTunnelInfo {
hostname: string;
runToken: string;
reused?: boolean;
createdAt?: string;
}
export interface GetTunnelResponse {
tunnel: {
hostname: string;
runToken: string;
createdAt: string;
activeSession: {
clientId: string;
startedAt: string | null;
lastHeartbeatAt: string | null;
} | null;
} | null;
}
export interface AcquireSessionResponse {
acquired: true;
hostname: string;
}
export interface SessionConflictResponse {
error: string;
heldBy: string | null;
startedAt: string | null;
lastHeartbeatAt: string | null;
hostname: string;
}
export interface Swarm {

@@ -74,2 +103,7 @@ id: string;

};
/** Stable per-user persistent tunnel hostname. When set, the ux-agent installs
* a Playwright `page.route()` handler that rewrites cross-origin OAuth
* callback query params and final loopback redirects to `https://<hostname>`,
* so OAuth flows work without the user changing their app code. */
persistentTunnelHostname?: string;
}

@@ -163,2 +197,6 @@ export interface CreateTestResponse {

};
/** cloudflared run-token (JWT) for the user's persistent named tunnel. */
persistentTunnelToken?: string;
/** Stable hostname e.g. `<user-id>-acme.swarmtunnel.com`. */
persistentTunnelHostname?: string;
}
{
"name": "@useswarm/cli",
"version": "0.3.7",
"version": "0.4.0",
"description": "Swarm CLI — AI-powered UX testing from your terminal",

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