speqs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ Copyright (c) 2025 Speqs. All rights reserved.
2
+
3
+ This software is the proprietary property of Speqs and is provided under the
4
+ terms of the Speqs Terms of Service (https://speqs.io/terms). Unauthorized
5
+ copying, modification, distribution, or use of this software, in whole or in
6
+ part, is strictly prohibited without prior written consent from Speqs.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # speqs
2
+
3
+ CLI tool to expose your localhost to [Speqs](https://speqs.io) via Cloudflare tunnels for simulation testing.
4
+
5
+ ## Prerequisites
6
+
7
+ [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) must be installed:
8
+
9
+ - **macOS**: `brew install cloudflare/cloudflare/cloudflared`
10
+ - **Debian/Ubuntu**: `sudo apt install cloudflared`
11
+ - **Windows**: `scoop install cloudflared` or [download](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
12
+
13
+ ## Install
14
+
15
+ ### npm (all platforms)
16
+
17
+ ```bash
18
+ npm install -g speqs
19
+ ```
20
+
21
+ ### Homebrew (macOS / Linux)
22
+
23
+ ```bash
24
+ brew tap speqs-io/tap
25
+ brew install speqs
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ speqs tunnel <port>
32
+ ```
33
+
34
+ ### Options
35
+
36
+ | Flag | Description |
37
+ |------|-------------|
38
+ | `-t, --token <token>` | Auth token (or set `SPEQS_TOKEN` env var, or enter interactively) |
39
+ | `--api-url <url>` | Backend API URL (default: `https://api.speqs.io` or `SPEQS_API_URL` env var) |
40
+ | `--version` | Show version |
41
+
42
+ ### Token Configuration
43
+
44
+ The CLI resolves your auth token in this order:
45
+
46
+ 1. `--token` CLI argument
47
+ 2. `SPEQS_TOKEN` environment variable
48
+ 3. Saved token in `~/.speqs/config.json`
49
+ 4. Interactive prompt (token is saved for future use)
50
+
51
+ Find your token in the Speqs app under **Settings**.
52
+
53
+ ## Examples
54
+
55
+ ```bash
56
+ # Expose port 3000
57
+ speqs tunnel 3000
58
+
59
+ # With explicit token
60
+ speqs tunnel 3000 --token YOUR_TOKEN
61
+
62
+ # Using environment variable
63
+ SPEQS_TOKEN=YOUR_TOKEN speqs tunnel 8080
64
+ ```
65
+
66
+ ## License
67
+
68
+ Copyright (c) 2025 Speqs. All rights reserved. See [LICENSE](LICENSE).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { program, Option } from "commander";
4
+ import { runTunnel } from "./tunnel.js";
5
+ const require = createRequire(import.meta.url);
6
+ const { version } = require("../package.json");
7
+ program
8
+ .name("speqs")
9
+ .description("Speqs CLI tools")
10
+ .version(version);
11
+ program
12
+ .command("tunnel")
13
+ .description("Expose your localhost to Speqs via a Cloudflare tunnel")
14
+ .argument("<port>", "Local port to tunnel (e.g. 3000)")
15
+ .option("-t, --token <token>", "Auth token (or set SPEQS_TOKEN, or save via interactive prompt)")
16
+ .option("--api-url <url>", "Backend API URL (default: SPEQS_API_URL or https://api.speqs.io)")
17
+ .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
18
+ .action(async (port, options) => {
19
+ const portNum = parseInt(port, 10);
20
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
21
+ console.error(`Invalid port: ${port}`);
22
+ process.exit(1);
23
+ }
24
+ const apiUrl = options.dev ? "http://localhost:8000" : options.apiUrl;
25
+ await runTunnel(portNum, options.token, apiUrl);
26
+ });
27
+ program.parse();
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Localhost tunnel CLI — wraps cloudflared and registers with Speqs backend.
3
+ */
4
+ export declare function runTunnel(port: number, tokenArg?: string, apiUrlArg?: string): Promise<void>;
package/dist/tunnel.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Localhost tunnel CLI — wraps cloudflared and registers with Speqs backend.
3
+ */
4
+ import { spawn, execSync } from "node:child_process";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import * as readline from "node:readline";
9
+ const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
10
+ const HEARTBEAT_INTERVAL = 30_000;
11
+ const MAX_HEARTBEAT_FAILURES = 3;
12
+ const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
13
+ const DEFAULT_API_URL = "https://api.speqs.io";
14
+ const API_BASE = "/api/v1";
15
+ const CONFIG_DIR = path.join(os.homedir(), ".speqs");
16
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
+ function loadConfig() {
18
+ try {
19
+ if (fs.existsSync(CONFIG_FILE)) {
20
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
21
+ }
22
+ }
23
+ catch {
24
+ // Corrupted config — ignore
25
+ }
26
+ return {};
27
+ }
28
+ function saveConfig(config) {
29
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
30
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
31
+ }
32
+ async function verifyToken(token, apiUrl) {
33
+ try {
34
+ const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/active`, {
35
+ headers: { Authorization: `Bearer ${token}` },
36
+ signal: AbortSignal.timeout(10_000),
37
+ });
38
+ // 404 = valid token, no tunnel (expected). 401/403 = bad token.
39
+ return resp.status !== 401 && resp.status !== 403;
40
+ }
41
+ catch {
42
+ // Network error — can't verify, assume ok
43
+ console.error("Warning: Could not verify token (network error). Proceeding anyway.");
44
+ return true;
45
+ }
46
+ }
47
+ function resolveApiUrl(apiUrlArg) {
48
+ if (apiUrlArg)
49
+ return apiUrlArg;
50
+ return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
51
+ }
52
+ function prompt(question) {
53
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
54
+ return new Promise((resolve) => {
55
+ rl.question(question, (answer) => {
56
+ rl.close();
57
+ resolve(answer.trim());
58
+ });
59
+ });
60
+ }
61
+ async function resolveToken(tokenArg, apiUrl) {
62
+ if (tokenArg)
63
+ return tokenArg;
64
+ const envToken = process.env.SPEQS_TOKEN;
65
+ if (envToken)
66
+ return envToken;
67
+ const config = loadConfig();
68
+ if (config.token) {
69
+ if (await verifyToken(config.token, apiUrl)) {
70
+ return config.token;
71
+ }
72
+ console.error("Saved token is invalid or expired.\n");
73
+ }
74
+ // Interactive prompt
75
+ console.log("You can find your token in the simulation view.\n");
76
+ while (true) {
77
+ const token = await prompt("Paste your token: ");
78
+ if (!token) {
79
+ console.log("No token provided, exiting.");
80
+ process.exit(1);
81
+ }
82
+ if (await verifyToken(token, apiUrl)) {
83
+ config.token = token;
84
+ saveConfig(config);
85
+ return token;
86
+ }
87
+ console.error("Invalid token. Try again.\n");
88
+ }
89
+ }
90
+ // --- Cloudflared ---
91
+ function checkCloudflared() {
92
+ try {
93
+ execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
94
+ }
95
+ catch {
96
+ console.error("Missing dependency. Install it:\n" +
97
+ " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
98
+ " sudo apt install cloudflared # Debian/Ubuntu\n" +
99
+ "\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
100
+ process.exit(1);
101
+ }
102
+ }
103
+ function startCloudflared(port) {
104
+ return new Promise((resolve, reject) => {
105
+ console.log(`Starting tunnel to localhost:${port}...`);
106
+ const proc = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ });
109
+ let tunnelUrl = null;
110
+ const timeout = setTimeout(() => {
111
+ if (!tunnelUrl) {
112
+ proc.kill();
113
+ reject(new Error("Failed to get tunnel URL within timeout."));
114
+ }
115
+ }, CLOUDFLARED_STARTUP_TIMEOUT);
116
+ proc.stderr?.on("data", (data) => {
117
+ const line = data.toString("utf-8");
118
+ const match = line.match(TUNNEL_URL_PATTERN);
119
+ if (match && !tunnelUrl) {
120
+ tunnelUrl = match[0];
121
+ clearTimeout(timeout);
122
+ console.log(`Tunnel active: ${tunnelUrl}`);
123
+ resolve({ process: proc, tunnelUrl });
124
+ }
125
+ });
126
+ proc.on("exit", (code) => {
127
+ clearTimeout(timeout);
128
+ if (!tunnelUrl) {
129
+ reject(new Error("cloudflared exited unexpectedly."));
130
+ }
131
+ });
132
+ proc.on("error", (err) => {
133
+ clearTimeout(timeout);
134
+ reject(err);
135
+ });
136
+ });
137
+ }
138
+ // --- API calls ---
139
+ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
140
+ try {
141
+ const resp = await fetch(`${apiUrl}${API_BASE}/tunnel`, {
142
+ method: "POST",
143
+ headers: {
144
+ Authorization: `Bearer ${token}`,
145
+ "Content-Type": "application/json",
146
+ },
147
+ body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
148
+ signal: AbortSignal.timeout(10_000),
149
+ });
150
+ if (!resp.ok)
151
+ throw new Error(`HTTP ${resp.status}`);
152
+ console.log("Registered with Speqs backend");
153
+ }
154
+ catch (e) {
155
+ console.error(`Warning: Failed to register tunnel: ${e}`);
156
+ console.error("Tunnel is still active — you can retry manually.");
157
+ }
158
+ }
159
+ async function deregisterTunnel(apiUrl, token) {
160
+ try {
161
+ const resp = await fetch(`${apiUrl}${API_BASE}/tunnel`, {
162
+ method: "DELETE",
163
+ headers: { Authorization: `Bearer ${token}` },
164
+ signal: AbortSignal.timeout(2_000),
165
+ });
166
+ if (!resp.ok)
167
+ throw new Error(`HTTP ${resp.status}`);
168
+ console.log("Tunnel deregistered");
169
+ }
170
+ catch (e) {
171
+ console.error(`Warning: Failed to deregister tunnel: ${e}`);
172
+ }
173
+ }
174
+ function startHeartbeat(apiUrl, token, onFatal) {
175
+ let consecutiveFailures = 0;
176
+ let stopped = false;
177
+ const interval = setInterval(async () => {
178
+ if (stopped)
179
+ return;
180
+ try {
181
+ const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/heartbeat`, {
182
+ method: "POST",
183
+ headers: { Authorization: `Bearer ${token}` },
184
+ signal: AbortSignal.timeout(10_000),
185
+ });
186
+ if (!resp.ok)
187
+ throw new Error(`HTTP ${resp.status}`);
188
+ consecutiveFailures = 0;
189
+ }
190
+ catch (e) {
191
+ consecutiveFailures++;
192
+ console.error(`Heartbeat failed (${consecutiveFailures}/${MAX_HEARTBEAT_FAILURES}): ${e}`);
193
+ if (consecutiveFailures >= MAX_HEARTBEAT_FAILURES) {
194
+ console.error("Lost connection to Speqs backend. Shutting down.");
195
+ stopped = true;
196
+ clearInterval(interval);
197
+ onFatal();
198
+ }
199
+ }
200
+ }, HEARTBEAT_INTERVAL);
201
+ return {
202
+ stop: () => {
203
+ stopped = true;
204
+ clearInterval(interval);
205
+ },
206
+ };
207
+ }
208
+ // --- Main ---
209
+ export async function runTunnel(port, tokenArg, apiUrlArg) {
210
+ const apiUrl = resolveApiUrl(apiUrlArg);
211
+ if (apiUrl !== DEFAULT_API_URL) {
212
+ console.log(`Using API: ${apiUrl}`);
213
+ }
214
+ const token = await resolveToken(tokenArg, apiUrl);
215
+ checkCloudflared();
216
+ let cfResult;
217
+ try {
218
+ cfResult = await startCloudflared(port);
219
+ }
220
+ catch (e) {
221
+ console.error(`Failed to start cloudflared: ${e}`);
222
+ process.exit(1);
223
+ }
224
+ const { process: cfProcess, tunnelUrl } = cfResult;
225
+ await registerTunnel(apiUrl, token, tunnelUrl, port);
226
+ let shuttingDown = false;
227
+ const shutdown = async () => {
228
+ if (shuttingDown)
229
+ process.exit(1);
230
+ shuttingDown = true;
231
+ console.log("\nShutting down...");
232
+ heartbeat.stop();
233
+ cfProcess.kill();
234
+ await deregisterTunnel(apiUrl, token);
235
+ process.exit(0);
236
+ };
237
+ const heartbeat = startHeartbeat(apiUrl, token, async () => {
238
+ await deregisterTunnel(apiUrl, token);
239
+ cfProcess.kill();
240
+ process.exit(1);
241
+ });
242
+ process.on("SIGINT", shutdown);
243
+ process.on("SIGTERM", shutdown);
244
+ console.log("\nPress Ctrl+C to disconnect.\n");
245
+ cfProcess.on("exit", async () => {
246
+ if (!shuttingDown) {
247
+ heartbeat.stop();
248
+ await deregisterTunnel(apiUrl, token);
249
+ process.exit(0);
250
+ }
251
+ });
252
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "speqs",
3
+ "version": "0.1.0",
4
+ "description": "The command-line interface for Speqs",
5
+ "type": "module",
6
+ "bin": {
7
+ "speqs": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "speqs",
24
+ "tunnel",
25
+ "localhost",
26
+ "testing"
27
+ ],
28
+ "author": "Speqs",
29
+ "license": "SEE LICENSE IN LICENSE",
30
+ "homepage": "https://speqs.io",
31
+ "bugs": {
32
+ "email": "support@speqs.io"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^13.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.7.0"
40
+ }
41
+ }