blitzwing 0.1.10 → 0.2.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/src/tunnel.js ADDED
@@ -0,0 +1,231 @@
1
+ import { spawn, execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { createWriteStream } from "node:fs";
6
+ import { Readable } from "node:stream";
7
+ import { HOME_DIR } from "./config.js";
8
+ import { which } from "./net.js";
9
+
10
+ const BIN_DIR = path.join(HOME_DIR, "bin");
11
+ const URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
12
+
13
+ function platformAsset() {
14
+ const plat = process.platform;
15
+ const arch = process.arch;
16
+ if (plat === "linux" && arch === "x64") {
17
+ return { name: "cloudflared-linux-amd64", archive: false };
18
+ }
19
+ if (plat === "linux" && (arch === "arm64" || arch === "aarch64")) {
20
+ return { name: "cloudflared-linux-arm64", archive: false };
21
+ }
22
+ if (plat === "darwin" && arch === "arm64") {
23
+ return { name: "cloudflared-darwin-arm64.tgz", archive: true };
24
+ }
25
+ if (plat === "darwin" && arch === "x64") {
26
+ return { name: "cloudflared-darwin-amd64.tgz", archive: true };
27
+ }
28
+ if (plat === "win32" && arch === "x64") {
29
+ return { name: "cloudflared-windows-amd64.exe", archive: false, exe: true };
30
+ }
31
+ throw new Error(
32
+ `Unsupported platform for cloudflared auto-install: ${plat}/${arch}. Install cloudflared manually and retry.`
33
+ );
34
+ }
35
+
36
+ function localBinaryPath() {
37
+ const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
38
+ return path.join(BIN_DIR, exe);
39
+ }
40
+
41
+ /**
42
+ * Resolve cloudflared binary: PATH first, then ~/.blitzwing/bin (download if missing).
43
+ * @param {{ onLog?: (msg: string) => void }} [opts]
44
+ */
45
+ export async function ensureCloudflared({ onLog } = {}) {
46
+ const onPath = which("cloudflared");
47
+ if (onPath) return onPath;
48
+
49
+ const dest = localBinaryPath();
50
+ if (fs.existsSync(dest)) {
51
+ try {
52
+ fs.chmodSync(dest, 0o755);
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ return dest;
57
+ }
58
+
59
+ const asset = platformAsset();
60
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`;
61
+ onLog?.(`Downloading cloudflared (${asset.name})…`);
62
+ fs.mkdirSync(BIN_DIR, { recursive: true });
63
+
64
+ const res = await fetch(url, { redirect: "follow" });
65
+ if (!res.ok || !res.body) {
66
+ throw new Error(`Failed to download cloudflared: HTTP ${res.status} from ${url}`);
67
+ }
68
+
69
+ if (asset.archive) {
70
+ const tgzPath = path.join(BIN_DIR, asset.name);
71
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(tgzPath));
72
+ execFileSync("tar", ["-xzf", tgzPath, "-C", BIN_DIR], { stdio: "ignore" });
73
+ try {
74
+ fs.unlinkSync(tgzPath);
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ // tgz extracts a binary named cloudflared
79
+ const extracted = path.join(BIN_DIR, "cloudflared");
80
+ if (!fs.existsSync(extracted) && fs.existsSync(dest)) {
81
+ /* already named correctly */
82
+ } else if (fs.existsSync(extracted) && extracted !== dest) {
83
+ fs.renameSync(extracted, dest);
84
+ }
85
+ } else {
86
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
87
+ }
88
+
89
+ try {
90
+ fs.chmodSync(dest, 0o755);
91
+ } catch {
92
+ /* windows */
93
+ }
94
+ if (!fs.existsSync(dest)) {
95
+ throw new Error(`cloudflared download finished but binary missing at ${dest}`);
96
+ }
97
+ onLog?.(`cloudflared ready at ${dest}`);
98
+ return dest;
99
+ }
100
+
101
+ /**
102
+ * Kill a process (and its group on Unix).
103
+ * @param {number} pid
104
+ */
105
+ export function stopTunnelProcess(pid) {
106
+ if (!pid || !Number.isFinite(pid)) return;
107
+ try {
108
+ if (process.platform === "win32") {
109
+ execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
110
+ } else {
111
+ try {
112
+ process.kill(-pid, "SIGTERM");
113
+ } catch {
114
+ process.kill(pid, "SIGTERM");
115
+ }
116
+ setTimeout(() => {
117
+ try {
118
+ process.kill(-pid, "SIGKILL");
119
+ } catch {
120
+ try {
121
+ process.kill(pid, "SIGKILL");
122
+ } catch {
123
+ /* already gone */
124
+ }
125
+ }
126
+ }, 2000).unref?.();
127
+ }
128
+ } catch {
129
+ /* already gone */
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Start a Cloudflare Quick Tunnel to http://127.0.0.1:port (no account/token).
135
+ * @param {{ port: number, binary?: string, logPath?: string, timeoutMs?: number }} opts
136
+ * @returns {Promise<{ url: string, pid: number, stop: () => void }>}
137
+ */
138
+ export async function startQuickTunnel({
139
+ port,
140
+ binary,
141
+ logPath = path.join(HOME_DIR, "cloudflared.log"),
142
+ timeoutMs = 90_000,
143
+ }) {
144
+ const bin = binary || (await ensureCloudflared());
145
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
146
+ const logFd = fs.openSync(logPath, "a");
147
+
148
+ const child = spawn(
149
+ bin,
150
+ ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"],
151
+ {
152
+ detached: process.platform !== "win32",
153
+ stdio: ["ignore", "pipe", "pipe"],
154
+ windowsHide: true,
155
+ }
156
+ );
157
+
158
+ let settled = false;
159
+ let buffer = "";
160
+
161
+ const appendLog = (chunk) => {
162
+ const text = chunk.toString();
163
+ buffer += text;
164
+ try {
165
+ fs.writeSync(logFd, text);
166
+ } catch {
167
+ /* ignore */
168
+ }
169
+ };
170
+
171
+ child.stdout?.on("data", appendLog);
172
+ child.stderr?.on("data", appendLog);
173
+
174
+ const stop = () => {
175
+ stopTunnelProcess(child.pid);
176
+ try {
177
+ fs.closeSync(logFd);
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ };
182
+
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ if (settled) return;
186
+ settled = true;
187
+ stop();
188
+ reject(
189
+ new Error(
190
+ `Timed out waiting for Cloudflare Quick Tunnel URL. See ${logPath}`
191
+ )
192
+ );
193
+ }, timeoutMs);
194
+
195
+ const tryMatch = () => {
196
+ const m = buffer.match(URL_RE);
197
+ if (!m || settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ if (process.platform !== "win32") {
201
+ child.unref();
202
+ }
203
+ resolve({
204
+ url: m[0].replace(/\/$/, ""),
205
+ pid: child.pid,
206
+ stop,
207
+ });
208
+ };
209
+
210
+ child.stdout?.on("data", tryMatch);
211
+ child.stderr?.on("data", tryMatch);
212
+
213
+ child.on("error", (err) => {
214
+ if (settled) return;
215
+ settled = true;
216
+ clearTimeout(timer);
217
+ reject(err);
218
+ });
219
+
220
+ child.on("exit", (code) => {
221
+ if (settled) return;
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ reject(
225
+ new Error(
226
+ `cloudflared exited early (code ${code}). See ${logPath}`
227
+ )
228
+ );
229
+ });
230
+ });
231
+ }
package/src/wizard.js CHANGED
@@ -1,22 +1,17 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import color from "picocolors";
3
- import fs from "node:fs";
4
3
  import path from "node:path";
5
- import { spawn } from "node:child_process";
6
4
  import { DEFAULT_DISCOVERY_URL, HOME_DIR, SHARD_PORT, PETALS_PORT } from "./config.js";
7
5
  import { listMothers, motherHosts, joinHost, readyHost, leaveHost } from "./api.js";
8
- import {
9
- detectPublicIp,
10
- detectLocalIp,
11
- discoverNgrokTcpAnnounce,
12
- extractPeerMultiaddrFromLog,
13
- } from "./net.js";
6
+ import { detectPublicIp, detectLocalIp } from "./net.js";
14
7
  import {
15
8
  ensureVenvAndPetals,
16
9
  startShardManagerProcess,
17
10
  waitForShardRunning,
11
+ syncRuntimeFiles,
18
12
  venvPython,
19
13
  } from "./install.js";
14
+ import { ensureCloudflared, startQuickTunnel, stopTunnelProcess } from "./tunnel.js";
20
15
  import { saveState, loadState, clearState, ensureHome } from "./state.js";
21
16
  import { startContributorHeartbeatDaemon } from "./heartbeat.js";
22
17
 
@@ -60,10 +55,9 @@ ${color.bold("blitzwing")} — join a Blitzwing mother swarm as a compute node
60
55
  blitzwing leave Leave the swarm and reclaim your layers
61
56
 
62
57
  Env:
63
- BLITZWING_DISCOVERY_URL Override discovery service (default ${DEFAULT_DISCOVERY_URL})
64
- BLITZWING_ANNOUNCE_MADDRS Explicit Petals announce multiaddr (VM with public IP)
65
- BLITZWING_LOCAL_IP Local IP for shard manager metadata
66
- PETALS_USE_AUTO_RELAY=0 Disable libp2p auto-relay (use with public IP / port-forward)
58
+ BLITZWING_DISCOVERY_URL Override discovery service (default ${DEFAULT_DISCOVERY_URL})
59
+ BLITZWING_HEDERA_ACCOUNT_ID Prefill Hedera payout account (0.0.N)
60
+ BLITZWING_SHARD_PORT Local shard HTTP port (default ${SHARD_PORT})
67
61
  `);
68
62
  }
69
63
 
@@ -71,6 +65,7 @@ async function wizard(args) {
71
65
  p.intro(color.bgCyan(color.black(" blitzwing ")));
72
66
  ensureHome();
73
67
 
68
+ const existing = loadState();
74
69
  const spin = p.spinner();
75
70
  spin.start("Loading network from Discovery Service…");
76
71
  let mothers;
@@ -138,74 +133,14 @@ async function wizard(args) {
138
133
  }
139
134
  const layersN = Number(layers);
140
135
 
141
- const detected = await detectPublicIp();
142
- const networkMode = await p.select({
143
- message: "How will other peers reach your Petals node?",
144
- options: [
145
- {
146
- value: "relay",
147
- label: "Auto (recommended for home / NAT)",
148
- hint: "Uses Petals libp2p relay — no port forwarding",
149
- },
150
- {
151
- value: "public",
152
- label: "Public IP / cloud VM",
153
- hint: "TCP 31337 (and 8001) open on the internet",
154
- },
155
- {
156
- value: "ngrok",
157
- label: "Dev tunnel (ngrok TCP on :4040)",
158
- hint: "Only if ngrok is already running",
159
- },
160
- ],
161
- initialValue: "relay",
162
- });
163
- if (p.isCancel(networkMode)) {
164
- p.cancel("Setup cancelled");
165
- process.exit(0);
166
- }
167
-
168
- let publicIp = "";
169
- let announceMaddrs = "";
170
- let useAutoRelay = "1";
171
-
172
- if (networkMode === "public") {
173
- publicIp = await p.text({
174
- message: "Public IPv4 (must be reachable on TCP " + PETALS_PORT + ")",
175
- initialValue: detected || "",
176
- validate(v) {
177
- if (!v || !/^\d+\.\d+\.\d+\.\d+$/.test(String(v).trim())) {
178
- return "Enter a valid public IPv4 address";
179
- }
180
- },
181
- });
182
- if (p.isCancel(publicIp)) {
183
- p.cancel("Setup cancelled");
184
- process.exit(0);
185
- }
186
- publicIp = String(publicIp).trim();
187
- announceMaddrs = `/ip4/${publicIp}/tcp/${PETALS_PORT}`;
188
- useAutoRelay = "0";
189
- } else if (networkMode === "ngrok") {
190
- const ngrok = await discoverNgrokTcpAnnounce(PETALS_PORT);
191
- if (!ngrok) {
192
- p.cancel("No ngrok TCP tunnel on http://127.0.0.1:4040. Start: ngrok tcp " + PETALS_PORT);
193
- process.exit(1);
194
- }
195
- announceMaddrs = ngrok;
196
- publicIp = ngrok.match(/dns4\/([^/]+)/)?.[1] || detected || "relay";
197
- useAutoRelay = "0";
198
- p.log.info(`Using ngrok announce ${announceMaddrs}`);
199
- } else {
200
- publicIp = detected || "relay";
201
- p.log.info("Using Petals libp2p auto-relay (no inbound port forward required).");
202
- }
203
-
204
- const localIp = await detectLocalIp();
205
-
136
+ const hederaPrefill =
137
+ process.env.BLITZWING_HEDERA_ACCOUNT_ID ||
138
+ process.env.HEDERA_ACCOUNT_ID ||
139
+ existing?.hedera_account_id ||
140
+ "";
206
141
  const hederaAccount = await p.text({
207
142
  message: "Hedera account ID for layer payouts (e.g. 0.0.123456)",
208
- initialValue: process.env.BLITZWING_HEDERA_ACCOUNT_ID || "",
143
+ initialValue: hederaPrefill,
209
144
  validate(v) {
210
145
  const s = String(v || "").trim();
211
146
  if (!/^0\.0\.\d+$/.test(s)) return "Enter a Hedera account like 0.0.123456";
@@ -218,7 +153,7 @@ async function wizard(args) {
218
153
  const hederaAccountId = String(hederaAccount).trim();
219
154
 
220
155
  const confirm = await p.confirm({
221
- message: `Join ${selected.model} hosting ${layersN} layers (${networkMode}) paying to ${hederaAccountId}?`,
156
+ message: `Join ${selected.model} hosting ${layersN} layers, payouts to ${hederaAccountId}?`,
222
157
  initialValue: true,
223
158
  });
224
159
  if (p.isCancel(confirm) || !confirm) {
@@ -233,6 +168,7 @@ async function wizard(args) {
233
168
  spin.message(msg);
234
169
  },
235
170
  });
171
+ syncRuntimeFiles();
236
172
  } catch (err) {
237
173
  spin.stop("Install failed");
238
174
  p.cancel(err.message);
@@ -241,8 +177,28 @@ async function wizard(args) {
241
177
  const python = venvPython();
242
178
  spin.stop("Environment ready");
243
179
 
244
- const shardManagerUrl = `http://${localIp}:${SHARD_PORT}`;
245
- const petalsLogPath = path.join(HOME_DIR, "petals.log");
180
+ spin.start("Setting up Cloudflare Quick Tunnel…");
181
+ let tunnel;
182
+ try {
183
+ const cfBin = await ensureCloudflared({
184
+ onLog: (msg) => {
185
+ spin.message(msg);
186
+ },
187
+ });
188
+ tunnel = await startQuickTunnel({
189
+ port: SHARD_PORT,
190
+ binary: cfBin,
191
+ logPath: path.join(HOME_DIR, "cloudflared.log"),
192
+ });
193
+ } catch (err) {
194
+ spin.stop("Tunnel failed");
195
+ p.cancel(err.message);
196
+ process.exit(1);
197
+ }
198
+ spin.stop(`Public URL ${tunnel.url}`);
199
+
200
+ const localIp = await detectLocalIp();
201
+ const publicIp = (await detectPublicIp()) || localIp || "tunnel";
246
202
 
247
203
  spin.start("Requesting layer assignment from mother…");
248
204
  let assignment;
@@ -251,10 +207,11 @@ async function wizard(args) {
251
207
  model: selected.model,
252
208
  layers: layersN,
253
209
  public_ip: String(publicIp).trim(),
254
- shard_manager_url: shardManagerUrl,
210
+ shard_manager_url: tunnel.url,
255
211
  hedera_account_id: hederaAccountId,
256
212
  });
257
213
  } catch (err) {
214
+ tunnel.stop();
258
215
  spin.stop("Join rejected");
259
216
  const max = err.body?.detail?.max_layers;
260
217
  p.cancel(`${err.message}${max != null ? ` (max available: ${max})` : ""}`);
@@ -265,19 +222,21 @@ async function wizard(args) {
265
222
  const logPath = path.join(HOME_DIR, "shard_manager.log");
266
223
  spin.start("Starting local Petals server…");
267
224
  const shardEnv = {
268
- MODEL_NAME: selected.model,
269
- PUBLIC_IP: announceMaddrs ? "" : String(publicIp).trim(),
270
- BLOCK_INDICES: assignment.block_indices,
271
- INITIAL_PEERS: (assignment.initial_peers || []).join(","),
272
- PETALS_PORT: String(PETALS_PORT),
273
- IDENTITY_PATH: path.join(HOME_DIR, "petals-identity"),
274
- SHARD_AUTO_START: "1",
275
- PETALS_USE_AUTO_RELAY: useAutoRelay,
276
- PETALS_SKIP_REACHABILITY_CHECK: "1",
277
- };
278
- if (announceMaddrs) {
279
- shardEnv.ANNOUNCE_MADDRS = announceMaddrs;
280
- }
225
+ MODEL_NAME: selected.model,
226
+ PUBLIC_IP: String(publicIp).trim(),
227
+ BLOCK_INDICES: assignment.block_indices,
228
+ INITIAL_PEERS: "",
229
+ NEW_SWARM: "1",
230
+ BLITZWING_HTTP_ONLY: "1",
231
+ PETALS_PORT: String(PETALS_PORT),
232
+ IDENTITY_PATH: path.join(HOME_DIR, "petals-identity"),
233
+ SHARD_AUTO_START: "1",
234
+ PETALS_USE_AUTO_RELAY: "0",
235
+ PETALS_SKIP_REACHABILITY_CHECK: "1",
236
+ BLITZWING_MOTHER_URL: selected.mother_url,
237
+ MOTHER_PUBLIC_SHARD_URL: selected.mother_url,
238
+ BLITZWING_HOST_ID: assignment.host_id,
239
+ };
281
240
  const { pid } = startShardManagerProcess({
282
241
  python,
283
242
  logPath,
@@ -287,6 +246,7 @@ async function wizard(args) {
287
246
  try {
288
247
  await waitForShardRunning({ timeoutMs: 600000, statusHost: "127.0.0.1" });
289
248
  } catch (err) {
249
+ tunnel.stop();
290
250
  spin.stop("Petals did not become ready");
291
251
  p.cancel(`${err.message}. See ${logPath}`);
292
252
  process.exit(1);
@@ -294,13 +254,12 @@ async function wizard(args) {
294
254
  spin.stop("Petals is serving your layers");
295
255
 
296
256
  spin.start("Finalizing handoff with mother…");
297
- const peerMultiaddr = extractPeerMultiaddrFromLog(petalsLogPath, fs);
298
257
  try {
299
258
  await readyHost(selected.mother_url, {
300
259
  host_id: assignment.host_id,
301
- peer_multiaddr: peerMultiaddr || undefined,
302
260
  });
303
261
  } catch (err) {
262
+ tunnel.stop();
304
263
  spin.stop("Handoff failed");
305
264
  p.cancel(err.message);
306
265
  process.exit(1);
@@ -314,9 +273,10 @@ async function wizard(args) {
314
273
  block_indices: assignment.block_indices,
315
274
  layers_hosted: assignment.layers_hosted,
316
275
  public_ip: String(publicIp).trim(),
317
- network_mode: networkMode,
318
- announce_maddrs: announceMaddrs || null,
319
- shard_manager_url: shardManagerUrl,
276
+ network_mode: "cloudflare",
277
+ shard_manager_url: tunnel.url,
278
+ tunnel_url: tunnel.url,
279
+ tunnel_pid: tunnel.pid,
320
280
  shard_pid: pid,
321
281
  discovery_url: args.discoveryUrl,
322
282
  hedera_account_id: hederaAccountId,
@@ -330,6 +290,7 @@ async function wizard(args) {
330
290
 
331
291
  p.outro(
332
292
  `${color.green("You are online.")} Hosting ${color.cyan(String(state.layers_hosted))} layers of ${color.cyan(state.model)} at ${state.block_indices}\n` +
293
+ `Tunnel: ${color.cyan(tunnel.url)}\n` +
333
294
  `Run ${color.bold("blitzwing status")} anytime, or ${color.bold("blitzwing leave")} to exit.`
334
295
  );
335
296
  }
@@ -346,10 +307,10 @@ async function showStatus() {
346
307
  console.log(` layers_hosted: ${state.layers_hosted}`);
347
308
  console.log(` block_indices: ${state.block_indices}`);
348
309
  console.log(` mother: ${state.mother_url}`);
349
- console.log(` public_ip: ${state.public_ip}`);
310
+ console.log(` tunnel: ${state.tunnel_url || state.shard_manager_url || "—"}`);
311
+ console.log(` hedera: ${state.hedera_account_id || "—"}`);
350
312
  try {
351
- const statusHost = "127.0.0.1";
352
- const res = await fetch(`http://${statusHost}:${SHARD_PORT}/status`);
313
+ const res = await fetch(`http://127.0.0.1:${SHARD_PORT}/status`);
353
314
  if (res.ok) {
354
315
  const st = await res.json();
355
316
  console.log(` petals_running: ${st.running}`);
@@ -376,11 +337,13 @@ async function doLeave() {
376
337
  p.log.warn(`Mother leave call failed: ${err.message}`);
377
338
  }
378
339
  try {
379
- const statusHost = "127.0.0.1";
380
- await fetch(`http://${statusHost}:${SHARD_PORT}/stop`, { method: "POST" });
340
+ await fetch(`http://127.0.0.1:${SHARD_PORT}/stop`, { method: "POST" });
381
341
  } catch {
382
342
  /* ignore */
383
343
  }
344
+ if (state.tunnel_pid) {
345
+ stopTunnelProcess(state.tunnel_pid);
346
+ }
384
347
  clearState();
385
348
  spin.stop("Left swarm");
386
349
  p.outro("Your layers were reclaimed by the mother (when reachable).");