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 +12 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +102 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +25 -0
- package/dist/index.js +40 -3
- package/dist/tunnel.js +209 -70
- package/dist/upgrade.d.ts +1 -0
- package/dist/upgrade.js +94 -0
- package/package.json +2 -1
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
75
|
-
console.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 ${
|
|
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
|
|
215
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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>;
|
package/dist/upgrade.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createWriteStream, renameSync, unlinkSync, chmodSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
const 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.
|
|
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
|
},
|