blitzwing 0.1.9 → 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,23 +1,19 @@
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";
16
+ import { startContributorHeartbeatDaemon } from "./heartbeat.js";
21
17
 
22
18
  function parseArgs(argv) {
23
19
  const out = { cmd: null, discoveryUrl: process.env.BLITZWING_DISCOVERY_URL || DEFAULT_DISCOVERY_URL };
@@ -59,10 +55,9 @@ ${color.bold("blitzwing")} — join a Blitzwing mother swarm as a compute node
59
55
  blitzwing leave Leave the swarm and reclaim your layers
60
56
 
61
57
  Env:
62
- BLITZWING_DISCOVERY_URL Override discovery service (default ${DEFAULT_DISCOVERY_URL})
63
- BLITZWING_ANNOUNCE_MADDRS Explicit Petals announce multiaddr (VM with public IP)
64
- BLITZWING_LOCAL_IP Local IP for shard manager metadata
65
- 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})
66
61
  `);
67
62
  }
68
63
 
@@ -70,6 +65,7 @@ async function wizard(args) {
70
65
  p.intro(color.bgCyan(color.black(" blitzwing ")));
71
66
  ensureHome();
72
67
 
68
+ const existing = loadState();
73
69
  const spin = p.spinner();
74
70
  spin.start("Loading network from Discovery Service…");
75
71
  let mothers;
@@ -137,74 +133,14 @@ async function wizard(args) {
137
133
  }
138
134
  const layersN = Number(layers);
139
135
 
140
- const detected = await detectPublicIp();
141
- const networkMode = await p.select({
142
- message: "How will other peers reach your Petals node?",
143
- options: [
144
- {
145
- value: "relay",
146
- label: "Auto (recommended for home / NAT)",
147
- hint: "Uses Petals libp2p relay — no port forwarding",
148
- },
149
- {
150
- value: "public",
151
- label: "Public IP / cloud VM",
152
- hint: "TCP 31337 (and 8001) open on the internet",
153
- },
154
- {
155
- value: "ngrok",
156
- label: "Dev tunnel (ngrok TCP on :4040)",
157
- hint: "Only if ngrok is already running",
158
- },
159
- ],
160
- initialValue: "relay",
161
- });
162
- if (p.isCancel(networkMode)) {
163
- p.cancel("Setup cancelled");
164
- process.exit(0);
165
- }
166
-
167
- let publicIp = "";
168
- let announceMaddrs = "";
169
- let useAutoRelay = "1";
170
-
171
- if (networkMode === "public") {
172
- publicIp = await p.text({
173
- message: "Public IPv4 (must be reachable on TCP " + PETALS_PORT + ")",
174
- initialValue: detected || "",
175
- validate(v) {
176
- if (!v || !/^\d+\.\d+\.\d+\.\d+$/.test(String(v).trim())) {
177
- return "Enter a valid public IPv4 address";
178
- }
179
- },
180
- });
181
- if (p.isCancel(publicIp)) {
182
- p.cancel("Setup cancelled");
183
- process.exit(0);
184
- }
185
- publicIp = String(publicIp).trim();
186
- announceMaddrs = `/ip4/${publicIp}/tcp/${PETALS_PORT}`;
187
- useAutoRelay = "0";
188
- } else if (networkMode === "ngrok") {
189
- const ngrok = await discoverNgrokTcpAnnounce(PETALS_PORT);
190
- if (!ngrok) {
191
- p.cancel("No ngrok TCP tunnel on http://127.0.0.1:4040. Start: ngrok tcp " + PETALS_PORT);
192
- process.exit(1);
193
- }
194
- announceMaddrs = ngrok;
195
- publicIp = ngrok.match(/dns4\/([^/]+)/)?.[1] || detected || "relay";
196
- useAutoRelay = "0";
197
- p.log.info(`Using ngrok announce ${announceMaddrs}`);
198
- } else {
199
- publicIp = detected || "relay";
200
- p.log.info("Using Petals libp2p auto-relay (no inbound port forward required).");
201
- }
202
-
203
- const localIp = await detectLocalIp();
204
-
136
+ const hederaPrefill =
137
+ process.env.BLITZWING_HEDERA_ACCOUNT_ID ||
138
+ process.env.HEDERA_ACCOUNT_ID ||
139
+ existing?.hedera_account_id ||
140
+ "";
205
141
  const hederaAccount = await p.text({
206
142
  message: "Hedera account ID for layer payouts (e.g. 0.0.123456)",
207
- initialValue: process.env.BLITZWING_HEDERA_ACCOUNT_ID || "",
143
+ initialValue: hederaPrefill,
208
144
  validate(v) {
209
145
  const s = String(v || "").trim();
210
146
  if (!/^0\.0\.\d+$/.test(s)) return "Enter a Hedera account like 0.0.123456";
@@ -217,7 +153,7 @@ async function wizard(args) {
217
153
  const hederaAccountId = String(hederaAccount).trim();
218
154
 
219
155
  const confirm = await p.confirm({
220
- message: `Join ${selected.model} hosting ${layersN} layers (${networkMode}) paying to ${hederaAccountId}?`,
156
+ message: `Join ${selected.model} hosting ${layersN} layers, payouts to ${hederaAccountId}?`,
221
157
  initialValue: true,
222
158
  });
223
159
  if (p.isCancel(confirm) || !confirm) {
@@ -232,6 +168,7 @@ async function wizard(args) {
232
168
  spin.message(msg);
233
169
  },
234
170
  });
171
+ syncRuntimeFiles();
235
172
  } catch (err) {
236
173
  spin.stop("Install failed");
237
174
  p.cancel(err.message);
@@ -240,8 +177,28 @@ async function wizard(args) {
240
177
  const python = venvPython();
241
178
  spin.stop("Environment ready");
242
179
 
243
- const shardManagerUrl = `http://${localIp}:${SHARD_PORT}`;
244
- 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";
245
202
 
246
203
  spin.start("Requesting layer assignment from mother…");
247
204
  let assignment;
@@ -250,10 +207,11 @@ async function wizard(args) {
250
207
  model: selected.model,
251
208
  layers: layersN,
252
209
  public_ip: String(publicIp).trim(),
253
- shard_manager_url: shardManagerUrl,
210
+ shard_manager_url: tunnel.url,
254
211
  hedera_account_id: hederaAccountId,
255
212
  });
256
213
  } catch (err) {
214
+ tunnel.stop();
257
215
  spin.stop("Join rejected");
258
216
  const max = err.body?.detail?.max_layers;
259
217
  p.cancel(`${err.message}${max != null ? ` (max available: ${max})` : ""}`);
@@ -264,19 +222,21 @@ async function wizard(args) {
264
222
  const logPath = path.join(HOME_DIR, "shard_manager.log");
265
223
  spin.start("Starting local Petals server…");
266
224
  const shardEnv = {
267
- MODEL_NAME: selected.model,
268
- PUBLIC_IP: announceMaddrs ? "" : String(publicIp).trim(),
269
- BLOCK_INDICES: assignment.block_indices,
270
- INITIAL_PEERS: (assignment.initial_peers || []).join(","),
271
- PETALS_PORT: String(PETALS_PORT),
272
- IDENTITY_PATH: path.join(HOME_DIR, "petals-identity"),
273
- SHARD_AUTO_START: "1",
274
- PETALS_USE_AUTO_RELAY: useAutoRelay,
275
- PETALS_SKIP_REACHABILITY_CHECK: "1",
276
- };
277
- if (announceMaddrs) {
278
- shardEnv.ANNOUNCE_MADDRS = announceMaddrs;
279
- }
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
+ };
280
240
  const { pid } = startShardManagerProcess({
281
241
  python,
282
242
  logPath,
@@ -286,6 +246,7 @@ async function wizard(args) {
286
246
  try {
287
247
  await waitForShardRunning({ timeoutMs: 600000, statusHost: "127.0.0.1" });
288
248
  } catch (err) {
249
+ tunnel.stop();
289
250
  spin.stop("Petals did not become ready");
290
251
  p.cancel(`${err.message}. See ${logPath}`);
291
252
  process.exit(1);
@@ -293,13 +254,12 @@ async function wizard(args) {
293
254
  spin.stop("Petals is serving your layers");
294
255
 
295
256
  spin.start("Finalizing handoff with mother…");
296
- const peerMultiaddr = extractPeerMultiaddrFromLog(petalsLogPath, fs);
297
257
  try {
298
258
  await readyHost(selected.mother_url, {
299
259
  host_id: assignment.host_id,
300
- peer_multiaddr: peerMultiaddr || undefined,
301
260
  });
302
261
  } catch (err) {
262
+ tunnel.stop();
303
263
  spin.stop("Handoff failed");
304
264
  p.cancel(err.message);
305
265
  process.exit(1);
@@ -313,49 +273,28 @@ async function wizard(args) {
313
273
  block_indices: assignment.block_indices,
314
274
  layers_hosted: assignment.layers_hosted,
315
275
  public_ip: String(publicIp).trim(),
316
- network_mode: networkMode,
317
- announce_maddrs: announceMaddrs || null,
318
- shard_manager_url: shardManagerUrl,
276
+ network_mode: "cloudflare",
277
+ shard_manager_url: tunnel.url,
278
+ tunnel_url: tunnel.url,
279
+ tunnel_pid: tunnel.pid,
319
280
  shard_pid: pid,
320
281
  discovery_url: args.discoveryUrl,
321
282
  hedera_account_id: hederaAccountId,
322
283
  joined_at: new Date().toISOString(),
323
284
  };
324
285
  saveState(state);
325
- startHeartbeatDaemon(state);
286
+ startContributorHeartbeatDaemon({
287
+ motherUrl: state.mother_url,
288
+ hostId: state.host_id,
289
+ });
326
290
 
327
291
  p.outro(
328
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` +
329
294
  `Run ${color.bold("blitzwing status")} anytime, or ${color.bold("blitzwing leave")} to exit.`
330
295
  );
331
296
  }
332
297
 
333
- function startHeartbeatDaemon(state) {
334
- const hbPath = path.join(HOME_DIR, "heartbeat.mjs");
335
- fs.writeFileSync(
336
- hbPath,
337
- `const mother = ${JSON.stringify(state.mother_url)};
338
- const hostId = ${JSON.stringify(state.host_id)};
339
- async function beat() {
340
- try {
341
- await fetch(mother.replace(/\\/$/, "") + "/v1/hosts/heartbeat", {
342
- method: "POST",
343
- headers: { "Content-Type": "application/json" },
344
- body: JSON.stringify({ host_id: hostId }),
345
- });
346
- } catch {}
347
- }
348
- setInterval(beat, 60000);
349
- beat();
350
- `
351
- );
352
- const child = spawn(process.execPath, [hbPath], {
353
- detached: true,
354
- stdio: "ignore",
355
- });
356
- child.unref();
357
- }
358
-
359
298
  async function showStatus() {
360
299
  const state = loadState();
361
300
  if (!state) {
@@ -368,10 +307,10 @@ async function showStatus() {
368
307
  console.log(` layers_hosted: ${state.layers_hosted}`);
369
308
  console.log(` block_indices: ${state.block_indices}`);
370
309
  console.log(` mother: ${state.mother_url}`);
371
- 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 || "—"}`);
372
312
  try {
373
- const statusHost = "127.0.0.1";
374
- const res = await fetch(`http://${statusHost}:${SHARD_PORT}/status`);
313
+ const res = await fetch(`http://127.0.0.1:${SHARD_PORT}/status`);
375
314
  if (res.ok) {
376
315
  const st = await res.json();
377
316
  console.log(` petals_running: ${st.running}`);
@@ -398,11 +337,13 @@ async function doLeave() {
398
337
  p.log.warn(`Mother leave call failed: ${err.message}`);
399
338
  }
400
339
  try {
401
- const statusHost = "127.0.0.1";
402
- await fetch(`http://${statusHost}:${SHARD_PORT}/stop`, { method: "POST" });
340
+ await fetch(`http://127.0.0.1:${SHARD_PORT}/stop`, { method: "POST" });
403
341
  } catch {
404
342
  /* ignore */
405
343
  }
344
+ if (state.tunnel_pid) {
345
+ stopTunnelProcess(state.tunnel_pid);
346
+ }
406
347
  clearState();
407
348
  spin.stop("Left swarm");
408
349
  p.outro("Your layers were reclaimed by the mother (when reachable).");