speqs 0.2.0 → 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/index.js +10 -3
- package/dist/tunnel.js +85 -37
- 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/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { program, Option } from "commander";
|
|
4
3
|
import { runTunnel } from "./tunnel.js";
|
|
5
4
|
import { login, getAppUrl } from "./auth.js";
|
|
6
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { upgrade } from "./upgrade.js";
|
|
7
|
+
import pkg from "../package.json" with { type: "json" };
|
|
8
|
+
const { version } = pkg;
|
|
9
9
|
program
|
|
10
10
|
.name("speqs")
|
|
11
11
|
.description("Speqs CLI tools")
|
|
@@ -54,4 +54,11 @@ program
|
|
|
54
54
|
const apiUrl = options.dev ? "http://localhost:8000" : options.apiUrl;
|
|
55
55
|
await runTunnel(portNum, options.token, apiUrl);
|
|
56
56
|
});
|
|
57
|
+
program
|
|
58
|
+
.command("upgrade")
|
|
59
|
+
.description("Update speqs to the latest version")
|
|
60
|
+
.option("--version <version>", "Install a specific version")
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
await upgrade(version, options.version);
|
|
63
|
+
});
|
|
57
64
|
program.parse();
|
package/dist/tunnel.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
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
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
9
|
import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
|
|
8
10
|
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
@@ -11,6 +13,8 @@ const MAX_HEARTBEAT_FAILURES = 3;
|
|
|
11
13
|
const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
|
|
12
14
|
const DEFAULT_API_URL = "https://api.speqs.io";
|
|
13
15
|
const API_BASE = "/api/v1";
|
|
16
|
+
const SPEQS_DIR = join(homedir(), ".speqs");
|
|
17
|
+
const CLOUDFLARED_BIN = join(SPEQS_DIR, "bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
14
18
|
// --- Token resolution ---
|
|
15
19
|
async function verifyToken(token, apiUrl) {
|
|
16
20
|
try {
|
|
@@ -32,15 +36,6 @@ function resolveApiUrl(apiUrlArg) {
|
|
|
32
36
|
return apiUrlArg;
|
|
33
37
|
return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
|
|
34
38
|
}
|
|
35
|
-
function prompt(question) {
|
|
36
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
rl.question(question, (answer) => {
|
|
39
|
-
rl.close();
|
|
40
|
-
resolve(answer.trim());
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
39
|
/**
|
|
45
40
|
* Resolve an access token, refreshing if needed.
|
|
46
41
|
* Returns both the token and a mutable holder for runtime refresh.
|
|
@@ -95,40 +90,93 @@ async function resolveToken(tokenArg, apiUrl) {
|
|
|
95
90
|
}
|
|
96
91
|
console.error("Saved token is invalid or expired.\n");
|
|
97
92
|
}
|
|
98
|
-
// 5.
|
|
99
|
-
console.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
`);
|
|
114
112
|
}
|
|
115
113
|
// --- Cloudflared ---
|
|
116
|
-
function
|
|
114
|
+
async function resolveCloudflaredBin() {
|
|
115
|
+
// 1. Prefer system-installed cloudflared
|
|
117
116
|
try {
|
|
118
117
|
execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
|
|
118
|
+
return "cloudflared";
|
|
119
119
|
}
|
|
120
120
|
catch {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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();
|
|
125
151
|
process.exit(1);
|
|
126
152
|
}
|
|
127
153
|
}
|
|
128
|
-
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) {
|
|
129
177
|
return new Promise((resolve, reject) => {
|
|
130
178
|
console.log(`Starting tunnel to localhost:${port}...`);
|
|
131
|
-
const proc = spawn(
|
|
179
|
+
const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
|
|
132
180
|
stdio: ["ignore", "pipe", "pipe"],
|
|
133
181
|
});
|
|
134
182
|
let tunnelUrl = null;
|
|
@@ -144,7 +192,7 @@ function startCloudflared(port) {
|
|
|
144
192
|
if (match && !tunnelUrl) {
|
|
145
193
|
tunnelUrl = match[0];
|
|
146
194
|
clearTimeout(timeout);
|
|
147
|
-
|
|
195
|
+
printBanner();
|
|
148
196
|
resolve({ process: proc, tunnelUrl });
|
|
149
197
|
}
|
|
150
198
|
});
|
|
@@ -174,7 +222,7 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
|
174
222
|
});
|
|
175
223
|
if (!resp.ok)
|
|
176
224
|
throw new Error(`HTTP ${resp.status}`);
|
|
177
|
-
|
|
225
|
+
// Registration successful — banner already shown
|
|
178
226
|
}
|
|
179
227
|
catch (e) {
|
|
180
228
|
console.error(`Warning: Failed to register tunnel: ${e}`);
|
|
@@ -300,10 +348,10 @@ export async function runTunnel(port, tokenArg, apiUrlArg) {
|
|
|
300
348
|
return refreshInFlight;
|
|
301
349
|
}
|
|
302
350
|
: null;
|
|
303
|
-
|
|
351
|
+
const cloudflaredPath = await resolveCloudflaredBin();
|
|
304
352
|
let cfResult;
|
|
305
353
|
try {
|
|
306
|
-
cfResult = await startCloudflared(port);
|
|
354
|
+
cfResult = await startCloudflared(port, cloudflaredPath);
|
|
307
355
|
}
|
|
308
356
|
catch (e) {
|
|
309
357
|
console.error(`Failed to start cloudflared: ${e}`);
|
|
@@ -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
|
},
|