speqs 0.3.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 +7 -18
- package/dist/{tunnel.d.ts → connect.d.ts} +1 -1
- package/dist/{tunnel.js → connect.js} +202 -20
- package/dist/index.js +3 -3
- package/dist/upgrade.js +4 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,26 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
CLI tool to expose your localhost to [Speqs](https://speqs.io) for simulation testing.
|
|
4
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
5
|
## Install
|
|
14
6
|
|
|
15
7
|
### Quick install (recommended)
|
|
16
8
|
|
|
17
9
|
**macOS / Linux:**
|
|
18
10
|
```bash
|
|
19
|
-
curl -fsSL https://
|
|
11
|
+
curl -fsSL https://speqs.io/install.sh | sh
|
|
20
12
|
```
|
|
21
13
|
|
|
22
14
|
**Windows (PowerShell):**
|
|
23
15
|
```powershell
|
|
24
|
-
irm https://
|
|
16
|
+
irm https://speqs.io/install.ps1 | iex
|
|
25
17
|
```
|
|
26
18
|
|
|
27
19
|
### npm (all platforms)
|
|
@@ -40,7 +32,7 @@ brew install speqs
|
|
|
40
32
|
## Usage
|
|
41
33
|
|
|
42
34
|
```bash
|
|
43
|
-
speqs
|
|
35
|
+
speqs connect <port>
|
|
44
36
|
```
|
|
45
37
|
|
|
46
38
|
### Options
|
|
@@ -57,22 +49,19 @@ The CLI resolves your auth token in this order:
|
|
|
57
49
|
|
|
58
50
|
1. `--token` CLI argument
|
|
59
51
|
2. `SPEQS_TOKEN` environment variable
|
|
60
|
-
3. Saved token in `~/.speqs/config.json`
|
|
61
|
-
4. Interactive prompt (token is saved for future use)
|
|
62
|
-
|
|
63
|
-
Find your token in the Speqs app under **Settings**.
|
|
52
|
+
3. Saved token from `speqs login` (stored in `~/.speqs/config.json`)
|
|
64
53
|
|
|
65
54
|
## Examples
|
|
66
55
|
|
|
67
56
|
```bash
|
|
68
57
|
# Expose port 3000
|
|
69
|
-
speqs
|
|
58
|
+
speqs connect 3000
|
|
70
59
|
|
|
71
60
|
# With explicit token
|
|
72
|
-
speqs
|
|
61
|
+
speqs connect 3000 --token YOUR_TOKEN
|
|
73
62
|
|
|
74
63
|
# Using environment variable
|
|
75
|
-
SPEQS_TOKEN=YOUR_TOKEN speqs
|
|
64
|
+
SPEQS_TOKEN=YOUR_TOKEN speqs connect 8080
|
|
76
65
|
```
|
|
77
66
|
|
|
78
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>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Localhost
|
|
2
|
+
* Localhost connect CLI — wraps cloudflared and registers with Speqs backend.
|
|
3
3
|
*/
|
|
4
4
|
import { spawn, execSync } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -8,21 +8,181 @@ import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { loadConfig, saveConfig } from "./config.js";
|
|
9
9
|
import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
|
|
10
10
|
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
11
|
-
const HEARTBEAT_INTERVAL =
|
|
11
|
+
const HEARTBEAT_INTERVAL = 10_000;
|
|
12
12
|
const MAX_HEARTBEAT_FAILURES = 3;
|
|
13
13
|
const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
|
|
14
14
|
const DEFAULT_API_URL = "https://api.speqs.io";
|
|
15
15
|
const API_BASE = "/api/v1";
|
|
16
16
|
const SPEQS_DIR = join(homedir(), ".speqs");
|
|
17
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
|
+
}
|
|
18
178
|
// --- Token resolution ---
|
|
19
179
|
async function verifyToken(token, apiUrl) {
|
|
20
180
|
try {
|
|
21
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/
|
|
181
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
|
|
22
182
|
headers: { Authorization: `Bearer ${token}` },
|
|
23
183
|
signal: AbortSignal.timeout(10_000),
|
|
24
184
|
});
|
|
25
|
-
// 404 = valid token, no
|
|
185
|
+
// 404 = valid token, no connection (expected). 401/403 = bad token.
|
|
26
186
|
return resp.status !== 401 && resp.status !== 403;
|
|
27
187
|
}
|
|
28
188
|
catch {
|
|
@@ -55,16 +215,15 @@ async function resolveToken(tokenArg, apiUrl) {
|
|
|
55
215
|
// Refresh if expired or close to expiry
|
|
56
216
|
if (isTokenExpired(accessToken)) {
|
|
57
217
|
try {
|
|
58
|
-
console.log("Refreshing access token...");
|
|
59
218
|
const tokens = await refreshTokens(config.refresh_token);
|
|
60
219
|
accessToken = tokens.accessToken;
|
|
61
220
|
config.access_token = tokens.accessToken;
|
|
62
221
|
config.refresh_token = tokens.refreshToken;
|
|
63
222
|
saveConfig(config);
|
|
64
223
|
}
|
|
65
|
-
catch
|
|
66
|
-
console.error(
|
|
67
|
-
|
|
224
|
+
catch {
|
|
225
|
+
console.error('Session expired. Run "speqs login" to re-authenticate.');
|
|
226
|
+
process.exit(1);
|
|
68
227
|
}
|
|
69
228
|
}
|
|
70
229
|
if (await verifyToken(accessToken, apiUrl)) {
|
|
@@ -81,14 +240,16 @@ async function resolveToken(tokenArg, apiUrl) {
|
|
|
81
240
|
};
|
|
82
241
|
return { token: accessToken, refresh: doRefresh };
|
|
83
242
|
}
|
|
84
|
-
console.error('Saved token is invalid. Run "speqs login" to re-authenticate
|
|
243
|
+
console.error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
244
|
+
process.exit(1);
|
|
85
245
|
}
|
|
86
246
|
// 4. Legacy saved token (no refresh token)
|
|
87
247
|
if (config.token) {
|
|
88
248
|
if (await verifyToken(config.token, apiUrl)) {
|
|
89
249
|
return { token: config.token, refresh: null };
|
|
90
250
|
}
|
|
91
|
-
console.error(
|
|
251
|
+
console.error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
252
|
+
process.exit(1);
|
|
92
253
|
}
|
|
93
254
|
// 5. No valid token found — direct user to login
|
|
94
255
|
console.error('No valid token found. Run "speqs login" to authenticate.');
|
|
@@ -98,6 +259,11 @@ async function resolveToken(tokenArg, apiUrl) {
|
|
|
98
259
|
const RESET = "\x1b[0m";
|
|
99
260
|
const ORANGE = "\x1b[38;2;212;117;78m";
|
|
100
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";
|
|
101
267
|
function printBanner() {
|
|
102
268
|
console.log(`
|
|
103
269
|
${ORANGE}${BOLD} ███████╗██████╗ ███████╗ ██████╗ ███████╗
|
|
@@ -107,7 +273,7 @@ ${ORANGE}${BOLD} ███████╗██████╗ █████
|
|
|
107
273
|
███████║██║ ███████╗╚██████╔╝███████║
|
|
108
274
|
╚══════╝╚═╝ ╚══════╝ ╚══▀▀═╝ ╚══════╝${RESET}
|
|
109
275
|
|
|
110
|
-
|
|
276
|
+
Connected
|
|
111
277
|
`);
|
|
112
278
|
}
|
|
113
279
|
// --- Cloudflared ---
|
|
@@ -175,7 +341,7 @@ function printManualInstallInstructions() {
|
|
|
175
341
|
}
|
|
176
342
|
function startCloudflared(port, binPath) {
|
|
177
343
|
return new Promise((resolve, reject) => {
|
|
178
|
-
console.log(`
|
|
344
|
+
console.log(`Connecting to localhost:${port}...`);
|
|
179
345
|
const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
|
|
180
346
|
stdio: ["ignore", "pipe", "pipe"],
|
|
181
347
|
});
|
|
@@ -211,7 +377,7 @@ function startCloudflared(port, binPath) {
|
|
|
211
377
|
// --- API calls ---
|
|
212
378
|
async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
213
379
|
try {
|
|
214
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/
|
|
380
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
215
381
|
method: "POST",
|
|
216
382
|
headers: {
|
|
217
383
|
Authorization: `Bearer ${token}`,
|
|
@@ -225,25 +391,39 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
|
225
391
|
// Registration successful — banner already shown
|
|
226
392
|
}
|
|
227
393
|
catch (e) {
|
|
228
|
-
console.error(`Warning: Failed to register
|
|
229
|
-
console.error("
|
|
394
|
+
console.error(`Warning: Failed to register connection: ${e}`);
|
|
395
|
+
console.error("Connection is still active — you can retry manually.");
|
|
230
396
|
}
|
|
231
397
|
}
|
|
232
398
|
async function deregisterTunnel(apiUrl, token) {
|
|
233
399
|
try {
|
|
234
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/
|
|
400
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
235
401
|
method: "DELETE",
|
|
236
402
|
headers: { Authorization: `Bearer ${token}` },
|
|
237
403
|
signal: AbortSignal.timeout(2_000),
|
|
238
404
|
});
|
|
239
405
|
if (!resp.ok)
|
|
240
406
|
throw new Error(`HTTP ${resp.status}`);
|
|
241
|
-
console.log("
|
|
407
|
+
console.log("Disconnected");
|
|
242
408
|
}
|
|
243
409
|
catch (e) {
|
|
244
|
-
console.error(`Warning: Failed to deregister
|
|
410
|
+
console.error(`Warning: Failed to deregister connection: ${e}`);
|
|
245
411
|
}
|
|
246
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
|
+
}
|
|
247
427
|
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
|
|
248
428
|
let consecutiveFailures = 0;
|
|
249
429
|
let stopped = false;
|
|
@@ -251,7 +431,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
|
|
|
251
431
|
if (stopped)
|
|
252
432
|
return;
|
|
253
433
|
try {
|
|
254
|
-
const resp = await fetch(`${apiUrl}${API_BASE}/
|
|
434
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
|
|
255
435
|
method: "POST",
|
|
256
436
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
257
437
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -263,7 +443,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
|
|
|
263
443
|
onTokenRefreshed(newToken);
|
|
264
444
|
console.log("Token refreshed.");
|
|
265
445
|
// Retry heartbeat with new token
|
|
266
|
-
const retry = await fetch(`${apiUrl}${API_BASE}/
|
|
446
|
+
const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
|
|
267
447
|
method: "POST",
|
|
268
448
|
headers: { Authorization: `Bearer ${newToken}` },
|
|
269
449
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -271,6 +451,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
|
|
|
271
451
|
if (!retry.ok)
|
|
272
452
|
throw new Error(`HTTP ${retry.status}`);
|
|
273
453
|
consecutiveFailures = 0;
|
|
454
|
+
processHeartbeatResponse(retry);
|
|
274
455
|
return;
|
|
275
456
|
}
|
|
276
457
|
catch (refreshErr) {
|
|
@@ -280,6 +461,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
|
|
|
280
461
|
if (!resp.ok)
|
|
281
462
|
throw new Error(`HTTP ${resp.status}`);
|
|
282
463
|
consecutiveFailures = 0;
|
|
464
|
+
processHeartbeatResponse(resp);
|
|
283
465
|
}
|
|
284
466
|
catch (e) {
|
|
285
467
|
consecutiveFailures++;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program, Option } from "commander";
|
|
3
|
-
import { runTunnel } from "./
|
|
3
|
+
import { runTunnel } from "./connect.js";
|
|
4
4
|
import { login, getAppUrl } from "./auth.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { upgrade } from "./upgrade.js";
|
|
@@ -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())
|
package/dist/upgrade.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createWriteStream, renameSync, unlinkSync, chmodSync } from "node:fs";
|
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { pipeline } from "node:stream/promises";
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
|
-
const
|
|
5
|
+
const BASE_URL = "https://speqs.io";
|
|
6
6
|
function getPlatformTarget() {
|
|
7
7
|
const platform = process.platform;
|
|
8
8
|
const arch = process.arch;
|
|
@@ -18,11 +18,11 @@ function getPlatformTarget() {
|
|
|
18
18
|
return target;
|
|
19
19
|
}
|
|
20
20
|
async function getLatestVersion() {
|
|
21
|
-
const res = await fetch(
|
|
21
|
+
const res = await fetch(`${BASE_URL}/api/releases/latest`);
|
|
22
22
|
if (!res.ok)
|
|
23
23
|
throw new Error(`Failed to fetch latest version: ${res.statusText}`);
|
|
24
24
|
const data = (await res.json());
|
|
25
|
-
return data.
|
|
25
|
+
return data.version;
|
|
26
26
|
}
|
|
27
27
|
export async function upgrade(currentVersion, targetVersion) {
|
|
28
28
|
if (targetVersion && !/^\d+\.\d+\.\d+/.test(targetVersion)) {
|
|
@@ -37,7 +37,7 @@ export async function upgrade(currentVersion, targetVersion) {
|
|
|
37
37
|
const target = getPlatformTarget();
|
|
38
38
|
const ext = process.platform === "win32" ? ".exe" : "";
|
|
39
39
|
const assetName = `speqs-${target}${ext}`;
|
|
40
|
-
const url =
|
|
40
|
+
const url = `${BASE_URL}/api/releases/v${latest}/${assetName}`;
|
|
41
41
|
const res = await fetch(url, { redirect: "follow" });
|
|
42
42
|
if (!res.ok) {
|
|
43
43
|
throw new Error(`Download failed: ${res.statusText} (${url})`);
|
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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"keywords": [
|
|
24
24
|
"speqs",
|
|
25
|
-
"
|
|
25
|
+
"connect",
|
|
26
26
|
"localhost",
|
|
27
27
|
"testing"
|
|
28
28
|
],
|