speqs 0.1.2 → 0.3.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
@@ -12,6 +12,18 @@ CLI tool to expose your localhost to [Speqs](https://speqs.io) for simulation te
12
12
 
13
13
  ## Install
14
14
 
15
+ ### Quick install (recommended)
16
+
17
+ **macOS / Linux:**
18
+ ```bash
19
+ curl -fsSL https://raw.githubusercontent.com/speqs-io/speqs-cli/main/install.sh | sh
20
+ ```
21
+
22
+ **Windows (PowerShell):**
23
+ ```powershell
24
+ irm https://raw.githubusercontent.com/speqs-io/speqs-cli/main/install.ps1 | iex
25
+ ```
26
+
15
27
  ### npm (all platforms)
16
28
 
17
29
  ```bash
package/dist/auth.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser-based authentication via the Speqs frontend plugin auth flow.
3
+ * Uses the existing /auth/plugin + /api/plugin/auth/poll infrastructure.
4
+ */
5
+ export declare function getAppUrl(): string;
6
+ export declare function getSupabaseUrl(): string;
7
+ export declare function getSupabaseAnonKey(): string;
8
+ export declare function decodeJwtExp(token: string): number;
9
+ export declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
10
+ export declare function login(appUrl?: string): Promise<{
11
+ accessToken: string;
12
+ refreshToken: string;
13
+ }>;
14
+ export declare function refreshTokens(refreshToken: string, supabaseUrl?: string, anonKey?: string): Promise<{
15
+ accessToken: string;
16
+ refreshToken: string;
17
+ }>;
package/dist/auth.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Browser-based authentication via the Speqs frontend plugin auth flow.
3
+ * Uses the existing /auth/plugin + /api/plugin/auth/poll infrastructure.
4
+ */
5
+ import * as crypto from "node:crypto";
6
+ import { execFile } from "node:child_process";
7
+ const POLL_INTERVAL = 2_000;
8
+ const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes (matches server-side token TTL)
9
+ const DEFAULT_APP_URL = "https://app.speqs.io";
10
+ const DEFAULT_SUPABASE_URL = "https://hngymyxdyamokpbeakps.supabase.co";
11
+ const DEFAULT_SUPABASE_ANON_KEY = "sb_publishable_JlS-HfwNyDqLNbrfbrkUlw_PSdZJdo2";
12
+ export function getAppUrl() {
13
+ return process.env.SPEQS_APP_URL ?? DEFAULT_APP_URL;
14
+ }
15
+ export function getSupabaseUrl() {
16
+ return process.env.SPEQS_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
17
+ }
18
+ export function getSupabaseAnonKey() {
19
+ return process.env.SPEQS_SUPABASE_ANON_KEY ?? DEFAULT_SUPABASE_ANON_KEY;
20
+ }
21
+ // --- Browser open ---
22
+ function openBrowser(url) {
23
+ if (process.platform === "win32") {
24
+ execFile("cmd", ["/c", "start", "", url]);
25
+ }
26
+ else if (process.platform === "darwin") {
27
+ execFile("open", [url]);
28
+ }
29
+ else {
30
+ execFile("xdg-open", [url]);
31
+ }
32
+ }
33
+ // --- JWT decode ---
34
+ export function decodeJwtExp(token) {
35
+ try {
36
+ const payload = token.split(".")[1];
37
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString());
38
+ return decoded.exp;
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ }
44
+ export function isTokenExpired(token, bufferSeconds = 300) {
45
+ const exp = decodeJwtExp(token);
46
+ if (!exp)
47
+ return true;
48
+ return Date.now() / 1000 >= exp - bufferSeconds;
49
+ }
50
+ // --- Login via browser polling ---
51
+ export async function login(appUrl) {
52
+ const url = appUrl ?? getAppUrl();
53
+ const state = crypto.randomBytes(32).toString("hex");
54
+ const loginUrl = `${url}/auth/plugin?state=${state}`;
55
+ console.log("Opening browser to sign in...");
56
+ console.log(`If the browser doesn't open, visit:\n ${loginUrl}\n`);
57
+ openBrowser(loginUrl);
58
+ console.log("Waiting for authentication...");
59
+ const deadline = Date.now() + LOGIN_TIMEOUT;
60
+ while (Date.now() < deadline) {
61
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
62
+ try {
63
+ const resp = await fetch(`${url}/api/plugin/auth/poll?state=${state}`, {
64
+ signal: AbortSignal.timeout(10_000),
65
+ });
66
+ if (resp.status === 200) {
67
+ const data = await resp.json();
68
+ if (data.status === "complete" && data.access_token && data.refresh_token) {
69
+ return { accessToken: data.access_token, refreshToken: data.refresh_token };
70
+ }
71
+ }
72
+ // 202 = pending, keep polling
73
+ }
74
+ catch {
75
+ // Network error, keep polling
76
+ }
77
+ }
78
+ throw new Error("Login timed out. Please try again.");
79
+ }
80
+ // --- Token refresh ---
81
+ export async function refreshTokens(refreshToken, supabaseUrl, anonKey) {
82
+ const url = supabaseUrl ?? getSupabaseUrl();
83
+ const key = anonKey ?? getSupabaseAnonKey();
84
+ const resp = await fetch(`${url}/auth/v1/token?grant_type=refresh_token`, {
85
+ method: "POST",
86
+ headers: {
87
+ apikey: key,
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify({ refresh_token: refreshToken }),
91
+ signal: AbortSignal.timeout(10_000),
92
+ });
93
+ if (!resp.ok) {
94
+ const body = await resp.text().catch(() => "");
95
+ throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
96
+ }
97
+ const data = await resp.json();
98
+ if (!data.access_token || !data.refresh_token) {
99
+ throw new Error("Token refresh response missing required fields");
100
+ }
101
+ return { accessToken: data.access_token, refreshToken: data.refresh_token };
102
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Config persistence for ~/.speqs/config.json
3
+ */
4
+ export interface SpeqsConfig {
5
+ access_token?: string;
6
+ refresh_token?: string;
7
+ token?: string;
8
+ [key: string]: string | undefined;
9
+ }
10
+ export declare function loadConfig(): SpeqsConfig;
11
+ export declare function saveConfig(config: SpeqsConfig): void;
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Config persistence for ~/.speqs/config.json
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import * as os from "node:os";
7
+ const CONFIG_DIR = path.join(os.homedir(), ".speqs");
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
9
+ export function loadConfig() {
10
+ try {
11
+ if (fs.existsSync(CONFIG_FILE)) {
12
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
13
+ }
14
+ }
15
+ catch {
16
+ // Corrupted config — ignore
17
+ }
18
+ return {};
19
+ }
20
+ export function saveConfig(config) {
21
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
22
+ const tmp = CONFIG_FILE + ".tmp";
23
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
24
+ fs.renameSync(tmp, CONFIG_FILE);
25
+ }
package/dist/index.js CHANGED
@@ -1,13 +1,43 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from "node:module";
3
2
  import { program, Option } from "commander";
4
3
  import { runTunnel } from "./tunnel.js";
5
- const require = createRequire(import.meta.url);
6
- const { version } = require("../package.json");
4
+ import { login, getAppUrl } from "./auth.js";
5
+ import { loadConfig, saveConfig } from "./config.js";
6
+ import { upgrade } from "./upgrade.js";
7
+ import pkg from "../package.json" with { type: "json" };
8
+ const { version } = pkg;
7
9
  program
8
10
  .name("speqs")
9
11
  .description("Speqs CLI tools")
10
12
  .version(version);
13
+ program
14
+ .command("login")
15
+ .description("Authenticate with Speqs via your browser")
16
+ .action(async () => {
17
+ try {
18
+ const tokens = await login(getAppUrl());
19
+ const config = loadConfig();
20
+ config.access_token = tokens.accessToken;
21
+ config.refresh_token = tokens.refreshToken;
22
+ saveConfig(config);
23
+ console.log("\nLogin successful!");
24
+ }
25
+ catch (e) {
26
+ console.error(`Login failed: ${e instanceof Error ? e.message : e}`);
27
+ process.exit(1);
28
+ }
29
+ });
30
+ program
31
+ .command("logout")
32
+ .description("Remove saved authentication credentials")
33
+ .action(() => {
34
+ const config = loadConfig();
35
+ delete config.access_token;
36
+ delete config.refresh_token;
37
+ delete config.token;
38
+ saveConfig(config);
39
+ console.log("Logged out.");
40
+ });
11
41
  program
12
42
  .command("tunnel")
13
43
  .description("Expose your localhost to Speqs via a Cloudflare tunnel")
@@ -24,4 +54,11 @@ program
24
54
  const apiUrl = options.dev ? "http://localhost:8000" : options.apiUrl;
25
55
  await runTunnel(portNum, options.token, apiUrl);
26
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
+ });
27
64
  program.parse();
package/dist/tunnel.js CHANGED
@@ -2,33 +2,20 @@
2
2
  * Localhost tunnel CLI — wraps cloudflared and registers with Speqs backend.
3
3
  */
4
4
  import { spawn, execSync } from "node:child_process";
5
- import * as fs from "node:fs";
6
- import * as path from "node:path";
7
- import * as os from "node:os";
8
- import * as readline from "node:readline";
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";
9
10
  const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
10
11
  const HEARTBEAT_INTERVAL = 30_000;
11
12
  const MAX_HEARTBEAT_FAILURES = 3;
12
13
  const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
13
14
  const DEFAULT_API_URL = "https://api.speqs.io";
14
15
  const API_BASE = "/api/v1";
15
- const CONFIG_DIR = path.join(os.homedir(), ".speqs");
16
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
- function loadConfig() {
18
- try {
19
- if (fs.existsSync(CONFIG_FILE)) {
20
- return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
21
- }
22
- }
23
- catch {
24
- // Corrupted config — ignore
25
- }
26
- return {};
27
- }
28
- function saveConfig(config) {
29
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
30
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
31
- }
16
+ const SPEQS_DIR = join(homedir(), ".speqs");
17
+ const CLOUDFLARED_BIN = join(SPEQS_DIR, "bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
18
+ // --- Token resolution ---
32
19
  async function verifyToken(token, apiUrl) {
33
20
  try {
34
21
  const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/active`, {
@@ -49,61 +36,147 @@ function resolveApiUrl(apiUrlArg) {
49
36
  return apiUrlArg;
50
37
  return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
51
38
  }
52
- function prompt(question) {
53
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
54
- return new Promise((resolve) => {
55
- rl.question(question, (answer) => {
56
- rl.close();
57
- resolve(answer.trim());
58
- });
59
- });
60
- }
39
+ /**
40
+ * Resolve an access token, refreshing if needed.
41
+ * Returns both the token and a mutable holder for runtime refresh.
42
+ */
61
43
  async function resolveToken(tokenArg, apiUrl) {
44
+ // 1. Explicit token argument
62
45
  if (tokenArg)
63
- return tokenArg;
46
+ return { token: tokenArg, refresh: null };
47
+ // 2. Environment variable
64
48
  const envToken = process.env.SPEQS_TOKEN;
65
49
  if (envToken)
66
- return envToken;
50
+ return { token: envToken, refresh: null };
51
+ // 3. Saved config with refresh token
67
52
  const config = loadConfig();
53
+ if (config.access_token && config.refresh_token) {
54
+ let accessToken = config.access_token;
55
+ // Refresh if expired or close to expiry
56
+ if (isTokenExpired(accessToken)) {
57
+ try {
58
+ console.log("Refreshing access token...");
59
+ const tokens = await refreshTokens(config.refresh_token);
60
+ accessToken = tokens.accessToken;
61
+ config.access_token = tokens.accessToken;
62
+ config.refresh_token = tokens.refreshToken;
63
+ saveConfig(config);
64
+ }
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');
68
+ }
69
+ }
70
+ if (await verifyToken(accessToken, apiUrl)) {
71
+ // Return with refresh capability for long-running tunnel
72
+ const doRefresh = async () => {
73
+ const cfg = loadConfig();
74
+ if (!cfg.refresh_token)
75
+ throw new Error("No refresh token");
76
+ const tokens = await refreshTokens(cfg.refresh_token);
77
+ cfg.access_token = tokens.accessToken;
78
+ cfg.refresh_token = tokens.refreshToken;
79
+ saveConfig(cfg);
80
+ return tokens.accessToken;
81
+ };
82
+ return { token: accessToken, refresh: doRefresh };
83
+ }
84
+ console.error('Saved token is invalid. Run "speqs login" to re-authenticate.\n');
85
+ }
86
+ // 4. Legacy saved token (no refresh token)
68
87
  if (config.token) {
69
88
  if (await verifyToken(config.token, apiUrl)) {
70
- return config.token;
89
+ return { token: config.token, refresh: null };
71
90
  }
72
91
  console.error("Saved token is invalid or expired.\n");
73
92
  }
74
- // Interactive prompt
75
- console.log("You can find your token in the simulation view.\n");
76
- while (true) {
77
- const token = await prompt("Paste your token: ");
78
- if (!token) {
79
- console.log("No token provided, exiting.");
80
- process.exit(1);
81
- }
82
- if (await verifyToken(token, apiUrl)) {
83
- config.token = token;
84
- saveConfig(config);
85
- return token;
86
- }
87
- console.error("Invalid token. Try again.\n");
88
- }
93
+ // 5. No valid token found — direct user to login
94
+ console.error('No valid token found. Run "speqs login" to authenticate.');
95
+ process.exit(1);
96
+ }
97
+ // --- Branding ---
98
+ const RESET = "\x1b[0m";
99
+ const ORANGE = "\x1b[38;2;212;117;78m";
100
+ const BOLD = "\x1b[1m";
101
+ function printBanner() {
102
+ console.log(`
103
+ ${ORANGE}${BOLD} ███████╗██████╗ ███████╗ ██████╗ ███████╗
104
+ ██╔════╝██╔══██╗██╔════╝██╔═══██╗██╔════╝
105
+ ███████╗██████╔╝█████╗ ██║ ██║███████╗
106
+ ╚════██║██╔═══╝ ██╔══╝ ██║▄▄ ██║╚════██║
107
+ ███████║██║ ███████╗╚██████╔╝███████║
108
+ ╚══════╝╚═╝ ╚══════╝ ╚══▀▀═╝ ╚══════╝${RESET}
109
+
110
+ Tunnel active
111
+ `);
89
112
  }
90
113
  // --- Cloudflared ---
91
- function checkCloudflared() {
114
+ async function resolveCloudflaredBin() {
115
+ // 1. Prefer system-installed cloudflared
92
116
  try {
93
117
  execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
118
+ return "cloudflared";
94
119
  }
95
120
  catch {
96
- console.error("Missing dependency. Install it:\n" +
97
- " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
98
- " sudo apt install cloudflared # Debian/Ubuntu\n" +
99
- "\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
121
+ // Not on PATH
122
+ }
123
+ // 2. Check ~/.speqs/bin/cloudflared
124
+ if (existsSync(CLOUDFLARED_BIN))
125
+ return CLOUDFLARED_BIN;
126
+ // 3. Download from Cloudflare releases
127
+ console.log("cloudflared not found. Installing...");
128
+ const url = getCloudflaredDownloadUrl();
129
+ if (!url) {
130
+ printManualInstallInstructions();
131
+ process.exit(1);
132
+ }
133
+ try {
134
+ const binDir = join(SPEQS_DIR, "bin");
135
+ mkdirSync(binDir, { recursive: true, mode: 0o755 });
136
+ if (url.endsWith(".tgz")) {
137
+ execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}" cloudflared`, { stdio: "ignore" });
138
+ }
139
+ else {
140
+ const resp = await fetch(url);
141
+ if (!resp.ok)
142
+ throw new Error(`HTTP ${resp.status}`);
143
+ writeFileSync(CLOUDFLARED_BIN, Buffer.from(await resp.arrayBuffer()));
144
+ }
145
+ chmodSync(CLOUDFLARED_BIN, 0o755);
146
+ return CLOUDFLARED_BIN;
147
+ }
148
+ catch (e) {
149
+ console.error(`Failed to install cloudflared: ${e instanceof Error ? e.message : e}\n`);
150
+ printManualInstallInstructions();
100
151
  process.exit(1);
101
152
  }
102
153
  }
103
- function startCloudflared(port) {
154
+ function getCloudflaredDownloadUrl() {
155
+ const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
156
+ const platform = process.platform;
157
+ const arch = process.arch;
158
+ if (platform === "darwin" && arch === "arm64")
159
+ return `${base}/cloudflared-darwin-arm64.tgz`;
160
+ if (platform === "darwin" && arch === "x64")
161
+ return `${base}/cloudflared-darwin-amd64.tgz`;
162
+ if (platform === "linux" && arch === "x64")
163
+ return `${base}/cloudflared-linux-amd64`;
164
+ if (platform === "linux" && arch === "arm64")
165
+ return `${base}/cloudflared-linux-arm64`;
166
+ if (platform === "win32" && arch === "x64")
167
+ return `${base}/cloudflared-windows-amd64.exe`;
168
+ return null;
169
+ }
170
+ function printManualInstallInstructions() {
171
+ console.error("You can install it manually:\n" +
172
+ " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
173
+ " sudo apt install cloudflared # Debian/Ubuntu\n" +
174
+ "\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
175
+ }
176
+ function startCloudflared(port, binPath) {
104
177
  return new Promise((resolve, reject) => {
105
178
  console.log(`Starting tunnel to localhost:${port}...`);
106
- const proc = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
179
+ const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
107
180
  stdio: ["ignore", "pipe", "pipe"],
108
181
  });
109
182
  let tunnelUrl = null;
@@ -119,7 +192,7 @@ function startCloudflared(port) {
119
192
  if (match && !tunnelUrl) {
120
193
  tunnelUrl = match[0];
121
194
  clearTimeout(timeout);
122
- console.log(`Tunnel active: ${tunnelUrl}`);
195
+ printBanner();
123
196
  resolve({ process: proc, tunnelUrl });
124
197
  }
125
198
  });
@@ -149,7 +222,7 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
149
222
  });
150
223
  if (!resp.ok)
151
224
  throw new Error(`HTTP ${resp.status}`);
152
- console.log("Registered with Speqs backend");
225
+ // Registration successful — banner already shown
153
226
  }
154
227
  catch (e) {
155
228
  console.error(`Warning: Failed to register tunnel: ${e}`);
@@ -171,7 +244,7 @@ async function deregisterTunnel(apiUrl, token) {
171
244
  console.error(`Warning: Failed to deregister tunnel: ${e}`);
172
245
  }
173
246
  }
174
- function startHeartbeat(apiUrl, token, onFatal) {
247
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
175
248
  let consecutiveFailures = 0;
176
249
  let stopped = false;
177
250
  const interval = setInterval(async () => {
@@ -180,9 +253,30 @@ function startHeartbeat(apiUrl, token, onFatal) {
180
253
  try {
181
254
  const resp = await fetch(`${apiUrl}${API_BASE}/tunnel/heartbeat`, {
182
255
  method: "POST",
183
- headers: { Authorization: `Bearer ${token}` },
256
+ headers: { Authorization: `Bearer ${getToken()}` },
184
257
  signal: AbortSignal.timeout(10_000),
185
258
  });
259
+ // If 401 and we can refresh, try once
260
+ if (resp.status === 401 && doRefresh) {
261
+ try {
262
+ const newToken = await doRefresh();
263
+ onTokenRefreshed(newToken);
264
+ console.log("Token refreshed.");
265
+ // Retry heartbeat with new token
266
+ const retry = await fetch(`${apiUrl}${API_BASE}/tunnel/heartbeat`, {
267
+ method: "POST",
268
+ headers: { Authorization: `Bearer ${newToken}` },
269
+ signal: AbortSignal.timeout(10_000),
270
+ });
271
+ if (!retry.ok)
272
+ throw new Error(`HTTP ${retry.status}`);
273
+ consecutiveFailures = 0;
274
+ return;
275
+ }
276
+ catch (refreshErr) {
277
+ console.error(`Token refresh failed: ${refreshErr}`);
278
+ }
279
+ }
186
280
  if (!resp.ok)
187
281
  throw new Error(`HTTP ${resp.status}`);
188
282
  consecutiveFailures = 0;
@@ -205,47 +299,92 @@ function startHeartbeat(apiUrl, token, onFatal) {
205
299
  },
206
300
  };
207
301
  }
302
+ /**
303
+ * Schedule a proactive token refresh before the JWT expires.
304
+ * Refreshes 10 minutes before expiry.
305
+ */
306
+ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
307
+ if (!doRefresh)
308
+ return { stop: () => { } };
309
+ const exp = decodeJwtExp(token);
310
+ if (!exp)
311
+ return { stop: () => { } };
312
+ const refreshAt = (exp - 600) * 1000; // 10 min before expiry
313
+ const delay = refreshAt - Date.now();
314
+ if (delay <= 0)
315
+ return { stop: () => { } };
316
+ const timer = setTimeout(async () => {
317
+ try {
318
+ const newToken = await doRefresh();
319
+ onTokenRefreshed(newToken);
320
+ console.log("Token proactively refreshed.");
321
+ // Schedule next refresh for the new token
322
+ scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed);
323
+ }
324
+ catch (e) {
325
+ console.error(`Proactive token refresh failed: ${e}`);
326
+ }
327
+ }, delay);
328
+ return { stop: () => clearTimeout(timer) };
329
+ }
208
330
  // --- Main ---
209
331
  export async function runTunnel(port, tokenArg, apiUrlArg) {
210
332
  const apiUrl = resolveApiUrl(apiUrlArg);
211
333
  if (apiUrl !== DEFAULT_API_URL) {
212
334
  console.log(`Using API: ${apiUrl}`);
213
335
  }
214
- const token = await resolveToken(tokenArg, apiUrl);
215
- checkCloudflared();
336
+ const resolved = await resolveToken(tokenArg, apiUrl);
337
+ let currentToken = resolved.token;
338
+ const onTokenRefreshed = (newToken) => {
339
+ currentToken = newToken;
340
+ };
341
+ // Serialize refresh calls to prevent concurrent use of single-use refresh tokens
342
+ let refreshInFlight = null;
343
+ const serializedRefresh = resolved.refresh
344
+ ? async () => {
345
+ if (refreshInFlight)
346
+ return refreshInFlight;
347
+ refreshInFlight = resolved.refresh().finally(() => { refreshInFlight = null; });
348
+ return refreshInFlight;
349
+ }
350
+ : null;
351
+ const cloudflaredPath = await resolveCloudflaredBin();
216
352
  let cfResult;
217
353
  try {
218
- cfResult = await startCloudflared(port);
354
+ cfResult = await startCloudflared(port, cloudflaredPath);
219
355
  }
220
356
  catch (e) {
221
357
  console.error(`Failed to start cloudflared: ${e}`);
222
358
  process.exit(1);
223
359
  }
224
360
  const { process: cfProcess, tunnelUrl } = cfResult;
225
- await registerTunnel(apiUrl, token, tunnelUrl, port);
361
+ await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
226
362
  let shuttingDown = false;
363
+ const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
364
+ await deregisterTunnel(apiUrl, currentToken);
365
+ cfProcess.kill();
366
+ process.exit(1);
367
+ });
368
+ const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed);
227
369
  const shutdown = async () => {
228
370
  if (shuttingDown)
229
371
  process.exit(1);
230
372
  shuttingDown = true;
231
373
  console.log("\nShutting down...");
232
374
  heartbeat.stop();
375
+ proactiveRefresh.stop();
233
376
  cfProcess.kill();
234
- await deregisterTunnel(apiUrl, token);
377
+ await deregisterTunnel(apiUrl, currentToken);
235
378
  process.exit(0);
236
379
  };
237
- const heartbeat = startHeartbeat(apiUrl, token, async () => {
238
- await deregisterTunnel(apiUrl, token);
239
- cfProcess.kill();
240
- process.exit(1);
241
- });
242
380
  process.on("SIGINT", shutdown);
243
381
  process.on("SIGTERM", shutdown);
244
382
  console.log("\nPress Ctrl+C to disconnect.\n");
245
383
  cfProcess.on("exit", async () => {
246
384
  if (!shuttingDown) {
247
385
  heartbeat.stop();
248
- await deregisterTunnel(apiUrl, token);
386
+ proactiveRefresh.stop();
387
+ await deregisterTunnel(apiUrl, currentToken);
249
388
  process.exit(0);
250
389
  }
251
390
  });
@@ -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 GITHUB_REPO = "speqs-io/speqs-cli";
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(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { headers: { Accept: "application/vnd.github.v3+json" } });
22
+ if (!res.ok)
23
+ throw new Error(`Failed to fetch latest version: ${res.statusText}`);
24
+ const data = (await res.json());
25
+ return data.tag_name.replace(/^v/, "");
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 = `https://github.com/${GITHUB_REPO}/releases/download/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.1.2",
3
+ "version": "0.3.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
  },