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.4.0
to
0.5.0
+2
dist/commands/check-tunnel.d.ts
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,
};
}
+3
-12

@@ -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 () => {

@@ -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];

@@ -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;

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) {

{
"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": {