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 +6 -0
- package/README.md +68 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +27 -0
- package/dist/tunnel.d.ts +4 -0
- package/dist/tunnel.js +252 -0
- package/package.json +41 -0
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).
|
package/dist/index.d.ts
ADDED
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();
|
package/dist/tunnel.d.ts
ADDED
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
|
+
}
|