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 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
- ## Prerequisites
5
+ ## Install
6
6
 
7
- [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) must be installed:
7
+ ### Quick install (recommended)
8
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/)
9
+ **macOS / Linux:**
10
+ ```bash
11
+ curl -fsSL https://speqs.io/install.sh | sh
12
+ ```
12
13
 
13
- ## Install
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 tunnel <port>
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 tunnel 3000
58
+ speqs connect 3000
58
59
 
59
60
  # With explicit token
60
- speqs tunnel 3000 --token YOUR_TOKEN
61
+ speqs connect 3000 --token YOUR_TOKEN
61
62
 
62
63
  # Using environment variable
63
- SPEQS_TOKEN=YOUR_TOKEN speqs tunnel 8080
64
+ SPEQS_TOKEN=YOUR_TOKEN speqs connect 8080
64
65
  ```
65
66
 
66
67
  ## License
@@ -1,4 +1,4 @@
1
1
  /**
2
- * Localhost tunnel CLI — wraps cloudflared and registers with Speqs backend.
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>;
@@ -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 "./tunnel.js";
3
+ import { runTunnel } from "./connect.js";
5
4
  import { login, getAppUrl } from "./auth.js";
6
5
  import { loadConfig, saveConfig } from "./config.js";
7
- const require = createRequire(import.meta.url);
8
- const { version } = require("../package.json");
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("tunnel")
42
+ .command("connect")
43
43
  .description("Expose your localhost to Speqs via a Cloudflare tunnel")
44
- .argument("<port>", "Local port to tunnel (e.g. 3000)")
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>;
@@ -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.2.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
- "tunnel",
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
- }