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 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://raw.githubusercontent.com/speqs-io/speqs-cli/main/install.sh | sh
11
+ curl -fsSL https://speqs.io/install.sh | sh
20
12
  ```
21
13
 
22
14
  **Windows (PowerShell):**
23
15
  ```powershell
24
- irm https://raw.githubusercontent.com/speqs-io/speqs-cli/main/install.ps1 | iex
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 tunnel <port>
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 tunnel 3000
58
+ speqs connect 3000
70
59
 
71
60
  # With explicit token
72
- speqs tunnel 3000 --token YOUR_TOKEN
61
+ speqs connect 3000 --token YOUR_TOKEN
73
62
 
74
63
  # Using environment variable
75
- SPEQS_TOKEN=YOUR_TOKEN speqs tunnel 8080
64
+ SPEQS_TOKEN=YOUR_TOKEN speqs connect 8080
76
65
  ```
77
66
 
78
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>;
@@ -1,5 +1,5 @@
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
  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 = 30_000;
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}/tunnel/active`, {
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 tunnel (expected). 401/403 = bad token.
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 (e) {
66
- console.error(`Token refresh failed: ${e instanceof Error ? e.message : e}`);
67
- console.error('Run "speqs login" to re-authenticate.\n');
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.\n');
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("Saved token is invalid or expired.\n");
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
- Tunnel active
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(`Starting tunnel to localhost:${port}...`);
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}/tunnel`, {
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 tunnel: ${e}`);
229
- console.error("Tunnel is still active — you can retry manually.");
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}/tunnel`, {
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("Tunnel deregistered");
407
+ console.log("Disconnected");
242
408
  }
243
409
  catch (e) {
244
- console.error(`Warning: Failed to deregister tunnel: ${e}`);
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}/tunnel/heartbeat`, {
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}/tunnel/heartbeat`, {
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 "./tunnel.js";
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("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())
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 GITHUB_REPO = "speqs-io/speqs-cli";
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(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { headers: { Accept: "application/vnd.github.v3+json" } });
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.tag_name.replace(/^v/, "");
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 = `https://github.com/${GITHUB_REPO}/releases/download/v${latest}/${assetName}`;
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.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
- "tunnel",
25
+ "connect",
26
26
  "localhost",
27
27
  "testing"
28
28
  ],