@useswarm/cli
Advanced tools
| import { Command } from "commander"; | ||
| export declare const checkTunnelCommand: Command; |
| import { execFile } from "node:child_process"; | ||
| import { promisify } from "node:util"; | ||
| import { Command } from "commander"; | ||
| import chalk from "chalk"; | ||
| import ora from "ora"; | ||
| import { ApiClient } from "../lib/api-client.js"; | ||
| import { evaluatePersistentTunnelStatus, } from "../lib/check-persistent-tunnel.js"; | ||
| import { requireAuth, saveConfig } from "../lib/config.js"; | ||
| const execFileAsync = promisify(execFile); | ||
| async function isCloudflaredAvailable() { | ||
| try { | ||
| await execFileAsync("cloudflared", ["--version"], { timeout: 5_000 }); | ||
| return true; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| function issueFix(issue) { | ||
| switch (issue) { | ||
| case "local_credentials_missing": | ||
| case "server_not_provisioned": | ||
| return "swarm setup-tunnel"; | ||
| case "local_credentials_stale": | ||
| return "swarm setup-tunnel (or `swarm check-tunnel --fix-stale` to clear local state)"; | ||
| case "hostname_mismatch": | ||
| return "swarm setup-tunnel (re-syncs run token)"; | ||
| } | ||
| } | ||
| function sessionLabel(status) { | ||
| switch (status) { | ||
| case "available": | ||
| return chalk.green("none (available)"); | ||
| case "in_use": | ||
| return chalk.yellow("in use"); | ||
| case "stale": | ||
| return chalk.yellow("stale (likely reclaimable)"); | ||
| case "none": | ||
| return chalk.dim("n/a"); | ||
| } | ||
| } | ||
| export const checkTunnelCommand = new Command("check-tunnel") | ||
| .description("Verify persistent tunnel provisioning and whether local credentials match the server") | ||
| .option("--json", "Print machine-readable status JSON") | ||
| .option("--fix-stale", "Clear local tunnel credentials when the server has no provisioned tunnel " + | ||
| "(does not fix hostname mismatches — run `swarm setup-tunnel` for those)") | ||
| .action(async (opts) => { | ||
| const config = requireAuth(); | ||
| const client = new ApiClient(config.apiUrl, config.apiKey); | ||
| const localHostname = config.persistentTunnelHostname; | ||
| const localHasCredentials = !!(config.persistentTunnelToken && config.persistentTunnelHostname); | ||
| const spinner = ora("Checking persistent tunnel on server...").start(); | ||
| let remote; | ||
| try { | ||
| remote = await client.getTunnel(); | ||
| } | ||
| catch (err) { | ||
| spinner.fail("Could not reach API"); | ||
| if (opts.json) { | ||
| console.log(JSON.stringify({ | ||
| ok: false, | ||
| error: err.message, | ||
| })); | ||
| } | ||
| else { | ||
| console.error(chalk.red(err.message)); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| spinner.stop(); | ||
| let status = evaluatePersistentTunnelStatus({ | ||
| localHostname, | ||
| localHasCredentials, | ||
| serverTunnel: remote.tunnel, | ||
| }); | ||
| const fixStaleApplied = !!opts.fixStale && status.issues.includes("local_credentials_stale"); | ||
| if (fixStaleApplied) { | ||
| saveConfig({ | ||
| ...config, | ||
| persistentTunnelToken: undefined, | ||
| persistentTunnelHostname: undefined, | ||
| }); | ||
| // Re-evaluate so the reported state matches what is now on disk. | ||
| status = evaluatePersistentTunnelStatus({ | ||
| localHostname: undefined, | ||
| localHasCredentials: false, | ||
| serverTunnel: remote.tunnel, | ||
| }); | ||
| } | ||
| const cloudflaredAvailable = await isCloudflaredAvailable(); | ||
| if (opts.json) { | ||
| console.log(JSON.stringify({ | ||
| ...status, | ||
| cloudflaredAvailable, | ||
| fixStaleApplied, | ||
| })); | ||
| process.exit(status.ok ? 0 : 1); | ||
| } | ||
| console.log(); | ||
| console.log(chalk.bold(" Persistent tunnel status")); | ||
| console.log(); | ||
| console.log(` Local credentials: ${status.local.hasCredentials ? chalk.green("present") : chalk.yellow("missing")}`); | ||
| if (status.local.hostname) { | ||
| console.log(` Local hostname: ${chalk.cyan(`https://${status.local.hostname}`)}`); | ||
| } | ||
| if (!status.server.provisioned) { | ||
| console.log(` Server tunnel: ${chalk.red("not provisioned")}`); | ||
| } | ||
| else { | ||
| console.log(` Server tunnel: ${chalk.green("provisioned")}`); | ||
| console.log(` Hostname: ${chalk.cyan.underline(`https://${status.server.hostname}`)}`); | ||
| if (status.server.createdAt) { | ||
| console.log(` Created: ${status.server.createdAt}`); | ||
| } | ||
| } | ||
| console.log(` Active session: ${sessionLabel(status.server.session.status)}`); | ||
| if (status.server.session.clientId) { | ||
| console.log(chalk.dim(` clientId: ${status.server.session.clientId}`)); | ||
| } | ||
| if (status.server.session.startedAt) { | ||
| console.log(chalk.dim(` started: ${status.server.session.startedAt}`)); | ||
| } | ||
| if (status.server.session.lastHeartbeatAt) { | ||
| console.log(chalk.dim(` heartbeat: ${status.server.session.lastHeartbeatAt}`)); | ||
| } | ||
| console.log(` cloudflared: ${cloudflaredAvailable ? chalk.green("installed") : chalk.yellow("not found on PATH")}`); | ||
| if (status.issues.length > 0) { | ||
| console.log(); | ||
| console.log(chalk.bold(" Issues")); | ||
| for (const issue of status.issues) { | ||
| console.log(` - ${chalk.yellow(issue.replaceAll("_", " "))}`); | ||
| console.log(chalk.dim(` Fix: ${issueFix(issue)}`)); | ||
| } | ||
| } | ||
| if (fixStaleApplied) { | ||
| console.log(); | ||
| console.log(chalk.dim(" Cleared stale local credentials (--fix-stale).")); | ||
| } | ||
| if (status.ok) { | ||
| console.log(); | ||
| console.log(chalk.green(" Persistent tunnel is provisioned and local credentials match.")); | ||
| console.log(chalk.dim(" Run `swarm test --url http://localhost:3000 ...` to use it for localhost testing.")); | ||
| console.log(); | ||
| return; | ||
| } | ||
| console.log(); | ||
| process.exit(1); | ||
| }); |
| import type { GetTunnelResponse } from "../types.js"; | ||
| export declare const LEASE_STALE_MS = 90000; | ||
| export type TunnelCheckIssue = "local_credentials_missing" | "server_not_provisioned" | "local_credentials_stale" | "hostname_mismatch"; | ||
| export type SessionStatus = "none" | "available" | "in_use" | "stale"; | ||
| export interface TunnelCheckInput { | ||
| localHostname?: string; | ||
| localHasCredentials: boolean; | ||
| serverTunnel: GetTunnelResponse["tunnel"]; | ||
| now?: number; | ||
| } | ||
| export interface TunnelCheckResult { | ||
| ok: boolean; | ||
| issues: TunnelCheckIssue[]; | ||
| local: { | ||
| hasCredentials: boolean; | ||
| hostname?: string; | ||
| }; | ||
| server: { | ||
| provisioned: boolean; | ||
| hostname?: string; | ||
| createdAt?: string; | ||
| session: { | ||
| status: SessionStatus; | ||
| clientId?: string; | ||
| startedAt?: string | null; | ||
| lastHeartbeatAt?: string | null; | ||
| }; | ||
| }; | ||
| } | ||
| export declare function evaluatePersistentTunnelStatus(input: TunnelCheckInput): TunnelCheckResult; |
| export const LEASE_STALE_MS = 90_000; | ||
| export function evaluatePersistentTunnelStatus(input) { | ||
| const { localHostname, serverTunnel } = input; | ||
| const now = input.now ?? Date.now(); | ||
| const issues = []; | ||
| // Credentials are only usable when the hostname is also present, so | ||
| // normalize inconsistent input (token without hostname) to "missing". | ||
| const localHasCredentials = input.localHasCredentials && !!localHostname; | ||
| const serverProvisioned = serverTunnel !== null; | ||
| if (!localHasCredentials) { | ||
| issues.push("local_credentials_missing"); | ||
| } | ||
| if (!serverProvisioned) { | ||
| issues.push("server_not_provisioned"); | ||
| if (localHasCredentials) { | ||
| issues.push("local_credentials_stale"); | ||
| } | ||
| } | ||
| else if (localHostname && localHostname !== serverTunnel.hostname) { | ||
| issues.push("hostname_mismatch"); | ||
| } | ||
| const session = resolveSessionStatus(serverTunnel, now); | ||
| // localHasCredentials implies localHostname is set (normalized above). | ||
| const ok = serverProvisioned && | ||
| localHasCredentials && | ||
| localHostname === serverTunnel.hostname; | ||
| return { | ||
| ok, | ||
| issues, | ||
| local: { | ||
| hasCredentials: localHasCredentials, | ||
| hostname: localHostname, | ||
| }, | ||
| server: { | ||
| provisioned: serverProvisioned, | ||
| hostname: serverTunnel?.hostname, | ||
| createdAt: serverTunnel?.createdAt, | ||
| session, | ||
| }, | ||
| }; | ||
| } | ||
| function resolveSessionStatus(serverTunnel, now) { | ||
| if (!serverTunnel) { | ||
| return { status: "none" }; | ||
| } | ||
| const active = serverTunnel.activeSession; | ||
| if (!active) { | ||
| return { status: "available" }; | ||
| } | ||
| // Fall back to startedAt when the holder never sent a heartbeat (e.g. it | ||
| // crashed right after acquiring the lease) so the session can still be | ||
| // reported as stale instead of permanently "in use". | ||
| const lastHeartbeatAt = active.lastHeartbeatAt; | ||
| const startMs = active.startedAt ? new Date(active.startedAt).getTime() : 0; | ||
| const lastMs = lastHeartbeatAt ? new Date(lastHeartbeatAt).getTime() : startMs; | ||
| const stale = lastMs > 0 && now - lastMs > LEASE_STALE_MS; | ||
| return { | ||
| status: stale ? "stale" : "in_use", | ||
| clientId: active.clientId, | ||
| startedAt: active.startedAt, | ||
| lastHeartbeatAt, | ||
| }; | ||
| } |
@@ -10,3 +10,3 @@ import { Command } from "commander"; | ||
| import { requireAuth, saveConfig } from "../lib/config.js"; | ||
| import { openTunnel } from "../lib/tunnel.js"; | ||
| import { openTunnel, isLocalhostHostname, isLocalhostUrl, getPortFromUrl } from "../lib/tunnel.js"; | ||
| import { startProxy } from "../lib/proxy.js"; | ||
@@ -18,10 +18,2 @@ import { renderStep, renderRunComplete, renderSynthesis, renderJudge, renderDone } from "../lib/renderer.js"; | ||
| // ── 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") { | ||
@@ -109,4 +101,3 @@ return new Promise((resolve) => { | ||
| catch { } | ||
| const hostname = parsedUrl?.hostname ?? ""; | ||
| const isLocal = isLocalhostHostname(hostname); | ||
| const isLocal = isLocalhostUrl(targetUrl); | ||
| // ── 2. Localhost setup (backend, paths, tunnel) ────────────────── | ||
@@ -445,3 +436,3 @@ let backendUrl = opts.backend; | ||
| if (isLocal && !skipTunnel) { | ||
| const frontendPort = parsedUrl?.port ? parseInt(parsedUrl.port, 10) : 3000; | ||
| const frontendPort = getPortFromUrl(targetUrl, 3000); | ||
| // The local origin we'll spoof on outbound Origin/Referer headers so the | ||
@@ -448,0 +439,0 @@ // user's CSRF middleware sees a same-origin request. Falls back to the |
+2
-0
@@ -11,2 +11,3 @@ #!/usr/bin/env node | ||
| import { setupTunnelCommand } from "./commands/setup-tunnel.js"; | ||
| import { checkTunnelCommand } from "./commands/check-tunnel.js"; | ||
| import { promptSelect } from "./lib/prompt.js"; | ||
@@ -26,2 +27,3 @@ import { getConfig } from "./lib/config.js"; | ||
| program.addCommand(setupTunnelCommand); | ||
| program.addCommand(checkTunnelCommand); | ||
| // When no subcommand is given, show interactive menu | ||
@@ -28,0 +30,0 @@ program.action(async () => { |
+13
-0
@@ -235,2 +235,7 @@ import http from "node:http"; | ||
| } | ||
| // x-forwarded-host must agree with the spoofed Origin: Next.js Server | ||
| // Actions reject any POST where the Origin host differs from | ||
| // x-forwarded-host ("Invalid Server Actions request", 500). Other | ||
| // frameworks (Rails, SvelteKit) run equivalent checks. | ||
| headers["x-forwarded-host"] = parsedLocal.host; | ||
| if (typeof headers.origin === "string" && headers.origin.length > 0 && headers.origin !== "null") { | ||
@@ -266,5 +271,11 @@ headers.origin = parsedLocal.origin; | ||
| : DEFAULT_BACKEND_PATH_PREFIXES; | ||
| // https variants included: apps that build absolute URLs from | ||
| // x-forwarded-proto (https) + x-forwarded-host (localhost:<port>) emit | ||
| // https://localhost:<port>/... references that must also be rewritten | ||
| // back to the tunnel URL. | ||
| const backendOrigins = [ | ||
| `http://localhost:${backendPort}`, | ||
| `http://127.0.0.1:${backendPort}`, | ||
| `https://localhost:${backendPort}`, | ||
| `https://127.0.0.1:${backendPort}`, | ||
| ]; | ||
@@ -274,2 +285,4 @@ const frontendOrigins = [ | ||
| `http://127.0.0.1:${frontendPort}`, | ||
| `https://localhost:${frontendPort}`, | ||
| `https://127.0.0.1:${frontendPort}`, | ||
| ]; | ||
@@ -276,0 +289,0 @@ const allOrigins = [...backendOrigins, ...frontendOrigins]; |
+10
-0
@@ -12,2 +12,12 @@ export type TunnelProvider = "cloudflare" | "ngrok" | "cloudflare-named" | "auto"; | ||
| } | ||
| /** True for hostnames that resolve to the local machine. */ | ||
| export declare function isLocalhostHostname(hostname: string): boolean; | ||
| /** True when `url` points at the local machine. Returns false for unparseable input. */ | ||
| export declare function isLocalhostUrl(url: string): boolean; | ||
| /** | ||
| * Resolve the port for a URL: the explicit port if present, otherwise | ||
| * `fallbackPort` when given, otherwise the protocol default (443/80). | ||
| * An unparseable URL resolves to `fallbackPort` (or 80). | ||
| */ | ||
| export declare function getPortFromUrl(url: string, fallbackPort?: number): number; | ||
| export interface OpenTunnelOptions { | ||
@@ -14,0 +24,0 @@ provider?: TunnelProvider; |
+37
-0
| import ngrok from "@ngrok/ngrok"; | ||
| import { spawn } from "node:child_process"; | ||
| import { once } from "node:events"; | ||
| /** True for hostnames that resolve to the local machine. */ | ||
| export function isLocalhostHostname(hostname) { | ||
| return (hostname === "localhost" || | ||
| hostname === "127.0.0.1" || | ||
| hostname === "0.0.0.0" || | ||
| hostname === "::1" || | ||
| hostname === "[::1]" || | ||
| hostname.endsWith(".local") || | ||
| hostname.endsWith(".localhost")); | ||
| } | ||
| /** True when `url` points at the local machine. Returns false for unparseable input. */ | ||
| export function isLocalhostUrl(url) { | ||
| try { | ||
| return isLocalhostHostname(new URL(url).hostname); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** | ||
| * Resolve the port for a URL: the explicit port if present, otherwise | ||
| * `fallbackPort` when given, otherwise the protocol default (443/80). | ||
| * An unparseable URL resolves to `fallbackPort` (or 80). | ||
| */ | ||
| export function getPortFromUrl(url, fallbackPort) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| if (parsed.port) | ||
| return parseInt(parsed.port, 10); | ||
| if (fallbackPort != null) | ||
| return fallbackPort; | ||
| return parsed.protocol === "https:" ? 443 : 80; | ||
| } | ||
| catch { | ||
| return fallbackPort ?? 80; | ||
| } | ||
| } | ||
| // ── Provider: ngrok ────────────────────────────────────────────────────────── | ||
@@ -5,0 +42,0 @@ async function openNgrokTunnel(localPort, authToken) { |
+4
-2
| { | ||
| "name": "@useswarm/cli", | ||
| "version": "0.4.0", | ||
| "version": "0.5.0", | ||
| "description": "Swarm CLI — AI-powered UX testing from your terminal", | ||
@@ -17,2 +17,3 @@ "type": "module", | ||
| "start": "node dist/index.js", | ||
| "test": "vitest run --project cli", | ||
| "prepublishOnly": "npm run build" | ||
@@ -46,3 +47,4 @@ }, | ||
| "tsx": "^4.0.0", | ||
| "typescript": "^5.0.0" | ||
| "typescript": "^5.0.0", | ||
| "vitest": "^3.2.4" | ||
| }, | ||
@@ -49,0 +51,0 @@ "engines": { |
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
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
138865
9.13%34
13.33%2920
11.28%4
33.33%