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 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
- const require = createRequire(import.meta.url);
8
- const { version } = require("../package.json");
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 * 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";
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. Interactive prompt (legacy fallback)
99
- console.log('Tip: Run "speqs login" for browser-based authentication with auto-refresh.\n');
100
- console.log("You can find your token in the simulation view.\n");
101
- while (true) {
102
- const token = await prompt("Paste your token: ");
103
- if (!token) {
104
- console.log("No token provided, exiting.");
105
- process.exit(1);
106
- }
107
- if (await verifyToken(token, apiUrl)) {
108
- config.token = token;
109
- saveConfig(config);
110
- return { token, refresh: null };
111
- }
112
- console.error("Invalid token. Try again.\n");
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 checkCloudflared() {
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
- console.error("Missing dependency. Install it:\n" +
122
- " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
123
- " sudo apt install cloudflared # Debian/Ubuntu\n" +
124
- "\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();
125
151
  process.exit(1);
126
152
  }
127
153
  }
128
- 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) {
129
177
  return new Promise((resolve, reject) => {
130
178
  console.log(`Starting tunnel to localhost:${port}...`);
131
- const proc = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
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
- console.log(`Tunnel active: ${tunnelUrl}`);
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
- console.log("Registered with Speqs backend");
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
- checkCloudflared();
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>;
@@ -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.2.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
  },