@useswarm/cli
Advanced tools
| 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"), | ||
| }; |
@@ -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; |
+106
-7
@@ -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) => { |
+2
-0
@@ -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 @@ }, |
+24
-26
@@ -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>; |
+78
-0
@@ -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 { |
+38
-0
@@ -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; | ||
| } |
+1
-1
| { | ||
| "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", |
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
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
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
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
127248
23.74%30
25%2624
25.25%7
40%