speqs 0.2.0 → 0.4.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/README.md +15 -14
- package/dist/{tunnel.d.ts → connect.d.ts} +1 -1
- package/dist/connect.js +573 -0
- package/dist/index.js +13 -6
- package/dist/upgrade.d.ts +1 -0
- package/dist/upgrade.js +94 -0
- package/package.json +3 -2
- package/dist/tunnel.js +0 -343
package/README.md
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
CLI tool to expose your localhost to [Speqs](https://speqs.io) for simulation testing.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### Quick install (recommended)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
9
|
+
**macOS / Linux:**
|
|
10
|
+
```bash
|
|
11
|
+
curl -fsSL https://speqs.io/install.sh | sh
|
|
12
|
+
```
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
**Windows (PowerShell):**
|
|
15
|
+
```powershell
|
|
16
|
+
irm https://speqs.io/install.ps1 | iex
|
|
17
|
+
```
|
|
14
18
|
|
|
15
19
|
### npm (all platforms)
|
|
16
20
|
|
|
@@ -28,7 +32,7 @@ brew install speqs
|
|
|
28
32
|
## Usage
|
|
29
33
|
|
|
30
34
|
```bash
|
|
31
|
-
speqs
|
|
35
|
+
speqs connect <port>
|
|
32
36
|
```
|
|
33
37
|
|
|
34
38
|
### Options
|
|
@@ -45,22 +49,19 @@ The CLI resolves your auth token in this order:
|
|
|
45
49
|
|
|
46
50
|
1. `--token` CLI argument
|
|
47
51
|
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
|
+
3. Saved token from `speqs login` (stored in `~/.speqs/config.json`)
|
|
52
53
|
|
|
53
54
|
## Examples
|
|
54
55
|
|
|
55
56
|
```bash
|
|
56
57
|
# Expose port 3000
|
|
57
|
-
speqs
|
|
58
|
+
speqs connect 3000
|
|
58
59
|
|
|
59
60
|
# With explicit token
|
|
60
|
-
speqs
|
|
61
|
+
speqs connect 3000 --token YOUR_TOKEN
|
|
61
62
|
|
|
62
63
|
# Using environment variable
|
|
63
|
-
SPEQS_TOKEN=YOUR_TOKEN speqs
|
|
64
|
+
SPEQS_TOKEN=YOUR_TOKEN speqs connect 8080
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
## License
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Localhost
|
|
2
|
+
* Localhost connect CLI — wraps cloudflared and registers with Speqs backend.
|
|
3
3
|
*/
|
|
4
4
|
export declare function runTunnel(port: number, tokenArg?: string, apiUrlArg?: string): Promise<void>;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localhost connect CLI — wraps cloudflared and registers with Speqs backend.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn, execSync } from "node:child_process";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
9
|
+
import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
|
|
10
|
+
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
11
|
+
const HEARTBEAT_INTERVAL = 10_000;
|
|
12
|
+
const MAX_HEARTBEAT_FAILURES = 3;
|
|
13
|
+
const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
|
|
14
|
+
const DEFAULT_API_URL = "https://api.speqs.io";
|
|
15
|
+
const API_BASE = "/api/v1";
|
|
16
|
+
const SPEQS_DIR = join(homedir(), ".speqs");
|
|
17
|
+
const CLOUDFLARED_BIN = join(SPEQS_DIR, "bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
18
|
+
// --- Simulation card rendering ---
|
|
19
|
+
const CARD_WIDTH = 64;
|
|
20
|
+
function statusColor(status) {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case "running": return "\x1b[32m";
|
|
23
|
+
case "pending": return "\x1b[33m";
|
|
24
|
+
case "completed": return "\x1b[32m";
|
|
25
|
+
case "failed": return "\x1b[31m";
|
|
26
|
+
default: return "\x1b[2m";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sentimentColor(sentiment) {
|
|
30
|
+
switch (sentiment) {
|
|
31
|
+
case "Excited":
|
|
32
|
+
case "Satisfied": return GREEN;
|
|
33
|
+
case "Frustrated":
|
|
34
|
+
case "Unsure": return RED;
|
|
35
|
+
default: return DIM; // Neutral
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function padVisible(str, len) {
|
|
39
|
+
const visible = str.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
40
|
+
return visible >= len ? str : str + " ".repeat(len - visible);
|
|
41
|
+
}
|
|
42
|
+
function truncate(str, maxLen) {
|
|
43
|
+
return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
|
|
44
|
+
}
|
|
45
|
+
function row(content, inner) {
|
|
46
|
+
return `\x1b[2m│\x1b[0m ${padVisible(content, inner)}\x1b[2m│\x1b[0m`;
|
|
47
|
+
}
|
|
48
|
+
function renderCard(sim) {
|
|
49
|
+
const inner = CARD_WIDTH - 4;
|
|
50
|
+
const name = truncate(sim.tester_name ?? sim.instance_name ?? sim.tester_id.slice(0, 8), inner - 2);
|
|
51
|
+
// Top border with name
|
|
52
|
+
const nameSegment = `─ ${name} `;
|
|
53
|
+
const topPad = CARD_WIDTH - 2 - nameSegment.length;
|
|
54
|
+
const top = `\x1b[2m┌\x1b[0m${ORANGE}${BOLD}${nameSegment}${RESET}\x1b[2m${"─".repeat(Math.max(0, topPad))}┐\x1b[0m`;
|
|
55
|
+
const li = sim.last_interaction;
|
|
56
|
+
const lines = [top];
|
|
57
|
+
// Status badge: ● Status (N steps) — right-aligned
|
|
58
|
+
const titleStatus = sim.status.charAt(0).toUpperCase() + sim.status.slice(1);
|
|
59
|
+
const steps = `${sim.interaction_count} step${sim.interaction_count !== 1 ? "s" : ""}`;
|
|
60
|
+
const badge = `${statusColor(sim.status)}●${RESET} ${titleStatus} ${DIM}(${steps})${RESET}`;
|
|
61
|
+
const badgePlain = `● ${titleStatus} (${steps})`;
|
|
62
|
+
if (li) {
|
|
63
|
+
// Frame name (bold) + status badge right-aligned
|
|
64
|
+
if (li.current_frame_name) {
|
|
65
|
+
const frameStr = truncate(li.current_frame_name, inner - badgePlain.length - 2);
|
|
66
|
+
const gap = inner - frameStr.length - badgePlain.length;
|
|
67
|
+
lines.push(row(`${BOLD}${frameStr}${RESET}${" ".repeat(Math.max(1, gap))}${badge}`, inner));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const pad = inner - badgePlain.length;
|
|
71
|
+
lines.push(row(`${" ".repeat(Math.max(0, pad))}${badge}`, inner));
|
|
72
|
+
}
|
|
73
|
+
// Comment (dim, italic quotes — up to 3 lines)
|
|
74
|
+
if (li.comment) {
|
|
75
|
+
const maxChars = inner - 3;
|
|
76
|
+
const words = li.comment.split(" ");
|
|
77
|
+
const commentLines = [];
|
|
78
|
+
let current = "";
|
|
79
|
+
for (const word of words) {
|
|
80
|
+
const next = current ? `${current} ${word}` : word;
|
|
81
|
+
if (next.length > maxChars && current) {
|
|
82
|
+
commentLines.push(current);
|
|
83
|
+
current = word;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
current = next;
|
|
87
|
+
}
|
|
88
|
+
if (commentLines.length === 3)
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (current && commentLines.length < 3)
|
|
92
|
+
commentLines.push(current);
|
|
93
|
+
if (commentLines.length > 0) {
|
|
94
|
+
commentLines[0] = `"${commentLines[0]}`;
|
|
95
|
+
const lastIdx = commentLines.length - 1;
|
|
96
|
+
commentLines[lastIdx] = `${commentLines[lastIdx]}"`;
|
|
97
|
+
}
|
|
98
|
+
for (const cl of commentLines) {
|
|
99
|
+
lines.push(row(`${DIM}${truncate(cl, inner)}${RESET}`, inner));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Action rows — one per action, action_type in cyan
|
|
103
|
+
for (const action of li.actions) {
|
|
104
|
+
if (!action.action_type)
|
|
105
|
+
continue;
|
|
106
|
+
const label = action.element_label
|
|
107
|
+
? ` ${truncate(action.element_label, inner - action.action_type.length - 2)}`
|
|
108
|
+
: "";
|
|
109
|
+
lines.push(row(`${CYAN}${action.action_type}${RESET}${label}`, inner));
|
|
110
|
+
}
|
|
111
|
+
// Sentiment badge
|
|
112
|
+
if (li.sentiment) {
|
|
113
|
+
const sc = sentimentColor(li.sentiment);
|
|
114
|
+
lines.push(row(`${sc}${li.sentiment}${RESET}`, inner));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// No interaction — just show status badge
|
|
119
|
+
const pad = inner - badgePlain.length;
|
|
120
|
+
lines.push(row(`${" ".repeat(Math.max(0, pad))}${badge}`, inner));
|
|
121
|
+
}
|
|
122
|
+
// Bottom border
|
|
123
|
+
lines.push(`\x1b[2m└${"─".repeat(CARD_WIDTH - 2)}┘\x1b[0m`);
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
function renderAllCards(simulations) {
|
|
127
|
+
if (simulations.length === 0)
|
|
128
|
+
return [];
|
|
129
|
+
const lines = [];
|
|
130
|
+
const ts = new Date().toLocaleTimeString("en-GB", { hour12: false });
|
|
131
|
+
const study = simulations[0]?.study_name;
|
|
132
|
+
lines.push(`\x1b[2m${study ? `${study} · ` : ""}Updated ${ts}\x1b[0m`);
|
|
133
|
+
lines.push("");
|
|
134
|
+
for (const sim of simulations) {
|
|
135
|
+
lines.push(...renderCard(sim));
|
|
136
|
+
lines.push("");
|
|
137
|
+
}
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
let cardLineCount = 0;
|
|
141
|
+
function clearCards() {
|
|
142
|
+
if (cardLineCount > 0) {
|
|
143
|
+
process.stdout.write(`\x1b[${cardLineCount}A`);
|
|
144
|
+
for (let i = 0; i < cardLineCount; i++) {
|
|
145
|
+
process.stdout.write("\x1b[2K\n");
|
|
146
|
+
}
|
|
147
|
+
process.stdout.write(`\x1b[${cardLineCount}A`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function renderSimulationCards(simulations) {
|
|
151
|
+
clearCards();
|
|
152
|
+
const lines = renderAllCards(simulations);
|
|
153
|
+
cardLineCount = lines.length;
|
|
154
|
+
if (lines.length > 0) {
|
|
155
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// --- Local storage for completed simulations ---
|
|
159
|
+
const SIMULATIONS_DIR = join(SPEQS_DIR, "simulations");
|
|
160
|
+
const storedTesterIds = new Set();
|
|
161
|
+
function storeCompletedSimulation(sim) {
|
|
162
|
+
if (storedTesterIds.has(sim.tester_id))
|
|
163
|
+
return;
|
|
164
|
+
storedTesterIds.add(sim.tester_id);
|
|
165
|
+
mkdirSync(SIMULATIONS_DIR, { recursive: true });
|
|
166
|
+
const logFile = join(SIMULATIONS_DIR, "history.jsonl");
|
|
167
|
+
const entry = {
|
|
168
|
+
tester_id: sim.tester_id,
|
|
169
|
+
instance_name: sim.instance_name,
|
|
170
|
+
status: sim.status,
|
|
171
|
+
study_name: sim.study_name,
|
|
172
|
+
interaction_count: sim.interaction_count,
|
|
173
|
+
last_interaction: sim.last_interaction,
|
|
174
|
+
completed_at: new Date().toISOString(),
|
|
175
|
+
};
|
|
176
|
+
writeFileSync(logFile, JSON.stringify(entry) + "\n", { flag: "a" });
|
|
177
|
+
}
|
|
178
|
+
// --- Token resolution ---
|
|
179
|
+
async function verifyToken(token, apiUrl) {
|
|
180
|
+
try {
|
|
181
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
|
|
182
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
183
|
+
signal: AbortSignal.timeout(10_000),
|
|
184
|
+
});
|
|
185
|
+
// 404 = valid token, no connection (expected). 401/403 = bad token.
|
|
186
|
+
return resp.status !== 401 && resp.status !== 403;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Network error — can't verify, assume ok
|
|
190
|
+
console.error("Warning: Could not verify token (network error). Proceeding anyway.");
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function resolveApiUrl(apiUrlArg) {
|
|
195
|
+
if (apiUrlArg)
|
|
196
|
+
return apiUrlArg;
|
|
197
|
+
return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve an access token, refreshing if needed.
|
|
201
|
+
* Returns both the token and a mutable holder for runtime refresh.
|
|
202
|
+
*/
|
|
203
|
+
async function resolveToken(tokenArg, apiUrl) {
|
|
204
|
+
// 1. Explicit token argument
|
|
205
|
+
if (tokenArg)
|
|
206
|
+
return { token: tokenArg, refresh: null };
|
|
207
|
+
// 2. Environment variable
|
|
208
|
+
const envToken = process.env.SPEQS_TOKEN;
|
|
209
|
+
if (envToken)
|
|
210
|
+
return { token: envToken, refresh: null };
|
|
211
|
+
// 3. Saved config with refresh token
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
if (config.access_token && config.refresh_token) {
|
|
214
|
+
let accessToken = config.access_token;
|
|
215
|
+
// Refresh if expired or close to expiry
|
|
216
|
+
if (isTokenExpired(accessToken)) {
|
|
217
|
+
try {
|
|
218
|
+
const tokens = await refreshTokens(config.refresh_token);
|
|
219
|
+
accessToken = tokens.accessToken;
|
|
220
|
+
config.access_token = tokens.accessToken;
|
|
221
|
+
config.refresh_token = tokens.refreshToken;
|
|
222
|
+
saveConfig(config);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
console.error('Session expired. Run "speqs login" to re-authenticate.');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (await verifyToken(accessToken, apiUrl)) {
|
|
230
|
+
// Return with refresh capability for long-running tunnel
|
|
231
|
+
const doRefresh = async () => {
|
|
232
|
+
const cfg = loadConfig();
|
|
233
|
+
if (!cfg.refresh_token)
|
|
234
|
+
throw new Error("No refresh token");
|
|
235
|
+
const tokens = await refreshTokens(cfg.refresh_token);
|
|
236
|
+
cfg.access_token = tokens.accessToken;
|
|
237
|
+
cfg.refresh_token = tokens.refreshToken;
|
|
238
|
+
saveConfig(cfg);
|
|
239
|
+
return tokens.accessToken;
|
|
240
|
+
};
|
|
241
|
+
return { token: accessToken, refresh: doRefresh };
|
|
242
|
+
}
|
|
243
|
+
console.error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
// 4. Legacy saved token (no refresh token)
|
|
247
|
+
if (config.token) {
|
|
248
|
+
if (await verifyToken(config.token, apiUrl)) {
|
|
249
|
+
return { token: config.token, refresh: null };
|
|
250
|
+
}
|
|
251
|
+
console.error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
// 5. No valid token found — direct user to login
|
|
255
|
+
console.error('No valid token found. Run "speqs login" to authenticate.');
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
// --- Branding ---
|
|
259
|
+
const RESET = "\x1b[0m";
|
|
260
|
+
const ORANGE = "\x1b[38;2;212;117;78m";
|
|
261
|
+
const BOLD = "\x1b[1m";
|
|
262
|
+
const DIM = "\x1b[2m";
|
|
263
|
+
const GREEN = "\x1b[32m";
|
|
264
|
+
const RED = "\x1b[31m";
|
|
265
|
+
const YELLOW = "\x1b[33m";
|
|
266
|
+
const CYAN = "\x1b[36m";
|
|
267
|
+
function printBanner() {
|
|
268
|
+
console.log(`
|
|
269
|
+
${ORANGE}${BOLD} ███████╗██████╗ ███████╗ ██████╗ ███████╗
|
|
270
|
+
██╔════╝██╔══██╗██╔════╝██╔═══██╗██╔════╝
|
|
271
|
+
███████╗██████╔╝█████╗ ██║ ██║███████╗
|
|
272
|
+
╚════██║██╔═══╝ ██╔══╝ ██║▄▄ ██║╚════██║
|
|
273
|
+
███████║██║ ███████╗╚██████╔╝███████║
|
|
274
|
+
╚══════╝╚═╝ ╚══════╝ ╚══▀▀═╝ ╚══════╝${RESET}
|
|
275
|
+
|
|
276
|
+
Connected
|
|
277
|
+
`);
|
|
278
|
+
}
|
|
279
|
+
// --- Cloudflared ---
|
|
280
|
+
async function resolveCloudflaredBin() {
|
|
281
|
+
// 1. Prefer system-installed cloudflared
|
|
282
|
+
try {
|
|
283
|
+
execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
|
|
284
|
+
return "cloudflared";
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Not on PATH
|
|
288
|
+
}
|
|
289
|
+
// 2. Check ~/.speqs/bin/cloudflared
|
|
290
|
+
if (existsSync(CLOUDFLARED_BIN))
|
|
291
|
+
return CLOUDFLARED_BIN;
|
|
292
|
+
// 3. Download from Cloudflare releases
|
|
293
|
+
console.log("cloudflared not found. Installing...");
|
|
294
|
+
const url = getCloudflaredDownloadUrl();
|
|
295
|
+
if (!url) {
|
|
296
|
+
printManualInstallInstructions();
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const binDir = join(SPEQS_DIR, "bin");
|
|
301
|
+
mkdirSync(binDir, { recursive: true, mode: 0o755 });
|
|
302
|
+
if (url.endsWith(".tgz")) {
|
|
303
|
+
execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}" cloudflared`, { stdio: "ignore" });
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const resp = await fetch(url);
|
|
307
|
+
if (!resp.ok)
|
|
308
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
309
|
+
writeFileSync(CLOUDFLARED_BIN, Buffer.from(await resp.arrayBuffer()));
|
|
310
|
+
}
|
|
311
|
+
chmodSync(CLOUDFLARED_BIN, 0o755);
|
|
312
|
+
return CLOUDFLARED_BIN;
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
console.error(`Failed to install cloudflared: ${e instanceof Error ? e.message : e}\n`);
|
|
316
|
+
printManualInstallInstructions();
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function getCloudflaredDownloadUrl() {
|
|
321
|
+
const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
|
|
322
|
+
const platform = process.platform;
|
|
323
|
+
const arch = process.arch;
|
|
324
|
+
if (platform === "darwin" && arch === "arm64")
|
|
325
|
+
return `${base}/cloudflared-darwin-arm64.tgz`;
|
|
326
|
+
if (platform === "darwin" && arch === "x64")
|
|
327
|
+
return `${base}/cloudflared-darwin-amd64.tgz`;
|
|
328
|
+
if (platform === "linux" && arch === "x64")
|
|
329
|
+
return `${base}/cloudflared-linux-amd64`;
|
|
330
|
+
if (platform === "linux" && arch === "arm64")
|
|
331
|
+
return `${base}/cloudflared-linux-arm64`;
|
|
332
|
+
if (platform === "win32" && arch === "x64")
|
|
333
|
+
return `${base}/cloudflared-windows-amd64.exe`;
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
function printManualInstallInstructions() {
|
|
337
|
+
console.error("You can install it manually:\n" +
|
|
338
|
+
" brew install cloudflare/cloudflare/cloudflared # macOS\n" +
|
|
339
|
+
" sudo apt install cloudflared # Debian/Ubuntu\n" +
|
|
340
|
+
"\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
|
|
341
|
+
}
|
|
342
|
+
function startCloudflared(port, binPath) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
console.log(`Connecting to localhost:${port}...`);
|
|
345
|
+
const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
|
|
346
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
347
|
+
});
|
|
348
|
+
let tunnelUrl = null;
|
|
349
|
+
const timeout = setTimeout(() => {
|
|
350
|
+
if (!tunnelUrl) {
|
|
351
|
+
proc.kill();
|
|
352
|
+
reject(new Error("Failed to get tunnel URL within timeout."));
|
|
353
|
+
}
|
|
354
|
+
}, CLOUDFLARED_STARTUP_TIMEOUT);
|
|
355
|
+
proc.stderr?.on("data", (data) => {
|
|
356
|
+
const line = data.toString("utf-8");
|
|
357
|
+
const match = line.match(TUNNEL_URL_PATTERN);
|
|
358
|
+
if (match && !tunnelUrl) {
|
|
359
|
+
tunnelUrl = match[0];
|
|
360
|
+
clearTimeout(timeout);
|
|
361
|
+
printBanner();
|
|
362
|
+
resolve({ process: proc, tunnelUrl });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
proc.on("exit", (code) => {
|
|
366
|
+
clearTimeout(timeout);
|
|
367
|
+
if (!tunnelUrl) {
|
|
368
|
+
reject(new Error("cloudflared exited unexpectedly."));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
proc.on("error", (err) => {
|
|
372
|
+
clearTimeout(timeout);
|
|
373
|
+
reject(err);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
// --- API calls ---
|
|
378
|
+
async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
379
|
+
try {
|
|
380
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: {
|
|
383
|
+
Authorization: `Bearer ${token}`,
|
|
384
|
+
"Content-Type": "application/json",
|
|
385
|
+
},
|
|
386
|
+
body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
|
|
387
|
+
signal: AbortSignal.timeout(10_000),
|
|
388
|
+
});
|
|
389
|
+
if (!resp.ok)
|
|
390
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
391
|
+
// Registration successful — banner already shown
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
console.error(`Warning: Failed to register connection: ${e}`);
|
|
395
|
+
console.error("Connection is still active — you can retry manually.");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function deregisterTunnel(apiUrl, token) {
|
|
399
|
+
try {
|
|
400
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
401
|
+
method: "DELETE",
|
|
402
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
403
|
+
signal: AbortSignal.timeout(2_000),
|
|
404
|
+
});
|
|
405
|
+
if (!resp.ok)
|
|
406
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
407
|
+
console.log("Disconnected");
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
console.error(`Warning: Failed to deregister connection: ${e}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function processHeartbeatResponse(resp) {
|
|
414
|
+
resp.json().then((data) => {
|
|
415
|
+
const sims = data.simulations ?? [];
|
|
416
|
+
renderSimulationCards(sims);
|
|
417
|
+
// Store completed simulations locally
|
|
418
|
+
for (const sim of sims) {
|
|
419
|
+
if (sim.status === "completed" || sim.status === "failed" || sim.status === "cancelled") {
|
|
420
|
+
storeCompletedSimulation(sim);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}).catch(() => {
|
|
424
|
+
// Non-fatal: response parsing failed, silently continue
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
|
|
428
|
+
let consecutiveFailures = 0;
|
|
429
|
+
let stopped = false;
|
|
430
|
+
const interval = setInterval(async () => {
|
|
431
|
+
if (stopped)
|
|
432
|
+
return;
|
|
433
|
+
try {
|
|
434
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers: { Authorization: `Bearer ${getToken()}` },
|
|
437
|
+
signal: AbortSignal.timeout(10_000),
|
|
438
|
+
});
|
|
439
|
+
// If 401 and we can refresh, try once
|
|
440
|
+
if (resp.status === 401 && doRefresh) {
|
|
441
|
+
try {
|
|
442
|
+
const newToken = await doRefresh();
|
|
443
|
+
onTokenRefreshed(newToken);
|
|
444
|
+
console.log("Token refreshed.");
|
|
445
|
+
// Retry heartbeat with new token
|
|
446
|
+
const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { Authorization: `Bearer ${newToken}` },
|
|
449
|
+
signal: AbortSignal.timeout(10_000),
|
|
450
|
+
});
|
|
451
|
+
if (!retry.ok)
|
|
452
|
+
throw new Error(`HTTP ${retry.status}`);
|
|
453
|
+
consecutiveFailures = 0;
|
|
454
|
+
processHeartbeatResponse(retry);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
catch (refreshErr) {
|
|
458
|
+
console.error(`Token refresh failed: ${refreshErr}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (!resp.ok)
|
|
462
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
463
|
+
consecutiveFailures = 0;
|
|
464
|
+
processHeartbeatResponse(resp);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
consecutiveFailures++;
|
|
468
|
+
console.error(`Heartbeat failed (${consecutiveFailures}/${MAX_HEARTBEAT_FAILURES}): ${e}`);
|
|
469
|
+
if (consecutiveFailures >= MAX_HEARTBEAT_FAILURES) {
|
|
470
|
+
console.error("Lost connection to Speqs backend. Shutting down.");
|
|
471
|
+
stopped = true;
|
|
472
|
+
clearInterval(interval);
|
|
473
|
+
onFatal();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}, HEARTBEAT_INTERVAL);
|
|
477
|
+
return {
|
|
478
|
+
stop: () => {
|
|
479
|
+
stopped = true;
|
|
480
|
+
clearInterval(interval);
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Schedule a proactive token refresh before the JWT expires.
|
|
486
|
+
* Refreshes 10 minutes before expiry.
|
|
487
|
+
*/
|
|
488
|
+
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
|
|
489
|
+
if (!doRefresh)
|
|
490
|
+
return { stop: () => { } };
|
|
491
|
+
const exp = decodeJwtExp(token);
|
|
492
|
+
if (!exp)
|
|
493
|
+
return { stop: () => { } };
|
|
494
|
+
const refreshAt = (exp - 600) * 1000; // 10 min before expiry
|
|
495
|
+
const delay = refreshAt - Date.now();
|
|
496
|
+
if (delay <= 0)
|
|
497
|
+
return { stop: () => { } };
|
|
498
|
+
const timer = setTimeout(async () => {
|
|
499
|
+
try {
|
|
500
|
+
const newToken = await doRefresh();
|
|
501
|
+
onTokenRefreshed(newToken);
|
|
502
|
+
console.log("Token proactively refreshed.");
|
|
503
|
+
// Schedule next refresh for the new token
|
|
504
|
+
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed);
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
console.error(`Proactive token refresh failed: ${e}`);
|
|
508
|
+
}
|
|
509
|
+
}, delay);
|
|
510
|
+
return { stop: () => clearTimeout(timer) };
|
|
511
|
+
}
|
|
512
|
+
// --- Main ---
|
|
513
|
+
export async function runTunnel(port, tokenArg, apiUrlArg) {
|
|
514
|
+
const apiUrl = resolveApiUrl(apiUrlArg);
|
|
515
|
+
if (apiUrl !== DEFAULT_API_URL) {
|
|
516
|
+
console.log(`Using API: ${apiUrl}`);
|
|
517
|
+
}
|
|
518
|
+
const resolved = await resolveToken(tokenArg, apiUrl);
|
|
519
|
+
let currentToken = resolved.token;
|
|
520
|
+
const onTokenRefreshed = (newToken) => {
|
|
521
|
+
currentToken = newToken;
|
|
522
|
+
};
|
|
523
|
+
// Serialize refresh calls to prevent concurrent use of single-use refresh tokens
|
|
524
|
+
let refreshInFlight = null;
|
|
525
|
+
const serializedRefresh = resolved.refresh
|
|
526
|
+
? async () => {
|
|
527
|
+
if (refreshInFlight)
|
|
528
|
+
return refreshInFlight;
|
|
529
|
+
refreshInFlight = resolved.refresh().finally(() => { refreshInFlight = null; });
|
|
530
|
+
return refreshInFlight;
|
|
531
|
+
}
|
|
532
|
+
: null;
|
|
533
|
+
const cloudflaredPath = await resolveCloudflaredBin();
|
|
534
|
+
let cfResult;
|
|
535
|
+
try {
|
|
536
|
+
cfResult = await startCloudflared(port, cloudflaredPath);
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
console.error(`Failed to start cloudflared: ${e}`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
const { process: cfProcess, tunnelUrl } = cfResult;
|
|
543
|
+
await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
|
|
544
|
+
let shuttingDown = false;
|
|
545
|
+
const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
546
|
+
await deregisterTunnel(apiUrl, currentToken);
|
|
547
|
+
cfProcess.kill();
|
|
548
|
+
process.exit(1);
|
|
549
|
+
});
|
|
550
|
+
const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed);
|
|
551
|
+
const shutdown = async () => {
|
|
552
|
+
if (shuttingDown)
|
|
553
|
+
process.exit(1);
|
|
554
|
+
shuttingDown = true;
|
|
555
|
+
console.log("\nShutting down...");
|
|
556
|
+
heartbeat.stop();
|
|
557
|
+
proactiveRefresh.stop();
|
|
558
|
+
cfProcess.kill();
|
|
559
|
+
await deregisterTunnel(apiUrl, currentToken);
|
|
560
|
+
process.exit(0);
|
|
561
|
+
};
|
|
562
|
+
process.on("SIGINT", shutdown);
|
|
563
|
+
process.on("SIGTERM", shutdown);
|
|
564
|
+
console.log("\nPress Ctrl+C to disconnect.\n");
|
|
565
|
+
cfProcess.on("exit", async () => {
|
|
566
|
+
if (!shuttingDown) {
|
|
567
|
+
heartbeat.stop();
|
|
568
|
+
proactiveRefresh.stop();
|
|
569
|
+
await deregisterTunnel(apiUrl, currentToken);
|
|
570
|
+
process.exit(0);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { program, Option } from "commander";
|
|
4
|
-
import { runTunnel } from "./
|
|
3
|
+
import { runTunnel } from "./connect.js";
|
|
5
4
|
import { login, getAppUrl } from "./auth.js";
|
|
6
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { upgrade } from "./upgrade.js";
|
|
7
|
+
import pkg from "../package.json" with { type: "json" };
|
|
8
|
+
const { version } = pkg;
|
|
9
9
|
program
|
|
10
10
|
.name("speqs")
|
|
11
11
|
.description("Speqs CLI tools")
|
|
@@ -39,9 +39,9 @@ program
|
|
|
39
39
|
console.log("Logged out.");
|
|
40
40
|
});
|
|
41
41
|
program
|
|
42
|
-
.command("
|
|
42
|
+
.command("connect")
|
|
43
43
|
.description("Expose your localhost to Speqs via a Cloudflare tunnel")
|
|
44
|
-
.argument("<port>", "Local port to
|
|
44
|
+
.argument("<port>", "Local port to connect (e.g. 3000)")
|
|
45
45
|
.option("-t, --token <token>", "Auth token (or set SPEQS_TOKEN, or save via interactive prompt)")
|
|
46
46
|
.option("--api-url <url>", "Backend API URL (default: SPEQS_API_URL or https://api.speqs.io)")
|
|
47
47
|
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
@@ -54,4 +54,11 @@ program
|
|
|
54
54
|
const apiUrl = options.dev ? "http://localhost:8000" : options.apiUrl;
|
|
55
55
|
await runTunnel(portNum, options.token, apiUrl);
|
|
56
56
|
});
|
|
57
|
+
program
|
|
58
|
+
.command("upgrade")
|
|
59
|
+
.description("Update speqs to the latest version")
|
|
60
|
+
.option("--version <version>", "Install a specific version")
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
await upgrade(version, options.version);
|
|
63
|
+
});
|
|
57
64
|
program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function upgrade(currentVersion: string, targetVersion?: string): Promise<void>;
|
package/dist/upgrade.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createWriteStream, renameSync, unlinkSync, chmodSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
const BASE_URL = "https://speqs.io";
|
|
6
|
+
function getPlatformTarget() {
|
|
7
|
+
const platform = process.platform;
|
|
8
|
+
const arch = process.arch;
|
|
9
|
+
const targets = {
|
|
10
|
+
darwin: { arm64: "darwin-arm64", x64: "darwin-x64" },
|
|
11
|
+
linux: { arm64: "linux-arm64", x64: "linux-x64" },
|
|
12
|
+
win32: { x64: "windows-x64" },
|
|
13
|
+
};
|
|
14
|
+
const target = targets[platform]?.[arch];
|
|
15
|
+
if (!target) {
|
|
16
|
+
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
|
17
|
+
}
|
|
18
|
+
return target;
|
|
19
|
+
}
|
|
20
|
+
async function getLatestVersion() {
|
|
21
|
+
const res = await fetch(`${BASE_URL}/api/releases/latest`);
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
throw new Error(`Failed to fetch latest version: ${res.statusText}`);
|
|
24
|
+
const data = (await res.json());
|
|
25
|
+
return data.version;
|
|
26
|
+
}
|
|
27
|
+
export async function upgrade(currentVersion, targetVersion) {
|
|
28
|
+
if (targetVersion && !/^\d+\.\d+\.\d+/.test(targetVersion)) {
|
|
29
|
+
throw new Error(`Invalid version format: ${targetVersion}`);
|
|
30
|
+
}
|
|
31
|
+
const latest = targetVersion || (await getLatestVersion());
|
|
32
|
+
if (latest === currentVersion) {
|
|
33
|
+
console.log(`Already up to date (v${currentVersion}).`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(`Updating speqs v${currentVersion} → v${latest}...`);
|
|
37
|
+
const target = getPlatformTarget();
|
|
38
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
39
|
+
const assetName = `speqs-${target}${ext}`;
|
|
40
|
+
const url = `${BASE_URL}/api/releases/v${latest}/${assetName}`;
|
|
41
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new Error(`Download failed: ${res.statusText} (${url})`);
|
|
44
|
+
}
|
|
45
|
+
if (!res.body) {
|
|
46
|
+
throw new Error(`Download failed: empty response body (${url})`);
|
|
47
|
+
}
|
|
48
|
+
const execPath = process.execPath;
|
|
49
|
+
// Use same directory as the binary to avoid cross-device rename issues
|
|
50
|
+
const tmpPath = join(dirname(execPath), `.speqs-upgrade-${Date.now()}${ext}`);
|
|
51
|
+
const fileStream = createWriteStream(tmpPath);
|
|
52
|
+
try {
|
|
53
|
+
await pipeline(Readable.fromWeb(res.body), fileStream);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(tmpPath);
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
if (process.platform === "win32") {
|
|
63
|
+
const oldPath = execPath + ".old";
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(oldPath);
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
renameSync(execPath, oldPath);
|
|
69
|
+
try {
|
|
70
|
+
renameSync(tmpPath, execPath);
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(oldPath);
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// Restore original binary on failure
|
|
78
|
+
try {
|
|
79
|
+
renameSync(oldPath, execPath);
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(tmpPath);
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
chmodSync(tmpPath, 0o755);
|
|
91
|
+
renameSync(tmpPath, execPath);
|
|
92
|
+
}
|
|
93
|
+
console.log(`Updated to v${latest}.`);
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speqs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The command-line interface for Speqs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
|
+
"build:binary": "bun build --compile src/index.ts --outfile speqs",
|
|
11
12
|
"dev": "tsc --watch",
|
|
12
13
|
"prepublishOnly": "npm run build"
|
|
13
14
|
},
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"speqs",
|
|
24
|
-
"
|
|
25
|
+
"connect",
|
|
25
26
|
"localhost",
|
|
26
27
|
"testing"
|
|
27
28
|
],
|
package/dist/tunnel.js
DELETED
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Localhost tunnel CLI — wraps cloudflared and registers with Speqs backend.
|
|
3
|
-
*/
|
|
4
|
-
import { spawn, execSync } from "node:child_process";
|
|
5
|
-
import * as readline from "node:readline";
|
|
6
|
-
import { loadConfig, saveConfig } from "./config.js";
|
|
7
|
-
import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
|
|
8
|
-
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
9
|
-
const HEARTBEAT_INTERVAL = 30_000;
|
|
10
|
-
const MAX_HEARTBEAT_FAILURES = 3;
|
|
11
|
-
const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
|
|
12
|
-
const DEFAULT_API_URL = "https://api.speqs.io";
|
|
13
|
-
const API_BASE = "/api/v1";
|
|
14
|
-
// --- Token resolution ---
|
|
15
|
-
async function verifyToken(token, apiUrl) {
|
|
16
|
-
try {
|
|
17
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/active`, {
|
|
18
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
19
|
-
signal: AbortSignal.timeout(10_000),
|
|
20
|
-
});
|
|
21
|
-
// 404 = valid token, no tunnel (expected). 401/403 = bad token.
|
|
22
|
-
return resp.status !== 401 && resp.status !== 403;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// Network error — can't verify, assume ok
|
|
26
|
-
console.error("Warning: Could not verify token (network error). Proceeding anyway.");
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function resolveApiUrl(apiUrlArg) {
|
|
31
|
-
if (apiUrlArg)
|
|
32
|
-
return apiUrlArg;
|
|
33
|
-
return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
|
|
34
|
-
}
|
|
35
|
-
function prompt(question) {
|
|
36
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
rl.question(question, (answer) => {
|
|
39
|
-
rl.close();
|
|
40
|
-
resolve(answer.trim());
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Resolve an access token, refreshing if needed.
|
|
46
|
-
* Returns both the token and a mutable holder for runtime refresh.
|
|
47
|
-
*/
|
|
48
|
-
async function resolveToken(tokenArg, apiUrl) {
|
|
49
|
-
// 1. Explicit token argument
|
|
50
|
-
if (tokenArg)
|
|
51
|
-
return { token: tokenArg, refresh: null };
|
|
52
|
-
// 2. Environment variable
|
|
53
|
-
const envToken = process.env.SPEQS_TOKEN;
|
|
54
|
-
if (envToken)
|
|
55
|
-
return { token: envToken, refresh: null };
|
|
56
|
-
// 3. Saved config with refresh token
|
|
57
|
-
const config = loadConfig();
|
|
58
|
-
if (config.access_token && config.refresh_token) {
|
|
59
|
-
let accessToken = config.access_token;
|
|
60
|
-
// Refresh if expired or close to expiry
|
|
61
|
-
if (isTokenExpired(accessToken)) {
|
|
62
|
-
try {
|
|
63
|
-
console.log("Refreshing access token...");
|
|
64
|
-
const tokens = await refreshTokens(config.refresh_token);
|
|
65
|
-
accessToken = tokens.accessToken;
|
|
66
|
-
config.access_token = tokens.accessToken;
|
|
67
|
-
config.refresh_token = tokens.refreshToken;
|
|
68
|
-
saveConfig(config);
|
|
69
|
-
}
|
|
70
|
-
catch (e) {
|
|
71
|
-
console.error(`Token refresh failed: ${e instanceof Error ? e.message : e}`);
|
|
72
|
-
console.error('Run "speqs login" to re-authenticate.\n');
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (await verifyToken(accessToken, apiUrl)) {
|
|
76
|
-
// Return with refresh capability for long-running tunnel
|
|
77
|
-
const doRefresh = async () => {
|
|
78
|
-
const cfg = loadConfig();
|
|
79
|
-
if (!cfg.refresh_token)
|
|
80
|
-
throw new Error("No refresh token");
|
|
81
|
-
const tokens = await refreshTokens(cfg.refresh_token);
|
|
82
|
-
cfg.access_token = tokens.accessToken;
|
|
83
|
-
cfg.refresh_token = tokens.refreshToken;
|
|
84
|
-
saveConfig(cfg);
|
|
85
|
-
return tokens.accessToken;
|
|
86
|
-
};
|
|
87
|
-
return { token: accessToken, refresh: doRefresh };
|
|
88
|
-
}
|
|
89
|
-
console.error('Saved token is invalid. Run "speqs login" to re-authenticate.\n');
|
|
90
|
-
}
|
|
91
|
-
// 4. Legacy saved token (no refresh token)
|
|
92
|
-
if (config.token) {
|
|
93
|
-
if (await verifyToken(config.token, apiUrl)) {
|
|
94
|
-
return { token: config.token, refresh: null };
|
|
95
|
-
}
|
|
96
|
-
console.error("Saved token is invalid or expired.\n");
|
|
97
|
-
}
|
|
98
|
-
// 5. Interactive prompt (legacy fallback)
|
|
99
|
-
console.log('Tip: Run "speqs login" for browser-based authentication with auto-refresh.\n');
|
|
100
|
-
console.log("You can find your token in the simulation view.\n");
|
|
101
|
-
while (true) {
|
|
102
|
-
const token = await prompt("Paste your token: ");
|
|
103
|
-
if (!token) {
|
|
104
|
-
console.log("No token provided, exiting.");
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
if (await verifyToken(token, apiUrl)) {
|
|
108
|
-
config.token = token;
|
|
109
|
-
saveConfig(config);
|
|
110
|
-
return { token, refresh: null };
|
|
111
|
-
}
|
|
112
|
-
console.error("Invalid token. Try again.\n");
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
// --- Cloudflared ---
|
|
116
|
-
function checkCloudflared() {
|
|
117
|
-
try {
|
|
118
|
-
execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
console.error("Missing dependency. Install it:\n" +
|
|
122
|
-
" brew install cloudflare/cloudflare/cloudflared # macOS\n" +
|
|
123
|
-
" sudo apt install cloudflared # Debian/Ubuntu\n" +
|
|
124
|
-
"\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
|
|
125
|
-
process.exit(1);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
function startCloudflared(port) {
|
|
129
|
-
return new Promise((resolve, reject) => {
|
|
130
|
-
console.log(`Starting tunnel to localhost:${port}...`);
|
|
131
|
-
const proc = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
132
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
-
});
|
|
134
|
-
let tunnelUrl = null;
|
|
135
|
-
const timeout = setTimeout(() => {
|
|
136
|
-
if (!tunnelUrl) {
|
|
137
|
-
proc.kill();
|
|
138
|
-
reject(new Error("Failed to get tunnel URL within timeout."));
|
|
139
|
-
}
|
|
140
|
-
}, CLOUDFLARED_STARTUP_TIMEOUT);
|
|
141
|
-
proc.stderr?.on("data", (data) => {
|
|
142
|
-
const line = data.toString("utf-8");
|
|
143
|
-
const match = line.match(TUNNEL_URL_PATTERN);
|
|
144
|
-
if (match && !tunnelUrl) {
|
|
145
|
-
tunnelUrl = match[0];
|
|
146
|
-
clearTimeout(timeout);
|
|
147
|
-
console.log(`Tunnel active: ${tunnelUrl}`);
|
|
148
|
-
resolve({ process: proc, tunnelUrl });
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
proc.on("exit", (code) => {
|
|
152
|
-
clearTimeout(timeout);
|
|
153
|
-
if (!tunnelUrl) {
|
|
154
|
-
reject(new Error("cloudflared exited unexpectedly."));
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
proc.on("error", (err) => {
|
|
158
|
-
clearTimeout(timeout);
|
|
159
|
-
reject(err);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
// --- API calls ---
|
|
164
|
-
async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
165
|
-
try {
|
|
166
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/tunnel`, {
|
|
167
|
-
method: "POST",
|
|
168
|
-
headers: {
|
|
169
|
-
Authorization: `Bearer ${token}`,
|
|
170
|
-
"Content-Type": "application/json",
|
|
171
|
-
},
|
|
172
|
-
body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
|
|
173
|
-
signal: AbortSignal.timeout(10_000),
|
|
174
|
-
});
|
|
175
|
-
if (!resp.ok)
|
|
176
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
177
|
-
console.log("Registered with Speqs backend");
|
|
178
|
-
}
|
|
179
|
-
catch (e) {
|
|
180
|
-
console.error(`Warning: Failed to register tunnel: ${e}`);
|
|
181
|
-
console.error("Tunnel is still active — you can retry manually.");
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
async function deregisterTunnel(apiUrl, token) {
|
|
185
|
-
try {
|
|
186
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/tunnel`, {
|
|
187
|
-
method: "DELETE",
|
|
188
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
189
|
-
signal: AbortSignal.timeout(2_000),
|
|
190
|
-
});
|
|
191
|
-
if (!resp.ok)
|
|
192
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
193
|
-
console.log("Tunnel deregistered");
|
|
194
|
-
}
|
|
195
|
-
catch (e) {
|
|
196
|
-
console.error(`Warning: Failed to deregister tunnel: ${e}`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
|
|
200
|
-
let consecutiveFailures = 0;
|
|
201
|
-
let stopped = false;
|
|
202
|
-
const interval = setInterval(async () => {
|
|
203
|
-
if (stopped)
|
|
204
|
-
return;
|
|
205
|
-
try {
|
|
206
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/heartbeat`, {
|
|
207
|
-
method: "POST",
|
|
208
|
-
headers: { Authorization: `Bearer ${getToken()}` },
|
|
209
|
-
signal: AbortSignal.timeout(10_000),
|
|
210
|
-
});
|
|
211
|
-
// If 401 and we can refresh, try once
|
|
212
|
-
if (resp.status === 401 && doRefresh) {
|
|
213
|
-
try {
|
|
214
|
-
const newToken = await doRefresh();
|
|
215
|
-
onTokenRefreshed(newToken);
|
|
216
|
-
console.log("Token refreshed.");
|
|
217
|
-
// Retry heartbeat with new token
|
|
218
|
-
const retry = await fetch(`${apiUrl}${API_BASE}/tunnel/heartbeat`, {
|
|
219
|
-
method: "POST",
|
|
220
|
-
headers: { Authorization: `Bearer ${newToken}` },
|
|
221
|
-
signal: AbortSignal.timeout(10_000),
|
|
222
|
-
});
|
|
223
|
-
if (!retry.ok)
|
|
224
|
-
throw new Error(`HTTP ${retry.status}`);
|
|
225
|
-
consecutiveFailures = 0;
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
catch (refreshErr) {
|
|
229
|
-
console.error(`Token refresh failed: ${refreshErr}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (!resp.ok)
|
|
233
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
234
|
-
consecutiveFailures = 0;
|
|
235
|
-
}
|
|
236
|
-
catch (e) {
|
|
237
|
-
consecutiveFailures++;
|
|
238
|
-
console.error(`Heartbeat failed (${consecutiveFailures}/${MAX_HEARTBEAT_FAILURES}): ${e}`);
|
|
239
|
-
if (consecutiveFailures >= MAX_HEARTBEAT_FAILURES) {
|
|
240
|
-
console.error("Lost connection to Speqs backend. Shutting down.");
|
|
241
|
-
stopped = true;
|
|
242
|
-
clearInterval(interval);
|
|
243
|
-
onFatal();
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}, HEARTBEAT_INTERVAL);
|
|
247
|
-
return {
|
|
248
|
-
stop: () => {
|
|
249
|
-
stopped = true;
|
|
250
|
-
clearInterval(interval);
|
|
251
|
-
},
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Schedule a proactive token refresh before the JWT expires.
|
|
256
|
-
* Refreshes 10 minutes before expiry.
|
|
257
|
-
*/
|
|
258
|
-
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
|
|
259
|
-
if (!doRefresh)
|
|
260
|
-
return { stop: () => { } };
|
|
261
|
-
const exp = decodeJwtExp(token);
|
|
262
|
-
if (!exp)
|
|
263
|
-
return { stop: () => { } };
|
|
264
|
-
const refreshAt = (exp - 600) * 1000; // 10 min before expiry
|
|
265
|
-
const delay = refreshAt - Date.now();
|
|
266
|
-
if (delay <= 0)
|
|
267
|
-
return { stop: () => { } };
|
|
268
|
-
const timer = setTimeout(async () => {
|
|
269
|
-
try {
|
|
270
|
-
const newToken = await doRefresh();
|
|
271
|
-
onTokenRefreshed(newToken);
|
|
272
|
-
console.log("Token proactively refreshed.");
|
|
273
|
-
// Schedule next refresh for the new token
|
|
274
|
-
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed);
|
|
275
|
-
}
|
|
276
|
-
catch (e) {
|
|
277
|
-
console.error(`Proactive token refresh failed: ${e}`);
|
|
278
|
-
}
|
|
279
|
-
}, delay);
|
|
280
|
-
return { stop: () => clearTimeout(timer) };
|
|
281
|
-
}
|
|
282
|
-
// --- Main ---
|
|
283
|
-
export async function runTunnel(port, tokenArg, apiUrlArg) {
|
|
284
|
-
const apiUrl = resolveApiUrl(apiUrlArg);
|
|
285
|
-
if (apiUrl !== DEFAULT_API_URL) {
|
|
286
|
-
console.log(`Using API: ${apiUrl}`);
|
|
287
|
-
}
|
|
288
|
-
const resolved = await resolveToken(tokenArg, apiUrl);
|
|
289
|
-
let currentToken = resolved.token;
|
|
290
|
-
const onTokenRefreshed = (newToken) => {
|
|
291
|
-
currentToken = newToken;
|
|
292
|
-
};
|
|
293
|
-
// Serialize refresh calls to prevent concurrent use of single-use refresh tokens
|
|
294
|
-
let refreshInFlight = null;
|
|
295
|
-
const serializedRefresh = resolved.refresh
|
|
296
|
-
? async () => {
|
|
297
|
-
if (refreshInFlight)
|
|
298
|
-
return refreshInFlight;
|
|
299
|
-
refreshInFlight = resolved.refresh().finally(() => { refreshInFlight = null; });
|
|
300
|
-
return refreshInFlight;
|
|
301
|
-
}
|
|
302
|
-
: null;
|
|
303
|
-
checkCloudflared();
|
|
304
|
-
let cfResult;
|
|
305
|
-
try {
|
|
306
|
-
cfResult = await startCloudflared(port);
|
|
307
|
-
}
|
|
308
|
-
catch (e) {
|
|
309
|
-
console.error(`Failed to start cloudflared: ${e}`);
|
|
310
|
-
process.exit(1);
|
|
311
|
-
}
|
|
312
|
-
const { process: cfProcess, tunnelUrl } = cfResult;
|
|
313
|
-
await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
|
|
314
|
-
let shuttingDown = false;
|
|
315
|
-
const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
316
|
-
await deregisterTunnel(apiUrl, currentToken);
|
|
317
|
-
cfProcess.kill();
|
|
318
|
-
process.exit(1);
|
|
319
|
-
});
|
|
320
|
-
const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed);
|
|
321
|
-
const shutdown = async () => {
|
|
322
|
-
if (shuttingDown)
|
|
323
|
-
process.exit(1);
|
|
324
|
-
shuttingDown = true;
|
|
325
|
-
console.log("\nShutting down...");
|
|
326
|
-
heartbeat.stop();
|
|
327
|
-
proactiveRefresh.stop();
|
|
328
|
-
cfProcess.kill();
|
|
329
|
-
await deregisterTunnel(apiUrl, currentToken);
|
|
330
|
-
process.exit(0);
|
|
331
|
-
};
|
|
332
|
-
process.on("SIGINT", shutdown);
|
|
333
|
-
process.on("SIGTERM", shutdown);
|
|
334
|
-
console.log("\nPress Ctrl+C to disconnect.\n");
|
|
335
|
-
cfProcess.on("exit", async () => {
|
|
336
|
-
if (!shuttingDown) {
|
|
337
|
-
heartbeat.stop();
|
|
338
|
-
proactiveRefresh.stop();
|
|
339
|
-
await deregisterTunnel(apiUrl, currentToken);
|
|
340
|
-
process.exit(0);
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
}
|